Skip to content

feat(forwarder): add nonce management to prevent replay of gasless transactions - #866

Open
Nife-tanny wants to merge 3 commits into
accesslayerorg:mainfrom
Nife-tanny:feat/forwarder-nonce-management
Open

Nife-tanny wants to merge 3 commits into
accesslayerorg:mainfrom
Nife-tanny:feat/forwarder-nonce-management

Conversation

@Nife-tanny

@Nife-tanny Nife-tanny commented Sep 24, 2026 •

Copy link
Copy Markdown

Closes #858
Supersedes #840

Note

Rebased onto main; no longer stacked on #840. #840 has not been updated since Sep 3, conflicts with main and has red CI, so as proposed in this comment, this PR now carries #840's forwarder code itself. It has three commits on top of current main:

Commit Scope
c497e71 feat: add trusted forwarder pattern for gasless transactions (#833) #840's forwarder code, unchanged, authored by @xsthar: set_trusted_forwarder, get_trusted_forwarder, forward_buy, the fwd_buy event and the two storage keys. #840's conflict fix-ups and its deletion of three test files are left out.
b4bb97a feat(forwarder): add get_nonce view and enforce/increment nonce in forward_buy the #858 nonce work plus maintainer decisions 1–3
eb67d21 fix(forwarder): bind forward_buy signing key to the buyer account small follow-up, open question below, easy to drop

The previous stacked version is kept on feat/forwarder-nonce-management-stacked. If this PR merges, #840 can be closed as superseded.

Summary

Completes replay protection for gasless buys through the trusted forwarder:

  • New view get_nonce(wallet) -> u64. Returns the nonce the wallet's next forward_buy must carry. It is 0 for wallets with no history, needs no auth and never panics.
  • forward_buy takes an explicit nonce: u64. It must equal the stored nonce, otherwise NonceAlreadyUsed. The nonce is then incremented, and a failed purchase reverts the increment.
  • The signed message now covers (contract_address, creator, buyer, quantity, nonce), replacing quantity || nonce.
  • The ed25519 signature is the buyer's authorization. forward_buy no longer goes through buyer.require_auth(). buy_key still requires the buyer's auth.

Maintainer decisions and how they're implemented

1. Nonce parameter. forward_buy(creator, buyer, public_key, quantity, nonce: u64, signature). This is an intentional interface change: nonce is inserted before signature. If nonce != get_nonce(buyer), it returns ContractError::NonceAlreadyUsed (104). That covers both replayed (stale) and future nonces.

2. Signed message. The message is (env.current_contract_address(), creator, buyer, quantity, nonce).to_xdr(). So a signature can't be reused against another deployment, creator, buyer, quantity or nonce. The byte layout (below) is documented on forward_buy, and a test pins it byte for byte. The unused _msg_hash was removed.

3. Signature is the primary authorization. The purchase body of buy_key_with_referrer (including every guard on current main: emergency pause, global halt, per-key pause, blacklist, deadline, frozen positions, whitelist, circuit breaker) moved, unchanged, into a private associated fn execute_buy. It is not exported, because only pub fns in a #[contractimpl] become entrypoints.

  • buy_key → buy_key_with_referrer = buyer.require_auth() + execute_buy (behavior unchanged)
  • forward_buy = forwarder auth + signature + nonce + execute_buy (no buyer require_auth)

forward_buy order

  1. The caller is the trusted forwarder (forwarder.require_auth(), unchanged from the forwarder commit).
  2. The signed message is built from the arguments and the signature is verified. With commit 3, this also checks that public_key is the buyer's key.
  3. nonce == stored nonce, else NonceAlreadyUsed.
  4. The nonce is incremented (checked_add, Overflow instead of wrapping) and its TTL extended.
  5. The purchase executes via execute_buy, then the fwd_buy event is emitted (schema unchanged).

Because the nonce is consumed before the purchase runs (checks-effects-interactions), a reentrant call can't reuse it. A Soroban invocation that fails reverts all its writes, so if step 5 fails the increment and TTL bump are rolled back too, and the same nonce stays usable. Tests cover this.

Signed-message byte layout

The buyer signs these raw bytes with ed25519 (no pre-hash). They are the XDR of the ScVal vector [contract_address, creator, buyer, quantity, nonce]:

00 00 00 10            ScValType::Vec
00 00 00 01            vector present
00 00 00 05            5 elements
<ScVal::Address>       this contract's address
<ScVal::Address>       creator
<ScVal::Address>       buyer
00 00 00 03  <4 bytes> ScVal::U32 quantity, big-endian
00 00 00 05  <8 bytes> ScVal::U64 nonce, big-endian

Each ScVal::Address is 00 00 00 12, followed by one of:

  • 00 00 00 00 00 00 00 00 <32-byte ed25519 key> for an account (G...)
  • 00 00 00 01 <32-byte contract hash> for a contract (C...)

With @stellar/stellar-sdk:

const msg = xdr.ScVal.scvVec([
  new Address(contractId).toScVal(),
  new Address(creator).toScVal(),
  new Address(buyer).toScVal(),
  xdr.ScVal.scvU32(quantity),
  xdr.ScVal.scvU64(new xdr.Uint64(nonce)),
]).toXDR();
const signature = keypair.sign(msg);

Nonce storage and TTL

  • Storage: persistent storage, under the forwarder commit's DataKey::ForwarderNonce(Address) (constants::storage::forwarder_nonce). No second nonce was added. Entries are sparse: none exists until a wallet's first successful forward_buy.
  • TTL: reuses the existing extend_key_ttl_to_full_window, which extends to CREATOR_TTL_LEDGERS (6,311,520 ledgers, about 2 years). That's the same policy the forwarder commit uses for TrustedForwarder and the repo uses for per-wallet balances. No new constants.

"TTL bumped on every read" together with "never panics"

In Soroban, extend_ttl on a key that doesn't exist traps. I confirmed this by deliberately breaking the code so it bumps unconditionally, and 11 tests failed. So the read helper does this:

  • Entry exists: return it and extend its TTL. This happens on every read, including the one inside forward_buy.
  • Entry missing: return 0 and don't touch the TTL, since there is nothing to extend. Nothing is written either, so get_nonce never creates entries.

Every nonce write (the increment) is followed by a TTL extension. So every read and write of an existing entry bumps its TTL, and no storage state can make get_nonce panic. It uses no unwrap/expect.

Error enum

The forwarder commit adds two variants, appended after main's current last variant (CircuitBreakerTripped = 102):

  • InvalidSignature = 103: signing key isn't the buyer's (commit 3)
  • NonceAlreadyUsed = 104: nonce mismatch

(#840 had them as 56/57 and the earlier rebase as 83/84; both ranges are now used by other variants on main.)

Acceptance criteria

All tests are in creator-keys/tests/forward_buy_nonce.rs (23 tests, real ed25519 keys via ed25519-dalek, which is already in Cargo.lock through soroban-sdk testutils).

  • get_nonce returns 0 for wallets with no forwarded history: get_nonce_returns_zero_for_wallet_without_forwarded_history, nonces_are_tracked_per_wallet
  • Nonce incremented after each successful forward_buy: successful_forward_buy_increments_nonce (0 → 1 → 2), nonces_are_tracked_per_wallet, failed_purchase_does_not_increment_nonce
  • forward_buy with incorrect nonce panics with NonceAlreadyUsed: replayed_nonce_is_rejected_with_nonce_already_used, future_nonce_is_rejected_with_nonce_already_used (asserts Err(Ok(ContractError::NonceAlreadyUsed)))
  • TTL bumped on every nonce read and write: nonce_ttl_is_extended_on_write, nonce_ttl_is_extended_on_get_nonce_read (advances the ledger, then a pure get_nonce restores the full window), rejected_forward_buy_rolls_back_nonce_ttl_bump
  • get_nonce never panics regardless of storage state: get_nonce_never_panics_on_fresh_contract_or_missing_entry (bare deployment with no admin, price or forwarder, called repeatedly; asserts no entry is created), get_nonce_requires_no_authorization

Other tests:

  • Signature covers every field (one test per field): signature_over_different_{contract_address,creator,buyer,quantity,nonce}_is_rejected, and signed_message_layout_matches_documented_encoding (signs hand-assembled bytes, not the SDK encoder)
  • Nonce check runs after signature verification: bad_signature_fails_before_nonce_check_and_leaves_nonce_untouched. A forged signature with a wrong nonce fails as a host crypto abort, not NonceAlreadyUsed, and the nonce is unchanged.
  • Works without the buyer's Soroban auth: forward_buy_succeeds_without_buyer_soroban_auth mocks only the forwarder's auth via mock_auths (not mock_all_auths) and asserts the forwarder is the only recorded auth.
  • buy_key still requires buyer auth: direct_buy_key_still_requires_buyer_auth
  • Forwarder gate unchanged: only_trusted_forwarder_can_submit
  • Key binding (commit 3): signature_from_key_other_than_buyers_is_rejected_with_invalid_signature, contract_address_buyer_is_rejected_with_invalid_signature

I checked that the tests catch regressions by breaking the code on purpose and rerunning them. Each break made the expected tests fail:

Deliberate break Tests that failed
Removed the nonce check the 3 replay/future/rollback tests
Moved the nonce check before signature verification the ordering test
Removed the TTL bump on read the read-TTL test
Bumped the TTL even for missing entries 11 tests
Dropped the contract address from the signed message the layout test and 10 others
Removed the key binding both binding tests

Verification

Run locally on top of main at 7f21398 (Linux, Rust stable, soroban-sdk 22.0.11), using the same commands as .github/workflows/ci.yml / make ci:

Check Result
cargo fmt --all -- --check ✅ pass
cargo clippy --workspace --all-targets -- -D warnings ✅ pass
cargo test --workspace ✅ 1757 passed, 0 failed, 1 ignored (includes the 23 new tests in forward_buy_nonce.rs)
cargo check --workspace ✅ pass
cargo build -p creator-keys --target wasm32-unknown-unknown --release ❌ fails with symbol 'init' is already defined in acl_dividend_twap_gov.rs. Pre-existing: plain main at 7f21398 fails identically, and CI doesn't run this step. Not caused by this PR.

No #[allow] or #[ignore] was added, and no CI config was changed. The 126 failures the stacked version inherited from #840 are gone, because they came from #840's out-of-date copy of main.

Rebase notes

  • Buy-path guards: main added emergency_pause::assert_trading_allowed to buy_key_with_referrer since the last rebase. It now lives at the top of execute_buy, so forward_buy is blocked by an emergency pause exactly like buy_key. All other guards on main's buy path are inside execute_buy too.
  • Other conflicts (imports, ContractError, constants::storage, DataKey, end of the impl block next to Build LP reward contract for liquidity providers on key pairs #1002's LP functions) were purely additive; both sides are kept.

Open questions for reviewers

1. Key binding (commit eb67d21)

In the forwarder code from #840: forward_buy verified the signature against a caller-supplied public_key that was never tied to buyer. This was masked because buy_key also required buyer.require_auth(). Once decision 3 takes require_auth off the forwarded path, someone could sign with their own key while naming any buyer. The test signature_from_key_other_than_buyers_is_rejected_with_invalid_signature shows that this succeeds without commit 3.

What commit 3 does: it requires buyer to be the account (G...) address of public_key, comparing buyer.to_xdr() with the XDR built from public_key. An account address is its ed25519 key, so no registry or new storage is needed. A mismatch returns InvalidSignature (103).

Trade-offs:

  • Contract wallets (C..., such as passkey or smart wallets) can't use forward_buy.
  • It checks the account's master key and ignores the account's signer weights or thresholds.

If you'd prefer a different scheme (for example, a registered key per wallet, or deriving the key from buyer so the public_key argument goes away), I can replace or drop commit 3.

2. Payment path: no funds move

buy_key/execute_buy never calls a token contract. payment is only compared with price, and no XLM or SAC transfer happens anywhere in the contract. So the gasless flow does work end to end: nothing on the forwarded path needs the buyer's Soroban auth, and the tests show a forwarded buy succeeding with only the forwarder's auth. But the buyer isn't charged, and forward_buy computes payment on-chain. This applies to buy_key too, so it's a contract-wide design point rather than something from #858. If a token transfer from = buyer is added later, it would need the buyer's Soroban auth, which conflicts with the gasless design. That would need something like a pre-approved allowance (approve + transfer_from) or an escrowed balance.

3. Other things noticed in the forwarder code (not changed here)

  • quantity is only used for payment. forward_buy computes payment = price × quantity but calls the single-key purchase once, so exactly one key is bought whatever quantity is. My tests use quantity = 1 for successful buys.
  • set_trusted_forwarder checks the admin without auth. It calls assert_is_admin, which compares the passed address with the stored admin, but never calls admin.require_auth(). As written, any caller can pass the admin's address and install their own forwarder. Suggested fix: add admin.require_auth(). I can add it to this PR, since the forwarder code now lives here. Commit 3 limits the impact, since a rogue forwarder still can't forge buyer signatures.
  • InvalidSignature isn't returned for a bad signature. ed25519_verify traps (Err(Err(InvokeError::Abort)) from try_ clients), so a bad signature surfaces as a host crypto error. The SDK has no fallible verify. InvalidSignature is now used only for the key-binding check.
  • A doc comment is wrong. bump_persistent_ttl's doc says "Extending an entry that does not exist is a runtime no-op", but it traps (see above). I left it alone to keep the scope tight.

Docs

  • forward_buy doc comment: order of checks, auth model and byte layout.
  • docs/read-only-methods.md: new "Trusted forwarder read methods → get_nonce" section.

🤖 Generated with Claude Code

@Nife-tanny

Copy link
Copy Markdown
Author

@Chucks1093 #858 is implemented and ready for review. Since #840 has stalled, I'd like to switch this PR onto main so it can be merged before the Sep 30 deadline.

Where it stands

As agreed, this PR is stacked on #840, so it shows #840's changes too. Only the last two commits are #858 (git diff 89250e3..3972d37). The PR description covers the details.

But #840 hasn't been updated since Sep 3. Its CI is red, it conflicts with main, and main has moved about 230 commits since. Because this PR conflicts too, GitHub hasn't run CI on it at all.

Proposal: rebase onto main and carry #840's forwarder with it

I've prepared this on a separate branch:
main...Nife-tanny:accesslayer-contracts:feat/forwarder-nonce-on-main

It has three commits on top of current main:

  1. feat: add trusted forwarder pattern for gasless transactions (#833) #840's forwarder code, unchanged, with @xsthar as the author. This covers set_trusted_forwarder, get_trusted_forwarder, forward_buy, the fwd_buy event and the two storage keys. It leaves out feat: add trusted forwarder pattern for gasless transactions (#833) #840's conflict fix-ups and its deletion of three test files that still exist on main. InvalidSignature and NonceAlreadyUsed become 83 and 84, because main now uses 55–82.
  2. The Add a nonce management function for the trusted forwarder to prevent replay attacks on gasless transactions #858 nonce work. Same as here.
  3. Binding the signing key to the buyer. Same as here.

The diff against main drops to 6 files, +925/−2. Locally, cargo fmt, clippy -D warnings, cargo test --workspace and the wasm build all pass. The 126 test failures inherited from #840 are gone, because they came from #840's out-of-date copy of main.

With this change, #866 can be merged on its own, and #840 could be closed as superseded. @xsthar, the forwarder commit keeps you as the author, and I'm happy to adjust if you'd prefer it done differently.

Deadline

If I don't hear back by Sep 29, I'll force-push this rebased version to #866, so a mergeable PR with green CI is waiting before Sep 30. The current stacked version stays on feat/forwarder-nonce-management-stacked, so switching back takes one push.

Questions (details in the PR description)

  1. Are you OK with commit 3, which binds the signing key to the buyer? It means contract wallets (C...) can't use forward_buy.
  2. set_trusted_forwarder checks the admin address but never calls admin.require_auth(), so anyone can pass the admin's address and set their own forwarder. Should I fix that in this PR, since the forwarder code now lives here?

xsthar and others added 3 commits October 2, 2026 12:04
…ayerorg#833)

Ports the trusted forwarder from accesslayerorg#840 onto current main. The forwarder
code is carried over unchanged; accesslayerorg#840's merge-conflict fix-ups and test
file deletions are not, since main has since moved on.

- set_trusted_forwarder / get_trusted_forwarder
- forward_buy, callable only by the trusted forwarder
- DataKey::TrustedForwarder and DataKey::ForwarderNonce(Address)
- fwd_buy event (ForwardedBuyEvent)
- InvalidSignature and NonceAlreadyUsed, renumbered to 83 and 84 because
  main now uses 55-82 for other variants

Originally authored by @xsthar in accesslayerorg#840.

Co-Authored-By: Codebuff <noreply@codebuff.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rward_buy

Completes replay protection for the trusted forwarder (accesslayerorg#858), stacked on
the forwarder introduced in accesslayerorg#840.

- forward_buy takes an explicit `nonce: u64`. It must equal the buyer's
  stored nonce, otherwise NonceAlreadyUsed (replayed and future nonces).
- The buyer signs the XDR of (contract_address, creator, buyer, quantity,
  nonce) instead of `quantity || nonce`, so a signature cannot be reused
  on another deployment, creator, buyer, quantity or nonce. The byte
  layout is documented on forward_buy.
- Order: verify signature -> check nonce -> increment nonce -> purchase.
  A failed purchase reverts the increment with the rest of the call.
- The ed25519 signature is the buyer's authorization: the purchase logic
  moves into a private `execute_buy`, so forward_buy no longer hits
  `buyer.require_auth()`. buy_key / buy_key_with_referrer still require
  buyer auth and are otherwise unchanged.
- New `get_nonce(wallet)` view: 0 for wallets with no history, no auth,
  never panics. The nonce TTL is extended on every read and write of an
  existing entry; a missing entry is not bumped because extend_ttl on a
  missing key traps.
- Tests use real ed25519 keys (ed25519-dalek dev-dependency, already in
  the lockfile via soroban-sdk testutils).

Closes accesslayerorg#858

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
forward_buy verified the ed25519 signature against a caller-supplied
`public_key` that was never tied to `buyer`. With `buyer.require_auth()`
off the forwarded path, anyone could sign with their own key while naming
any buyer.

Require `buyer` to be the account (G...) address of `public_key`, compared
via XDR (an account address is its ed25519 key, so no registry is needed).
A mismatch returns InvalidSignature. Consequence: contract (C...) wallets
cannot use forward_buy.

Kept as a separate commit so it can be dropped if reviewers prefer a
different binding scheme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Nife-tanny
Nife-tanny force-pushed the feat/forwarder-nonce-management branch from 3972d37 to eb67d21 Compare October 2, 2026 12:59
@Nife-tanny
Nife-tanny marked this pull request as ready for review October 2, 2026 13:00
@Nife-tanny

Copy link
Copy Markdown
Author

@Chucks1093 As mentioned in my last comment, I've force-pushed the rebased version, so this PR is now based on current main (7f21398) instead of being stacked on #840. Sorry it landed after the Sep 30 date. main had moved another 141 commits, so I had to rebase again.

What changed

If this merges, #840 can be closed as superseded.

Still open (details in the description)

  1. Are you OK with commit 3, which binds the signing key to the buyer? It means contract wallets (C...) can't use forward_buy.
  2. set_trusted_forwarder never calls admin.require_auth(), so anyone can pass the admin's address and set a forwarder. I'm happy to add the fix here, since the forwarder code now lives in this PR.

Unrelated, but I noticed that cargo build -p creator-keys --target wasm32-unknown-unknown --release fails on main itself (symbol 'init' is already defined in acl_dividend_twap_gov.rs). CI doesn't run that step, so it hasn't shown up.

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.

Add a nonce management function for the trusted forwarder to prevent replay attacks on gasless transactions

2 participants