diff --git a/package-lock.json b/package-lock.json index f080749..e0c30cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mirageprivacy/sdk", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mirageprivacy/sdk", - "version": "0.4.1", + "version": "0.4.2", "license": "UNLICENSED", "dependencies": { "@noble/curves": "^2.2.0", diff --git a/package.json b/package.json index d20f66a..629ec02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mirageprivacy/sdk", - "version": "0.4.1", + "version": "0.4.2", "description": "SDK for private transfers on Mirage", "type": "module", "main": "./dist/index.cjs", diff --git a/src/index.ts b/src/index.ts index 24c37a4..75e3317 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ // Public API export { networks, createNetworkConfig, MIRAGE_MRSIGNER } from "./networks.js"; -export { prepareTransfer, executeTransfer } from "./transfer.js"; -export type { TransferParams } from "./transfer.js"; +export { prepareTransfer, previewTransfer, executeTransfer } from "./transfer.js"; +export type { TransferParams, PreviewParams } from "./transfer.js"; export type { ApiHealth, GasHistoryAverages, @@ -52,6 +52,7 @@ export type { FeeRefreshOverrides, AssetRequirement, TransferEvent, + TransferPreview, TransferRow, TransferSecrets, TransferStep, diff --git a/src/internal/api.ts b/src/internal/api.ts index cdfa302..e05f4cc 100644 --- a/src/internal/api.ts +++ b/src/internal/api.ts @@ -120,6 +120,80 @@ export interface PricingQuote { sealedPricingAuthorization: `0x${string}`; } +/** + * Unsigned quote returned when no sender is supplied. Carries the same fees and + * funding as a signed quote but omits every deployable field. + */ +export interface PricingPreviewQuote { + chainId: number; + serviceFee: { asset: Address; amount: bigint }; + rewardAsset: Address; + rewardAmount: bigint; + depositByAsset: Record; + msgValue: bigint; +} + +interface PricingQuoteResponse { + chain_id: number; + service_fee: { asset: Address; amount: string }; + deployment: { + escrow_type: EscrowKind; + constructor_args: `0x${string}` | null; + quote_commitment: `0x${string}` | null; + reward_asset: Address; + reward_amount: string; + deposit_by_asset: Record; + msg_value: string; + }; + sealed_pricing_authorization: `0x${string}` | null; +} + +function parseDepositByAsset(depositByAsset: Record): Record { + return Object.fromEntries( + Object.entries(depositByAsset).map(([asset, amount]) => [asset, BigInt(amount)]), + ); +} + +function postPricingQuote( + apiServer: string, + body: Record, +): Promise { + return request(`${apiServer}/pricing/quote`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** + * Request unsigned fees and funding for a transfer with no committed sender. + * The result is display-only: the API returns no commitment, constructor, or + * pricing authorization, so it can never be deployed. + */ +export async function fetchPricingPreview( + apiServer: string, + params: { + chainId: number; + escrowType: EscrowKind; + signals: PricingSignalRequest[]; + }, +): Promise { + const res = await postPricingQuote(apiServer, { + chain_id: params.chainId, + escrow_type: params.escrowType, + signals: params.signals, + }); + + return { + chainId: res.chain_id, + serviceFee: { asset: res.service_fee.asset, amount: BigInt(res.service_fee.amount) }, + rewardAsset: res.deployment.reward_asset, + rewardAmount: BigInt(res.deployment.reward_amount), + depositByAsset: parseDepositByAsset(res.deployment.deposit_by_asset), + msgValue: BigInt(res.deployment.msg_value), + }; +} + /** Request the API-authored economics and exact escrow constructor. */ export async function fetchPricingQuote( apiServer: string, @@ -131,30 +205,25 @@ export async function fetchPricingQuote( signals: PricingSignalRequest[]; }, ): Promise { - const res = await request<{ - chain_id: number; - service_fee: { asset: Address; amount: string }; - deployment: { - escrow_type: EscrowKind; - constructor_args: `0x${string}`; - quote_commitment: `0x${string}`; - reward_asset: Address; - reward_amount: string; - deposit_by_asset: Record; - msg_value: string; - }; - sealed_pricing_authorization: `0x${string}`; - }>(`${apiServer}/pricing/quote`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chain_id: params.chainId, - sender: params.sender, - escrow_type: params.escrowType, - blinded_signers: params.blindedSigners, - signals: params.signals, - }), + const res = await postPricingQuote(apiServer, { + chain_id: params.chainId, + sender: params.sender, + escrow_type: params.escrowType, + blinded_signers: params.blindedSigners, + signals: params.signals, }); + // A signed request must never yield preview fields. Reject rather than + // deploying against a partial quote. + if ( + !res.sealed_pricing_authorization || + !res.deployment.constructor_args || + !res.deployment.quote_commitment + ) { + throw new MirageError( + "INVALID_PRICING_QUOTE", + "Pricing returned an unsigned preview quote for a committed sender", + ); + } return { chainId: res.chain_id, @@ -165,12 +234,7 @@ export async function fetchPricingQuote( quoteCommitment: res.deployment.quote_commitment, rewardAsset: res.deployment.reward_asset, rewardAmount: BigInt(res.deployment.reward_amount), - depositByAsset: Object.fromEntries( - Object.entries(res.deployment.deposit_by_asset).map(([asset, amount]) => [ - asset, - BigInt(amount), - ]), - ), + depositByAsset: parseDepositByAsset(res.deployment.deposit_by_asset), msgValue: BigInt(res.deployment.msg_value), }, sealedPricingAuthorization: res.sealed_pricing_authorization, diff --git a/src/transfer.ts b/src/transfer.ts index 504d273..644c330 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, + AssetRequirement, EscrowKind, FeeEstimate, FeeRefreshOverrides, @@ -9,6 +10,7 @@ import type { NetworkKeyStatus, PreparedTransfer, TransferEvent, + TransferPreview, TransferRow, TransferSecrets, TransferStep, @@ -27,6 +29,7 @@ import { fetchComplianceApproval, fetchNetworkKey, fetchObfuscation, + fetchPricingPreview, fetchPricingQuote, whitelistRequirementFromError, } from "./internal/api.js"; @@ -70,7 +73,12 @@ export interface TransferParams { const DEFAULT_POLL_TIMEOUT = 120_000; -function resolveRows(params: TransferParams): TransferRow[] { +function resolveRows(params: { + transfers?: TransferRow[]; + tokenAddress?: Address; + recipientAddress?: Address; + amount?: bigint; +}): TransferRow[] { const rows = params.transfers?.length ? params.transfers : params.tokenAddress && params.recipientAddress && params.amount !== undefined @@ -153,17 +161,29 @@ function buildPricingSignals(rows: TransferRow[]): PricingSignalRequest[] { return [...signals.values()]; } -function feeEstimate( +/** Pairs each API-required deposit with the principal the caller requested. */ +function assetRequirements( rows: TransferRow[], - quote: PricingQuote, - approvalGasEstimate?: bigint, - deploymentGasEstimate?: bigint, -): FeeEstimate { + depositByAsset: Record, +): AssetRequirement[] { const principal = new Map(); for (const row of rows) { const key = row.tokenAddress.toLowerCase(); principal.set(key, (principal.get(key) ?? 0n) + row.amount); } + return Object.entries(depositByAsset).map(([asset, amount]) => ({ + tokenAddress: asset as Address, + transferAmount: principal.get(asset.toLowerCase()) ?? 0n, + escrowAmount: amount, + })); +} + +function feeEstimate( + rows: TransferRow[], + quote: PricingQuote, + approvalGasEstimate?: bigint, + deploymentGasEstimate?: bigint, +): FeeEstimate { const totalWalletGasEstimate = approvalGasEstimate !== undefined && deploymentGasEstimate !== undefined ? approvalGasEstimate + deploymentGasEstimate @@ -177,11 +197,7 @@ function feeEstimate( rewardAmount: quote.deployment.rewardAmount, depositByAsset: { ...quote.deployment.depositByAsset }, msgValue: quote.deployment.msgValue, - assetRequirements: Object.entries(quote.deployment.depositByAsset).map(([asset, amount]) => ({ - tokenAddress: asset as Address, - transferAmount: principal.get(asset.toLowerCase()) ?? 0n, - escrowAmount: amount, - })), + assetRequirements: assetRequirements(rows, quote.deployment.depositByAsset), }; } @@ -328,6 +344,70 @@ function assertQuotedAccount(walletClient: WalletClient, sender: Address): Addre return account; } +/** + * Stand-in sender for preview gas simulation. ERC-20 `approve` writes an + * allowance slot without reading the caller's balance, so its gas is the same + * for any address; only the allowance slot's prior value matters, and a fresh + * address matches the zero-to-nonzero cost a first-time approver pays. + */ +const PREVIEW_SENDER = "0x0000000000000000000000000000000000000001" as const; + +export interface PreviewParams { + tokenAddress?: Address; + recipientAddress?: Address; + amount?: bigint; + transfers?: TransferRow[]; + network: NetworkConfig; + /** Enables approval gas simulation. Omit to return fees without wallet gas. */ + publicClient?: PublicClient; + abortSignal?: AbortSignal; +} + +/** + * Quote fees before a wallet is connected. Requires no sender, wallet, or + * attestation fetch, and the API returns nothing deployable. Supply a + * `publicClient` to include wallet gas, simulated against a stand-in sender. + * Call `prepareTransfer` once the wallet connects for the real sender-bound + * quote; the two agree on fees for identical rows. + */ +export async function previewTransfer(params: PreviewParams): Promise { + const rows = resolveRows(params); + const escrowType = selectEscrowType(rows); + const [preview, obfuscation] = await Promise.all([ + fetchPricingPreview(params.network.apiServer, { + chainId: params.network.chainId, + escrowType, + signals: buildPricingSignals(rows), + }), + fetchObfuscation(params.network.apiServer, escrowType).catch(() => undefined), + ]); + checkAbort(params.abortSignal); + + const approvalGasEstimate = params.publicClient + ? await estimateQuotedApprovalGas({ + depositByAsset: preview.depositByAsset, + publicClient: params.publicClient, + account: PREVIEW_SENDER, + }).catch(() => undefined) + : undefined; + + const deploymentGasEstimate = obfuscation?.deploymentGasEstimate; + return { + serviceFee: preview.serviceFee, + approvalGasEstimate, + deploymentGasEstimate, + totalWalletGasEstimate: + approvalGasEstimate !== undefined && deploymentGasEstimate !== undefined + ? approvalGasEstimate + deploymentGasEstimate + : undefined, + rewardAsset: preview.rewardAsset, + rewardAmount: preview.rewardAmount, + depositByAsset: { ...preview.depositByAsset }, + msgValue: preview.msgValue, + assetRequirements: assetRequirements(rows, preview.depositByAsset), + }; +} + export async function prepareTransfer(params: TransferParams): Promise { const context = await buildContext(params); let transactionGasPrice = params.gasPrice; diff --git a/src/types.ts b/src/types.ts index 9b5e077..51d4755 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,31 @@ export interface FeeEstimate { assetRequirements: AssetRequirement[]; } +/** + * Display-only fees for a transfer with no committed sender. Carries the same + * API-authored economics and wallet gas as a quoted transfer, but omits every + * deployable field. Re-quote with `prepareTransfer` once a wallet is + * connected: the sender is committed into the real quote, so a preview can + * never be executed. + */ +export interface TransferPreview { + serviceFee: AssetAmount; + /** + * Sum of gas units for every exact ERC-20 approval, simulated against a + * stand-in sender. Present only when a `publicClient` was supplied. + */ + approvalGasEstimate?: bigint; + /** API-simulated gas units for deploying the obfuscated escrow. */ + deploymentGasEstimate?: bigint; + /** Complete wallet gas units: approvals plus escrow deployment. */ + totalWalletGasEstimate?: bigint; + rewardAsset: Address; + rewardAmount: bigint; + depositByAsset: Record; + msgValue: bigint; + assetRequirements: AssetRequirement[]; +} + export interface AssetAmount { asset: Address; amount: bigint; diff --git a/test/transfer-pricing.test.ts b/test/transfer-pricing.test.ts index 31f3f23..be66739 100644 --- a/test/transfer-pricing.test.ts +++ b/test/transfer-pricing.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { secp256k1 } from "@noble/curves/secp256k1.js"; -import { prepareTransfer } from "../src/transfer.js"; +import { prepareTransfer, previewTransfer } from "../src/transfer.js"; import { createNetworkConfig } from "../src/networks.js"; import { NATIVE_TOKEN_ADDRESS } from "../src/token.js"; @@ -40,6 +40,7 @@ let obfuscationBody: any; let quotedEscrowType: "erc20" | "native" | "batch" | undefined; let attestedChainId: number; let attestUrl: string | undefined; +let forceUnsignedQuote: boolean; beforeEach(() => { pricingBody = undefined; @@ -47,6 +48,7 @@ beforeEach(() => { quotedEscrowType = undefined; attestedChainId = 31337; attestUrl = undefined; + forceUnsignedQuote = false; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith("/attest")) { @@ -76,6 +78,9 @@ beforeEach(() => { if (url.endsWith("/pricing/quote")) { pricingBody = JSON.parse(String(init?.body)); const rewardAsset = pricingBody.signals[0].asset; + // A request without a sender receives an unsigned preview, matching the + // API contract. + const preview = forceUnsignedQuote || pricingBody.sender === undefined; return { ok: true, json: async () => ({ @@ -83,14 +88,14 @@ beforeEach(() => { service_fee: { asset: rewardAsset, amount: "25" }, deployment: { escrow_type: quotedEscrowType ?? pricingBody.escrow_type, - constructor_args: "0x1234", - quote_commitment: COMMITMENT, + constructor_args: preview ? null : "0x1234", + quote_commitment: preview ? null : COMMITMENT, reward_asset: rewardAsset, reward_amount: "25", deposit_by_asset: { [rewardAsset]: "125" }, msg_value: rewardAsset === NATIVE_TOKEN_ADDRESS ? "125" : "0", }, - sealed_pricing_authorization: "0xabcd", + sealed_pricing_authorization: preview ? null : "0xabcd", }), } as Response; } @@ -339,3 +344,153 @@ describe("nomad proxy routing", () => { await expect(prepare()).resolves.toBeDefined(); }); }); + +describe("previewTransfer", () => { + it("quotes fees with no sender, wallet, or attestation", async () => { + const preview = await previewTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + network, + }); + + expect(pricingBody.sender).toBeUndefined(); + expect(pricingBody.blinded_signers).toBeUndefined(); + expect(pricingBody.escrow_type).toBe("erc20"); + expect(attestUrl).toBeUndefined(); + expect(preview.serviceFee).toEqual({ asset: USDC, amount: 25n }); + expect(preview.rewardAmount).toBe(25n); + expect(preview.depositByAsset).toEqual({ [USDC]: 125n }); + expect(preview.assetRequirements).toEqual([ + { tokenAddress: USDC, transferAmount: 100n, escrowAmount: 125n }, + ]); + }); + + it("simulates wallet gas against a stand-in sender when given a public client", async () => { + const estimateContractGas = vi.fn().mockResolvedValue(46_000n); + const preview = await previewTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + network, + publicClient: { + getTransactionCount: vi.fn().mockResolvedValue(0), + estimateContractGas, + } as any, + }); + + // Approve gas does not depend on the caller's balance, so a stand-in + // sender yields the same estimate the connected wallet will pay. + expect(estimateContractGas).toHaveBeenCalledWith( + expect.objectContaining({ account: "0x0000000000000000000000000000000000000001" }), + ); + expect(preview.approvalGasEstimate).toBe(46_000n); + expect(preview.deploymentGasEstimate).toBe(1_234_567n); + expect(preview.totalWalletGasEstimate).toBe(1_280_567n); + }); + + it("omits wallet gas when no public client is supplied", async () => { + const preview = await previewTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + network, + }); + + expect(preview.approvalGasEstimate).toBeUndefined(); + expect(preview.totalWalletGasEstimate).toBeUndefined(); + }); + + it("keeps previewing when approval gas simulation fails", async () => { + const preview = await previewTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + network, + publicClient: { + getTransactionCount: vi.fn().mockRejectedValue(new Error("RPC unavailable")), + } as any, + }); + + expect(preview.approvalGasEstimate).toBeUndefined(); + expect(preview.totalWalletGasEstimate).toBeUndefined(); + expect(preview.serviceFee).toEqual({ asset: USDC, amount: 25n }); + }); + + it("exposes no deployable field", async () => { + const preview = await previewTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + network, + }); + + expect(preview).not.toHaveProperty("quoteCommitment"); + expect(preview).not.toHaveProperty("constructorArgs"); + expect(preview).not.toHaveProperty("sealedPricingAuthorization"); + }); + + it("agrees with the sender-bound quote on fees for identical rows", async () => { + const rows = [ + { tokenAddress: USDC, recipientAddress: RECIPIENT_A, amount: 100n }, + { tokenAddress: USDC, recipientAddress: RECIPIENT_B, amount: 200n }, + ]; + const publicClient = () => + ({ + getTransactionCount: vi.fn().mockResolvedValue(5), + estimateContractGas: vi.fn().mockResolvedValue(46_000n), + }) as any; + const preview = await previewTransfer({ + transfers: rows, + network, + publicClient: publicClient(), + }); + const prepared = await prepareTransfer({ + transfers: rows, + senderAddress: SENDER, + publicClient: publicClient(), + network, + }); + + expect(preview.approvalGasEstimate).toBe(prepared.fees.approvalGasEstimate); + expect(preview.deploymentGasEstimate).toBe(prepared.fees.deploymentGasEstimate); + expect(preview.totalWalletGasEstimate).toBe(prepared.fees.totalWalletGasEstimate); + expect(preview.serviceFee).toEqual(prepared.fees.serviceFee); + expect(preview.rewardAsset).toBe(prepared.fees.rewardAsset); + expect(preview.rewardAmount).toBe(prepared.fees.rewardAmount); + expect(preview.depositByAsset).toEqual(prepared.fees.depositByAsset); + expect(preview.msgValue).toBe(prepared.fees.msgValue); + expect(preview.assetRequirements).toEqual(prepared.fees.assetRequirements); + }); + + it("still previews a native transfer", async () => { + const preview = await previewTransfer({ + tokenAddress: NATIVE_TOKEN_ADDRESS, + recipientAddress: RECIPIENT_A, + amount: 100n, + network, + }); + + expect(pricingBody.escrow_type).toBe("native"); + expect(preview.msgValue).toBe(125n); + }); + + it("rejects an unsigned quote returned for a committed sender", async () => { + // Guards against a downgrade: a sender-bound request must never be + // satisfied by a preview response. + forceUnsignedQuote = true; + await expect( + prepareTransfer({ + tokenAddress: USDC, + recipientAddress: RECIPIENT_A, + amount: 100n, + senderAddress: SENDER, + publicClient: { + getTransactionCount: vi.fn().mockResolvedValue(5), + estimateContractGas: vi.fn().mockResolvedValue(46_000n), + } as any, + network, + }), + ).rejects.toMatchObject({ code: "INVALID_PRICING_QUOTE" }); + }); +});