From b38d1d06d33026a89a023b637af9473dfd97632d Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 08:45:00 +0100 Subject: [PATCH 1/3] fix(sdk): restore buffered wallet gas recommendations --- package-lock.json | 4 ++-- package.json | 2 +- src/internal/escrow.ts | 47 ++++++++++++++++++++++++++++++++++++++---- src/transfer.ts | 9 ++++++-- src/types.ts | 2 +- test/escrow.test.ts | 35 ++++++++++++++++++++++++++++++- 6 files changed, 88 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 54fb7ab..f080749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mirageprivacy/sdk", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mirageprivacy/sdk", - "version": "0.4.0", + "version": "0.4.1", "license": "UNLICENSED", "dependencies": { "@noble/curves": "^2.2.0", diff --git a/package.json b/package.json index 87362de..d20f66a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mirageprivacy/sdk", - "version": "0.4.0", + "version": "0.4.1", "description": "SDK for private transfers on Mirage", "type": "module", "main": "./dist/index.cjs", diff --git a/src/internal/escrow.ts b/src/internal/escrow.ts index 76c43bc..58ddf8d 100644 --- a/src/internal/escrow.ts +++ b/src/internal/escrow.ts @@ -14,7 +14,17 @@ import { } from "viem"; import { ContractError } from "../errors.js"; import { isNativeToken } from "../token.js"; -import type { ApprovalCheckpoint } from "../types.js"; +import type { ApprovalCheckpoint, GasPrice } from "../types.js"; + +const GAS_BUFFER_NUMERATOR = 13n; +const GAS_BUFFER_DENOMINATOR = 10n; + +/** Add the same 30% transaction gas-limit buffer used before the SDK migration. */ +export function bufferedGasLimit(estimate: bigint): bigint { + return ( + (estimate * GAS_BUFFER_NUMERATOR + GAS_BUFFER_DENOMINATOR - 1n) / GAS_BUFFER_DENOMINATOR + ); +} const escrowAbi = parseAbi(["function is_bonded() external view returns (bool)"]); @@ -92,8 +102,16 @@ async function approveToken(params: { walletClient: WalletClient; publicClient: PublicClient; account: Address; + gasPrice?: GasPrice; }): Promise<{ hash: Hash; gasUsed: bigint }> { - const { tokenAddress, spender, amount, walletClient, publicClient, account } = params; + const { tokenAddress, spender, amount, walletClient, publicClient, account, gasPrice } = params; + const gasEstimate = await publicClient.estimateContractGas({ + address: tokenAddress, + abi: erc20Abi, + functionName: "approve", + args: [spender, amount], + account, + }); const hash = await walletClient.writeContract({ address: tokenAddress, abi: erc20Abi, @@ -101,6 +119,8 @@ async function approveToken(params: { args: [spender, amount], chain: walletClient.chain, account, + gas: bufferedGasLimit(gasEstimate), + ...gasPrice, }); const receipt = await publicClient.waitForTransactionReceipt({ hash }); if (receipt.status !== "success") { @@ -115,12 +135,13 @@ export async function* approveQuotedForDeployment(params: { walletClient: WalletClient; publicClient: PublicClient; account: Address; + gasPrice?: GasPrice; onAbortCheck?: () => void; }): AsyncGenerator< { hash: Hash; tokenAddress: Address; gasUsed: bigint; index: number; total: number }, ApprovalCheckpoint > { - const { depositByAsset, walletClient, publicClient, account, onAbortCheck } = params; + const { depositByAsset, walletClient, publicClient, account, gasPrice, onAbortCheck } = params; const buckets = buildQuotedApprovalBuckets(depositByAsset); const nonce = await publicClient.getTransactionCount({ address: account, blockTag: "pending" }); const predictedEscrowAddress = predictContractAddress(account, nonce + buckets.length); @@ -136,6 +157,7 @@ export async function* approveQuotedForDeployment(params: { walletClient, publicClient, account, + gasPrice, }); approveGasUsed += result.gasUsed; const approval = { ...result, tokenAddress: bucket.tokenAddress }; @@ -169,6 +191,8 @@ export async function deployQuotedApproved(params: { walletClient: WalletClient; publicClient: PublicClient; account: Address; + gasEstimate?: bigint; + gasPrice?: GasPrice; checkpoint?: ApprovalCheckpoint; }): Promise { const { @@ -179,6 +203,8 @@ export async function deployQuotedApproved(params: { walletClient, publicClient, account, + gasEstimate, + gasPrice, checkpoint, } = params; if (buildQuotedApprovalBuckets(depositByAsset).length > 0 && !checkpoint) { @@ -194,12 +220,25 @@ export async function deployQuotedApproved(params: { account, await publicClient.getTransactionCount({ address: account, blockTag: "pending" }), ); - const hash = await walletClient.sendTransaction({ + const transaction = { to: null, data: `${bytecode}${constructorArgs.slice(2)}` as `0x${string}`, value: msgValue, chain: walletClient.chain, account, + } as const; + const estimatedGas = + gasEstimate ?? + (await publicClient.estimateGas({ + account, + to: null, + data: transaction.data, + value: msgValue, + })); + const hash = await walletClient.sendTransaction({ + ...transaction, + gas: bufferedGasLimit(estimatedGas), + ...gasPrice, }); const receipt = await publicClient.waitForTransactionReceipt({ hash }); if (receipt.status !== "success") { diff --git a/src/transfer.ts b/src/transfer.ts index 7409ec9..504d273 100644 --- a/src/transfer.ts +++ b/src/transfer.ts @@ -62,7 +62,7 @@ export interface TransferParams { /** Exact pricing authorization and scalar retained after deployment. */ resume?: TransferSecrets; accessToken?: string; - /** @deprecated Pricing gas inputs are resolved by the API. */ + /** Recommended EIP-1559 fees for wallet approval and deployment transactions. */ gasPrice?: GasPrice; abortSignal?: AbortSignal; pollTimeout?: number; @@ -330,6 +330,7 @@ function assertQuotedAccount(walletClient: WalletClient, sender: Address): Addre export async function prepareTransfer(params: TransferParams): Promise { const context = await buildContext(params); + let transactionGasPrice = params.gasPrice; let checkpoint: ApprovalCheckpoint | undefined; let deployedSecrets: TransferSecrets | undefined = params.resume; let approvalBroadcast = false; @@ -349,6 +350,7 @@ export async function prepareTransfer(params: TransferParams): Promise { checkAbort(params.abortSignal); assertAccountUnchanged(walletClient, account); @@ -403,6 +405,8 @@ export async function prepareTransfer(params: TransferParams): Promise { + async function refreshFees(overrides: FeeRefreshOverrides = {}): Promise { if (approvalBroadcast || approvalInProgress || checkpoint || deployedSecrets) { throw new MirageError("INVALID_STAGE", "The quote is locked once approval has begun"); } + if (overrides.gasPrice) transactionGasPrice = overrides.gasPrice; const refreshedQuote = await fetchPricingQuote(params.network.apiServer, { chainId: params.network.chainId, sender: context.sender, diff --git a/src/types.ts b/src/types.ts index 8f5372e..9b5e077 100644 --- a/src/types.ts +++ b/src/types.ts @@ -237,7 +237,7 @@ export type TransferStep = | { step: "complete"; transfers: TransferEvent[] }; export interface FeeRefreshOverrides { - /** @deprecated Pricing inputs are owned by the API and cannot be overridden. */ + /** Updated EIP-1559 recommendation for wallet transactions; it does not alter quoted fees. */ gasPrice?: GasPrice; /** @deprecated Pricing inputs are owned by the API and cannot be overridden. */ ethToTokenRate?: number; diff --git a/test/escrow.test.ts b/test/escrow.test.ts index 9e917e2..e1e35e1 100644 --- a/test/escrow.test.ts +++ b/test/escrow.test.ts @@ -3,6 +3,7 @@ import { getContractAddress, zeroAddress } from "viem"; import { buildQuotedApprovalBuckets, approveQuotedForDeployment, + bufferedGasLimit, deployQuotedApproved, estimateQuotedApprovalGas, predictContractAddress, @@ -13,6 +14,11 @@ const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" as const; const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7" as const; describe("quoted escrow funding", () => { + it("adds a 30% gas-limit buffer and rounds up", () => { + expect(bufferedGasLimit(10n)).toBe(13n); + expect(bufferedGasLimit(11n)).toBe(15n); + }); + it("uses exact API deposits and excludes native msg.value from approvals", () => { expect( buildQuotedApprovalBuckets({ [USDC]: 1_025n, [USDT]: 500n, [zeroAddress]: 2n }), @@ -94,14 +100,23 @@ describe("quoted escrow transactions", () => { } as any; const publicClient = { getTransactionCount: vi.fn(async () => 5), + estimateContractGas: vi + .fn() + .mockResolvedValueOnce(40_000n) + .mockResolvedValueOnce(60_000n), waitForTransactionReceipt: vi.fn(async () => ({ status: "success", gasUsed: 50_000n })), } as any; + const gasPrice = { + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 100_000_000n, + }; const iterator = approveQuotedForDeployment({ depositByAsset: { [USDC]: 1_025n, [USDT]: 500n, [zeroAddress]: 2n }, walletClient, publicClient, account: DEPLOYER, + gasPrice, }); let checkpoint; while (true) { @@ -119,6 +134,10 @@ describe("quoted escrow transactions", () => { [predicted, 1_025n], [predicted, 500n], ]); + expect(writes).toEqual([ + expect.objectContaining({ gas: 52_000n, ...gasPrice }), + expect.objectContaining({ gas: 78_000n, ...gasPrice }), + ]); }); it("appends the exact constructor suffix and uses quoted msg.value", async () => { @@ -127,6 +146,7 @@ describe("quoted escrow transactions", () => { const sendTransaction = vi.fn(async () => hash); const walletClient = { chain: undefined, sendTransaction } as any; const publicClient = { + estimateGas: vi.fn(), waitForTransactionReceipt: vi.fn(async () => ({ status: "success", contractAddress: predicted, @@ -144,6 +164,11 @@ describe("quoted escrow transactions", () => { walletClient, publicClient, account: DEPLOYER, + gasEstimate: 1_000_000n, + gasPrice: { + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 100_000_000n, + }, checkpoint: { stage: "approved", account: DEPLOYER, @@ -154,8 +179,16 @@ describe("quoted escrow transactions", () => { }); expect(sendTransaction).toHaveBeenCalledWith( - expect.objectContaining({ to: null, data: "0x60001234", value: 7n }), + expect.objectContaining({ + to: null, + data: "0x60001234", + value: 7n, + gas: 1_300_000n, + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 100_000_000n, + }), ); + expect(publicClient.estimateGas).not.toHaveBeenCalled(); expect(result.escrowAddress).toBe(predicted); }); }); From 0f7f3b3720d2569127b6f3974b046845f226b835 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 19:15:02 +0100 Subject: [PATCH 2/3] fix(sdk): guard deployment gas with live estimation --- src/internal/escrow.ts | 18 ++++++++++------- test/escrow.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/internal/escrow.ts b/src/internal/escrow.ts index 58ddf8d..53eff43 100644 --- a/src/internal/escrow.ts +++ b/src/internal/escrow.ts @@ -227,14 +227,18 @@ export async function deployQuotedApproved(params: { chain: walletClient.chain, account, } as const; + // The API simulation is used for the quote and fee display, while this live + // simulation sees the sender's confirmed approvals and current chain state. + // Enforce the larger estimate so a stale API profile cannot create an + // under-gassed deployment transaction. + const localGasEstimate = await publicClient.estimateGas({ + account, + to: null, + data: transaction.data, + value: msgValue, + }); const estimatedGas = - gasEstimate ?? - (await publicClient.estimateGas({ - account, - to: null, - data: transaction.data, - value: msgValue, - })); + gasEstimate === undefined || localGasEstimate > gasEstimate ? localGasEstimate : gasEstimate; const hash = await walletClient.sendTransaction({ ...transaction, gas: bufferedGasLimit(estimatedGas), diff --git a/test/escrow.test.ts b/test/escrow.test.ts index e1e35e1..92304cc 100644 --- a/test/escrow.test.ts +++ b/test/escrow.test.ts @@ -146,7 +146,7 @@ describe("quoted escrow transactions", () => { const sendTransaction = vi.fn(async () => hash); const walletClient = { chain: undefined, sendTransaction } as any; const publicClient = { - estimateGas: vi.fn(), + estimateGas: vi.fn().mockResolvedValue(900_000n), waitForTransactionReceipt: vi.fn(async () => ({ status: "success", contractAddress: predicted, @@ -188,7 +188,48 @@ describe("quoted escrow transactions", () => { maxPriorityFeePerGas: 100_000_000n, }), ); - expect(publicClient.estimateGas).not.toHaveBeenCalled(); + expect(publicClient.estimateGas).toHaveBeenCalledWith({ + account: DEPLOYER, + to: null, + data: "0x60001234", + value: 7n, + }); expect(result.escrowAddress).toBe(predicted); }); + + it("buffers the live deployment estimate when it exceeds the API simulation", async () => { + const hash = `0x${"44".repeat(32)}` as const; + const predicted = predictContractAddress(DEPLOYER, 5); + const sendTransaction = vi.fn(async () => hash); + const publicClient = { + estimateGas: vi.fn().mockResolvedValue(1_500_000n), + waitForTransactionReceipt: vi.fn(async () => ({ + status: "success", + contractAddress: predicted, + gasUsed: 1_400_000n, + effectiveGasPrice: 2n, + blockNumber: 9n, + })), + } as any; + + await deployQuotedApproved({ + bytecode: "0x6000", + constructorArgs: "0x1234", + depositByAsset: { [USDC]: 1_025n }, + msgValue: 7n, + walletClient: { chain: undefined, sendTransaction } as any, + publicClient, + account: DEPLOYER, + gasEstimate: 1_000_000n, + checkpoint: { + stage: "approved", + account: DEPLOYER, + predictedEscrowAddress: predicted, + approvals: [], + approveGasUsed: 0n, + }, + }); + + expect(sendTransaction).toHaveBeenCalledWith(expect.objectContaining({ gas: 1_950_000n })); + }); }); From 3bde0a49801d2e2823c5f30bf402db5e9b8d8a67 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Fri, 7 Aug 2026 20:07:43 +0100 Subject: [PATCH 3/3] fix(sdk): reduce wallet gas limit buffer to 20 percent --- src/internal/escrow.ts | 4 ++-- test/escrow.test.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/internal/escrow.ts b/src/internal/escrow.ts index 53eff43..eac6ae1 100644 --- a/src/internal/escrow.ts +++ b/src/internal/escrow.ts @@ -16,10 +16,10 @@ import { ContractError } from "../errors.js"; import { isNativeToken } from "../token.js"; import type { ApprovalCheckpoint, GasPrice } from "../types.js"; -const GAS_BUFFER_NUMERATOR = 13n; +const GAS_BUFFER_NUMERATOR = 12n; const GAS_BUFFER_DENOMINATOR = 10n; -/** Add the same 30% transaction gas-limit buffer used before the SDK migration. */ +/** Add 20% headroom to simulated wallet transaction gas limits. */ export function bufferedGasLimit(estimate: bigint): bigint { return ( (estimate * GAS_BUFFER_NUMERATOR + GAS_BUFFER_DENOMINATOR - 1n) / GAS_BUFFER_DENOMINATOR diff --git a/test/escrow.test.ts b/test/escrow.test.ts index 92304cc..a65d092 100644 --- a/test/escrow.test.ts +++ b/test/escrow.test.ts @@ -14,9 +14,9 @@ const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" as const; const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7" as const; describe("quoted escrow funding", () => { - it("adds a 30% gas-limit buffer and rounds up", () => { - expect(bufferedGasLimit(10n)).toBe(13n); - expect(bufferedGasLimit(11n)).toBe(15n); + it("adds a 20% gas-limit buffer and rounds up", () => { + expect(bufferedGasLimit(10n)).toBe(12n); + expect(bufferedGasLimit(11n)).toBe(14n); }); it("uses exact API deposits and excludes native msg.value from approvals", () => { @@ -135,8 +135,8 @@ describe("quoted escrow transactions", () => { [predicted, 500n], ]); expect(writes).toEqual([ - expect.objectContaining({ gas: 52_000n, ...gasPrice }), - expect.objectContaining({ gas: 78_000n, ...gasPrice }), + expect.objectContaining({ gas: 48_000n, ...gasPrice }), + expect.objectContaining({ gas: 72_000n, ...gasPrice }), ]); }); @@ -183,7 +183,7 @@ describe("quoted escrow transactions", () => { to: null, data: "0x60001234", value: 7n, - gas: 1_300_000n, + gas: 1_200_000n, maxFeePerGas: 2_000_000_000n, maxPriorityFeePerGas: 100_000_000n, }), @@ -230,6 +230,6 @@ describe("quoted escrow transactions", () => { }, }); - expect(sendTransaction).toHaveBeenCalledWith(expect.objectContaining({ gas: 1_950_000n })); + expect(sendTransaction).toHaveBeenCalledWith(expect.objectContaining({ gas: 1_800_000n })); }); });