Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-7573: Conditional-upon-Transfer-Decryption for DvP

A Protocol for Secure Delivery-versus-Payment across two Blockchains

Authors Christian Fries (@cfries), Peter Kohl-Landgraf (@pekola)
Created 2023-12-05
Discussion Link https://ethereum-magicians.org/t/eip-7573-conditional-upon-transfer-decryption-for-delivery-versus-payment/17232

Abstract

The interfaces in this proposal model a functional transaction scheme to establish a secure delivery-versus-payment across two blockchains, where a) no intermediary is required and b) one of the two chains can securely interact with a stateless “decryption oracle”. Here, delivery-versus-payment refers to the exchange of, e.g., an asset against a payment; however, the concept is generic to make a transfer of one token on one chain (e.g., the payment) conditional to the successful transfer of another token on another chain (e.g., the asset).

The scheme is realized by two smart contracts, one on each chain. One smart contract implements the ILockingContract interface on one chain (e.g. the “asset chain”), and another smart contract implements the IDecryptionContract interface on the other chain (e.g., the “payment chain”). An implementation that generates the encrypted keys asynchronously may additionally implement IDecryptionContractWithKeyGeneration. On the same chain, an asset contract may implement ILockingContractWithKeyGeneration and authorize that decryption contract as its key source before the keys exist. An on-chain consumer may implement IDecryptionContractInceptionCallback to receive an asynchronous completion notification and then read the immutable inception context and key material from standardized getters. The smart contract implementing ILockingContract locks a token (e.g., the asset) on its chain until a presented key’s hash or other locking representation matches one of two committed values. The smart contract implementing IDecryptionContract, decrypts one of two keys (via the decryption oracle) conditional to the success or failure of the token transfer (e.g., the payment). A stateless decryption oracle is attached to the chain running IDecryptionContract for the decryption.

In addition, there are two interfaces that standardize the communication with external decryption oracle(s):

  • IKeyDecryptionOracle.sol is implemented by a decryption oracle proxy contract (on-chain router/proxy for an off-chain oracle).
  • IKeyDecryptionOracleCallback.sol is implemented by a callback receiving the decrypted key (or derived verification material).

Fulfillment semantics note: The oracle proxy may implement either (i) strict fulfillment (reverting fulfill* when the callback fails) or (ii) best-effort fulfillment (not reverting fulfill* on callback failure, but signaling failure via events). This proposal describes both modes and their operational trade-offs.

Motivation

Within the domain of financial transactions and distributed ledger technology (DLT), the Hash-Linked Contract (HLC) concept has been recognized as valuable and has been thoroughly investigated. The concept may help to solve the challenge of delivery-versus-payment (DvP), especially in cases where the asset chain and payment system (which may be a chain, too) are separated. A prominent application of smart contracts realizing a secure DvP is that of buying an asset, where the asset is managed on one chain (the asset chain), but the payments are executed on another chain (the payment chain). Proposed solutions are based on an API-based interaction mechanism which bridges the communication between a so-called asset chain and a corresponding payment system or requires complex and problematic time locks.1

Here, we propose a protocol that facilitates secure delivery-versus-payment with less overhead, especially with a stateless oracle.2

Specification

Methods

Smart Contract on the chain that performs the locking (e.g. the asset chain)

The following methods specify the functionality of the smart contract implementing the locking. For further information, please also look at the interface documentation ILockingContract.sol.

Initiation of Transfer: inceptTransfer
function inceptTransfer(
    uint256 id,
    int amount,
    address from,
    address to,
    bytes memory transaction,
    bytes memory keyHashedSeller,
    bytes memory keyEncryptedSeller
) external;

Initiates token transfer and emits a TransferIncepted event. The parameter id is the lifetime-unique identifier of this transfer leg. The parameters from and to are the seller and buyer, respectively. The parameter transaction contains immutable application-specific transfer data. The parameter keyHashedSeller is a hash of the key that can be used by the seller to (re-)claim the token. The parameter keyEncryptedSeller is an encryption of the key that can be used by the seller to (re-)claim the token. It is possible to implement the protocol in a way where the hashing method agrees with the encryption method. See below on “encryption”.

Confirmation of Transfer: confirmTransfer
function confirmTransfer(
    uint256 id,
    int amount,
    address from,
    address to,
    bytes memory transaction,
    bytes memory keyHashedBuyer,
    bytes memory keyEncryptedBuyer
) external;

Confirms token transfer, locks the token, and emits a TransferConfirmed event. The parameters id, amount, from, to, and transaction MUST exactly match the immutable inception. The parameter keyHashedBuyer is a hash of the key that can be used by the buyer to (re-)claim the token. The parameter keyEncryptedBuyer is an encryption of the key that can be used by the buyer to (re-)claim the token. It is possible to implement the protocol in a way where the hashing method agrees with the encryption method. See below on “encryption”.

If the trade specification, that is, (id, amount, from, to, transaction), in a call to confirmTransfer matches that of a previous call to inceptTransfer, and the balance is sufficient, the corresponding amount of tokens is locked (transferred from from to the smart contract) and TransferConfirmed is emitted.

Both methods receive from and to explicitly. msg.sender identifies only the caller and MUST NOT, by itself, determine either participant. Implementations MAY require the caller to be a participant or an authorized operator.

Cancellation of Transfer: cancelTransfer
function cancelTransfer(
    uint256 id,
    int amount,
    address from,
    address to,
    bytes memory transaction,
    bytes memory keyHashedSeller,
    bytes memory keyEncryptedSeller
) external;

Cancels an incepted token transfer before confirmation. Every argument MUST exactly match the immutable inception. Implementations MAY require the caller to be a participant or an authorized operator. Cancellation does not permit the id to be reused for another transfer.

Transfer: transferWithKey
function transferWithKey(uint256 id, bytes memory key) external;

The key may be submitted by the buyer, seller, a decryption contract, or another relayer. An implementation may restrict callers. Where the participants and terminal destinations are already stored, however, the matching key can provide the authorization and msg.sender need not be a trade participant.

Subject to the implementation’s caller policy, if the hashing of key matches keyHashedBuyer, the locked tokens are transferred to the stored buyer (to). This emits TokenClaimed.

Subject to the implementation’s caller policy, if the hashing of key matches keyHashedSeller, the locked tokens are transferred (back) to the stored seller (from). This emits TokenReclaimed.

Summary

The interface ILockingContract:

interface ILockingContract {
    event TransferIncepted(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes transaction,
        bytes keyHashedSeller,
        bytes keyEncryptedSeller
    );
    event TransferConfirmed(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes transaction,
        bytes keyHashedBuyer,
        bytes keyEncryptedBuyer
    );
    event TokenClaimed(uint256 id, bytes key);
    event TokenReclaimed(uint256 id, bytes key);

    function inceptTransfer(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes memory transaction,
        bytes memory keyHashedSeller,
        bytes memory keyEncryptedSeller
    ) external;
    function confirmTransfer(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes memory transaction,
        bytes memory keyHashedBuyer,
        bytes memory keyEncryptedBuyer
    ) external;
    function cancelTransfer(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes memory transaction,
        bytes memory keyHashedSeller,
        bytes memory keyEncryptedSeller
    ) external;
    function transferWithKey(uint256 id, bytes memory key) external;
}
Optional Same-Chain Locking with Generated Keys

ILockingContractWithKeyGeneration adds a generated-key inception for deployments where both contracts can call each other.

interface ILockingContractWithKeyGeneration is
    ILockingContract,
    IDecryptionContractInceptionCallback
{
    event TransferInceptedWithKeyGeneration(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes transaction,
        IDecryptionContractWithKeyGeneration decryptionContract
    );

    function inceptTransfer(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes memory transaction,
        IDecryptionContractWithKeyGeneration decryptionContract
    ) external;

    function completeLock(uint256 id) external;
    function cancelTransfer(uint256 id) external;
}

The generated-key inception supplies the seller (from), buyer (to), and application transaction explicitly. msg.sender identifies only the caller and MUST NOT, by itself, determine either participant. Implementations MAY require the caller to be a participant or an authorized operator. Instead of supplying unavailable failure-key material, the call authorizes one exact decryption contract as the immutable key source. After the corresponding asynchronous inceptTransfer is submitted to that decryption contract, a direct onInceptionCompleted callback can invoke the same internal transition as completeLock.

completeLock MAY be permissionless because its caller supplies no participants or key material and conveys no authority. The locking contract MUST load the stored decryption contract, require explicit inception existence and key availability, match the unique leg identifier, transaction, buyer/seller context, and exact callback address to itself, and consume the inception at most once. The resulting transition confirms and locks the asset; it does not complete the terminal transfer, which remains the responsibility of transferWithKey. In a same-chain deployment, the decryption contract MAY call transferWithKey on the exact callback-bound locking contract after releasing a terminal key. Such a relay MUST be best-effort: the decryption contract records and exposes its terminal outcome before the external call, and a locking-contract failure MUST NOT roll that outcome back. A permitted relayer can retry with the released key. If the asset inception is cancelled while key generation is pending, the locking contract MUST retain a tombstone and acknowledge a later matching authenticated callback without locking, so the decryption contract can complete and expose the failure-key path. An implementation accepting arbitrary interface-typed decryption contracts is unsafe: it MUST use a trusted implementation, an allowlist, or an independently authenticated seller authorization.

Smart Contract on the other chain that performs the conditional decryption (e.g. the payment chain)

The following methods specify the functionality of the smart contract implementing the conditional decryption. For further information, please also look at the interface documentation IDecryptionContract.sol.

Initiation of Transfer: inceptTransfer
function inceptTransfer(
    uint256 id,
    int amount,
    address from,
    address to,
    bytes memory transaction,
    bytes memory keyEncryptedSuccess,
    bytes memory keyEncryptedFailure
) external;

Initiates payment transfer and emits a TransferIncepted event. The parameter id is the lifetime-unique identifier of this transfer leg. The parameters from and to are the sender and receiver of the payment, respectively. The parameter transaction contains immutable application-specific transfer data. The parameter keyEncryptedSuccess is an encryption of a key and will be decrypted if the transfer is successful in a call to transferAndDecrypt. The parameter keyEncryptedFailure is an encryption of a key and will be decrypted if the transfer fails in a call to transferAndDecrypt or if cancelAndDecrypt is successful.

Application Initialization and Transfer Identity

ERC-7573 does not standardize an initTransfer method. Before the first ERC-7573 call, the application workflow MUST allocate an identifier for each transfer leg and bind the arbitrary application data carried in transaction. Each implementation MUST treat that identifier as lifetime-unique within the contract. Corresponding locking and decryption contracts MAY use the same numeric id to correlate the two sides of one DvP operation. How the parties establish that application-level agreement is outside this interface.

The first valid inception stores id, amount, from, to, transaction, and its key references as immutable state. The implementation MUST reject any later inception that reuses the id, including after completion or cancellation. A multi-party or group identifier MUST be encoded inside transaction; distinct transfer legs handled by the same implementation MUST NOT share an ERC-7573 id. Oracle operations continue to use their separate oracle-assigned requestId for callback correlation.

The from and to participants are supplied explicitly. msg.sender identifies only the caller and MUST NOT, by itself, determine either participant. Implementations MAY require the caller to be a participant or an authorized operator. Because id identifies one immutable leg and confirmation and cancellation repeat the complete transfer context—including the asynchronous callback binding or zero—and both key references, separate inception and confirmation hashes add no commitment and are omitted. Implementations MUST compare every repeated argument, including dynamic byte strings, with the stored inception before applying a state transition.

The success/failure key order is normative. Implementations receiving an unordered key batch MUST select the keys by their semantic identifiers and MUST NOT use callback array position. The two outcome references MUST identify distinct keys. An implementation MUST reject equal encrypted references or equal locking representations where those values are directly comparable; otherwise success and failure cannot produce unambiguous terminal outcomes.

Asynchronous Initiation of Transfer: inceptTransfer

The optional IDecryptionContractWithKeyGeneration interface extends IDecryptionContract with one asynchronous function that does not require keys to exist at inception:

function inceptTransfer(
    uint256 id,
    int amount,
    address from,
    address to,
    bytes memory transaction,
    IDecryptionContractInceptionCallback callback
) external;

The parameter transaction is the transaction specification used for asynchronous generation of the encrypted success and failure keys. The call stores id, amount, from, to, transaction, and callback as an immutable pending inception. The id MUST satisfy the same lifetime-uniqueness requirement as synchronous inception. The callback parameter MAY be address(0) when event-based off-chain continuation is sufficient. Solidity callers express this as IDecryptionContractInceptionCallback(address(0)); ABI callers supply the full twenty-byte zero address. Any nonzero callback MUST implement IDecryptionContractInceptionCallback and provide an on-chain completion trigger. The generated encrypted and hashed success/failure key material MUST be validated and stored atomically with the pending inception and MUST NOT be replaceable afterwards. In the same state transition, the contract MUST select the success and failure keys by semantic role and complete the inception. The contract MUST emit TransferIncepted only after both keys have been stored.

If callback is not address(0), after storing the complete state and emitting TransferIncepted, the decryption contract MUST call:

callback.onInceptionCompleted(id)

Before this call, the decryption contract MUST make the complete inception context and immutable key material readable through getInceptionContext(id) and getInceptionKeyMaterial(id). The getters return explicit exists and available values; callers MUST NOT infer either state from zero or empty values. The callback MUST authenticate the calling decryption contract, match id and every semantically corresponding context field to a pending local operation, require available, and verify the immutable key material before applying effects. Corresponding asset and payment legs MAY use different amounts, so an asset receiver matches the reversed participants, transaction, and callback binding rather than requiring its asset amount to equal the payment amount. The success/failure ordering of the getter is normative. The callback MUST return IDecryptionContractInceptionCallback.onInceptionCompleted.selector. If the callback reverts, runs out of its bounded gas allowance, or returns any other value, the completion attempt MUST revert and leave the inception pending for the asynchronous fulfillment mechanism to retry. If callback is address(0), the decryption contract MUST skip callback delivery. No additional readiness event is required because TransferIncepted already signals that the keys are final.

Calls to confirmTransfer, transferAndDecrypt, and cancelAndDecrypt for a pending inception MUST revert until then. The key-generation mechanism is outside the scope of this interface.

Confirmation of Transfer: confirmTransfer
function confirmTransfer(
    uint256 id,
    int amount,
    address from,
    address to,
    bytes memory transaction,
    IDecryptionContractInceptionCallback callback,
    bytes memory keyEncryptedSuccess,
    bytes memory keyEncryptedFailure
) external;

Confirms a completed payment inception and emits a TransferConfirmed event. Every argument MUST exactly match the immutable completed inception. callback MUST equal the stored asynchronous callback, or address(0) for a synchronous inception. The from and to participants MUST be supplied explicitly. msg.sender identifies only the caller and MUST NOT, by itself, determine either participant. Implementations MAY require the caller to be a participant or an authorized operator and MUST validate the lifecycle state.

For a two-party DvP, a successful confirmation MAY directly invoke the internal finalization logic. For a multi-party DvP, the implementation MUST freeze the expected leg set and finalizer policy before accepting the first confirmation; confirmation then marks this payment leg as confirmed without finalizing the group. The implementation MUST authorize the finalizer independently of the explicit transfer participants.

Transfer: transferAndDecrypt
function transferAndDecrypt(uint256 id) external;

Called by an authorized finalizer or operator to initiate completion of the confirmed payment transfer or multi-party DvP. Emits a TransferKeyRequested with the encrypted key selected by the completion result. The method loads the confirmed leg or group state and its immutable transfer values from storage. It MUST reject the call unless the caller satisfies the configured finalizer policy, the selected leg is confirmed, every required leg in any frozen group is confirmed, and neither execution nor cancellation has already been requested. No additional context is needed at finalization because the lifetime-unique id selects immutable confirmed state.

Cancellation of Transfer: cancelAndDecrypt
function cancelAndDecrypt(
    uint256 id,
    int amount,
    address from,
    address to,
    bytes memory transaction,
    IDecryptionContractInceptionCallback callback,
    bytes memory keyEncryptedSuccess,
    bytes memory keyEncryptedFailure
) external;

Cancels the specific payment transfer and requests its failure key. Every argument MUST exactly match the immutable completed inception. Implementations MAY require the caller to be a participant or an authorized operator. If these preconditions are met and a valid call to transferAndDecrypt has not been issued before, i.e. if the stored success key has not been issued in a TransferKeyRequested event, then this method emits a TransferKeyRequested with the stored failure key.

Release of ILockingContract Access Key: releaseKey
function releaseKey(uint256 id, bytes memory key) external;

Called from the (possibly external) decryption oracle.

Emits the event TransferKeyReleased with the value of key if the call was eligible.

Summary

The interface IDecryptionContract:

interface IDecryptionContract {
    event TransferIncepted(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes transaction,
        bytes keyEncryptedSuccess,
        bytes keyEncryptedFailure
    );
    event TransferConfirmed(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes transaction,
        IDecryptionContractInceptionCallback callback,
        bytes keyEncryptedSuccess,
        bytes keyEncryptedFailure
    );
    event TransferKeyRequested(address sender, uint256 id, bytes encryptedKey);
    event TransferKeyReleased(address sender, uint256 id, bool success, bytes key);

    function inceptTransfer(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes memory transaction,
        bytes memory keyEncryptedSuccess,
        bytes memory keyEncryptedFailure
    ) external;
    function confirmTransfer(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes memory transaction,
        IDecryptionContractInceptionCallback callback,
        bytes memory keyEncryptedSuccess,
        bytes memory keyEncryptedFailure
    ) external;
    function transferAndDecrypt(uint256 id) external;
    function cancelAndDecrypt(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes memory transaction,
        IDecryptionContractInceptionCallback callback,
        bytes memory keyEncryptedSuccess,
        bytes memory keyEncryptedFailure
    ) external;
    function releaseKey(uint256 id, bytes memory key) external;
}

The optional key-generation extension:

interface IDecryptionContractInceptionCallback {
    function onInceptionCompleted(uint256 id) external returns (bytes4 acknowledgement);
}

interface IDecryptionContractWithKeyGeneration is IDecryptionContract {
    function inceptTransfer(
        uint256 id,
        int amount,
        address from,
        address to,
        bytes memory transaction,
        IDecryptionContractInceptionCallback callback
    ) external;

    function getInceptionContext(uint256 id) external view returns (
        bool exists,
        int amount,
        address from,
        address to,
        bytes memory transaction,
        IDecryptionContractInceptionCallback callback
    );

    function getInceptionKeyMaterial(uint256 id) external view returns (
        bool available,
        bytes memory keyEncryptedSuccess,
        bytes memory keyHashedSuccess,
        bytes memory keyEncryptedFailure,
        bytes memory keyHashedFailure
    );
}

Interfaces to External Decryption Oracles (Oracle Proxy + Callback)

This proposal additionally standardizes the on-chain interaction with external (off-chain) decryption oracles via:

  • IKeyDecryptionOracle (oracle proxy / router)
  • IKeyDecryptionOracleCallback (consumer callback)

The general flow is:

  1. The consumer calls request* on the oracle proxy contract (payable), receives the oracle-assigned requestId, and stores its operation context under (oracleProxy, requestId).
  2. The oracle proxy emits a request event containing the same requestId and the consumer-supplied id.
  3. The off-chain oracle observes the request event, performs decryption or verification off-chain, and calls fulfill* on the proxy.
  4. The oracle proxy calls the consumer callback on* with requestId and the fulfillment payload.

The requestId MUST be unique within its oracle proxy. Request methods MUST return before attempting the corresponding callback so the consumer can store the returned identifier first. The oracle method’s consumer-supplied context id remains event context with consumer-defined semantics and MAY be the ERC-7573 transfer-leg identifier. It is independent of, and MUST NOT be confused with, the oracle proxy’s separately assigned requestId.

This changes the meaning, but not the ABI type, of the callback’s first uint256 argument. Existing callback implementations that interpret it as the consumer-supplied id MUST be migrated before use with a requestId-based oracle proxy; the unchanged function selector does not provide a version boundary.

Batch key generation

Key generation is batch-oriented. The consumer calls requestGenerateEncryptedHashedKeys with a non-empty array of distinct keyIds. Each keyId identifies the semantic role of one generated key; a request for a single key uses a one-element array, so no separate singular method is required.

struct EncryptedHashedKey {
    bytes32 keyId;
    bytes encryptedKey;
    bytes hashedKey;
}

function requestGenerateEncryptedHashedKeys(
    uint256 id,
    IKeyDecryptionOracleCallback callback,
    address receiverContract,
    bytes calldata transaction,
    bytes32[] calldata keyIds
) external payable returns (uint256 requestId);

function fulfillEncryptedHashedKeysGeneration(
    uint256 requestId,
    IKeyDecryptionOracleCallback.EncryptedHashedKey[] calldata keys,
    address receiverContract,
    bytes calldata transaction
) external;

function onEncryptedHashedKeysGenerated(
    uint256 requestId,
    EncryptedHashedKey[] calldata keys,
    address receiverContract,
    bytes calldata transaction
) external;

The fulfillment MUST contain exactly one result for every requested keyId: duplicate, missing, or unrequested identifiers MUST be rejected. Array ordering has no semantic meaning. The oracle proxy MUST also verify that receiverContract and transaction match the request before invoking onEncryptedHashedKeysGenerated once with the complete batch. It MUST NOT deliver a partial batch.

Implementations MUST enforce a documented finite maximum generation batch size. Every generated reference MUST authenticate its keyId and the same unique, one-use settlement context, bound to the requesting contract, lifetime-unique transfer id, and full generation context. The consuming contract MUST enforce lifetime non-reuse of that context. Generation that binds only recurring trade economics is replayable even if the complete key pair is later verified.

Batch key verification

Verification is likewise batch-oriented and atomic. The request contains the encrypted keys together with their semantic roles; a one-element batch covers the singular case.

struct EncryptedKey {
    bytes32 keyId;
    bytes encryptedKey;
}

function requestVerifyEncryptedKeys(
    uint256 id,
    EncryptedKey[] calldata keys,
    IKeyDecryptionOracleCallback callback
) external payable returns (uint256 requestId);

function fulfillEncryptedKeysVerification(
    uint256 requestId,
    bool verified,
    IKeyDecryptionOracleCallback.EncryptedHashedKey[] calldata keys,
    address receiverContract,
    bytes calldata transaction
) external;

function onEncryptedKeysVerificationCompleted(
    uint256 requestId,
    bool verified,
    EncryptedHashedKey[] calldata keys,
    address receiverContract,
    bytes calldata transaction
) external;

The proxy MUST reject an empty request, duplicate keyId values, empty encrypted keys, and oversized batches. It MUST retain or commit to the exact requested (keyId, encryptedKey) set. A fulfillment MUST echo exactly that complete set, independent of array order, and MUST be delivered in one callback. Missing, additional, substituted, or partially verified entries MUST be rejected.

verified is the authoritative batch result; consumers MUST NOT infer it from empty values. If it is true, every returned hash MUST be non-empty and every encrypted key MUST have verified against the same receiverContract and transaction. If it is false, the receiver MUST be address(0), the transaction MUST be empty, and the entire batch is rejected. Per-key success is deliberately not represented.

Every encrypted reference MUST authenticate its keyId and a common, unique, one-use settlement context, such as (chainId, decryptionContract, id), in addition to the common external transaction/batch identifier. Merely returning an array does not prevent replay of an old complete key pair. Verification-only access to a reference MUST NOT itself authorize decryption or release of that key.

encryptedKey is the protocol’s portable key reference; confidentiality of that reference is not required. An integration MAY use a publicly readable, versioned and signed byte sequence that identifies the external settlement and authenticates the fields above. This lets another participant use its own oracle adapter to verify every outcome reference. The actual key MUST remain undisclosed, and decryption/release authority MUST be enforced independently of possession or readability of the reference.

Decryption remains a singular operation. A DvP outcome releases exactly one of the verified outcome keys; an array decryption API would weaken that exclusivity invariant.

Callback execution semantics: strict vs best-effort

Implementations MAY choose one of the following fulfillment semantics. Both are compatible with this proposal.

Strict fulfillment (reverting)

In strict fulfillment, the proxy MUST revert fulfill* if the callback call fails (including OOG).

Properties:

  • The off-chain oracle operator can treat receipt.status == 1 as “callback succeeded”.
  • If receipt.status == 0, the request is not fulfilled and can be retried (e.g., with higher tx gas limit).
  • The proxy MUST ensure that request state is not lost on revert (e.g., by relying on revert rollback of state changes).

This mode is operationally simple for closed deployments where the off-chain oracle and the consumer are coordinated and where failure handling/retries are primarily managed by the oracle operator.

Best-effort fulfillment (non-reverting)

In best-effort fulfillment, the proxy MUST NOT revert fulfill* solely because the callback call fails (including OOG). Instead, it SHOULD signal callback outcome via events (e.g. CallbackSucceeded / CallbackFailed). CallbackFailed carries (requestId, callback, selector, consumerId, reason); reason contains callback revert or return data when available and MAY be empty, including after OOG.

Properties:

  • The off-chain oracle operator MUST NOT interpret receipt.status == 1 as “callback succeeded”; it must also evaluate the emitted outcome signal.
  • Failure handling can be shifted to the callback implementer/operator: a consumer can run an off-chain watcher that subscribes to CallbackFailed and reacts accordingly (e.g. pull/consume flow, re-request, alerting).
  • The proxy may either keep the request pending for retries or consume it and shift retry responsibility to the consumer. The chosen policy SHOULD be documented by the implementation.

This mode is useful when the proxy wants to provide an on-chain observable audit trail for callback failures and to decouple “oracle fulfillment” from “consumer processing”.

Gas budgeting and forwarding

  • The off-chain oracle controls the total cost of fulfill* by setting the transaction gas limit.
  • The proxy MAY cap or budget the gas forwarded to the callback (e.g., by forwarding “all but a reserve”).
  • Keeping a small gas reserve in the proxy can help ensure the proxy can finalize fulfill* and emit outcome events even if the callback consumes most forwarded gas.

Calldata fallback (retrieving fulfillment payload without logging)

In either strict or best-effort mode, the fulfill* payload is present in the transaction input calldata of the fulfill* call.

Implementations SHOULD document the following operational fallback:

  • Off-chain systems that observe an event (e.g., CallbackFailed) can use the event’s transactionHash to fetch the corresponding transaction and decode the input calldata using the IKeyDecryptionOracle ABI to recover the fulfillment arguments.
  • Practical caveat: Some RPC providers prune old transaction bodies; indexers SHOULD persist decoded fulfillment payload off-chain if long-term retention is required.

This fallback can be used to support consumer-side “pull/consume” flows, or as a recovery mechanism when callback execution fails.

Encryption and Decryption

The linkage of the two smart contracts relies on use of a key, encryptedKey and hashedKey. For compatibility the field is named encryptedKey, but it may contain either ciphertext or an authenticated external-system key reference. The implementation is free to support several encodings as long as the decryption oracle supports them and key-release authorization does not depend merely on keeping the reference confidential.

The encryption is performed with the public key of the decryption oracle. Either the encryption oracle offers a method performing encryption, in which case the encryption method isn’t even required to be known, or both parties know the public key of the decryption oracle and can perform the generation of the key and its encryption.

It is implicitly assumed that the two parties may check that the strings keyEncryptedBuyer and keyEncryptedSeller are in a valid format.

To avoid on-chain encryption in the ILockingContract, it is possible to use a simpler hashing algorithm on the ILockingContract. In that case, the decryption oracle has to provide a method that allows to obtain the hash H(K) (keyHashed) for an encrypted key E(K) (keyEncrypted) without exposing the key K (``key`), cf. 2.

Sequence diagram of delivery versus payment

The interplay of the two smart contracts is summarized in the following sequence diagram:

sequence diagram dvp

The method declarations above are normative; the diagram illustrates the protocol flow and predates the current explicit-argument and requestId-based callback APIs.

Rationale

Each implementation treats id as a lifetime-unique identifier for one transfer leg and MUST reject reuse even after that leg completes or is cancelled. Corresponding locking and decryption contracts may use the same numeric id for the two sides of that operation. Applications correlate several distinct legs by encoding a shared group identifier and any other group definition in each leg’s immutable transaction data. Oracle requests use a separate, oracle-assigned requestId.

The key and the encryptedKey arguments are strings to allow the flexible use of different encryption schemes. The decryption/encryption scheme should be inferable from the contents of the encryptedKey.

Ensuring Secure Key Decryption - Key Format

It has to be ensured that the decryption oracle decrypts a key only for the eligible contract.

It seems as if this would require us to introduce a concept of eligibility to the decryption oracle, which would imply a kind of state.

A fully stateless decryption can be realized by introducing a document format for the key and a corresponding eligibility verification protocol. We propose the following elements:

  • The (unencrypted) key documents contain the address of the payment contract implementing IDecryptionContract.
  • The decryption oracle offers a stateless batch function verify that receives role-tagged encrypted keys and returns their hashes and common transaction context without returning any decrypted key. Every key must bind the same callback/receiver and unique settlement context.
  • When an encrypted key is presented to the decryption oracle, the oracle decrypts the document and passes the decrypted key to releaseKey of the callback contract address found within the document decrypted key.

We propose the following XML schema for the document of the decrypted key:

<?xml version="1.0" encoding="utf-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" targetNamespace="http://finnmath.net/erc/ILockingContract" xmlns:xs="http://www.w3.org/2001/XMLSchema">
    <xs:element name="releaseKey">
        <xs:complexType>
            <xs:simpleContent>
                <xs:extension base="xs:string">
                    <xs:attribute name="contract" type="xs:string" use="required" />
                    <xs:attribute name="transaction" type="xs:unsignedShort" use="required" />
                </xs:extension>
            </xs:simpleContent>
        </xs:complexType>
    </xs:element>
</xs:schema>

A corresponding XML sample is shown below.

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<releaseKey contract="eip155:1:0x1234567890abcdef1234567890abcdef12345678" transaction="3141" xmlns="http://finnmath.net/erc/ILockingContract">
    <!-- random data -->
    zZsnePj9ZLPkelpSKUUcg93VGNOPC2oBwX1oCcVwa+U=
</releaseKey>

Multi-Party Delivery versus Payment

Locking is a Feature

In Delivery-versus-Payment (DvP) protocols like ERC-7573, at least one token must be locked to ensure atomicity, even if only for a short period during the transaction.

While locking may appear as an inconvenient necessity, it is in fact a feature that becomes valuable in the construction of conditional trades or multi-party DvPs.

If n parties wish to perform bilateral transactions atomically, there are at least m := 2 • (n - 1) transactions, of which m-1 require locking. The last one can operate directly, and its success or failure decides whether the other locks are released or reverted.

A multi-party delivery versus payment is a valuable trade feature. Consider, for example, the case where counterparty A wishes to buy a token Y (e.g., a bond) from counterparty C, but in order to fund this transaction, counterparty A wishes to sell a token X (e.g., another bond) to counterparty B. However, A does not want to sell bond X if the purchase of Y fails. A multi-party DvP allows these two transactions to be bound into a single atomic unit.

While for a two-party DvP with two tokens only one token requires locking—and hence a DvP can be constructed without locking on the cash chain—a three-party DvP with three tokens in general requires the ability to lock all three tokens.

This highlights that locking is not just a constraint, but a required feature to enable advanced and economically meaningful protocols.

N-DvP with ERC-7573

A multi-party DvP can be created elegantly by combining multiple (n-1) two-party DvPs, for example based on the ERC-7573 protocol.

Every payment leg in the group uses its own lifetime-unique ERC-7573 id. A common group identifier and any frozen group definition are encoded in the immutable transaction data of each leg. Instead of finalizing the respective two-party DvP immediately, each completed payment leg is first confirmed by repeating its complete context, callback binding (or zero), and both key references, leaving group finalization open.

At any time before finalization, an authorized submitter can call cancelAndDecrypt with that leg’s complete matching context and key references to release the failure key and revert all lockings.

Before accepting the first confirmation, the implementation MUST freeze the expected set of payment legs and the finalizer policy for the group. It MUST NOT add a leg after that point or treat merely all currently registered legs as a complete group. Every leg MUST be bound to the same group outcome: an implementation MAY use one shared success/failure key pair, or it MUST store and request the complete frozen set of per-leg outcome keys.

The asynchronous inception callback remains singular and is part of the immutable inception context. Under ILockingContractWithKeyGeneration, each asset lock MUST consume an inception that binds that exact asset as callback; one callback-bound inception cannot be replayed across several asset contracts. A future coordinator fan-out extension would need to commit the complete frozen asset set and define one-shot group completion explicitly. An unbounded callback array is not required by this interface.

Once every leg in the frozen set is confirmed, a call to transferAndDecrypt(id) on the designated coordinating leg performs locking of the token implementing the IDecryptionContract and requests all success keys on success or all failure keys on failure.

Initiation and Finalization

The frozen group definition MUST determine the from and to participants and establish which submitters are authorized to finalize the group via transferAndDecrypt. msg.sender identifies only the finalization caller and MUST NOT, by itself, determine either participant. The finalizer policy MAY authorize a participant or another operator.

Sequence Diagram

Below we depict the corresponding sequence diagram of a multi-party DvP via ERC-7573. Note that the individual DvP may come in two different flavors depending on which counterparty is the receiver of the token on the IDecryptionContract.

The diagram depicts a multi-party dvp with n+1 counterparties trading n+1 tokens out of which the DvPs are bound by the contract on token 0.

sequence diagram multi party dvp

The method declarations above are normative. This historical diagram is illustrative and predates the current explicit-argument ABI.

Note: The more general case of N counterparties trading M tokens is just a special case where we enumerate all combination as new counterparties and new tokens.

Security Considerations

The decryption oracle does not need to be a single trusted entity. Instead, a threshold decryption scheme can be employed, where multiple oracles perform partial decryption, requiring a quorum of them to reconstruct the secret key. This enhances security by mitigating the risk associated with a single point of failure or trust.

In such cases, each participating decryption oracle will observe the decryption request from an emitted TransferKeyRequested event, and subsequently call the releaseKey method with a partial decryption result. The following sequence diagram illustrates this.

sequence diagram distributed oracle

See 2 for details.

Additional considerations for the oracle proxy + callback pattern:

  • Callback implementations SHOULD restrict callers (e.g. require(msg.sender == oracleProxy)), otherwise any address could invoke on* directly.
  • Callback implementations MUST validate a pending (oracleProxy, requestId) of the expected operation kind and consume or mark it before applying callback effects.
  • Oracle proxies MUST make a request unavailable to concurrent or reentrant fulfillment before invoking its callback. A best-effort proxy MAY restore the request to pending after callback failure to permit retry.
  • Batch verification MUST match the exact requested key set by keyId, authenticate every encrypted reference to the same one-use settlement and external batch context, and resolve atomically. Verification of only the success key does not authenticate the failure path.
  • A verifier’s ability to authenticate a failure-key reference MUST remain separate from authority to decrypt or release that failure key.
  • Callback implementations SHOULD be cheap and should avoid unbounded loops or expensive state changes. If heavy work is required, prefer a pull/consume pattern initiated by the consumer.
  • Oracle proxy implementations SHOULD document whether they use strict or best-effort fulfillment semantics, and how retries are intended to be handled (oracle-operated retry vs consumer-operated recovery).

For a nonzero asynchronous inception callback, implementations MUST store the completed inception before the external call, use a bounded gas allowance, validate the selector-valued acknowledgement, and rely on the asynchronous fulfillment retry path if delivery fails. The receiver MUST authenticate the expected decryption contract, match the lifetime-unique id and every semantically corresponding getter field to a pending local operation, require explicit key availability, and verify the immutable key material before applying effects. For paired asset and payment legs, the respective amounts are independent; the receiver instead matches the reversed participants, transaction, and callback binding. An ILockingContractWithKeyGeneration receiver MUST also require the getter’s callback to equal its own address; matching only the identifier and participants would permit one inception to be replayed across asset contracts.

Copyright and related rights waived via CC0.

  1.     {
          "type": "article",
          "id": 1,
          "author": [
            {
              "family": "La Rocca",
              "given": "Rosario"
            },
            {
              "family": "Mancini",
              "given": "Riccardo"
            },
            {
              "family": "Benedetti",
              "given": "Marco"
            },
            {
              "family": "Caruso",
              "given": "Matteo"
            },
            {
              "family": "Cossu",
              "given": "Stefano"
            },
            {
              "family": "Galano",
              "given": "Giuseppe"
            },
            {
              "family": "Mancini",
              "given": "Simone"
            },
            {
              "family": "Marcelli",
              "given": "Gabriele"
            },
            {
              "family": "Martella",
              "given": "Piero"
            },
            {
              "family": "Nardelli",
              "given": "Matteo"
            },
            {
              "family": "Oliviero",
              "given": "Ciro"
            }
          ],
          "DOI": "10.2139/ssrn.4386904",
          "title": "Integrating DLTs with Market Infrastructures: Analysis and Proof-of-Concept for Secure DvP between TIPS and DLT Platforms",
          "original-date": {
            "date-parts": [
              [2022, 7, 19]
            ]
          },
          "URL": "http://dx.doi.org/10.2139/ssrn.4386904"
        }
    

  2.     {
          "type": "article",
          "id": 2,
          "author": [
            {
              "family": "Fries",
              "given": "Christian"
            },
            {
              "family": "Kohl-Landgraf",
              "given": "Peter"
            }
          ],
          "DOI": "10.2139/ssrn.4628811",
          "title": "A Proposal for a Lean and Functional Delivery versus Payment across two Blockchains",
          "original-date": {
            "date-parts": [
              [2023, 11, 9]
            ]
          },
          "URL": "http://dx.doi.org/10.2139/ssrn.4628811"
        }
    

     2 3

Citation

Please cite this document as:

Christian Fries (@cfries), Peter Kohl-Landgraf (@pekola), "ERC-7573: Conditional-upon-Transfer-Decryption for DvP [DRAFT]," Ethereum Improvement Proposals, no. 7573, December 2023. Available: https://eips.ethereum.org/EIPS/eip-7573.