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.1",
"version": "0.4.2",
"description": "SDK for private transfers on Mirage",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -52,6 +52,7 @@ export type {
FeeRefreshOverrides,
AssetRequirement,
TransferEvent,
TransferPreview,
TransferRow,
TransferSecrets,
TransferStep,
Expand Down
122 changes: 93 additions & 29 deletions src/internal/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, bigint>;
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<string, string>;
msg_value: string;
};
sealed_pricing_authorization: `0x${string}` | null;
}

function parseDepositByAsset(depositByAsset: Record<string, string>): Record<string, bigint> {
return Object.fromEntries(
Object.entries(depositByAsset).map(([asset, amount]) => [asset, BigInt(amount)]),
);
}

function postPricingQuote(
apiServer: string,
body: Record<string, unknown>,
): Promise<PricingQuoteResponse> {
return request<PricingQuoteResponse>(`${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<PricingPreviewQuote> {
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,
Expand All @@ -131,30 +205,25 @@ export async function fetchPricingQuote(
signals: PricingSignalRequest[];
},
): Promise<PricingQuote> {
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<string, string>;
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,
Expand All @@ -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,
Expand Down
102 changes: 91 additions & 11 deletions src/transfer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isAddress, type Address, type PublicClient, type WalletClient } from "viem";
import type {
ApprovalCheckpoint,
AssetRequirement,
EscrowKind,
FeeEstimate,
FeeRefreshOverrides,
Expand All @@ -9,6 +10,7 @@ import type {
NetworkKeyStatus,
PreparedTransfer,
TransferEvent,
TransferPreview,
TransferRow,
TransferSecrets,
TransferStep,
Expand All @@ -27,6 +29,7 @@ import {
fetchComplianceApproval,
fetchNetworkKey,
fetchObfuscation,
fetchPricingPreview,
fetchPricingQuote,
whitelistRequirementFromError,
} from "./internal/api.js";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, bigint>,
): AssetRequirement[] {
const principal = new Map<string, bigint>();
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
Expand All @@ -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),
};
}

Expand Down Expand Up @@ -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<TransferPreview> {
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<PreparedTransfer> {
const context = await buildContext(params);
let transactionGasPrice = params.gasPrice;
Expand Down
25 changes: 25 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, bigint>;
msgValue: bigint;
assetRequirements: AssetRequirement[];
}

export interface AssetAmount {
asset: Address;
amount: bigint;
Expand Down
Loading
Loading