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.
An on-chain consumer may implement IDecryptionContractInceptionCallback to receive both hashes when that asynchronous inception completes.
The smart contract implementing ILockingContract locks a token (e.g., the asset) on its chain until a key is presented to encrypt to one of two given 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.
Called from the buyer of the token to initiate token transfer. Emits a TransferIncepted event.
The parameter id is an identifier of the trade. The parameter from is the address of the seller (the address of the buyer is msg.sender).
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 buyer to 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”.
Called from the seller of the token to confirm token transfer. Emits a TransferConfirmed event.
The parameter id is an identifier of the trade. The parameter to is the address of the buyer (the address of the seller is msg.sender).
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 possibly 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, the quadruple (id, amount, from, to), 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.
Called from either the buyer or the seller of the token
of the trade with id id.
If the method is called from the buyer (to) and the hashing of key matches keyHashedBuyer,
then the locked tokens are transferred to the buyer (to). This emits TokenClaimed.
If the method is called from the seller (from) and the hashing of key matches keyHashedSeller,
then the locked tokens are transferred (back) to the seller (to). This emits TokenReclaimed.
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.
Called from the receiver of the amount to initiate payment transfer. Emits a TransferIncepted.
The parameter id is an identifier of the trade. The parameter from is the address of the sender of the payment (the address of the receiver is msg.sender).
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.
The return value inceptionHash is the hash of the canonical inception call. It is used to derive confirmationHash and to identify the exact inception on cancellation.
For every inception variant, the inception hash MUST be calculated from the canonical ABI encoding:
Here, arguments is the applicable argument encoding, inceptionCaller is msg.sender, and selector is the canonical selector of the applicable inceptTransfer signature.
The inception hash therefore binds the decryption contract, inception caller (the receiver), selected signature, and all decoded arguments.
For asynchronous inception, this always includes the immutable callback value, including address(0).
Non-canonical encodings or trailing calldata are not part of the inception hash.
The contract MUST store it with the inception and MUST reject a later inception that would reuse it.
Once the encrypted success and failure keys have both been validated, stored, and made immutable, the contract MUST derive and store:
The key order is normative: success first, failure second. Implementations receiving an unordered key batch MUST first select the keys by their semantic identifiers and MUST NOT use callback array position.
For synchronous inception, both hashes can be calculated atomically.
For asynchronous inception, confirmationHash is calculated only when key generation completes.
The contract compares confirmationHash in confirmTransfer and inceptionHash in cancelAndDecrypt; no hash is supplied to transferAndDecrypt.
The id identifies a DvP. All legs of one multi-party DvP MAY intentionally use the same id.
An implementation MUST nevertheless identify every individual inception unambiguously using the participant context, for example (from, id) when at most one leg per payment sender is permitted.
If repeated legs from one payment sender are supported, the implementation MUST use a more specific leg key.
Oracle operations use their oracle-assigned requestId for callback correlation rather than requiring a contract-global, lifetime-unique DvP id.
Returning inceptionHash supports contract-to-contract calls; off-chain callers can calculate it from the call data without listening for an event.
After observing the immutable keys, a confirmer can calculate confirmationHash from the communicated inceptionHash and those keys; an implementation MAY additionally expose or emit the stored value.
Asynchronous Initiation of Transfer: inceptTransfer
The optional IDecryptionContractWithKeyGeneration interface extends IDecryptionContract with one asynchronous function that does not require keys to exist at inception:
The parameter transaction is the transaction specification used for asynchronous generation of the encrypted success and failure keys.
The call stores a pending inception and returns its inceptionHash immediately.
Because the keys do not exist when the call returns, inceptionHash binds the transaction and callback arguments but not the generated keys.
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 keys 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, derive and store confirmationHash, and complete the inception.
The contract MUST emit TransferIncepted only after both keys and confirmationHash have been stored.
If callback is not address(0), after storing the complete state and emitting TransferIncepted, the decryption contract MUST call:
The callback MUST return IDecryptionContractInceptionCallback.onInceptionCompleted.selector.
The completed state MUST already be observable so an authorized callback can directly call confirmTransfer.
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 hash-ready event is required because TransferIncepted already signals that the keys and confirmationHash 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.
Called from the sender of the amount to confirm a completed payment inception. Emits a TransferConfirmed event containing the stored transfer values.
The caller MUST be the stored payment sender (from), and the supplied confirmationHash MUST equal the hash stored for that caller’s completed inception.
The hash is an exact-state check, not an authorization capability; implementations MUST still validate the caller and 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 first confirmer becomes the finalizer unless the implementation establishes the finalizer explicitly.
Transfer: transferAndDecrypt
functiontransferAndDecrypt(uint256id)external;
Called by the recorded finalizer 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 is the recorded finalizer, every leg in the frozen expected set is confirmed, and neither execution nor cancellation has already been requested.
No hash parameter is needed at finalization because every leg was bound to its stored completed state by confirmTransfer.
Called from the receiver of the amount to cancel payment transfer (cancels the incept transfer).
The method must be called from the caller of a previous call to inceptTransfer
with the returned inceptionHash and cancels this specific transfer.
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
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:
The consumer calls request* on the oracle proxy contract (payable), receives the oracle-assigned requestId, and stores its operation context under (oracleProxy, requestId).
The oracle proxy emits a request event containing the same requestId and the consumer-supplied id.
The off-chain oracle observes the request event, performs decryption or verification off-chain, and calls fulfill* on the proxy.
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 consumer-supplied id remains event context and need not be globally unique.
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.
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.
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).
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.
The implementation is free to support several encryption methods for
as long as the decryption oracle supports it.
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:
The method declarations above are normative; the diagram illustrates the protocol flow and predates the two-hash scheme and requestId-based callback signatures.
Rationale
The protocol tries to be parsimonious. The id identifies the DvP and is shared by all
inceptions belonging to the same multi-party DvP. Individual payment legs are identified
by the DvP id together with their participant context. Implementations MUST reject
overlapping live legs with the same internal leg key. Oracle requests use the separate,
oracle-assigned requestId and therefore do not impose contract-global lifetime uniqueness
on the DvP id.
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 description 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 function verify that receives an encrypted key and returns the callback address (that will be used for the releaseKey call) that is stored inside the decrypted key without returning the decrypted key.
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" standalone="yes"?><releaseKeycontract="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 leastm := 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.
A multi-party DvP can be created elegantly by combining multiple (n-1) two-party DvPs, for example based on the ERC-7573 protocol.
The procedure is simple: every payment inception in the group uses the same DvP id but has its own inceptionHash and confirmationHash.
Instead of finalizing the respective two-party DvP by a call to transferAndDecrypt, each completed payment leg is first confirmed with confirmTransfer(id, confirmationHash), leaving group finalization open.
At any time before finalization, an original inception caller can call cancelAndDecrypt with that leg’s matching inceptionHash 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.
Once every leg in the frozen set is confirmed, a single call to transferAndDecrypt(id) 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 first payment sender that confirms a leg is allowed to finalize the group via transferAndDecrypt
unless the frozen group definition records an explicit finalizer. If first confirmation determines the
finalizer, implementations and callers MUST account for that intentionally transaction-order-dependent rule;
the original inception caller may cancel it via cancelAndDecrypt.
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.
The method declarations and hash lifecycle above are normative. This historical diagram shows the transfer parameters instead of confirmationHash on the decryption-side confirmation.
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.
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.
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.
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.