Skip to content

feat: circuit-enforced relayer fee mechanism - #169

Merged
tech-adrian merged 2 commits into
Dshield-xyz:devfrom
Clevervikson:withdrawals
Aug 30, 2026
Merged

feat: circuit-enforced relayer fee mechanism#169
tech-adrian merged 2 commits into
Dshield-xyz:devfrom
Clevervikson:withdrawals

Conversation

@Clevervikson

Copy link
Copy Markdown
Contributor

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.md and README.md already 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.nr

  • Added relayer_fee: pub Field as the 5th public input (between withdraw_amount and change_commitment).

  • Extended the value-conservation constraint from a two-way to a three-way split:

    // Before
    withdraw_amount + change == amount
    
    // After
    withdraw_amount + relayer_fee + change == amount
    
  • relayer_fee goes through the same constrain_u64 gate as amount and withdraw_amount, preventing field-wraparound arithmetic that would let a prover mint value.

  • Prover.toml updated with relayer_fee = "50000" example witness.

contracts/pool/src/lib.rs

  • PUBLIC_INPUT_BYTES updated from 5 * 32 to 6 * 32 (192 bytes). Old 5-field proofs are rejected outright — there is no silent compatibility with the pre-fee circuit.
  • WithdrawInputs struct gains a relayer_fee: [u8; 32] field; parse_public_inputs reads it as field index 4.
  • withdraw entrypoint:
    • Extracts relayer_fee via amount_from_field (same 64-bit overflow guard as payout).
    • Pays relayer_fee to env.invoker() before paying payout to recipient, maintaining checks-effects-interactions ordering.
    • Zero fee is handled correctly — the SAC transfer is skipped rather than attempted with a zero amount.

frontend/src/lib/prover.ts

  • proveWithdrawal input type gains relayerFee: string.
  • New field relayer_fee: decimal(inputs.relayerFee) passed to the circuit witness.

frontend/src/app/api/relay-withdraw/route.ts

  • extractRelayerFee(publicInputsHex) reads the 5th field element (bytes 128–160 of the 192-byte public inputs blob) and returns it as a bigint.
  • Fee bounds enforced before submission:
    • Minimum: 100,000 stroops (0.01 USDC) — ensures the relayer covers gas.
    • Maximum: 10,000,000 stroops (1 USDC) — prevents accidental runaway fees.
  • Proofs with a fee outside those bounds are rejected with 400 fee_out_of_bounds before any RPC call is made.
  • Response body now includes fee alongside hash and relayer.

frontend/src/app/withdraw/page.tsx

  • New Relayer Fee card between the recipient input and the submit button.
    • Default: 0.05 USDC (500,000 stroops).
    • "Reset" button restores the default.
    • Input hint explains what the fee does and who receives it.
  • Validation:
    • Fee must be between 0.01 and 1 USDC (feeOutOfBounds).
    • payout + fee must not exceed the note's value (feeExceedsNote).
    • Submit button is disabled and handleBatchWithdraw returns early when either guard fires.
  • changeValue now deducts the fee: noteValue - payout - fee.
  • withdrawNote signature gains relayerFeeStroops: string; this is passed down to proveWithdrawal and used for value accounting.

docs/THREAT_MODEL.md

  • Security boundaries table: circuit now enforces withdraw_amount + relayer_fee <= amount and 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".
  • New Relayer fee sub-section under Amount integrity and value conservation covering:
    • Why the fee is verifiable and non-forgeable.
    • Why bounded extraction matters.
    • What the fee leaks (the amount, same as withdraw_amount) and what it does not leak (note value, deposit linkage, user identity).
    • Incentive-alignment rationale.
  • Circuit guarantees section updated to state "three-way value conservation with all components range-constrained".

New Contract Tests

Test What it covers
test_public_inputs_with_fee_are_six_fields Locks in the 192-byte layout; rejects old 5-field proofs
test_parse_public_inputs_extracts_relayer_fee_field Field ordering — relayer_fee is at index 4, change_commitment at 5
test_withdraw_with_relayer_fee_pays_both_recipient_and_relayer Public-input parsing round-trip; documents atomic payout + fee intent
test_withdraw_with_zero_relayer_fee_accepted Zero fee is valid; no zero-value SAC transfer attempted
test_withdraw_relayer_fee_out_of_range_rejected Fee bytes above 64-bit range → InvalidPublicInputs (same guard as payout)
test_value_conservation_with_fee_in_circuit_semantics Documents payout + fee + change = amount invariant and why the contract trusts the proof for the arithmetic

Security Properties

Property Mechanism
Fee cannot be forged after proof generation relayer_fee is a public input; changing it post-generation invalidates the proof
Relayer cannot redirect the payout Recipient binding is unchanged; payout still goes to the address whose hash the proof commits to
Fee and payout are atomic Both transfers execute after state updates (checks-effects-interactions); neither can succeed without the other
Fee cannot exceed note value Circuit arithmetic withdraw + fee + change = amount with 64-bit guards prevents overflow
Out-of-range fee fields rejected amount_from_field rejects any byte set above bit 63, same as it does for withdraw_amount
Old proofs cannot replay against the new contract 5-field public inputs (160 bytes) fail parse_public_inputs length check

Privacy Properties

The relayer fee is a public input, visible on-chain in the same way withdraw_amount is. It does not:

  • Reveal the note's total value (a 0.05 USDC fee could come from any size note).
  • Link the withdrawal to a specific deposit.
  • Reveal the withdrawer's identity.

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:

  1. Recompile circuits/shielded_pool → regenerate proving key and verification key.
  2. Redeploy the pool contract (new PUBLIC_INPUT_BYTES = 6 * 32).
  3. Ship updated frontend (new circuit artifact + fee UI).
  4. Update relayer binary (new fee extraction + bounds check).

Staging on a fresh testnet pool before migrating the production pool is strongly recommended.


Testing Checklist

  • 6 new contract unit tests added and passing
  • Circuit recompiled and shielded_pool.json updated (requires Nargo toolchain)
  • Prover.toml change_commitment recalculated for updated value split
  • Integration test: full withdrawal with real proof, verify relayer receives fee on-chain
  • Manual QA: withdraw UI shows fee, default is 0.05 USDC, out-of-range rejected in UI
  • Manual QA: relay API rejects fee < 0.01 USDC and fee > 1 USDC with fee_out_of_bounds

Related

  • Addresses the "unfunded relayer = single point of abandonment" concern from SECURITY.md
  • Complements the multi-relayer-failover work (a fee market is more meaningful when multiple competing relayers exist)
  • THREAT_MODEL.md updated to reflect the fee as a new public input

closes #141

@tech-adrian
tech-adrian merged commit 2dc1ff5 into Dshield-xyz:dev Aug 30, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: on-chain, circuit-enforced relayer fee for withdrawals

2 participants