Repository navigation
feat(forwarder): add nonce management to prevent replay of gasless transactions - #866
Nife-tanny wants to merge 3 commits into
Conversation
|
@Chucks1093 #858 is implemented and ready for review. Since #840 has stalled, I'd like to switch this PR onto 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 ( But #840 hasn't been updated since Sep 3. Its CI is red, it conflicts with Proposal: rebase onto I've prepared this on a separate branch: It has three commits on top of current
The diff against 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 Questions (details in the PR description)
|
…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>
3972d37 to
eb67d21
Compare
|
@Chucks1093 As mentioned in my last comment, I've force-pushed the rebased version, so this PR is now based on current What changed
If this merges, #840 can be closed as superseded. Still open (details in the description)
Unrelated, but I noticed that |
Closes #858
Supersedes #840
Note
Rebased onto
main; no longer stacked on #840. #840 has not been updated since Sep 3, conflicts withmainand 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 currentmain:c497e71feat: add trusted forwarder pattern for gasless transactions (#833)set_trusted_forwarder,get_trusted_forwarder,forward_buy, thefwd_buyevent and the two storage keys. #840's conflict fix-ups and its deletion of three test files are left out.b4bb97afeat(forwarder): add get_nonce view and enforce/increment nonce in forward_buyeb67d21fix(forwarder): bind forward_buy signing key to the buyer accountThe 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:
get_nonce(wallet) -> u64. Returns the nonce the wallet's nextforward_buymust carry. It is0for wallets with no history, needs no auth and never panics.forward_buytakes an explicitnonce: u64. It must equal the stored nonce, otherwiseNonceAlreadyUsed. The nonce is then incremented, and a failed purchase reverts the increment.(contract_address, creator, buyer, quantity, nonce), replacingquantity || nonce.forward_buyno longer goes throughbuyer.require_auth().buy_keystill 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:nonceis inserted beforesignature. Ifnonce != get_nonce(buyer), it returnsContractError::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 onforward_buy, and a test pins it byte for byte. The unused_msg_hashwas removed.3. Signature is the primary authorization. The purchase body of
buy_key_with_referrer(including every guard on currentmain: emergency pause, global halt, per-key pause, blacklist, deadline, frozen positions, whitelist, circuit breaker) moved, unchanged, into a private associated fnexecute_buy. It is not exported, because onlypub 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 buyerrequire_auth)forward_buyorderforwarder.require_auth(), unchanged from the forwarder commit).public_keyis the buyer's key.nonce == stored nonce, elseNonceAlreadyUsed.checked_add,Overflowinstead of wrapping) and its TTL extended.execute_buy, then thefwd_buyevent 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
ScValvector[contract_address, creator, buyer, quantity, nonce]:Each
ScVal::Addressis00 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:Nonce storage and TTL
DataKey::ForwarderNonce(Address)(constants::storage::forwarder_nonce). No second nonce was added. Entries are sparse: none exists until a wallet's first successfulforward_buy.extend_key_ttl_to_full_window, which extends toCREATOR_TTL_LEDGERS(6,311,520 ledgers, about 2 years). That's the same policy the forwarder commit uses forTrustedForwarderand the repo uses for per-wallet balances. No new constants."TTL bumped on every read" together with "never panics"
In Soroban,
extend_ttlon 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:forward_buy.0and don't touch the TTL, since there is nothing to extend. Nothing is written either, soget_noncenever 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_noncepanic. It uses nounwrap/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 viaed25519-dalek, which is already inCargo.lockthroughsoroban-sdktestutils).get_nonce_returns_zero_for_wallet_without_forwarded_history,nonces_are_tracked_per_walletsuccessful_forward_buy_increments_nonce(0 → 1 → 2),nonces_are_tracked_per_wallet,failed_purchase_does_not_increment_noncereplayed_nonce_is_rejected_with_nonce_already_used,future_nonce_is_rejected_with_nonce_already_used(assertsErr(Ok(ContractError::NonceAlreadyUsed)))nonce_ttl_is_extended_on_write,nonce_ttl_is_extended_on_get_nonce_read(advances the ledger, then a pureget_noncerestores the full window),rejected_forward_buy_rolls_back_nonce_ttl_bumpget_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_authorizationOther tests:
signature_over_different_{contract_address,creator,buyer,quantity,nonce}_is_rejected, andsigned_message_layout_matches_documented_encoding(signs hand-assembled bytes, not the SDK encoder)bad_signature_fails_before_nonce_check_and_leaves_nonce_untouched. A forged signature with a wrong nonce fails as a host crypto abort, notNonceAlreadyUsed, and the nonce is unchanged.forward_buy_succeeds_without_buyer_soroban_authmocks only the forwarder's auth viamock_auths(notmock_all_auths) and asserts the forwarder is the only recorded auth.buy_keystill requires buyer auth:direct_buy_key_still_requires_buyer_authonly_trusted_forwarder_can_submitsignature_from_key_other_than_buyers_is_rejected_with_invalid_signature,contract_address_buyer_is_rejected_with_invalid_signatureI checked that the tests catch regressions by breaking the code on purpose and rerunning them. Each break made the expected tests fail:
Verification
Run locally on top of
mainat7f21398(Linux, Rust stable, soroban-sdk 22.0.11), using the same commands as.github/workflows/ci.yml/make ci:cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningscargo test --workspaceforward_buy_nonce.rs)cargo check --workspacecargo build -p creator-keys --target wasm32-unknown-unknown --releasesymbol 'init' is already definedinacl_dividend_twap_gov.rs. Pre-existing: plainmainat7f21398fails 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 ofmain.Rebase notes
mainaddedemergency_pause::assert_trading_allowedtobuy_key_with_referrersince the last rebase. It now lives at the top ofexecute_buy, soforward_buyis blocked by an emergency pause exactly likebuy_key. All other guards onmain's buy path are insideexecute_buytoo.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_buyverified the signature against a caller-suppliedpublic_keythat was never tied tobuyer. This was masked becausebuy_keyalso requiredbuyer.require_auth(). Once decision 3 takesrequire_authoff the forwarded path, someone could sign with their own key while naming any buyer. The testsignature_from_key_other_than_buyers_is_rejected_with_invalid_signatureshows that this succeeds without commit 3.What commit 3 does: it requires
buyerto be the account (G...) address ofpublic_key, comparingbuyer.to_xdr()with the XDR built frompublic_key. An account address is its ed25519 key, so no registry or new storage is needed. A mismatch returnsInvalidSignature(103).Trade-offs:
C..., such as passkey or smart wallets) can't useforward_buy.If you'd prefer a different scheme (for example, a registered key per wallet, or deriving the key from
buyerso thepublic_keyargument goes away), I can replace or drop commit 3.2. Payment path: no funds move
buy_key/execute_buynever calls a token contract.paymentis only compared withprice, 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, andforward_buycomputespaymenton-chain. This applies tobuy_keytoo, so it's a contract-wide design point rather than something from #858. If a token transferfrom = buyeris 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)
quantityis only used for payment.forward_buycomputespayment = price × quantitybut calls the single-key purchase once, so exactly one key is bought whateverquantityis. My tests usequantity = 1for successful buys.set_trusted_forwarderchecks the admin without auth. It callsassert_is_admin, which compares the passed address with the stored admin, but never callsadmin.require_auth(). As written, any caller can pass the admin's address and install their own forwarder. Suggested fix: addadmin.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.InvalidSignatureisn't returned for a bad signature.ed25519_verifytraps (Err(Err(InvokeError::Abort))fromtry_clients), so a bad signature surfaces as a host crypto error. The SDK has no fallible verify.InvalidSignatureis now used only for the key-binding check.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_buydoc 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