From 5211d0846459328a894973a1e129d526116a31e2 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 04:22:48 +0100 Subject: [PATCH 1/7] feat(transfer): select priced escrow variants by transfer shape --- src/errors.ts | 6 ++--- src/internal/api.ts | 9 ++++---- src/internal/bond.ts | 15 ++++++------ src/transfer.ts | 54 ++++++++++++++++++++++++++++++++++++-------- src/types.ts | 6 ++--- 5 files changed, 63 insertions(+), 27 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index f26f890..da388ca 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -93,9 +93,9 @@ export class WhitelistRequiredError extends MirageError { } /** - * Thrown when a priced EscrowBatch is resumed without its base blinding - * scalar. Nomad cannot derive the constructor's one-time bid signers without - * it, so completion must use the secrets retained at deployment. + * Thrown when a priced escrow is resumed without its blinding scalar. Nomad + * cannot derive its one-time signer or batch signer set without it, so + * completion must use the secrets retained at deployment. */ export class MissingBlindingScalarError extends MirageError { escrowAddress?: Address; diff --git a/src/internal/api.ts b/src/internal/api.ts index 78bc441..cdfa302 100644 --- a/src/internal/api.ts +++ b/src/internal/api.ts @@ -109,7 +109,7 @@ export interface PricingQuote { chainId: number; serviceFee: { asset: Address; amount: bigint }; deployment: { - escrowType: "batch"; + escrowType: EscrowKind; constructorArgs: `0x${string}`; quoteCommitment: `0x${string}`; rewardAsset: Address; @@ -120,12 +120,13 @@ export interface PricingQuote { sealedPricingAuthorization: `0x${string}`; } -/** Request the API-authored economics and exact EscrowBatch constructor. */ +/** Request the API-authored economics and exact escrow constructor. */ export async function fetchPricingQuote( apiServer: string, params: { chainId: number; sender: Address; + escrowType: EscrowKind; blindedSigners: Address[]; signals: PricingSignalRequest[]; }, @@ -134,7 +135,7 @@ export async function fetchPricingQuote( chain_id: number; service_fee: { asset: Address; amount: string }; deployment: { - escrow_type: "batch"; + escrow_type: EscrowKind; constructor_args: `0x${string}`; quote_commitment: `0x${string}`; reward_asset: Address; @@ -149,7 +150,7 @@ export async function fetchPricingQuote( body: JSON.stringify({ chain_id: params.chainId, sender: params.sender, - escrow_type: "batch", + escrow_type: params.escrowType, blinded_signers: params.blindedSigners, signals: params.signals, }), diff --git a/src/internal/bond.ts b/src/internal/bond.ts index a686b5c..b87b556 100644 --- a/src/internal/bond.ts +++ b/src/internal/bond.ts @@ -3,10 +3,10 @@ import { publicKeyToAddress } from "viem/utils"; import { secp256k1 } from "@noble/curves/secp256k1.js"; import { MirageError } from "../errors.js"; -export interface BatchBlindedSigners { - /** Ordered one-time bid signers committed into EscrowBatch. */ +export interface BlindedSigners { + /** One signer for a single escrow or ordered one-time signers for EscrowBatch. */ blindedSigners: Address[]; - /** Base scalar needed by Nomad to derive each corresponding private key. */ + /** Scalar needed by Nomad to derive each corresponding private key. */ blindingScalar: `0x${string}`; } @@ -17,16 +17,17 @@ function toHex(bytes: Uint8Array): string { } /** - * Derive `G + (s + i)B` for every batch row from one fresh base scalar. + * Derive `G + (s + i)B` for each escrow signer from one fresh base scalar. + * A single escrow uses only index zero, which reduces to `G + sB`. * Nomad receives only `s` inside the encrypted Signal; the pricing API receives * only the resulting public addresses. */ -export function deriveBatchBlindedSigners( +export function deriveBlindedSigners( globalKeyHex: string, signerCount: number, -): BatchBlindedSigners { +): BlindedSigners { if (!Number.isSafeInteger(signerCount) || signerCount < 1) { - throw new MirageError("INVALID_PARAMS", "At least one batch signer is required"); + throw new MirageError("INVALID_PARAMS", "At least one escrow signer is required"); } let globalPoint; diff --git a/src/transfer.ts b/src/transfer.ts index f2e8b27..60fb9b5 100644 --- a/src/transfer.ts +++ b/src/transfer.ts @@ -1,6 +1,7 @@ import { isAddress, type Address, type PublicClient, type WalletClient } from "viem"; import type { ApprovalCheckpoint, + EscrowKind, FeeEstimate, FeeRefreshOverrides, GasPrice, @@ -36,7 +37,7 @@ import { deployQuotedAtomic, estimateQuotedApprovalGas, } from "./internal/escrow.js"; -import { deriveBatchBlindedSigners } from "./internal/bond.js"; +import { deriveBlindedSigners } from "./internal/bond.js"; import { submitSignal } from "./internal/nomad.js"; import { pollTransfers } from "./internal/poll.js"; import { checkAbort } from "./internal/abort.js"; @@ -48,7 +49,7 @@ export interface TransferParams { tokenAddress?: Address; recipientAddress?: Address; amount?: bigint; - /** Multi-recipient form. Every transfer uses EscrowBatch, including n = 1. */ + /** Multi-recipient form. Two or more rows use EscrowBatch. */ transfers?: TransferRow[]; /** * Sender committed into the API quote. Required when walletClient is not @@ -90,6 +91,12 @@ function resolveRows(params: TransferParams): TransferRow[] { return rows; } +/** Select the contract variant before requesting its quote and bytecode. */ +function selectEscrowType(rows: TransferRow[]): EscrowKind { + if (rows.length > 1) return "batch"; + return isNativeToken(rows[0].tokenAddress) ? "native" : "erc20"; +} + function resolveSender(params: TransferParams): Address { const walletSender = params.walletClient ? getAccount(params.walletClient) : undefined; const sender = params.resume?.senderAddress ?? params.senderAddress ?? walletSender; @@ -180,6 +187,7 @@ function feeEstimate( interface TransferContext { rows: TransferRow[]; + escrowType: EscrowKind; sender: Address; networkKey: NetworkKeyStatus; blindedSigners: Address[]; @@ -198,7 +206,15 @@ function isValidFundingMap(value: unknown): value is Record { function quoteFromResume(params: TransferParams): PricingQuote { const resume = params.resume!; + const rows = resolveRows(params); + const escrowMatchesRows = + resume.escrowType === "batch" || + (rows.length === 1 && + ((resume.escrowType === "native" && isNativeToken(rows[0].tokenAddress)) || + (resume.escrowType === "erc20" && !isNativeToken(rows[0].tokenAddress)))); if ( + !["erc20", "native", "batch"].includes(resume.escrowType) || + !escrowMatchesRows || typeof resume.quoteCommitment !== "string" || !resume.quoteCommitment || typeof resume.sealedPricingAuthorization !== "string" || @@ -223,7 +239,7 @@ function quoteFromResume(params: TransferParams): PricingQuote { chainId: params.network.chainId, serviceFee: resume.serviceFee, deployment: { - escrowType: "batch", + escrowType: resume.escrowType, constructorArgs: "0x", quoteCommitment: resume.quoteCommitment, rewardAsset: resume.rewardAsset, @@ -237,6 +253,7 @@ function quoteFromResume(params: TransferParams): PricingQuote { async function buildContext(params: TransferParams): Promise { const rows = resolveRows(params); + const escrowType = params.resume?.escrowType ?? selectEscrowType(rows); const sender = resolveSender(params); const networkKey = await fetchNetworkKey( params.network.apiServer, @@ -257,6 +274,7 @@ async function buildContext(params: TransferParams): Promise { const quote = quoteFromResume(params); return { rows, + escrowType, sender, networkKey, blindedSigners: [], @@ -266,16 +284,23 @@ async function buildContext(params: TransferParams): Promise { }; } - const blinded = deriveBatchBlindedSigners(networkKey.publicKey, rows.length); + const blinded = deriveBlindedSigners(networkKey.publicKey, rows.length); const [quote, obfuscation] = await Promise.all([ fetchPricingQuote(params.network.apiServer, { chainId: params.network.chainId, sender, + escrowType, blindedSigners: blinded.blindedSigners, signals: buildPricingSignals(rows), }), - fetchObfuscation(params.network.apiServer, "batch"), + fetchObfuscation(params.network.apiServer, escrowType), ]); + if (quote.deployment.escrowType !== escrowType) { + throw new MirageError( + "INVALID_PRICING_QUOTE", + "Pricing quote returned a different escrow type than the requested artifact", + ); + } const approvalGasEstimate = await estimateQuotedApprovalGas({ depositByAsset: quote.deployment.depositByAsset, publicClient: params.publicClient, @@ -284,6 +309,7 @@ async function buildContext(params: TransferParams): Promise { return { rows, + escrowType, sender, networkKey, blindedSigners: blinded.blindedSigners, @@ -357,7 +383,7 @@ export async function prepareTransfer(params: TransferParams): Promise; From ad133f1b40b6f9af9e9c2da770665954ab12c0e4 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 04:23:12 +0100 Subject: [PATCH 2/7] test(transfer): cover single and batch escrow selection --- test/bond.test.ts | 10 ++-- test/integration/mock-api.ts | 97 ++++++++++++++++++++++--------- test/integration/transfer.test.ts | 4 +- test/pricing.test.ts | 7 ++- test/transfer-pricing.test.ts | 61 ++++++++++++++++++- 5 files changed, 136 insertions(+), 43 deletions(-) diff --git a/test/bond.test.ts b/test/bond.test.ts index 997c924..2ce465d 100644 --- a/test/bond.test.ts +++ b/test/bond.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect } from "vitest"; import { secp256k1 } from "@noble/curves/secp256k1.js"; import { publicKeyToAddress } from "viem/utils"; -import { - deriveBatchBlindedSigners, -} from "../src/internal/bond.js"; +import { deriveBlindedSigners } from "../src/internal/bond.js"; function toHex(bytes: Uint8Array): string { return Array.from(bytes) @@ -11,12 +9,12 @@ function toHex(bytes: Uint8Array): string { .join(""); } -describe("deriveBatchBlindedSigners", () => { +describe("deriveBlindedSigners", () => { const secret = secp256k1.utils.randomSecretKey(); const globalKey = `0x${toHex(secp256k1.getPublicKey(secret, true))}`; it("derives one ordered signer per row from s + index", () => { - const result = deriveBatchBlindedSigners(globalKey, 3); + const result = deriveBlindedSigners(globalKey, 3); const scalarBytes = Uint8Array.from( (result.blindingScalar.slice(2).match(/../g) ?? []).map((byte) => parseInt(byte, 16)), ); @@ -36,6 +34,6 @@ describe("deriveBatchBlindedSigners", () => { }); it("rejects an empty signer set", () => { - expect(() => deriveBatchBlindedSigners(globalKey, 0)).toThrow(/at least one/i); + expect(() => deriveBlindedSigners(globalKey, 0)).toThrow(/at least one/i); }); }); diff --git a/test/integration/mock-api.ts b/test/integration/mock-api.ts index 74ba784..4f56fee 100644 --- a/test/integration/mock-api.ts +++ b/test/integration/mock-api.ts @@ -4,7 +4,7 @@ * Implements: * GET / — health check * POST /obfuscate_escrow — returns real escrow bytecode (unobfuscated) - * POST /pricing/quote — returns an exact EscrowBatch deployment quote + * POST /pricing/quote — returns an exact escrow deployment quote * POST /compliance — returns a quote-bound mock execution approval * ANY /nomad/{chainId}/* — forwarded to the mock nomad, mirroring the * real API's nomad proxy @@ -163,6 +163,7 @@ function createServer(port: number, nomadUrl?: string): http.Server { const request = JSON.parse(body) as { chain_id: number; sender: string; + escrow_type: "erc20" | "native" | "batch"; blinded_signers: string[]; signals: Array<{ asset: string; @@ -185,6 +186,9 @@ function createServer(port: number, nomadUrl?: string): http.Server { if (rows.length === 0 || request.blinded_signers.length !== rows.length) { throw new Error("pricing requires one blinded signer per row"); } + if (request.escrow_type !== "batch" && rows.length !== 1) { + throw new Error("single escrow pricing requires exactly one row"); + } const rewardAsset = request.signals[0].asset; const rewardAmount = 25n; const deposits = new Map(); @@ -194,38 +198,73 @@ function createServer(port: number, nomadUrl?: string): http.Server { } const rewardKey = rewardAsset.toLowerCase(); deposits.set(rewardKey, (deposits.get(rewardKey) ?? 0n) + rewardAmount); - const constructorArgs = encodeAbiParameters( - [ - { type: "address" }, - { - type: "tuple[]", - components: [ - { type: "address", name: "asset" }, - { type: "address", name: "recipient" }, - { type: "uint256", name: "amount" }, - { type: "uint256", name: "valueWeight" }, - ], - }, - { type: "uint256" }, - { type: "address[]" }, - ], - [ - rewardAsset as `0x${string}`, - rows.map((row) => ({ - asset: row.asset as `0x${string}`, - recipient: row.recipient as `0x${string}`, - amount: BigInt(row.amount), - valueWeight: BigInt(row.valueWeight), - })), - rewardAmount, - request.blinded_signers as `0x${string}`[], - ], - ); + const firstRow = rows[0]; + const constructorArgs = + request.escrow_type === "erc20" + ? encodeAbiParameters( + [ + { type: "address" }, + { type: "address" }, + { type: "uint256" }, + { type: "address" }, + { type: "uint256" }, + ], + [ + firstRow.asset as `0x${string}`, + firstRow.recipient as `0x${string}`, + BigInt(firstRow.amount), + request.blinded_signers[0] as `0x${string}`, + rewardAmount, + ], + ) + : request.escrow_type === "native" + ? encodeAbiParameters( + [ + { type: "address" }, + { type: "uint256" }, + { type: "address" }, + { type: "uint256" }, + ], + [ + firstRow.recipient as `0x${string}`, + BigInt(firstRow.amount), + request.blinded_signers[0] as `0x${string}`, + rewardAmount, + ], + ) + : encodeAbiParameters( + [ + { type: "address" }, + { + type: "tuple[]", + components: [ + { type: "address", name: "asset" }, + { type: "address", name: "recipient" }, + { type: "uint256", name: "amount" }, + { type: "uint256", name: "valueWeight" }, + ], + }, + { type: "uint256" }, + { type: "address[]" }, + ], + [ + rewardAsset as `0x${string}`, + rows.map((row) => ({ + asset: row.asset as `0x${string}`, + recipient: row.recipient as `0x${string}`, + amount: BigInt(row.amount), + valueWeight: BigInt(row.valueWeight), + })), + rewardAmount, + request.blinded_signers as `0x${string}`[], + ], + ); const quoteCommitment = keccak256( stringToHex(`${body}:${crypto.randomUUID()}`), ); const authorization = { version: 1, + escrowType: request.escrow_type, chainId: request.chain_id, sender: request.sender, rewardAsset, @@ -246,7 +285,7 @@ function createServer(port: number, nomadUrl?: string): http.Server { chain_id: request.chain_id, service_fee: { asset: rewardAsset, amount: rewardAmount.toString() }, deployment: { - escrow_type: "batch", + escrow_type: request.escrow_type, constructor_args: constructorArgs, quote_commitment: quoteCommitment, reward_asset: rewardAsset, diff --git a/test/integration/transfer.test.ts b/test/integration/transfer.test.ts index 04177b6..c4993fa 100644 --- a/test/integration/transfer.test.ts +++ b/test/integration/transfer.test.ts @@ -85,7 +85,7 @@ describe("Full transfer flow", () => { const deployStep = steps.find((s) => s.step === "deploy"); if (deployStep?.step === "deploy") { - expect(deployStep.escrowType).toBe("batch"); + expect(deployStep.escrowType).toBe("erc20"); expect(deployStep.secrets.quoteCommitment).toMatch(/^0x[0-9a-f]{64}$/); expect(deployStep.secrets.sealedPricingAuthorization).toMatch(/^0x[0-9a-f]+$/); expect(deployStep.secrets.blindingScalar).toMatch(/^0x[0-9a-f]{64}$/); @@ -192,7 +192,7 @@ describe("Full transfer flow", () => { const deployStep = steps.find((s) => s.step === "deploy"); if (deployStep?.step === "deploy") { - expect(deployStep.escrowType).toBe("batch"); + expect(deployStep.escrowType).toBe("native"); } const balanceAfter = await getTokenBalance( diff --git a/test/pricing.test.ts b/test/pricing.test.ts index 6631447..592134b 100644 --- a/test/pricing.test.ts +++ b/test/pricing.test.ts @@ -35,14 +35,14 @@ describe("fetchObfuscation", () => { }); describe("fetchPricingQuote", () => { - it("sends batch signers and Signals and parses exact deployment funding", async () => { + it("sends the selected escrow type, signers, and Signals", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ chain_id: 1, service_fee: { asset: TOKEN, amount: "25" }, deployment: { - escrow_type: "batch", + escrow_type: "erc20", constructor_args: "0x1234", quote_commitment: COMMITMENT, reward_asset: TOKEN, @@ -64,6 +64,7 @@ describe("fetchPricingQuote", () => { const quote = await fetchPricingQuote("https://api.test", { chainId: 1, sender: SENDER, + escrowType: "erc20", blindedSigners: [SIGNER], signals, }); @@ -75,7 +76,7 @@ describe("fetchPricingQuote", () => { expect(JSON.parse(String(init!.body))).toEqual({ chain_id: 1, sender: SENDER, - escrow_type: "batch", + escrow_type: "erc20", blinded_signers: [SIGNER], signals, }); diff --git a/test/transfer-pricing.test.ts b/test/transfer-pricing.test.ts index 7fa48bc..a69d2c3 100644 --- a/test/transfer-pricing.test.ts +++ b/test/transfer-pricing.test.ts @@ -36,11 +36,15 @@ function toHex(bytes: Uint8Array): string { const networkKey = `0x${toHex(secp256k1.getPublicKey(secp256k1.utils.randomSecretKey(), true))}`; let pricingBody: any; +let obfuscationBody: any; +let quotedEscrowType: "erc20" | "native" | "batch" | undefined; let attestedChainId: number; let attestUrl: string | undefined; beforeEach(() => { pricingBody = undefined; + obfuscationBody = undefined; + quotedEscrowType = undefined; attestedChainId = 31337; attestUrl = undefined; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -57,6 +61,7 @@ beforeEach(() => { } as Response; } if (url.endsWith("/obfuscate_escrow")) { + obfuscationBody = JSON.parse(String(init?.body)); return { ok: true, json: async () => ({ @@ -77,7 +82,7 @@ beforeEach(() => { chain_id: 31337, service_fee: { asset: rewardAsset, amount: "25" }, deployment: { - escrow_type: "batch", + escrow_type: quotedEscrowType ?? pricingBody.escrow_type, constructor_args: "0x1234", quote_commitment: COMMITMENT, reward_asset: rewardAsset, @@ -100,7 +105,7 @@ const network = createNetworkConfig("ethereum", { }); describe("prepareTransfer pricing flow", () => { - it("quotes a one-row transfer as an EscrowBatch with one blinded signer", async () => { + it("quotes a one-row ERC-20 transfer with one blinded signer", async () => { const publicClient = { getTransactionCount: vi.fn().mockResolvedValue(5), estimateContractGas: vi.fn().mockResolvedValue(46_000n), @@ -114,7 +119,8 @@ describe("prepareTransfer pricing flow", () => { network, }); - expect(pricingBody.escrow_type).toBe("batch"); + expect(pricingBody.escrow_type).toBe("erc20"); + expect(obfuscationBody.escrow_type).toBe("erc20"); expect(pricingBody.blinded_signers).toHaveLength(1); expect(pricingBody.signals).toEqual([ { @@ -129,6 +135,37 @@ describe("prepareTransfer pricing flow", () => { expect(prepared.fees.totalWalletGasEstimate).toBe(1_280_567n); }); + it("quotes a one-row native transfer as a native escrow", async () => { + const prepared = await prepareTransfer({ + tokenAddress: NATIVE_TOKEN_ADDRESS, + recipientAddress: RECIPIENT_A, + amount: 100n, + senderAddress: SENDER, + publicClient: {} as any, + network, + }); + + expect(pricingBody.escrow_type).toBe("native"); + expect(obfuscationBody.escrow_type).toBe("native"); + expect(pricingBody.blinded_signers).toHaveLength(1); + expect(prepared.fees.rewardAsset).toBe(NATIVE_TOKEN_ADDRESS); + }); + + it("rejects a quote for a different escrow artifact", async () => { + quotedEscrowType = "batch"; + + await expect( + prepareTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + senderAddress: SENDER, + publicClient: {} as any, + network, + }), + ).rejects.toMatchObject({ code: "INVALID_PRICING_QUOTE" }); + }); + it("keeps preparation available when approval gas simulation is unavailable", async () => { const prepared = await prepareTransfer({ tokenAddress: USDC, @@ -159,6 +196,8 @@ describe("prepareTransfer pricing flow", () => { }); expect(pricingBody.blinded_signers).toHaveLength(3); + expect(pricingBody.escrow_type).toBe("batch"); + expect(obfuscationBody.escrow_type).toBe("batch"); expect(pricingBody.signals.map((signal: any) => signal.asset)).toEqual([ NATIVE_TOKEN_ADDRESS, USDC, @@ -200,6 +239,22 @@ describe("prepareTransfer pricing flow", () => { }, ); + it("retains the deployed escrow type when resuming", async () => { + const prepared = await prepareTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + publicClient: {} as any, + network, + resume: { ...VALID_RESUME, escrowType: "erc20" }, + }); + + await expect(prepared.deploy({} as any)).resolves.toMatchObject({ + escrowType: "erc20", + secrets: { escrowType: "erc20" }, + }); + }); + it("surfaces real whitelist values returned by compliance", async () => { const prepared = await prepareTransfer({ tokenAddress: USDC, From b7c50e6e0a75d64732bbde33dc5dfe0a4a3cff35 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 04:23:36 +0100 Subject: [PATCH 3/7] ocs: document single-escrow SDK flow --- CLAUDE.md | 14 +++++++------- README.md | 8 +++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1e41926..5336d40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -`@mirageprivacy/sdk` is the TypeScript SDK for private transfers through Mirage. It coordinates wallet approvals, API-authored pricing, EscrowBatch deployment, compliance approval, encrypted Signal submission to Nomad, and recipient-transfer polling. +`@mirageprivacy/sdk` is the TypeScript SDK for private transfers through Mirage. It coordinates wallet approvals, API-authored pricing, escrow deployment, compliance approval, encrypted Signal submission to Nomad, and recipient-transfer polling. The API owns pricing and funding calculations. Nomad verifies the signed pricing and compliance authorizations and performs private execution. The SDK must not recreate the private platform/node fee split or accept executable economics from the application. @@ -84,12 +84,12 @@ The SDK implements the following private transfer flow: 1. **Prepare**: Preserve row order, group rows into Signals by asset, fetch Nomad's attested network key, and derive one blinded signer per row. 2. **Quote**: Send the chain, sender, ordered Signals, execution modes, and blinded signers to `/pricing/quote`. 3. **Approve**: Approve each non-native asset using the exact amount returned in `depositByAsset`. -4. **Deploy**: Deploy the API-provided EscrowBatch constructor with the exact quoted native `msgValue`. +4. **Deploy**: Deploy the API-provided escrow constructor with the exact quoted native `msgValue`. 5. **Compliance**: Submit the deployment transaction and quote commitment to `/compliance` and receive a quote-bound execution approval. 6. **Signal**: Encrypt a minimal Signal envelope with Nomad's attested network key and submit it to `/signal`. 7. **Complete**: Poll and emit each recipient delivery, followed by the final completion event. -Every transfer uses `EscrowBatch`, including a one-row transfer. The SDK does not fall back to the legacy ERC-20 or native escrow formats. +A one-row ERC-20 request uses `EscrowERC20`, a one-row native request uses `EscrowNative`, and two or more rows use `EscrowBatch`. The selected type is sent to both pricing and obfuscation and is retained for compliance and resume. ### Pricing and Signal Construction @@ -106,7 +106,7 @@ The pricing response provides: - Reward asset and reward amount - Exact deposits required per asset - Exact native `msgValue` -- Exact EscrowBatch constructor arguments +- Exact constructor arguments for the selected escrow - Quote commitment - Pricing authorization sealed directly for Nomad @@ -152,8 +152,8 @@ The encrypted Nomad envelope contains the escrow address, base blinding scalar, **Blinded signers** (`src/internal/bond.ts`) -- Generates the local batch scalar -- Derives one ordered blinded signer for every row +- Generates the local blinding scalar +- Derives one signer for a single escrow or one ordered blinded signer per batch row **Polling** (`src/internal/poll.ts`) @@ -193,7 +193,7 @@ Public interfaces live in `src/types.ts` and are exported through `src/index.ts` - **API-owned pricing**: Never calculate the platform fee, node fee, reward pot, floor, ceiling, gas buffer, or capital component in the SDK. - **Exact deployment**: Approval amounts, constructor arguments, and `msgValue` must come directly from the quote used for that deployment. -- **One-row batches**: A single transfer is the `n = 1` EscrowBatch case, not a separate protocol path. +- **Escrow selection**: One ERC-20 row uses `EscrowERC20`, one native row uses `EscrowNative`, and multiple rows use `EscrowBatch`. - **Row ordering**: Reordering rows changes signer derivation, Signal grouping, and potentially the reward denomination. Preserve the caller's order. - **No linked mode**: Do not add linked execution to API requests or Nomad Signals. - **Attestation hash**: The payload commitment is `sha256(publicKey . chainId_be . maxBalanceUsd_be . complianceKeys . pricingKeys)`. Preserve both signer arrays in served order. diff --git a/README.md b/README.md index 87f46bc..43dc4e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @mirageprivacy/sdk -TypeScript SDK for private transfers on the Mirage protocol. It requests API-authored pricing, deploys the exact quoted EscrowBatch, obtains a quote-bound execution approval, submits the encrypted Nomad Signal, and polls recipient transfers. +TypeScript SDK for private transfers on the Mirage protocol. It requests API-authored pricing, deploys the exact quoted escrow, obtains a quote-bound execution approval, submits the encrypted Nomad Signal, and polls recipient transfers. ## Install @@ -88,7 +88,7 @@ for await (const event of prepared.complete(walletClient, deployed.secrets)) { ``` `ApprovalCheckpoint` and `TransferSecrets` are serializable stage boundaries. -The latter includes the batch scalar, quote commitment, and opaque sealed +The latter includes the blinding scalar, quote commitment, and opaque sealed pricing authorization. Persist it immediately: a reload must submit the same authorization that produced the deployed constructor. @@ -120,11 +120,13 @@ now requires an API server with the proxy configured. 1. **fees** - Public API service fee and exact funding requirements 2. **approve** - One exact approval per ERC-20 funding asset -3. **deploy** - Exact API-quoted EscrowBatch deployment +3. **deploy** - Exact API-quoted escrow deployment 4. **compliance** - Execution approval bound to the deployment and quote 5. **signal** - Minimal encrypted Signal envelope submission to Nomad 6. **complete** - Transfer event observed on-chain +One ERC-20 row deploys `EscrowERC20`, one native row deploys `EscrowNative`, and two or more rows deploy `EscrowBatch`. + ### Cancellation Pass an `AbortSignal` to cancel mid-transfer: From c741de013aab18b0d0aa56016cb6c43a5a4e0f26 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 04:24:04 +0100 Subject: [PATCH 4/7] chore(release): bump SDK version to 0.4.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6544c85..54fb7ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mirageprivacy/sdk", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mirageprivacy/sdk", - "version": "0.3.2", + "version": "0.4.0", "license": "UNLICENSED", "dependencies": { "@noble/curves": "^2.2.0", diff --git a/package.json b/package.json index c369ced..87362de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mirageprivacy/sdk", - "version": "0.3.2", + "version": "0.4.0", "description": "SDK for private transfers on Mirage", "type": "module", "main": "./dist/index.cjs", From 22532865ed48975243717ab1a441a4aaf13d2d96 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 05:08:47 +0100 Subject: [PATCH 5/7] fix(transfer): validate batch row count when resuming --- src/transfer.ts | 2 +- test/transfer-pricing.test.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/transfer.ts b/src/transfer.ts index 60fb9b5..a0d4808 100644 --- a/src/transfer.ts +++ b/src/transfer.ts @@ -208,7 +208,7 @@ function quoteFromResume(params: TransferParams): PricingQuote { const resume = params.resume!; const rows = resolveRows(params); const escrowMatchesRows = - resume.escrowType === "batch" || + (resume.escrowType === "batch" && rows.length > 1) || (rows.length === 1 && ((resume.escrowType === "native" && isNativeToken(rows[0].tokenAddress)) || (resume.escrowType === "erc20" && !isNativeToken(rows[0].tokenAddress)))); diff --git a/test/transfer-pricing.test.ts b/test/transfer-pricing.test.ts index a69d2c3..31f3f23 100644 --- a/test/transfer-pricing.test.ts +++ b/test/transfer-pricing.test.ts @@ -13,7 +13,7 @@ const DEPLOY_HASH = `0x${"22".repeat(32)}` as `0x${string}`; const VALID_RESUME = { escrowAddress: SENDER, - escrowType: "batch" as const, + escrowType: "erc20" as const, blindingScalar: `0x${"33".repeat(32)}` as `0x${string}`, seed: `0x${"44".repeat(32)}`, deployHash: DEPLOY_HASH, @@ -255,6 +255,33 @@ describe("prepareTransfer pricing flow", () => { }); }); + it("rejects a batch resume with only one row", async () => { + await expect( + prepareTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + publicClient: {} as any, + network, + resume: { ...VALID_RESUME, escrowType: "batch" }, + }), + ).rejects.toMatchObject({ code: "INVALID_RESUME" }); + }); + + it("accepts a batch resume with multiple rows", async () => { + await expect( + prepareTransfer({ + transfers: [ + { tokenAddress: USDC, recipientAddress: RECIPIENT_A, amount: 40n }, + { tokenAddress: USDC, recipientAddress: RECIPIENT_B, amount: 60n }, + ], + publicClient: {} as any, + network, + resume: { ...VALID_RESUME, escrowType: "batch" }, + }), + ).resolves.toBeDefined(); + }); + it("surfaces real whitelist values returned by compliance", async () => { const prepared = await prepareTransfer({ tokenAddress: USDC, From bd5e8e5fc1bae0e7d9a666068e38163012c32cc1 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 07:15:35 +0100 Subject: [PATCH 6/7] chore: update test to match new escrow changes --- test/integration/mock-api.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/integration/mock-api.ts b/test/integration/mock-api.ts index 4f56fee..94cec4d 100644 --- a/test/integration/mock-api.ts +++ b/test/integration/mock-api.ts @@ -191,6 +191,9 @@ function createServer(port: number, nomadUrl?: string): http.Server { } const rewardAsset = request.signals[0].asset; const rewardAmount = 25n; + const gasBudgetAmount = 5n; + const freshPathOverheadAmount = 5n; + const maxGasAdvance = gasBudgetAmount + freshPathOverheadAmount; const deposits = new Map(); for (const row of rows) { const key = row.asset.toLowerCase(); @@ -208,6 +211,7 @@ function createServer(port: number, nomadUrl?: string): http.Server { { type: "uint256" }, { type: "address" }, { type: "uint256" }, + { type: "uint256" }, ], [ firstRow.asset as `0x${string}`, @@ -215,6 +219,7 @@ function createServer(port: number, nomadUrl?: string): http.Server { BigInt(firstRow.amount), request.blinded_signers[0] as `0x${string}`, rewardAmount, + maxGasAdvance, ], ) : request.escrow_type === "native" @@ -224,12 +229,14 @@ function createServer(port: number, nomadUrl?: string): http.Server { { type: "uint256" }, { type: "address" }, { type: "uint256" }, + { type: "uint256" }, ], [ firstRow.recipient as `0x${string}`, BigInt(firstRow.amount), request.blinded_signers[0] as `0x${string}`, rewardAmount, + maxGasAdvance, ], ) : encodeAbiParameters( @@ -245,6 +252,7 @@ function createServer(port: number, nomadUrl?: string): http.Server { ], }, { type: "uint256" }, + { type: "uint256" }, { type: "address[]" }, ], [ @@ -256,6 +264,7 @@ function createServer(port: number, nomadUrl?: string): http.Server { valueWeight: BigInt(row.valueWeight), })), rewardAmount, + maxGasAdvance, request.blinded_signers as `0x${string}`[], ], ); @@ -269,6 +278,9 @@ function createServer(port: number, nomadUrl?: string): http.Server { sender: request.sender, rewardAsset, rewardAmount: rewardAmount.toString(), + maxGasAdvance: maxGasAdvance.toString(), + gasBudgetAmount: gasBudgetAmount.toString(), + freshPathOverheadAmount: freshPathOverheadAmount.toString(), rows: rows.map((row, rowIndex) => ({ ...row, rowIndex })), quoteCommitment, }; From 48afabe960705dbc0dd18b85842e9f7a113662be Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 07:44:33 +0100 Subject: [PATCH 7/7] feat(sdk): include escrow type in encrypted signals --- src/internal/nomad.ts | 4 +++- src/transfer.ts | 1 + test/signal.test.ts | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/internal/nomad.ts b/src/internal/nomad.ts index 0bd0915..92b5b78 100644 --- a/src/internal/nomad.ts +++ b/src/internal/nomad.ts @@ -1,7 +1,7 @@ import type { Address } from "viem"; import { ApiError, MissingBlindingScalarError } from "../errors.js"; import { nomadProxyUrl } from "./api.js"; -import type { ExecutionApproval, NetworkKeyStatus } from "../types.js"; +import type { EscrowKind, ExecutionApproval, NetworkKeyStatus } from "../types.js"; async function encryptSignal(payload: Uint8Array, publicKeyHex: string): Promise { const { encrypt } = await import("eciesjs"); @@ -15,6 +15,7 @@ function toHexString(bytes: Uint8Array): string { } export interface SignalParams { + escrowType: EscrowKind; escrowAddress: Address; blindingScalar: `0x${string}`; sealedPricingAuthorization: `0x${string}`; @@ -36,6 +37,7 @@ export async function submitSignal(params: SignalParams): Promise { } const signal = { + escrowType: params.escrowType, escrowContract: params.escrowAddress, blindingScalar: params.blindingScalar, sealedPricingAuthorization: params.sealedPricingAuthorization, diff --git a/src/transfer.ts b/src/transfer.ts index a0d4808..7409ec9 100644 --- a/src/transfer.ts +++ b/src/transfer.ts @@ -586,6 +586,7 @@ async function* completeTransfer( checkAbort(params.abortSignal, { escrowAddress: escrow }); assertAccountUnchanged(walletClient, account, escrow); const response = await submitSignal({ + escrowType: resume.escrowType, escrowAddress: escrow, blindingScalar: resume.blindingScalar, sealedPricingAuthorization: resume.sealedPricingAuthorization, diff --git a/test/signal.test.ts b/test/signal.test.ts index 38cfa79..8c29162 100644 --- a/test/signal.test.ts +++ b/test/signal.test.ts @@ -39,6 +39,7 @@ const executionApproval: ExecutionApproval = { }; const baseParams = { + escrowType: "batch" as const, escrowAddress: ESCROW, blindingScalar: SCALAR, sealedPricingAuthorization: SEALED, @@ -58,6 +59,7 @@ describe("submitSignal", () => { await submitSignal(baseParams); expect(captured.payload).toEqual({ + escrowType: "batch", escrowContract: ESCROW, blindingScalar: SCALAR, sealedPricingAuthorization: SEALED,