From 3bc01d2f6d125d03d8c7ec6b0539525b467b28f6 Mon Sep 17 00:00:00 2001 From: ozwaldorf Date: Tue, 25 Aug 2026 16:32:13 -0400 Subject: [PATCH 1/3] feat(zk): send the ERC-20 intent commitment with the pricing quote An ERC-20 escrow now settles against a hiding commitment rather than stored transfer details, so the API requires an intent it cannot derive: the salt comes from the per-escrow blinding scalar the depositor generates. Adds byte-exact encoders mirroring nomad-types::zk, validated against the committed vectors, and threads the intent through prepareTransfer. The commitment binds the escrow address, so the deployment nonce is resolved from an unsigned preview first: deployment follows its approvals, and an atomic batch spends one nonce for the whole sequence. No Signal schema change is needed. The enclave reads the domain and request id from the escrow and rederives the salt from the scalar it already receives. --- src/internal/api.ts | 14 ++ src/internal/zk.ts | 108 +++++++++ src/transfer.ts | 78 +++++++ test/fixtures/zk_vectors.json | 421 ++++++++++++++++++++++++++++++++++ test/transfer-pricing.test.ts | 89 ++++++- test/zk.test.ts | 55 +++++ 6 files changed, 754 insertions(+), 11 deletions(-) create mode 100644 src/internal/zk.ts create mode 100644 test/fixtures/zk_vectors.json create mode 100644 test/zk.test.ts diff --git a/src/internal/api.ts b/src/internal/api.ts index e05f4cc..8b0d9de 100644 --- a/src/internal/api.ts +++ b/src/internal/api.ts @@ -99,6 +99,17 @@ export function isApprovalStale(approvedAt: number, nowSecs = Date.now() / 1000) export type ExecutionMode = "private" | "native"; +/** + * The ZK intent an ERC-20 escrow settles against. The commitment hides the + * recipient and amount, so the API cannot derive it: the client computes it and + * the API encodes it verbatim into the constructor. + */ +export interface ZkIntentRequest { + commitment: `0x${string}`; + instance_domain: `0x${string}`; + request_id: `0x${string}`; +} + export interface PricingSignalRequest { asset: string; execution_mode: ExecutionMode; @@ -202,6 +213,8 @@ export async function fetchPricingQuote( sender: Address; escrowType: EscrowKind; blindedSigners: Address[]; + /** Required for ERC-20 escrows and rejected for every other kind. */ + intent?: ZkIntentRequest; signals: PricingSignalRequest[]; }, ): Promise { @@ -210,6 +223,7 @@ export async function fetchPricingQuote( sender: params.sender, escrow_type: params.escrowType, blinded_signers: params.blindedSigners, + ...(params.intent ? { intent: params.intent } : {}), signals: params.signals, }); // A signed request must never yield preview fields. Reject rather than diff --git a/src/internal/zk.ts b/src/internal/zk.ts new file mode 100644 index 0000000..2d6778d --- /dev/null +++ b/src/internal/zk.ts @@ -0,0 +1,108 @@ +import { concatHex, keccak256, pad, toHex, type Address } from "viem"; + +/** + * Byte-exact encoders for the ZK receipt protocol, mirroring + * `nomad-types::zk`. The escrow recomputes the statement from its own storage + * and the circuit reproves the relation, so a single byte of disagreement + * makes every proof fail with no indication of which side is wrong. Widths are + * fixed by the protocol spec and are not negotiable per call site. + */ + +/** + * Protocol relation version committed by the intent commitment. Bumping this + * invalidates every previously generated proof. + */ +export const RELATION_VERSION = 2; + +/** Domain tag separating salt derivation from every other use of the scalar. */ +const SALT_DOMAIN = "mirage/zk/intent-salt/v1"; + +/** Settlement asset class committed inside the intent commitment. */ +export const ASSET_KIND_NATIVE = 0; +export const ASSET_KIND_ERC20 = 1; + +/** + * Classifies a settlement asset. The zero address is native by convention + * everywhere in the protocol. The commitment carries an explicit byte rather + * than inferring native settlement from a zero token address, so a native row + * can never be read as an ERC-20 row whose token encodes as zero. + */ +export function assetKind(asset: Address): number { + return /^0x0{40}$/i.test(asset) ? ASSET_KIND_NATIVE : ASSET_KIND_ERC20; +} + +/** Private opening of one settlement row. Never leaves the client or enclave. */ +export interface IntentOpening { + instanceDomain: `0x${string}`; + chainId: number; + /** Predicted escrow address, fixed so a proof cannot be replayed elsewhere. */ + escrow: Address; + requestId: `0x${string}`; + rowIndex: number; + /** Settlement token, or the zero address for native ETH. */ + asset: Address; + recipient: Address; + amount: bigint; + salt: `0x${string}`; +} + +function uint32(value: number): `0x${string}` { + return pad(toHex(value), { size: 4 }); +} + +/** + * Derives the commitment salt from material both sides already hold. + * + * The salt must be secret, and both the depositor and the enclave must arrive + * at the same value. Deriving it from the blinding scalar avoids threading a + * new field through the SDK, the API, and the Signal envelope. The instance + * domain and request id are folded in so a scalar reused across deployments + * still yields distinct salts. + */ +export function deriveSalt( + blindingScalar: `0x${string}`, + instanceDomain: `0x${string}`, + requestId: `0x${string}`, + rowIndex: number, +): `0x${string}` { + return keccak256( + concatHex([ + toHex(SALT_DOMAIN), + pad(blindingScalar, { size: 32 }), + pad(instanceDomain, { size: 32 }), + pad(requestId, { size: 32 }), + uint32(rowIndex), + ]), + ); +} + +/** Exact commitment preimage, exposed so fixtures can compare bytes. */ +export function intentPreimage(opening: IntentOpening): `0x${string}` { + return concatHex([ + pad(toHex(RELATION_VERSION), { size: 1 }), + pad(opening.instanceDomain, { size: 32 }), + pad(toHex(opening.chainId), { size: 32 }), + opening.escrow, + pad(opening.requestId, { size: 32 }), + uint32(opening.rowIndex), + pad(toHex(assetKind(opening.asset)), { size: 1 }), + opening.asset, + opening.recipient, + pad(toHex(opening.amount), { size: 32 }), + pad(opening.salt, { size: 32 }), + ]); +} + +/** Value stored on the escrow and recomputed inside the circuit. */ +export function intentCommitment(opening: IntentOpening): `0x${string}` { + return keccak256(intentPreimage(opening)); +} + +/** Fresh 32-byte random value for the per-deployment domain and request id. */ +export function randomBytes32(): `0x${string}` { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return `0x${Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("")}`; +} diff --git a/src/transfer.ts b/src/transfer.ts index 96ed485..fc73180 100644 --- a/src/transfer.ts +++ b/src/transfer.ts @@ -29,6 +29,7 @@ import type { ObfuscationResult, PricingQuote, PricingSignalRequest, + ZkIntentRequest, } from "./internal/api.js"; import { fetchComplianceApproval, @@ -41,11 +42,15 @@ import { import type { VerifyAttestationOptions } from "./internal/attestation.js"; import { approveQuotedForDeployment, + buildQuotedApprovalBuckets, deployQuotedApproved, deployQuotedAtomic, estimateQuotedApprovalGas, + predictContractAddress, } from "./internal/escrow.js"; import { deriveBlindedSigners } from "./internal/bond.js"; +import { deriveSalt, intentCommitment, randomBytes32 } from "./internal/zk.js"; +import type { IntentOpening as ZkIntentOpening } from "./internal/zk.js"; import { submitSignal } from "./internal/nomad.js"; import { pollTransfers } from "./internal/poll.js"; import { checkAbort } from "./internal/abort.js"; @@ -156,6 +161,64 @@ function attestationOptions(network: NetworkConfig): { }; } +/** + * Builds the intent an ERC-20 escrow settles against. + * + * The commitment binds the escrow address, so the deployment nonce is read here + * and the resulting address must be the one the deployment actually takes. The + * salt is derived from the blinding scalar rather than transmitted: the enclave + * already receives the scalar inside the Signal and recovers the same value. + */ +async function buildZkIntent(params: { + network: NetworkConfig; + sender: Address; + rows: TransferRow[]; + row: TransferRow; + blindingScalar: `0x${string}`; + publicClient: PublicClient; +}): Promise<{ request: ZkIntentRequest; opening: ZkIntentOpening }> { + const instanceDomain = randomBytes32(); + const requestId = randomBytes32(); + // Deployment follows its approvals, so the escrow lands that many nonces + // ahead. An atomic batch spends one nonce for the whole sequence. The deposits + // that set the count come from an unsigned preview, since the signed quote is + // what this commitment is being built for. + const preview = await fetchPricingPreview(params.network.apiServer, { + chainId: params.network.chainId, + escrowType: "erc20", + signals: buildPricingSignals(params.rows), + }); + const nonceOffset = params.network.enableAtomicBatch + ? 0 + : buildQuotedApprovalBuckets(preview.depositByAsset).length; + const escrow = predictContractAddress( + params.sender, + (await params.publicClient.getTransactionCount({ + address: params.sender, + blockTag: "pending", + })) + nonceOffset, + ); + const opening = { + instanceDomain, + chainId: params.network.chainId, + escrow, + requestId, + rowIndex: 0, + asset: params.row.tokenAddress, + recipient: params.row.recipientAddress, + amount: params.row.amount, + salt: deriveSalt(params.blindingScalar, instanceDomain, requestId, 0), + }; + return { + request: { + commitment: intentCommitment(opening), + instance_domain: instanceDomain, + request_id: requestId, + }, + opening, + }; +} + /** * One SDK transfer request becomes one Signal per asset. Asset groups preserve * first-appearance order, so the first transfer selects the reward asset. @@ -328,12 +391,27 @@ async function buildContext(params: TransferParams): Promise { } const blinded = deriveBlindedSigners(networkKey.publicKey, rows.length); + // An ERC-20 escrow settles against a commitment rather than stored transfer + // details, and the commitment binds the escrow address, so it must be + // predicted before the constructor is priced. + const intent = + escrowType === "erc20" + ? await buildZkIntent({ + network: params.network, + sender, + rows, + row: rows[0], + blindingScalar: blinded.blindingScalar, + publicClient: params.publicClient, + }) + : undefined; const [quote, obfuscation] = await Promise.all([ fetchPricingQuote(params.network.apiServer, { chainId: params.network.chainId, sender, escrowType, blindedSigners: blinded.blindedSigners, + intent: intent?.request, signals: buildPricingSignals(rows), }), fetchObfuscation(params.network.apiServer, escrowType), diff --git a/test/fixtures/zk_vectors.json b/test/fixtures/zk_vectors.json new file mode 100644 index 0000000..1211c20 --- /dev/null +++ b/test/fixtures/zk_vectors.json @@ -0,0 +1,421 @@ +{ + "fixture_version": 1, + "relation_version": 2, + "note": "Generated by `cargo test -p nomad-types --test zk_vectors`. The circuit and the Solidity escrow must reproduce every preimage and digest here byte for byte. Set VECTORS_CHECK=1 to verify instead of regenerate.", + "cases": [ + { + "name": "erc20_baseline", + "description": "First row, 1 USDC settlement, native ETH payout, first bond attempt.", + "intent": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "asset_kind": 1, + "asset": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "recipient": "0x4444444444444444444444444444444444444444", + "amount": "1000000", + "salt": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000001222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000001a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000f42405555555555555555555555555555555555555555555555555555555555555555", + "preimage_len": 226, + "commitment": "0xa4abd620fc7199a0d4586a11bd6d362b2307ffec6ce7acac21e0303fbde55207" + }, + "statement": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "intent_commitment": "0xa4abd620fc7199a0d4586a11bd6d362b2307ffec6ce7acac21e0303fbde55207", + "bond_attempt": 1, + "bond_start_block": 21000000, + "bond_deadline": 21000180, + "bonded_collector": "0x6666666666666666666666666666666666666666", + "payout_asset": "0x0000000000000000000000000000000000000000", + "payout_amount": "500000000000000000", + "witness_block_number": 21000050, + "witness_block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "preimage": "0x02111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000000000000000012222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333300000000a4abd620fc7199a0d4586a11bd6d362b2307ffec6ce7acac21e0303fbde55207000000010000000001406f400000000001406ff46666666666666666666666666666666666666666000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006f05b59d3b200000000000001406f72aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "preimage_len": 285, + "statement_hash": "0xed07b7a9871d537dafeca0e65d0c7d5315b01594b84888530fa3e2c7ae216fed", + "public_signals": [ + "315067106191367668287550802297222888787", + "28828069871988020029990350371212586989" + ] + } + }, + { + "name": "native_baseline", + "description": "Native ETH settlement of 1 ETH. Differs from erc20_baseline only in asset and amount, isolating the asset-kind byte.", + "intent": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "asset_kind": 0, + "asset": "0x0000000000000000000000000000000000000000", + "recipient": "0x4444444444444444444444444444444444444444", + "amount": "1000000000000000000", + "salt": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000001222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000000000000000000000000000000000000000000000044444444444444444444444444444444444444440000000000000000000000000000000000000000000000000de0b6b3a76400005555555555555555555555555555555555555555555555555555555555555555", + "preimage_len": 226, + "commitment": "0xb2396f7452bc9769ee02e1b4f2f2797998463bf1421b5e99af037690bf7695b3" + }, + "statement": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "intent_commitment": "0xb2396f7452bc9769ee02e1b4f2f2797998463bf1421b5e99af037690bf7695b3", + "bond_attempt": 1, + "bond_start_block": 21000000, + "bond_deadline": 21000180, + "bonded_collector": "0x6666666666666666666666666666666666666666", + "payout_asset": "0x0000000000000000000000000000000000000000", + "payout_amount": "100000000000000000", + "witness_block_number": 21000050, + "witness_block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "preimage": "0x02111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000000000000000012222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333300000000b2396f7452bc9769ee02e1b4f2f2797998463bf1421b5e99af037690bf7695b3000000010000000001406f400000000001406ff466666666666666666666666666666666666666660000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016345785d8a00000000000001406f72aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "preimage_len": 285, + "statement_hash": "0xe6a8482ec26636a814557f0e87f6a86e9329110204510d494db166ec9f7a45be", + "public_signals": [ + "306596208940915100833148732559542626414", + "195609744512338074130204399403575952830" + ] + } + }, + { + "name": "erc20_max_row_index", + "description": "Row index 31, the last valid index in a 32-row batch.", + "intent": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 31, + "asset_kind": 1, + "asset": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "recipient": "0x4444444444444444444444444444444444444444", + "amount": "1000000", + "salt": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000001222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000001f01a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000f42405555555555555555555555555555555555555555555555555555555555555555", + "preimage_len": 226, + "commitment": "0xfd07dc5083fb6c4bea35fc53ac4c0d0688588db222a51874f521a10187f80dcb" + }, + "statement": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 31, + "intent_commitment": "0xfd07dc5083fb6c4bea35fc53ac4c0d0688588db222a51874f521a10187f80dcb", + "bond_attempt": 1, + "bond_start_block": 21000000, + "bond_deadline": 21000180, + "bonded_collector": "0x6666666666666666666666666666666666666666", + "payout_asset": "0x0000000000000000000000000000000000000000", + "payout_amount": "500000000000000000", + "witness_block_number": 21000050, + "witness_block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000001222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000001ffd07dc5083fb6c4bea35fc53ac4c0d0688588db222a51874f521a10187f80dcb000000010000000001406f400000000001406ff46666666666666666666666666666666666666666000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006f05b59d3b200000000000001406f72aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "preimage_len": 285, + "statement_hash": "0x5d183ad51c6a4a3aa27bd4e92f3679976dc163e7b8a109c1735e548e079c4bb5", + "public_signals": [ + "123744011996751716326943171883386173847", + "145889991151649279123173885175026961333" + ] + } + }, + { + "name": "erc20_max_amount", + "description": "Settlement amount of 2^256 - 1. Catches truncation to a narrower type.", + "intent": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "asset_kind": 1, + "asset": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "recipient": "0x4444444444444444444444444444444444444444", + "amount": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "salt": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000001222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000001a0b86991c6218b36c1d19d4a2e9eb0ce3606eb484444444444444444444444444444444444444444ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff5555555555555555555555555555555555555555555555555555555555555555", + "preimage_len": 226, + "commitment": "0xece247711f05d327f7b5ed465e89aeb8f3bbdce88f564b5fb8e6600dacce5055" + }, + "statement": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "intent_commitment": "0xece247711f05d327f7b5ed465e89aeb8f3bbdce88f564b5fb8e6600dacce5055", + "bond_attempt": 1, + "bond_start_block": 21000000, + "bond_deadline": 21000180, + "bonded_collector": "0x6666666666666666666666666666666666666666", + "payout_asset": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "payout_amount": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "witness_block_number": 21000050, + "witness_block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "preimage": "0x02111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000000000000000012222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333300000000ece247711f05d327f7b5ed465e89aeb8f3bbdce88f564b5fb8e6600dacce5055000000010000000001406f400000000001406ff46666666666666666666666666666666666666666a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000001406f72aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "preimage_len": 285, + "statement_hash": "0x4363a8ef3b50fa771d7ae1273b157e1187f783f26720919d22aa1b7c314f1000", + "public_signals": [ + "89575739505286078243523549474944548369", + "180730952955811481366659105505464487936" + ] + } + }, + { + "name": "erc20_min_amount", + "description": "Settlement amount of 1 atomic unit, left-padded to 32 bytes.", + "intent": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "asset_kind": 1, + "asset": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "recipient": "0x4444444444444444444444444444444444444444", + "amount": "1", + "salt": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000001222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000001a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000000015555555555555555555555555555555555555555555555555555555555555555", + "preimage_len": 226, + "commitment": "0xbb52ca00c57ce9898356399928c89977b6c9827c4e70262cef2df1838ffa24f3" + }, + "statement": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "intent_commitment": "0xbb52ca00c57ce9898356399928c89977b6c9827c4e70262cef2df1838ffa24f3", + "bond_attempt": 1, + "bond_start_block": 21000000, + "bond_deadline": 21000180, + "bonded_collector": "0x6666666666666666666666666666666666666666", + "payout_asset": "0x0000000000000000000000000000000000000000", + "payout_amount": "1", + "witness_block_number": 21000050, + "witness_block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "preimage": "0x02111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000000000000000012222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333300000000bb52ca00c57ce9898356399928c89977b6c9827c4e70262cef2df1838ffa24f3000000010000000001406f400000000001406ff46666666666666666666666666666666666666666000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000001406f72aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "preimage_len": 285, + "statement_hash": "0x34d4e843cd362a7df8bbf970c17aef606fb60e8c1f58fd9a1499d38f815e5966", + "public_signals": [ + "70225333605649854162288819093651517280", + "148489600615757822018699320415559244134" + ] + } + }, + { + "name": "erc20_token_payout", + "description": "USDC payout instead of native ETH, exercising a nonzero payout asset in the statement hash.", + "intent": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 7, + "asset_kind": 1, + "asset": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "recipient": "0x4444444444444444444444444444444444444444", + "amount": "1000000", + "salt": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000001222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000701a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000f42405555555555555555555555555555555555555555555555555555555555555555", + "preimage_len": 226, + "commitment": "0x5e2db4aba20795d72d4bac1c2f8db19b7749d738f245e2551962676938c8c7f4" + }, + "statement": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 7, + "intent_commitment": "0x5e2db4aba20795d72d4bac1c2f8db19b7749d738f245e2551962676938c8c7f4", + "bond_attempt": 1, + "bond_start_block": 21000000, + "bond_deadline": 21000180, + "bonded_collector": "0x6666666666666666666666666666666666666666", + "payout_asset": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "payout_amount": "1010000", + "witness_block_number": 21000050, + "witness_block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "preimage": "0x021111111111111111111111111111111111111111111111111111111111111111000000000000000000000000000000000000000000000000000000000000000122222222222222222222222222222222222222223333333333333333333333333333333333333333333333333333333333333333000000075e2db4aba20795d72d4bac1c2f8db19b7749d738f245e2551962676938c8c7f4000000010000000001406f400000000001406ff46666666666666666666666666666666666666666a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000f69500000000001406f72aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "preimage_len": 285, + "statement_hash": "0xa38c06014b4b53f6b124c1c848d09ca306057a388f73429208a017f55ec790a9", + "public_signals": [ + "217391206670124389255755339554236701859", + "8003808394146612921902176078561513641" + ] + } + }, + { + "name": "erc20_second_bond_attempt", + "description": "Identical to erc20_baseline except bond_attempt = 2. Its statement hash must differ, which is what rejects a proof from the prior lease.", + "intent": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "asset_kind": 1, + "asset": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "recipient": "0x4444444444444444444444444444444444444444", + "amount": "1000000", + "salt": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000001222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000001a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000f42405555555555555555555555555555555555555555555555555555555555555555", + "preimage_len": 226, + "commitment": "0xa4abd620fc7199a0d4586a11bd6d362b2307ffec6ce7acac21e0303fbde55207" + }, + "statement": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 1, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "intent_commitment": "0xa4abd620fc7199a0d4586a11bd6d362b2307ffec6ce7acac21e0303fbde55207", + "bond_attempt": 2, + "bond_start_block": 21000000, + "bond_deadline": 21000180, + "bonded_collector": "0x6666666666666666666666666666666666666666", + "payout_asset": "0x0000000000000000000000000000000000000000", + "payout_amount": "500000000000000000", + "witness_block_number": 21000050, + "witness_block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "preimage": "0x02111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000000000000000012222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333300000000a4abd620fc7199a0d4586a11bd6d362b2307ffec6ce7acac21e0303fbde55207000000020000000001406f400000000001406ff46666666666666666666666666666666666666666000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006f05b59d3b200000000000001406f72aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "preimage_len": 285, + "statement_hash": "0xc374a6759414dee1d3474bccc0b0a380b17e99b3e29afa222b66813ad599f5cc", + "public_signals": [ + "259805141809166868068421984825988129664", + "235930702118746914561841968803997152716" + ] + } + }, + { + "name": "erc20_sepolia_chain_id", + "description": "Sepolia chain id, a multi-byte value left-padded to a 32-byte word.", + "intent": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 11155111, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "asset_kind": 1, + "asset": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "recipient": "0x4444444444444444444444444444444444444444", + "amount": "1000000", + "salt": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000aa36a7222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000001a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000f42405555555555555555555555555555555555555555555555555555555555555555", + "preimage_len": 226, + "commitment": "0x7b6f6f6900dead098ba590f667d7fff0c18f4c0d458dda27ca0bb9746c9bc26f" + }, + "statement": { + "inputs": { + "instance_domain": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain_id": 11155111, + "escrow": "0x2222222222222222222222222222222222222222", + "request_id": "0x3333333333333333333333333333333333333333333333333333333333333333", + "row_index": 0, + "intent_commitment": "0x7b6f6f6900dead098ba590f667d7fff0c18f4c0d458dda27ca0bb9746c9bc26f", + "bond_attempt": 1, + "bond_start_block": 21000000, + "bond_deadline": 21000180, + "bonded_collector": "0x6666666666666666666666666666666666666666", + "payout_asset": "0x0000000000000000000000000000000000000000", + "payout_amount": "500000000000000000", + "witness_block_number": 21000050, + "witness_block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "preimage": "0x0211111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000aa36a722222222222222222222222222222222222222223333333333333333333333333333333333333333333333333333333333333333000000007b6f6f6900dead098ba590f667d7fff0c18f4c0d458dda27ca0bb9746c9bc26f000000010000000001406f400000000001406ff46666666666666666666666666666666666666666000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006f05b59d3b200000000000001406f72aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "preimage_len": 285, + "statement_hash": "0x1391dcde2310350ef29853e728f8713286880b894e820286bff45af3886240c2", + "public_signals": [ + "26012694694017401843505205076324544818", + "178822937793000371202222476440813846722" + ] + } + }, + { + "name": "erc20_high_bytes", + "description": "Every 32-byte and address field set to 0xff, catching sign extension or signed handling in a non-Rust implementation.", + "intent": { + "inputs": { + "instance_domain": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chain_id": 1, + "escrow": "0xffffffffffffffffffffffffffffffffffffffff", + "request_id": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "row_index": 0, + "asset_kind": 1, + "asset": "0xffffffffffffffffffffffffffffffffffffffff", + "recipient": "0xffffffffffffffffffffffffffffffffffffffff", + "amount": "1000000", + "salt": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "preimage": "0x02ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000001ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000001ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000f4240ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "preimage_len": 226, + "commitment": "0x10e50c9454d21a02535967c8c9ffbb3b9b3c1fccf890adb2d339dc05aa71196a" + }, + "statement": { + "inputs": { + "instance_domain": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chain_id": 1, + "escrow": "0xffffffffffffffffffffffffffffffffffffffff", + "request_id": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "row_index": 0, + "intent_commitment": "0x10e50c9454d21a02535967c8c9ffbb3b9b3c1fccf890adb2d339dc05aa71196a", + "bond_attempt": 4294967295, + "bond_start_block": 21000000, + "bond_deadline": 21000180, + "bonded_collector": "0x6666666666666666666666666666666666666666", + "payout_asset": "0xffffffffffffffffffffffffffffffffffffffff", + "payout_amount": "500000000000000000", + "witness_block_number": 21000050, + "witness_block_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "preimage": "0x02ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000001ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000010e50c9454d21a02535967c8c9ffbb3b9b3c1fccf890adb2d339dc05aa71196affffffff0000000001406f400000000001406ff46666666666666666666666666666666666666666ffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000006f05b59d3b200000000000001406f72aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "preimage_len": 285, + "statement_hash": "0xaf50a810242832b90d3eee2d1a7544a85971ec6c1759c7e80d61769f93335b68", + "public_signals": [ + "233033691734697134595588566698459219112", + "118892816382406655200606823064410479464" + ] + } + } + ] +} diff --git a/test/transfer-pricing.test.ts b/test/transfer-pricing.test.ts index be66739..1bfee2b 100644 --- a/test/transfer-pricing.test.ts +++ b/test/transfer-pricing.test.ts @@ -146,7 +146,7 @@ describe("prepareTransfer pricing flow", () => { recipientAddress: RECIPIENT_A, amount: 100n, senderAddress: SENDER, - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, }); @@ -165,7 +165,7 @@ describe("prepareTransfer pricing flow", () => { recipientAddress: RECIPIENT_A, amount: 100n, senderAddress: SENDER, - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, }), ).rejects.toMatchObject({ code: "INVALID_PRICING_QUOTE" }); @@ -178,7 +178,8 @@ describe("prepareTransfer pricing flow", () => { amount: 100n, senderAddress: SENDER, publicClient: { - getTransactionCount: vi.fn().mockRejectedValue(new Error("RPC unavailable")), + getTransactionCount: vi.fn().mockResolvedValue(5), + estimateContractGas: vi.fn().mockRejectedValue(new Error("RPC unavailable")), } as any, network, }); @@ -196,7 +197,7 @@ describe("prepareTransfer pricing flow", () => { { tokenAddress: NATIVE_TOKEN_ADDRESS, recipientAddress: RECIPIENT_B, amount: 50n }, ], senderAddress: SENDER, - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, }); @@ -219,7 +220,7 @@ describe("prepareTransfer pricing flow", () => { tokenAddress: USDC, recipientAddress: RECIPIENT_A, amount: 100n, - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, }), ).rejects.toMatchObject({ code: "SENDER_REQUIRED" }); @@ -233,7 +234,7 @@ describe("prepareTransfer pricing flow", () => { tokenAddress: USDC, recipientAddress: RECIPIENT_A, amount: 100n, - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, resume: { ...VALID_RESUME, [field]: undefined } as any, }), @@ -249,7 +250,7 @@ describe("prepareTransfer pricing flow", () => { tokenAddress: USDC, recipientAddress: RECIPIENT_A, amount: 100n, - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, resume: { ...VALID_RESUME, escrowType: "erc20" }, }); @@ -266,7 +267,7 @@ describe("prepareTransfer pricing flow", () => { tokenAddress: USDC, recipientAddress: RECIPIENT_A, amount: 100n, - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, resume: { ...VALID_RESUME, escrowType: "batch" }, }), @@ -280,7 +281,7 @@ describe("prepareTransfer pricing flow", () => { { tokenAddress: USDC, recipientAddress: RECIPIENT_A, amount: 40n }, { tokenAddress: USDC, recipientAddress: RECIPIENT_B, amount: 60n }, ], - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, resume: { ...VALID_RESUME, escrowType: "batch" }, }), @@ -292,7 +293,7 @@ describe("prepareTransfer pricing flow", () => { tokenAddress: USDC, recipientAddress: RECIPIENT_A, amount: 100n, - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, resume: VALID_RESUME, }); @@ -323,7 +324,7 @@ describe("nomad proxy routing", () => { recipientAddress: RECIPIENT_A, amount: 100n, senderAddress: SENDER, - publicClient: {} as any, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, network, }); @@ -494,3 +495,69 @@ describe("previewTransfer", () => { ).rejects.toMatchObject({ code: "INVALID_PRICING_QUOTE" }); }); }); + +describe("zk intent", () => { + it("sends an intent for an ERC-20 escrow", async () => { + await prepareTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + senderAddress: SENDER, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, + network, + }); + + expect(pricingBody.escrow_type).toBe("erc20"); + expect(pricingBody.intent.commitment).toMatch(/^0x[0-9a-f]{64}$/); + expect(pricingBody.intent.instance_domain).toMatch(/^0x[0-9a-f]{64}$/); + expect(pricingBody.intent.request_id).toMatch(/^0x[0-9a-f]{64}$/); + // A zero commitment is openable by anyone who guesses the empty preimage. + expect(pricingBody.intent.commitment).not.toBe(`0x${"00".repeat(32)}`); + }); + + it("omits the intent for native and batch escrows", async () => { + await prepareTransfer({ + tokenAddress: NATIVE_TOKEN_ADDRESS, + recipientAddress: RECIPIENT_A, + amount: 100n, + senderAddress: SENDER, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, + network, + }); + expect(pricingBody.intent).toBeUndefined(); + + await prepareTransfer({ + transfers: [ + { tokenAddress: USDC, recipientAddress: RECIPIENT_A, amount: 100n }, + { tokenAddress: USDC, recipientAddress: RECIPIENT_B, amount: 200n }, + ], + senderAddress: SENDER, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, + network, + }); + expect(pricingBody.intent).toBeUndefined(); + }); + + it("binds a fresh domain and request id per preparation", async () => { + const args = { + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + senderAddress: SENDER, + network, + }; + await prepareTransfer({ + ...args, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, + }); + const first = pricingBody.intent; + await prepareTransfer({ + ...args, + publicClient: { getTransactionCount: vi.fn().mockResolvedValue(5) } as any, + }); + + expect(pricingBody.intent.instance_domain).not.toBe(first.instance_domain); + expect(pricingBody.intent.request_id).not.toBe(first.request_id); + expect(pricingBody.intent.commitment).not.toBe(first.commitment); + }); +}); diff --git a/test/zk.test.ts b/test/zk.test.ts new file mode 100644 index 0000000..fab48cd --- /dev/null +++ b/test/zk.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { deriveSalt, intentCommitment, intentPreimage } from "../src/internal/zk.js"; + +/** + * Vectors are generated by `cargo test -p nomad-types --test zk_vectors`. The + * circuit, the Solidity escrow, and this encoder must reproduce every preimage + * and digest byte for byte. + */ +const VECTORS = JSON.parse( + readFileSync(new URL("./fixtures/zk_vectors.json", import.meta.url), "utf-8"), +); + +describe("zk intent vectors", () => { + it("matches the pinned relation version", () => { + expect(VECTORS.relation_version).toBe(2); + }); + + for (const testCase of VECTORS.cases) { + it(`reproduces ${testCase.name}`, () => { + const input = testCase.intent.inputs; + const opening = { + instanceDomain: input.instance_domain, + chainId: input.chain_id, + escrow: input.escrow, + requestId: input.request_id, + rowIndex: input.row_index, + asset: input.asset, + recipient: input.recipient, + amount: BigInt(input.amount), + salt: input.salt, + }; + expect(intentPreimage(opening)).toBe(testCase.intent.preimage); + expect(intentCommitment(opening)).toBe(testCase.intent.commitment); + }); + } +}); + +describe("deriveSalt", () => { + const scalar = `0x${"11".repeat(32)}` as const; + const domain = `0x${"22".repeat(32)}` as const; + const request = `0x${"33".repeat(32)}` as const; + + it("is deterministic", () => { + expect(deriveSalt(scalar, domain, request, 0)).toBe(deriveSalt(scalar, domain, request, 0)); + }); + + it("separates every input", () => { + const baseline = deriveSalt(scalar, domain, request, 0); + expect(deriveSalt(`0x${"44".repeat(32)}`, domain, request, 0)).not.toBe(baseline); + expect(deriveSalt(scalar, `0x${"44".repeat(32)}`, request, 0)).not.toBe(baseline); + expect(deriveSalt(scalar, domain, `0x${"44".repeat(32)}`, 0)).not.toBe(baseline); + expect(deriveSalt(scalar, domain, request, 1)).not.toBe(baseline); + }); +}); From ae4d1d3297b2ed2235139b1c84fe55ac4e4fdc7f Mon Sep 17 00:00:00 2001 From: ozwaldorf Date: Tue, 25 Aug 2026 16:32:34 -0400 Subject: [PATCH 2/3] chore: bump to 0.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 31ca941..ecf78bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mirageprivacy/sdk", - "version": "0.4.3", + "version": "0.5.0", "description": "SDK for private transfers on Mirage", "type": "module", "main": "./dist/index.cjs", From 9c4beb8fd321adf1e140bd8d82be5fc9a692292b Mon Sep 17 00:00:00 2001 From: ozwaldorf Date: Tue, 25 Aug 2026 17:12:06 -0400 Subject: [PATCH 3/3] test(integration): support preview quotes and the ZK intent in the mock API The mock assumed every request carried a sender and blinded signers, so the unsigned preview the intent flow needs failed on a missing field. Mirrors the API contract: no sender means no signers and no deployable fields, and an ERC-20 quote requires an intent that other kinds reject. --- test/integration/mock-api.ts | 44 +++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/test/integration/mock-api.ts b/test/integration/mock-api.ts index 94cec4d..980e883 100644 --- a/test/integration/mock-api.ts +++ b/test/integration/mock-api.ts @@ -162,9 +162,14 @@ function createServer(port: number, nomadUrl?: string): http.Server { try { const request = JSON.parse(body) as { chain_id: number; - sender: string; + sender?: string; escrow_type: "erc20" | "native" | "batch"; - blinded_signers: string[]; + blinded_signers?: string[]; + intent?: { + commitment: string; + instance_domain: string; + request_id: string; + }; signals: Array<{ asset: string; execution_mode: "private" | "native"; @@ -183,9 +188,21 @@ function createServer(port: number, nomadUrl?: string): http.Server { rowIndex: itemIndex, })), ); - if (rows.length === 0 || request.blinded_signers.length !== rows.length) { + // A request without a sender is an unsigned preview: it carries no + // signers and receives no deployable fields. + const preview = request.sender === undefined; + if (rows.length === 0) { + throw new Error("pricing requires at least one row"); + } + if (!preview && (request.blinded_signers?.length ?? 0) !== rows.length) { throw new Error("pricing requires one blinded signer per row"); } + if (!preview && request.escrow_type === "erc20" && !request.intent) { + throw new Error("ERC-20 escrow requires a ZK intent"); + } + if (request.intent && request.escrow_type !== "erc20") { + throw new Error("only ERC-20 escrows take a ZK intent"); + } if (request.escrow_type !== "batch" && rows.length !== 1) { throw new Error("single escrow pricing requires exactly one row"); } @@ -201,6 +218,27 @@ function createServer(port: number, nomadUrl?: string): http.Server { } const rewardKey = rewardAsset.toLowerCase(); deposits.set(rewardKey, (deposits.get(rewardKey) ?? 0n) + rewardAmount); + if (preview) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + chain_id: request.chain_id, + service_fee: { asset: rewardAsset, amount: rewardAmount.toString() }, + deployment: { + escrow_type: request.escrow_type, + constructor_args: null, + quote_commitment: null, + reward_asset: rewardAsset, + reward_amount: rewardAmount.toString(), + deposit_by_asset: Object.fromEntries( + [...deposits].map(([asset, amount]) => [asset, amount.toString()]), + ), + msg_value: (deposits.get(zeroAddress) ?? 0n).toString(), + }, + sealed_pricing_authorization: null, + })); + return; + } + const firstRow = rows[0]; const constructorArgs = request.escrow_type === "erc20"