Binding and proofs
A fill has to be safe against two distinct failures: someone rewriting the settlement terms after the proof is made, and a filler delivering a note that does not match the order. ZenoLOP addresses each with a different mechanism, and gates the escrow release on a third check at claim time.
The adapt-params binding
ZenoLOP is a RAILGUN adapt contract. Every transaction passed to fillOrder must have committed,
inside its own zero-knowledge proof, to both the adapt contract and a hash of the entire settlement
bundle:
adaptContract == address(ZenoLOP)
adaptParams == keccak256(abi.encode(nullifiers, transactions.length, fills, recipient))
% SNARK_SCALAR_FIELDBecause the nullifiers, the transaction count, the full fill set, and the payout recipient are all
inside the hash, a shielded proof cannot be replayed, redirected to a different recipient,
partially executed with a subset of the transactions, or have its fills tampered with. This mirrors
RAILGUN's own RelayAdapt.getAdaptParams.
The note-requirements proof
The binding proves the settlement terms were not altered, but not that the delivered note is the right one. That is the job of a Noir circuit, verified on-chain per fill.
fn main(random: Field, token: pub Field, value: pub Field, mpk: pub Field) -> pub Field {
let npk = hash_2([mpk, random]);
let commitment = hash_3([npk, token, value]);
commitment
}The proof attests: I know a blinding random such that the delivered commitment equals
poseidon3(poseidon2(mpk, random), token, value). In other words the note is spendable by the
maker's pool key, carries the token the order asked for, and holds the value the filler claims.
The contract checks this with public inputs in exactly this order:
| Index | Value |
|---|---|
| 0 | railgun.getTokenID(order.makingToken) |
| 1 | the fill's amount |
| 2 | the order's mpk |
| 3 | the delivered commitment |
Before the proof is checked, fillOrder also requires that the commitment actually appears among the
commitments created by the RAILGUN transactions in the same call, that the order exists and has not
expired, and that the commitment has not already been used.
Verification runs through an UltraHonk HonkVerifier exposing:
interface IVerifier {
function verify(bytes calldata _proof, bytes32[] calldata _publicInputs)
external view returns (bool);
}Binding the claim to the delivered note
The escrow is not released by fillOrder. At claim time the filler reveals the note blinding, and
the contract recomputes the commitment from the order's mpk, the delivered note amount, and that
blinding, requiring it to match the note that was delivered:
npk = railgun.hashLeftRight(order.mpk, random)
commitment = railgun.hashCommitment({npk, order.makingToken, payout.noteAmount})
require(commitment == payout.commitment)This ties the revealed blinding to the real note, so an escrow cannot be claimed against a note the
filler never delivered. random becomes public in the PayoutExecuted event.
What is not enforced
ZenoLOP verifies the structure of a delivered note, not the provenance of the funds behind it. The note-requirements proof attests that the note carries the right token, the right value, and the maker's pool key. Nothing in the protocol attests where those funds came from.
Concretely, a fill is not checked against any blocklist or screening list, and no proof is required
that the funds backing the delivered note trace to an unblocked origin. Orders carry a
listProviderPPoIKey field and it is emitted in OrderCreated, but the contract never reads it.
Integrators who need provenance screening must enforce it out of band, before deciding which orders to fill or which fills to accept.
Related
- How ZenoLOP works - where these checks sit in the lifecycle.
- Integrating - producing these proofs from a client.