Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 14 additions & 0 deletions src/internal/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PricingQuote> {
Expand All @@ -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
Expand Down
108 changes: 108 additions & 0 deletions src/internal/zk.ts
Original file line number Diff line number Diff line change
@@ -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("")}`;
}
78 changes: 78 additions & 0 deletions src/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
ObfuscationResult,
PricingQuote,
PricingSignalRequest,
ZkIntentRequest,
} from "./internal/api.js";
import {
fetchComplianceApproval,
Expand All @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -328,12 +391,27 @@ async function buildContext(params: TransferParams): Promise<TransferContext> {
}

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),
Expand Down
Loading
Loading