Reentrancy-safe wiring of globe-wallet::send to token-wrapper::transfer_from - #107
Merged
ndii-dev merged 1 commit intoAug 29, 2026
Merged
Conversation
…ransfer_from Closes Orbit-Wal#92 - Added docs/design/wiring-reentrancy-threat-model.md: the design-decision write-up for the reentrancy-safe wiring, generalizing docs/record-spend-reentrancy.md's single-contract proof to the new 3-hop chain (GlobeWallet::send -> token-wrapper::transfer_from -> arbitrary token_id) and explaining both chosen mitigations. - Implemented GlobeWallet::send wiring record_spend to token-wrapper::transfer_from, gated by a new admin-curated token allowlist (add_allowed_token/remove_allowed_token/is_token_allowed) and an admin-configured token-wrapper instance (set_token_wrapper). - Added a mock malicious token contract and test_send_rejects_reentrant_malicious_token proving the wiring rejects a real reentrant callback attempt, plus 7 more tests covering the happy path, allowlist/wrapper-not-set rejections, CEI ordering (a rejected send moves zero tokens), and admin gating. - Fixed a pre-existing, workspace-wide build breakage that prevented cargo test --workspace from running on any fresh clone at all: soroban-env-host 21.2.1's unbounded ed25519-dalek dependency resolves to an incompatible 3.0.0 on a fresh (lockfile-less) resolve. A plain version-pinned dependency does not fix this, since Cargo resolves independent requirement strings for the same crate separately absent something forcing unification -- verified empirically. Fixed via a [patch.crates-io] pointing at a pinned git tag of the same upstream project (the one mechanism that reliably forces single-version resolution across the whole graph, lockfile or not). - Rebased onto the latest upstream main (through PR Orbit-Wal#81), which already fixed the other three build issues this work originally hit (token-wrapper's orphaned enum fragment, the missing globe_wallet.wasm fixture, and the WalletError discriminant numbering) -- picked up their fixes rather than duplicating them. cargo test --workspace: 73 passed / 4 failed (pre-existing, unrelated, confirmed present on current upstream main with only the ed25519-dalek fix applied -- see PR description) in globe-wallet; 11 passed in token-wrapper; 1 passed in the record_spend_reentrancy integration test.
yosemite01
force-pushed
the
fix/issue-92-reentrancy-safe-wiring
branch
from
August 29, 2026 18:16
9b928bb to
077f191
Compare
5 tasks
6 tasks
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.
Closes #92
Root cause
docs/design/architecture.mdcorrectly flags thatglobe-walletandtoken-wrapperdon't call each other yet, and sketches two ways to wire them. Neither sketch — norrecord_spend's own reentrancy proof indocs/record-spend-reentrancy.md— says anything about what happens once the wiring calls an arbitrary, caller-suppliedtoken_id.record_spend's proof is scoped to its own function body, which never makes an external call; the wiring's whole point is to make one. That's a new reentrancy surface with no existing analysis, and issue #92 is the request to close that gap before shipping the wiring, not after.The full analysis — including exactly what Soroban's platform reentry protection does and does not guarantee for the new 3-hop chain (
GlobeWallet::send→token-wrapper::transfer_from→ arbitrarytoken_id) — is written up indocs/design/wiring-reentrancy-threat-model.md. Please read that first; this description summarizes it but the doc has the full reasoning, alternatives considered, and citations.Short version: Soroban's
ContractReentryMode::Prohibiteddefault means an adversarialtoken_idgenuinely cannot call back intoGlobeWallet(or intotoken-wrapper) while their frames are active on the stack — this repo already proved that for the single-contract case, and this PR proves it empirically for the new multi-hop case with a real mock malicious contract (see below). What reentry protection does not cover is atoken_idthat simply isn't real — a fake token whosetransferreturns success without moving any value would still letrecord_spend's daily counter andtoken-wrapper's allowance both decrement against nothing. That's a trust problem, not a reentrancy problem, and it's why the fix is two mitigations, not one.Design decisions
Both of the issue's suggested mitigations are implemented, because they close two different halves of the problem — full rationale in §4 of the threat-model doc:
GlobeWallet::sendrejects anytoken_idthat isn't on a new admin-curated allowlist (add_allowed_token/remove_allowed_token/is_token_allowed, backed byDataKey::AllowedToken(Address)), before touching any state. This is what actually stops the fake-token/griefing class of problem, which reentry protection can't.record_spend.sendcallsrecord_spend(all of globe-wallet's own state changes) as a direct in-frame Rust call — not a cross-contract invocation, sorecord_spend's existing proof is untouched — and only then calls out totoken-wrapper::transfer_from, last. This is defense-in-depth on top of (1): even in a hypothetical future where Soroban's reentry rules changed, every one of globe-wallet's own writes for this operation is already committed before any external code runs.Implementation
contracts/globe-wallet/src/lib.rs:DataKey::TokenWrapperIdandDataKey::AllowedToken(Address), appended to the end of the enum per the file's existing storage-key-stability convention.WalletErrorvariants —TokenNotAllowed = 1034,TokenWrapperNotSet = 1035,TokenTransferFailed = 1036— continuing the contiguous numbering PR Fix asset validation, dedupe constants/imports, and cover upgrade + transfer_from tests #81 established.set_token_wrapper,get_token_wrapper,add_allowed_token,remove_allowed_token,is_token_allowed.send(user, token_id, asset_code, to, amount)— the actual wiring. Doc comment covers the error cases and the atomicity guarantee (a failure anywhere reverts the whole call, includingrecord_spend's write).globe-walletnow depends on thetoken-wrappercrate directly for a real, typedTokenWrapperClient, rather than hand-rollingenv.invoke_contract. Verified empirically, not assumed: builtglobe-wallettowasm32-unknown-unknown --releasewith the dependency in place and parsed the resulting binary's export section directly (hand-rolled a ~40-line WASM export-table parser — no wasm-inspection tooling was available in this environment) to confirmapprove/transfer_from/allowancedo not leak intoglobe-wallet's compiled.wasm. This workspace's release profile (lto = true,codegen-units = 1) correctly dead-code-eliminates them. Documented in a comment on the dependency incontracts/globe-wallet/Cargo.toml, with a note to re-verify if the release profile's LTO settings ever change.The reentrancy test — the actual proof
test_send_rejects_reentrant_malicious_tokendeploys a real, separate mock contract (MaliciousToken, defined right above the test) implementing just the standard token interface'stransferfunction — enough fortoken-wrapper'stoken::Client::transfercall to route to it exactly as it would for any real SAC, sincetoken_idis unconstrained beyond the allowlist check. Itstransferimplementation immediately tries to call back intoGlobeWalletClient::record_spendfor the same(victim, asset_code)pairsendis already processing — the exact double-count scenario the issue describes.The test asserts:
sendfails as a whole (try_send(...).is_err()) — the reentrant callback does not let it silently succeed.DailySpententry survives at all (read directly viaenv.as_contract) — proving the transaction fully reverted, so there's no partial state where the legitimate spend committed but the reentrant one didn't, or vice versa.Seven more tests cover the rest: the happy path (real SAC token actually moves, allowance decrements, daily spend records), rejection when
token_idisn't allowlisted, rejection when notoken-wrapperis configured, and — importantly — that asendrejected for exceeding the daily limit moves zero tokens and leaves the allowance untouched, demonstrating the CEI-ordering guarantee in practice. Plus two admin-gating tests (set_token_wrapper,add_allowed_tokenboth reject non-admin callers).No regression to
record_spend's existing reentrancy invariantrecord_spenditself is untouched —sendcalls it as a plain associated-function call, same as any other internal caller. All existing tests (test_record_spend_*,two_spends_in_one_host_invocation_accumulatein the separatetests/record_spend_reentrancy.rsintegration test) pass unmodified.A note on this branch's history
This branch has been rebased twice onto
mainduring review-prep as other PRs merged ahead of it:WalletErrordiscriminant renumbering — fix(globe-wallet): renumber WalletError to a single contiguous 1001+ scheme #72) rather than reintroducing conflicting duplicate fixes.feat/recovery-completed-event): that PR also independently hit and fixed the sameed25519-dalekbuild-blocking dependency conflict I'd found (see below) — via vendoring rather than my original git-tag-patch approach. I deferred to their solution (it's more robust: no network dependency at all) rather than keeping mine. That same rebase also picked up fixes for what were, as of my last update to this PR, 4 failing pre-existing tests unrelated to this change — the suite is now fully green (see Evidence below).Flagging this so reviewers aren't confused if the diff or description looks different from an earlier glance.
Evidence (
cargo test --workspace)Fully green — 80/80, 11/11, 1/1. (An earlier revision of this PR description noted 4 pre-existing, unrelated test failures at the time; those have since been resolved by other merges this branch picked up on rebase, so there's nothing left to call out as a known-unrelated gap.)
Files touched
docs/design/wiring-reentrancy-threat-model.md— new, the design-decision write-up.contracts/globe-wallet/src/lib.rs— the wiring (send+ allowlist + admin setters), the mock malicious contract, 8 new tests.contracts/globe-wallet/Cargo.toml— thetoken-wrapperdependency, commented with the wasm-export verification.test_snapshots/fixture files for the 8 new tests. Pre-existing snapshot files for untouched tests are not included in this diff — they regenerate with cosmetic/non-deterministic differences (trailing newline, freshly-Address::generated values) on everycargo testrun regardless of this change, so committing them would just be noise unrelated to this PR.