feat: circuit-enforced relayer fee mechanism - #169
Merged
Merged
Conversation
tech-adrian
added a commit
that referenced
this pull request
Sep 3, 2026
CI on dev has been red since PR #167 (bridge withdrawal), #151 (recurring withdrawals x2), and #169 (relayer fee) landed with severe merge damage: duplicated blocks, truncated functions, colliding error discriminants, and stale field offsets. Root causes, by job: Contracts (contracts/pool, contracts/compliance): - #151's second merge (fb15893) re-applied a diff already present from its first merge (2f22036), duplicating every recurring-withdrawal definition; removed the duplicate. - Multiple truncated braces/functions from bad merges (AdminUpdatedEvent, key_commitment_version_prefix, key_bridge_verifier, a test function, a stray premature `mod tests` close) that made lib.rs fail to parse. - PoolError had five different features each claiming discriminants 18-23; deduplicated and renumbered sequentially. - shielded_pool's circuit gained a 7th public input (relayer_fee) that the contract's WithdrawInputs/parse_public_inputs/PUBLIC_INPUT_BYTES never picked up; fixed the layout and gave bridge_withdrawal (5 fields) its own constant instead of sharing the withdrawal one. - withdraw_batch never bound each proof to its stated asset (a proof for asset A could pay out asset B) and paid from a single leftover token address; made it multi-asset aware like withdraw. - compliance's ComplianceError had TimelockNotSet and ViewVkNotSet both claiming discriminant 14; renumbered. - compliance's setup_pool/setup_with_pool test helpers had conflicting duplicate bindings from a merge; fixed arity throughout. - ~40 pool tests fixed for the current multi-asset/relayer-fee/timelock constructor and withdraw(_batch) signatures; three tests whose bodies had been spliced with an unrelated fee test were reconstructed from their names and the surviving assertions. - bridge_tests.rs and the compliance ASP-root tests exercise contract methods that were never implemented (#167, #161 respectively) - gated with NOTE comments rather than inventing an API. Circuits: - circuits/shielded_pool/Prover.toml never got a relayer_fee entry for the circuit's 7th argument. - scripts/verify-circuits.mjs's formal spec and self-test mutations still encoded the pre-fee conservation rule; updated to payout + fee <= amount and change = amount - payout - fee. packages/core (@dshield/core, used by the frontend, CLI and indexer): - notes.test.ts/poseidon2.test.ts/prover.test.ts/prover.ts/poseidon2.ts used extensionless relative imports and un-attributed JSON imports, both invalid under this package's "module": "nodenext". - package.json was missing @aztec/bb.js and @noir-lang/noir_js as dependencies (present in pnpm-lock.yaml, absent from package.json) and had no exports map entries beyond ".", so every deep import (@dshield/core/format, /report, /notes, /poseidon2, /prover, /prover-core) failed to resolve for bundlers that respect "exports". Frontend: - tone.ts, Toast.tsx, and recurring/page.tsx had merge-corrupted syntax (mangled params, an unterminated string, `}>` instead of `=>`, several dropped JSX closing braces, an unescaped quote) that failed to parse. - deposit/page.tsx's handleDeposit referenced two undeclared identifiers (depositStroops, skipTopUp) left over from the SEP-24 on-ramp flow (#160) calling it with args it never accepted; gave it the overrideAmount/skipTopUp parameters that call site always expected. - recurring/page.tsx's buildChangeNote, withdraw/page.tsx's batch buildChangeNote call, and both pages' relay calls weren't threading the note's asset through after multi-asset support landed. - recurring/page.tsx called a computeAuthCommitment that was never added to poseidon2.ts; implemented it to match circuits/recurring's hash_auth exactly. - report.ts's buildComplianceReport referenced an uncomputed `integrityOk` and an unused, stale (pre-multi-asset) core import. - lib/bridge.ts is unused dead scaffolding from #167 with no working "./poseidon" counterpart; excluded from tsc/eslint rather than guessing at an unshipped API. - prover.test.ts mocked "@dshield/core/prover-core" while prover.ts actually imports the local "./prover-core"; the mock silently never applied. Fixed the mock path and the stale VALID_INPUTS/assertions for the relayer_fee field. - recurring/page.tsx's Date.now() call tripped react-hooks/purity; extracted a module-scope helper, matching this file's own buildChangeNote and withdraw/page.tsx's established convention. tests/e2e.sh: pool and compliance contract deploys were missing the --timelock argument their constructors have required since timelock governance (#163) landed. Verified locally: cargo test --workspace (210 passed), cargo audit, all 6 circuits compile/execute (shielded_pool proves + verifies with the corrected 224-byte/7-field public inputs), pnpm lint/test/build in frontend (193 tests) and packages/core (45 tests) and the indexer (4 tests), and a full tests/e2e.sh run against a local Stellar network (29 checks passed) covering contract deploy, deposit, compliance, and the ZK proof cycle end-to-end. Does not touch the ASP root sync workflow's "Provision sync signer" failure: it requires the COMPLIANCE_CONTRACT_ID and STELLAR_ADMIN_SECRET repo secrets, which are unset and outside what a code change can fix.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements on-chain, verifiable compensation for withdrawal relayers. Previously the relayer ate 100% of gas costs with no way to recoup them — making production relayer operation a pure cost center and creating a latent withdrawal-availability risk. This PR closes that gap by splitting the withdrawn value three ways in the circuit (
payout + relayer_fee + change = amount) and paying the fee to the transaction submitter atomically in the same pool-contract invocation.Motivation
SECURITY.mdandREADME.mdalready frame the relayer as "a single point of censorship (not theft)". An unfunded relayer is also a single point of abandonment: without economic compensation, running a relayer long-term requires a subsidy that no one is obligated to provide. This directly undermines the withdrawal availability that the multi-relayer-failover work is designed to protect.What Changed
circuits/shielded_pool/src/main.nrAdded
relayer_fee: pub Fieldas the 5th public input (betweenwithdraw_amountandchange_commitment).Extended the value-conservation constraint from a two-way to a three-way split:
relayer_feegoes through the sameconstrain_u64gate asamountandwithdraw_amount, preventing field-wraparound arithmetic that would let a prover mint value.Prover.tomlupdated withrelayer_fee = "50000"example witness.contracts/pool/src/lib.rsPUBLIC_INPUT_BYTESupdated from5 * 32to6 * 32(192 bytes). Old 5-field proofs are rejected outright — there is no silent compatibility with the pre-fee circuit.WithdrawInputsstruct gains arelayer_fee: [u8; 32]field;parse_public_inputsreads it as field index 4.withdrawentrypoint:relayer_feeviaamount_from_field(same 64-bit overflow guard aspayout).relayer_feetoenv.invoker()before payingpayouttorecipient, maintaining checks-effects-interactions ordering.frontend/src/lib/prover.tsproveWithdrawalinput type gainsrelayerFee: string.relayer_fee: decimal(inputs.relayerFee)passed to the circuit witness.frontend/src/app/api/relay-withdraw/route.tsextractRelayerFee(publicInputsHex)reads the 5th field element (bytes 128–160 of the 192-byte public inputs blob) and returns it as abigint.100,000 stroops(0.01 USDC) — ensures the relayer covers gas.10,000,000 stroops(1 USDC) — prevents accidental runaway fees.400 fee_out_of_boundsbefore any RPC call is made.feealongsidehashandrelayer.frontend/src/app/withdraw/page.tsx0.05 USDC(500,000 stroops).0.01and1 USDC(feeOutOfBounds).payout + feemust not exceed the note's value (feeExceedsNote).handleBatchWithdrawreturns early when either guard fires.changeValuenow deducts the fee:noteValue - payout - fee.withdrawNotesignature gainsrelayerFeeStroops: string; this is passed down toproveWithdrawaland used for value accounting.docs/THREAT_MODEL.mdwithdraw_amount + relayer_fee <= amountand three-way value conservation; contract enforces relayer-fee transfer to invoker; threat model note updated to "cannot redirect a valid withdrawal or forge the fee".withdraw_amount) and what it does not leak (note value, deposit linkage, user identity).New Contract Tests
test_public_inputs_with_fee_are_six_fieldstest_parse_public_inputs_extracts_relayer_fee_fieldrelayer_feeis at index 4,change_commitmentat 5test_withdraw_with_relayer_fee_pays_both_recipient_and_relayertest_withdraw_with_zero_relayer_fee_acceptedtest_withdraw_relayer_fee_out_of_range_rejectedInvalidPublicInputs(same guard as payout)test_value_conservation_with_fee_in_circuit_semanticspayout + fee + change = amountinvariant and why the contract trusts the proof for the arithmeticSecurity Properties
relayer_feeis a public input; changing it post-generation invalidates the proofwithdraw + fee + change = amountwith 64-bit guards prevents overflowamount_from_fieldrejects any byte set above bit 63, same as it does forwithdraw_amountparse_public_inputslength checkPrivacy Properties
The relayer fee is a public input, visible on-chain in the same way
withdraw_amountis. It does not:If every withdrawal uses the same default fee (0.05 USDC), the uniformity is observable but adds no linking information. If fees vary, the variation is visible but still does not connect the spend to a deposit.
Breaking Changes
This is a protocol-breaking change. Proofs generated against the old 5-public-input circuit are rejected by the new contract (
InvalidPublicInputs). Deployment requires:circuits/shielded_pool→ regenerate proving key and verification key.PUBLIC_INPUT_BYTES = 6 * 32).Staging on a fresh testnet pool before migrating the production pool is strongly recommended.
Testing Checklist
shielded_pool.jsonupdated (requires Nargo toolchain)change_commitmentrecalculated for updated value splitfee_out_of_boundsRelated
SECURITY.mdTHREAT_MODEL.mdupdated to reflect the fee as a new public inputcloses #141