Programability

Programmability support in the Plasma Next Mainnet is currently under development. The contents of this document cannot be tested on the Mainnet at present. Please wait until the support is fully implemented.

In this section, we will delve into the programmability of Plasma Next, specifically focusing on ZKPTLC. For the sake of explanation, let's refer to the sender as Alice and the recipient as Bob. Transfers in Plasma Next are conducted through a payment channel from Alice to the operator and then via an airdrop (bulk transfer) from the operator to Bob.

In the payment channel from Alice to the operator, both parties sign a structure called Payment, defined as follows:

struct Payment {
    bytes32 uniqueIdentifier;
    address user;
    uint32 round;
    uint32 nonce;
    Assets userBalance;
    Assets operatorBalance;
    Assets airdropped; 
    Assets spentDeposit; 
    uint64 latestEbn; 
    address zkptlcAddress;
    bytes32 zkptlcInstance;
}

zkptlcAddress specifies the address of a ZKPTLC. zkptlcInstance is a fixed value inputted into the ZKPTLC. zkptlcAddress and zkptlcInstance become necessary only in the event of a dispute between Alice and the operator. If the transaction proceeds without any disputes, the zkptlcAddress and zkptlcInstance are either cleared or replaced with a different zkptlcAddress and zkptlcInstance in the next payment.

The ZKPTLC must have the following interface.

function verifyCondition(
    bytes32 instance,
    bytes memory witness
) external view;

Here, the instance is input directly as zkptlcInstance. witness is additional data required to verify that the state of the ZKPTLC has been satisfied. While the instance is a fixed value, witness is variable.

Default ZKPTLC

Here, we introduce the most basic ZKPTLC that is used by default in Plasma Next. This ZKPTLC verifies the existence of an airdrop from the operator to Bob (the receiver) and only allows the settlement of the payment if the verification is successful. The procedure for Alice to send funds X to Bob is as follows:

  1. Alice creates a transfer of funds X from the operator to Bob.

  2. Alice calculates the hash value of the transfer, specifies it in the ZKPTLC instance, prepares a Payment to the operator including X + δ (δ is the transaction fee), signs it, and hands it over to the operator.

  3. Once the operator agrees to Alice's Payment, the operator actually airdrops the specified transfer to Bob and return the Payment with the operator's signature to Alice. The operator also provides Alice with the Merkle Proof of the transfer as evidence of the airdrop to Bob.

  4. Alice verifies the Merkle Proof and, if convinced that the transfer to Bob was successful, does nothing further. For the next transfer, she will specify a different ZKPTLC instance.

  5. In case of a dispute about whether the operator actually performed the airdrop to Bob, the operator is obligated to execute the ZKPTLC with evidence of the airdrop to Bob and prove that the verification passes. If the operator cannot pass the ZKPTLC verification within a specified period, the Payment will be settled in favor of Alice's claim.

Here is the complete code for DefaultZKPTLC.

The complete code of Default ZKPTLC
/// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;

import {IZKPTLC} from "../common-interface/IZKPTLC.sol";
import {ITransfer} from "../common-interface/ITransfer.sol";
import {TransferLib} from "../utils/TransferLib.sol";
import {IRootManager} from "../root-manager/IRootManager.sol";
import {ITransfer} from "../common-interface/ITransfer.sol";
import {IMerkleProof} from "../common-interface/IMerkleProof.sol";
import {TransferLib} from "../utils/TransferLib.sol";

contract DefaultZKPTLC is IZKPTLC {
    using TransferLib for ITransfer.Transfer;

    address public rootManagerAddress;

    constructor(address _rootManagerAddress) {
        rootManagerAddress = _rootManagerAddress;
    }

    function computeInstance(
        ITransfer.Transfer memory transfer
    ) external pure returns (bytes32) {
        return transfer.transferCommitment();
    }

    function _verifyExistence(
        ITransfer.Transfer memory transfer,
        IMerkleProof.EvidenceWithMerkleProof memory proof
    ) internal view {
        if (transfer.transferCommitment() != proof.leaf.transferCommitment) {
            revert("Transfer commitment does not match");
        }
        IRootManager(rootManagerAddress).verifyEvidenceMerkleProof(proof);
    }

    struct Witness {
        ITransfer.Transfer transfer;
        IMerkleProof.EvidenceWithMerkleProof proof;
    }

    function encodeWitness(
        Witness memory witness
    ) external pure returns (bytes memory) {
        return abi.encode(witness);
    }

    function verifyCondition(
        bytes32 instance,
        bytes memory witness
    ) external view {
        Witness memory w = abi.decode(witness, (Witness));
        if (instance != w.transfer.transferCommitment()) {
            revert("Invalid instance");
        }
        _verifyExistence(w.transfer, w.proof);
    }
}

Here, we compute an instance of ZKPTLC. This time, since the actual transfer from the operator to Bob is a condition for the ZKPTLC verification, we adopt the hash value of the transfer as the instance.

function computeInstance(
    ITransfer.Transfer memory transfer
) external pure returns (bytes32) {
    return transfer.transferCommitment();
}

Here, we verify that the transfer from the operator to Bob has been made. The ZKPs that the specified transfer is included in Plasma Next Blocks is batched in a Merkle Tree called the evidence tree. The ZKP is aggregated through recursive ZKP, and the RootManager verifies the ZKP along with the root of the evidence tree. Therefore, if the Merkle path of the evidence tree is provided, it is possible to prove that the transfer is included in a Plasma Next Block by querying the RootManager.

function _verifyExistence(
    ITransfer.Transfer memory transfer,
    IMerkleProof.EvidenceWithMerkleProof memory proof
) internal view {
    if (transfer.transferCommitment() != proof.leaf.transferCommitment) {
        revert("Transfer commitment does not match");
    }
    IRootManager(rootManagerAddress).verifyEvidenceMerkleProof(proof);
}

Here, we specify the contents of the witness as a struct. The witness consists of the transfer from the operator to Bob and the Merkle proof of the evidence tree.

struct Witness {
    ITransfer.Transfer transfer;
    IMerkleProof.EvidenceWithMerkleProof proof;
}

This is the implementation of verifyCondition, which is the interface of ZKPTLC. The witness is decoded as the Witness structure mentioned above, and after confirming that the transfer within the witness matches the instance, it is passed to the _verifyExistence function.

function verifyCondition(
    bytes32 instance,
    bytes memory witness
) external view {
    Witness memory w = abi.decode(witness, (Witness));
    if (instance != w.transfer.transferCommitment()) {
        revert("Invalid instance");
    }
    _verifyExistence(w.transfer, w.proof);
}

Last updated