Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.0",
"version": "0.4.1",
"description": "SDK for private transfers on Mirage",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
51 changes: 47 additions & 4 deletions src/internal/escrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 12n;
const GAS_BUFFER_DENOMINATOR = 10n;

/** 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
);
}

const escrowAbi = parseAbi(["function is_bonded() external view returns (bool)"]);

Expand Down Expand Up @@ -92,15 +102,25 @@ 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,
functionName: "approve",
args: [spender, amount],
chain: walletClient.chain,
account,
gas: bufferedGasLimit(gasEstimate),
...gasPrice,
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") {
Expand All @@ -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);
Expand All @@ -136,6 +157,7 @@ export async function* approveQuotedForDeployment(params: {
walletClient,
publicClient,
account,
gasPrice,
});
approveGasUsed += result.gasUsed;
const approval = { ...result, tokenAddress: bucket.tokenAddress };
Expand Down Expand Up @@ -169,6 +191,8 @@ export async function deployQuotedApproved(params: {
walletClient: WalletClient;
publicClient: PublicClient;
account: Address;
gasEstimate?: bigint;
gasPrice?: GasPrice;
checkpoint?: ApprovalCheckpoint;
}): Promise<DeployResult> {
const {
Expand All @@ -179,6 +203,8 @@ export async function deployQuotedApproved(params: {
walletClient,
publicClient,
account,
gasEstimate,
gasPrice,
checkpoint,
} = params;
if (buildQuotedApprovalBuckets(depositByAsset).length > 0 && !checkpoint) {
Expand All @@ -194,12 +220,29 @@ 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;
// 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 === undefined || localGasEstimate > gasEstimate ? localGasEstimate : gasEstimate;
const hash = await walletClient.sendTransaction({
...transaction,
gas: bufferedGasLimit(estimatedGas),
...gasPrice,
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") {
Expand Down
9 changes: 7 additions & 2 deletions src/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -330,6 +330,7 @@ function assertQuotedAccount(walletClient: WalletClient, sender: Address): Addre

export async function prepareTransfer(params: TransferParams): Promise<PreparedTransfer> {
const context = await buildContext(params);
let transactionGasPrice = params.gasPrice;
let checkpoint: ApprovalCheckpoint | undefined;
let deployedSecrets: TransferSecrets | undefined = params.resume;
let approvalBroadcast = false;
Expand All @@ -349,6 +350,7 @@ export async function prepareTransfer(params: TransferParams): Promise<PreparedT
walletClient,
publicClient: params.publicClient,
account,
gasPrice: transactionGasPrice,
onAbortCheck: () => {
checkAbort(params.abortSignal);
assertAccountUnchanged(walletClient, account);
Expand Down Expand Up @@ -403,6 +405,8 @@ export async function prepareTransfer(params: TransferParams): Promise<PreparedT
walletClient,
publicClient: params.publicClient,
account,
gasEstimate: context.obfuscation.deploymentGasEstimate,
gasPrice: transactionGasPrice,
};
const result =
params.network.enableAtomicBatch && !approved
Expand Down Expand Up @@ -476,10 +480,11 @@ export async function prepareTransfer(params: TransferParams): Promise<PreparedT
yield* complete(walletClient, deployed.secrets);
}

async function refreshFees(_overrides: FeeRefreshOverrides = {}): Promise<FeeEstimate> {
async function refreshFees(overrides: FeeRefreshOverrides = {}): Promise<FeeEstimate> {
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,
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
76 changes: 75 additions & 1 deletion test/escrow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getContractAddress, zeroAddress } from "viem";
import {
buildQuotedApprovalBuckets,
approveQuotedForDeployment,
bufferedGasLimit,
deployQuotedApproved,
estimateQuotedApprovalGas,
predictContractAddress,
Expand All @@ -13,6 +14,11 @@ const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" as const;
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7" as const;

describe("quoted escrow funding", () => {
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", () => {
expect(
buildQuotedApprovalBuckets({ [USDC]: 1_025n, [USDT]: 500n, [zeroAddress]: 2n }),
Expand Down Expand Up @@ -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) {
Expand All @@ -119,6 +134,10 @@ describe("quoted escrow transactions", () => {
[predicted, 1_025n],
[predicted, 500n],
]);
expect(writes).toEqual([
expect.objectContaining({ gas: 48_000n, ...gasPrice }),
expect.objectContaining({ gas: 72_000n, ...gasPrice }),
]);
});

it("appends the exact constructor suffix and uses quoted msg.value", async () => {
Expand All @@ -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().mockResolvedValue(900_000n),
waitForTransactionReceipt: vi.fn(async () => ({
status: "success",
contractAddress: predicted,
Expand All @@ -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,
Expand All @@ -154,8 +179,57 @@ 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_200_000n,
maxFeePerGas: 2_000_000_000n,
maxPriorityFeePerGas: 100_000_000n,
}),
);
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_800_000n }));
});
});
Loading