diff --git a/offline-cli/src/build-tx.ts b/offline-cli/src/build-tx.ts index 0c016bac..dcba2bce 100644 --- a/offline-cli/src/build-tx.ts +++ b/offline-cli/src/build-tx.ts @@ -58,8 +58,10 @@ import { // Constants // --------------------------------------------------------------------------- -/** Must match the fee used by the web worker. */ -const ZKAPP_TX_FEE = 0.1e9; // 0.1 MINA in nanomina +/** Backwards-compat fallback for old bundles (pre-`fee` field). New bundles + * carry their own mempool-derived fee (resolved by the UI at export time); + * this constant is only used when bundle.fee is missing. */ +const ZKAPP_TX_FEE_FALLBACK = 0.1e9; // 0.1 MINA in nanomina const EMPTY_PUBKEY_B58 = 'B62qiTKpEPjGTSHZrtM8uXiKgn8So916pLmNJKDhKeyBQL9TDb3nvBG'; @@ -103,6 +105,10 @@ interface BundleBase { minaNetwork: 'testnet' | 'mainnet'; contractAddress: string; feePayerAddress: string; + // Mempool-derived fee in nanomina, populated by the UI at export time. + // Optional for backwards compat with bundles built before this field + // existed; resolveBundleFee() falls back to ZKAPP_TX_FEE_FALLBACK. + fee?: number; accounts: Record; events: Array<{ eventType: string; payload: unknown }>; } @@ -592,8 +598,21 @@ function signChildAccount(txJson: string, childKey: InstanceType) { - return { sender: pub, fee: ZKAPP_TX_FEE }; +function txSender(pub: InstanceType, fee: number) { + return { sender: pub, fee }; +} + +/** Returns the bundle's fee, or the backwards-compat fallback for bundles + * built before the fee field existed. Logs a warning to stderr when the + * fallback is used. */ +function resolveBundleFee(bundle: BundleBase): number { + if (typeof bundle.fee === 'number' && Number.isFinite(bundle.fee) && bundle.fee > 0) { + return bundle.fee; + } + process.stderr.write( + `[offline-cli] bundle has no "fee" field; falling back to ${ZKAPP_TX_FEE_FALLBACK} nanomina (0.1 MINA). Re-export the bundle for a mempool-derived fee.\n`, + ); + return ZKAPP_TX_FEE_FALLBACK; } /** Safely serializes tx.toJSON() regardless of whether it returns a string or object. */ @@ -648,6 +667,8 @@ export async function handlePropose( throw new Error('createChild proposal requires childPrivateKey, childOwners, and childThreshold in the bundle'); } + const fee = resolveBundleFee(bundle); + log('Configuring network and injecting accounts...'); configureNetwork(bundle); injectAccounts(bundle); @@ -718,7 +739,7 @@ export async function handlePropose( const contractAddress = PublicKey.fromBase58(bundle.contractAddress); const contract = new MinaGuard(contractAddress); - const tx = await Mina.transaction(txSender(proposer), async () => { + const tx = await Mina.transaction(txSender(proposer, fee), async () => { if (isCreateChild && childKey && childOwnerStore && childPaddedOwners) { const childAddress = childKey.toPublicKey(); const childZkApp = new MinaGuard(childAddress); @@ -776,6 +797,8 @@ export async function handleApprove( privateKey: string, log: LogFn, ): Promise { + const fee = resolveBundleFee(bundle); + log('Configuring network and injecting accounts...'); configureNetwork(bundle); injectAccounts(bundle); @@ -815,7 +838,7 @@ export async function handleApprove( log('Building transaction...'); const contract = new MinaGuard(PublicKey.fromBase58(bundle.contractAddress)); - const tx = await Mina.transaction(txSender(approver), async () => { + const tx = await Mina.transaction(txSender(approver, fee), async () => { await contract.approveProposal( proposalStruct, signature, @@ -861,6 +884,9 @@ export async function handleExecute( const isCreateChild = txType === 'createChild'; const isChildLifecycle = txType != null && CHILD_LIFECYCLE_TYPES.has(txType); + const fee = resolveBundleFee(bundle); + log(`Fee: ${fee} nanomina (${fee / 1e9} MINA)`); + log('Configuring network and injecting accounts...'); configureNetwork(bundle); injectAccounts(bundle); @@ -931,7 +957,7 @@ export async function handleExecute( const childZkApp = new MinaGuard(PublicKey.fromBase58(childAddr)); log('Building transaction...'); - const tx = await Mina.transaction(txSender(executor), async () => { + const tx = await Mina.transaction(txSender(executor, fee), async () => { await childZkApp.executeSetupChild( Field(bundle.childThreshold!), Field(bundle.childOwners!.length), @@ -973,7 +999,7 @@ export async function handleExecute( const childZkApp = new MinaGuard(PublicKey.fromBase58(childAddr)); log('Building transaction...'); - const tx = await Mina.transaction(txSender(executor), async () => { + const tx = await Mina.transaction(txSender(executor, fee), async () => { if (txType === 'reclaimChild') { const amount = UInt64.from(bundle.proposal.data ?? '0'); await childZkApp.executeReclaimToParent( @@ -1032,7 +1058,7 @@ export async function handleExecute( log('Building transaction...'); const contract = new MinaGuard(PublicKey.fromBase58(bundle.contractAddress)); - const tx = await Mina.transaction(txSender(executor), async () => { + const tx = await Mina.transaction(txSender(executor, fee), async () => { if (newAccountCount > 0) { AccountUpdate.fundNewAccount(executor, newAccountCount); } diff --git a/ui/lib/mempoolFee.ts b/ui/lib/mempoolFee.ts new file mode 100644 index 00000000..9e2f77bd --- /dev/null +++ b/ui/lib/mempoolFee.ts @@ -0,0 +1,130 @@ +// TODO: verify Mesa per-block zkApp command cap; 12 is unconfirmed and +// taken from a working assumption. Once confirmed against Mesa node config, +// remove this TODO. +const TOP_N = 12; + +// Mina spec minimum fee per account update, per mina-signer's +// getAccountUpdateMinimumFee docstring ("0.001 according to the Mina spec"). +// 0.001 MINA = 1e6 nanomina. +export const MIN_FEE_PER_AU = 1e6; + +// The daemon wraps each pooled command in a result object; the raw command +// JSON (mina-signer wire format, fee in nanomina) lives under `zkappCommand` +// — same convention as block `zkappCommands` and the sendZkapp response. +interface PooledZkappCommandResult { + zkappCommand: { + feePayer: { body: { fee: string } }; + accountUpdates: unknown[]; + }; +} + +export interface FeeEstimate { + fee: number; + feePerAU: number; + sampleSize: number; +} + +const QUERY = `{ + pooledZkappCommands { + zkappCommand { + feePayer { body { fee } } + accountUpdates { body { publicKey } } + } + } +}`; + +function median(sorted: number[]): number { + const n = sorted.length; + if (n === 0) throw new Error('median of empty array'); + const mid = Math.floor(n / 2); + return n % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} + +/** + * Estimate a zkApp tx fee from the node's current mempool. + * + * Algorithm: + * 1. Query pooledZkappCommands for fee + accountUpdates. + * 2. Compute fee-per-account-update for each entry. + * 3. Sort descending, take top TOP_N (= 12). + * 4. Take the median fee-per-AU, floored at MIN_FEE_PER_AU. + * 5. Multiply by `accountUpdateCount` for the caller's tx. + * + * NOTE: median-of-top-N from the mempool reflects what is *waiting*, not what is + * *clearing*. In a quiet mempool the raw median can be near zero, so the per-AU + * rate is floored at the spec minimum, the result is always a fee the pool + * accepts for `accountUpdateCount` AUs. Callers remain responsible for any + * market-level floor (see resolveZkappFee). + */ +export async function estimateZkappFee( + graphqlEndpoint: string, + accountUpdateCount: number, +): Promise { + if (accountUpdateCount <= 0) { + throw new Error(`accountUpdateCount must be positive, got ${accountUpdateCount}`); + } + + const res = await fetch(graphqlEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: QUERY }), + }); + if (!res.ok) { + throw new Error(`mempool query failed: HTTP ${res.status}`); + } + const json = await res.json(); + if (json.errors) { + throw new Error(`mempool query errors: ${JSON.stringify(json.errors)}`); + } + const pool: PooledZkappCommandResult[] = json.data?.pooledZkappCommands ?? []; + + const feePerAU: number[] = []; + for (const entry of pool) { + const cmd = entry.zkappCommand; + const auCount = cmd?.accountUpdates?.length ?? 0; + if (auCount === 0) continue; + const fee = Number(cmd?.feePayer?.body?.fee); + if (!Number.isFinite(fee) || fee < 0) continue; + feePerAU.push(fee / auCount); + } + if (feePerAU.length === 0) { + throw new Error('mempool empty or no usable entries'); + } + + feePerAU.sort((a, b) => b - a); + const top = feePerAU.slice(0, TOP_N); + const medianPerAU = median([...top].sort((a, b) => a - b)); + const flooredPerAU = Math.max(medianPerAU, MIN_FEE_PER_AU); + + return { + fee: Math.ceil(flooredPerAU * accountUpdateCount), + feePerAU: flooredPerAU, + sampleSize: top.length, + }; +} + +// Flat fallback when the mempool is empty or the query fails, and the floor +// under any mempool-derived estimate. 0.01 MINA covers 10 AUs at the spec +// minimum. The floor deliberately assumes 10 AUs rather than +// DEFAULT_AU_ESTIMATE: the estimate multiplies by an *assumed* AU count, but +// the built tx can carry more (per-row receivers, fundNewAccount, child +// setup) — flooring at 4 AUs could yield a sub-minimum fee the pool rejects. +// TODO: revisit if a multisig tx ever exceeds 10 AUs. +export const EMPTY_MEMPOOL_FALLBACK_FEE = 1e7; + +// AU count to assume when estimating fees for multisig flows where the actual +// count isn't known until after Mina.transaction() builds the tx. +export const DEFAULT_AU_ESTIMATE = 4; + +/** High-level wrapper around estimateZkappFee for callers that just want a + * ready-to-use fee value: returns the mempool-derived estimate floored at + * EMPTY_MEMPOOL_FALLBACK_FEE, which is also returned on any failure. + * Does not log; callers add their own logging if they want. */ +export async function resolveZkappFee(graphqlEndpoint: string): Promise { + try { + const estimate = await estimateZkappFee(graphqlEndpoint, DEFAULT_AU_ESTIMATE); + return Math.max(estimate.fee, EMPTY_MEMPOOL_FALLBACK_FEE); + } catch { + return EMPTY_MEMPOOL_FALLBACK_FEE; + } +} diff --git a/ui/lib/multisigClient.worker.ts b/ui/lib/multisigClient.worker.ts index 2eb48663..f20e91f3 100644 --- a/ui/lib/multisigClient.worker.ts +++ b/ui/lib/multisigClient.worker.ts @@ -50,6 +50,11 @@ import { import { fetchAllEvents, } from './api'; +import { + estimateZkappFee, + EMPTY_MEMPOOL_FALLBACK_FEE, + DEFAULT_AU_ESTIMATE, +} from './mempoolFee'; /** Callback type for sending a signed transaction via Auro wallet on the main thread. */ type SendTxFn = (txJson: string, memo?: string) => Promise; @@ -69,8 +74,33 @@ type ProgressFn = (step: string) => void; const MINA_ENDPOINT = process.env.NEXT_PUBLIC_MINA_ENDPOINT ?? 'https://api.minascan.io/node/devnet/v1/graphql'; const ARCHIVE_ENDPOINT = process.env.NEXT_PUBLIC_ARCHIVE_ENDPOINT ?? 'https://api.minascan.io/archive/devnet/v1/graphql'; -// TODO: make fee configurable per network (e.g. from env or UI input) -const ZKAPP_TX_FEE = 0.1e9; // 0.1 MINA in nanomina +/** Resolves the tx fee from the node mempool: median fee-per-AU of the top + * pooled zkApp commands × DEFAULT_AU_ESTIMATE, floored at + * EMPTY_MEMPOOL_FALLBACK_FEE (see mempoolFee.ts). The fee is always baked + * into the tx JSON for every signer flow: Ledger signs over the fee-payer + * commitment (which includes the fee), and Auro must receive the embedded + * fee unchanged — passing a feePayer.fee override makes Auro rebuild the + * fee payer, changing the commitment and invalidating pre-existing + * signatures such as the zkApp key signature on deploy (see auroWallet.ts). */ +async function getFee(): Promise { + try { + const estimate = await estimateZkappFee(MINA_ENDPOINT, DEFAULT_AU_ESTIMATE); + const fee = Math.max(estimate.fee, EMPTY_MEMPOOL_FALLBACK_FEE); + console.log( + `[MultisigWorker] mempool fee estimate: ${fee} nanomina ` + + `(${estimate.feePerAU} per AU × ${DEFAULT_AU_ESTIMATE}, sample ${estimate.sampleSize}, ` + + `floor ${EMPTY_MEMPOOL_FALLBACK_FEE})` + ); + return fee; + } catch (err) { + console.warn( + `[MultisigWorker] mempool fee estimate failed, using flat fallback ` + + `${EMPTY_MEMPOOL_FALLBACK_FEE} nanomina (0.01 MINA)`, + err, + ); + return EMPTY_MEMPOOL_FALLBACK_FEE; + } +} let compilePromise: Promise | null = null; @@ -108,11 +138,12 @@ async function maybeProve(tx: Awaited>) { } } -/** Returns Mina.transaction sender arg — includes fee since we always set it explicitly. */ -function txSender(pub: InstanceType, memo?: string) { +/** Returns Mina.transaction sender arg — the fee is always set explicitly + * (mempool-derived); see getFee() for why it must be baked into the tx. */ +function txSender(pub: InstanceType, fee: number, memo?: string) { return memo !== undefined - ? { sender: pub, fee: ZKAPP_TX_FEE, memo } - : { sender: pub, fee: ZKAPP_TX_FEE }; + ? { sender: pub, fee, memo } + : { sender: pub, fee }; } /** @@ -687,7 +718,8 @@ const workerApi = { await fetchAccount({ publicKey: feePayer }); clearStaleTransaction(); - const tx = await Mina.transaction(txSender(feePayer), async () => { + const fee = await getFee(); + const tx = await Mina.transaction(txSender(feePayer, fee), async () => { AccountUpdate.fundNewAccount(feePayer); await zkApp.deploy(); }); @@ -741,7 +773,8 @@ const workerApi = { await fetchAccount({ publicKey: feePayer }); clearStaleTransaction(); - const tx = await Mina.transaction(txSender(feePayer), async () => { + const fee = await getFee(); + const tx = await Mina.transaction(txSender(feePayer, fee), async () => { AccountUpdate.fundNewAccount(feePayer); await zkApp.deploy(); await zkApp.setup( @@ -793,7 +826,8 @@ const workerApi = { await fetchAccount({ publicKey: feePayer }); clearStaleTransaction(); - const tx = await Mina.transaction(txSender(feePayer), async () => { + const fee = await getFee(); + const tx = await Mina.transaction(txSender(feePayer, fee), async () => { await zkApp.setup( Field(params.threshold), Field(ownerStore.length), @@ -936,7 +970,8 @@ const workerApi = { clearStaleTransaction(); const proposalMemo = params.input.memo ?? undefined; - const tx = await Mina.transaction(txSender(proposer, proposalMemo), async () => { + const fee = await getFee(); + const tx = await Mina.transaction(txSender(proposer, fee, proposalMemo), async () => { // For CREATE_CHILD: deploy + reserve child in the same tx if (isCreateChild && childKey && childOwnerStore && childPaddedOwners) { const childAddress = childKey.toPublicKey(); @@ -1029,7 +1064,8 @@ const workerApi = { const contract = new MinaGuard(PublicKey.fromBase58(params.contractAddress)); await fetchAccount({ publicKey: approver }); clearStaleTransaction(); - const tx = await Mina.transaction(txSender(approver), async () => { + const fee = await getFee(); + const tx = await Mina.transaction(txSender(approver, fee), async () => { await contract.approveProposal( proposalStruct, signature, @@ -1109,7 +1145,8 @@ const workerApi = { clearStaleTransaction(); const proposalMemo = params.proposal.memo ?? undefined; - const tx = await Mina.transaction(txSender(executor, proposalMemo), async () => { + const fee = await getFee(); + const tx = await Mina.transaction(txSender(executor, fee, proposalMemo), async () => { if (newAccountCount > 0) { AccountUpdate.fundNewAccount(executor, newAccountCount); } @@ -1233,7 +1270,8 @@ const workerApi = { } clearStaleTransaction(); - const tx = await Mina.transaction(txSender(executor), async () => { + const fee = await getFee(); + const tx = await Mina.transaction(txSender(executor, fee), async () => { await childZkApp.executeSetupChild( threshold, numOwners, @@ -1311,7 +1349,8 @@ const workerApi = { await fetchAccount({ publicKey: PublicKey.fromBase58(params.parentAddress) }); clearStaleTransaction(); const childMemo = params.proposal.memo ?? undefined; - const tx = await Mina.transaction(txSender(executor, childMemo), async () => { + const fee = await getFee(); + const tx = await Mina.transaction(txSender(executor, fee, childMemo), async () => { if (txType === 'reclaimChild') { const amount = UInt64.from(params.proposal.data ?? '0'); await childZkApp.executeReclaimToParent( diff --git a/ui/lib/offline-signing.ts b/ui/lib/offline-signing.ts index 649e9865..94e4a157 100644 --- a/ui/lib/offline-signing.ts +++ b/ui/lib/offline-signing.ts @@ -1,4 +1,5 @@ import { parseChildConfigFromEvents } from './api'; +import { resolveZkappFee } from './mempoolFee'; export const OFFLINE_BUNDLE_VERSION = 1; @@ -36,6 +37,9 @@ interface BundleBase { minaNetwork: 'testnet' | 'mainnet'; contractAddress: string; feePayerAddress: string; + // Mempool-derived fee in nanomina, computed at bundle-build time. + // Locked into the offline signature; cannot be changed at broadcast time. + fee: number; accounts: Record; events: Array<{ eventType: string; payload: unknown }>; } @@ -198,6 +202,7 @@ export async function buildOfflineProposeBundle(params: { } const [contractAccount, feePayerAccount, childAccount] = await Promise.all(fetches); const events = await fetchAllEvents(params.contractAddress); + const fee = await resolveZkappFee(MINA_ENDPOINT); const accounts: Record = { [params.contractAddress]: contractAccount, @@ -213,6 +218,7 @@ export async function buildOfflineProposeBundle(params: { minaNetwork: MINA_NETWORK, contractAddress: params.contractAddress, feePayerAddress: params.feePayerAddress, + fee, accounts, events, input: params.input, @@ -234,6 +240,7 @@ export async function buildOfflineApproveBundle(params: { if (childAddr) fetches.push(fetchGraphQLAccount(childAddr)); const [contractAccount, feePayerAccount, childAccount] = await Promise.all(fetches); const events = await fetchAllEvents(params.contractAddress); + const fee = await resolveZkappFee(MINA_ENDPOINT); const accounts: Record = { [params.contractAddress]: contractAccount, @@ -247,6 +254,7 @@ export async function buildOfflineApproveBundle(params: { minaNetwork: MINA_NETWORK, contractAddress: params.contractAddress, feePayerAddress: params.feePayerAddress, + fee, accounts, events, proposal: params.proposal, @@ -268,6 +276,7 @@ export async function buildOfflineExecuteBundle(params: { if (childAddr) fetches.push(fetchGraphQLAccount(childAddr)); const [contractAccount, feePayerAccount, childAccount] = await Promise.all(fetches); const events = await fetchAllEvents(params.contractAddress); + const fee = await resolveZkappFee(MINA_ENDPOINT); const accounts: Record = { [params.contractAddress]: contractAccount, @@ -320,6 +329,7 @@ export async function buildOfflineExecuteBundle(params: { minaNetwork: MINA_NETWORK, contractAddress: params.contractAddress, feePayerAddress: params.feePayerAddress, + fee, accounts, events, proposal: params.proposal,