Symptom
sphere payments send <amount> UCT @<recipient> (the direct-send path) on two freshly-created testnet wallets produces a token whose recipient predicate does NOT bind to the recipient's HD signers, despite the recipient nametag resolving correctly via the relay. The recipient's payments receive --finalize runs through finalizeStrandedReceivedToken, hits VerificationError: Recipient address mismatch, and stamps the token with a permanent V6-RECOVER verdict (HD-index recovery exhausted).
End-to-end effect: alice's CONFIRMED balance correctly drops by 10 UCT (the source is spent), bob's CONFIRMED balance stays at 0 (the token is durably classified as 'invalid'). The funds are effectively lost to bob.
Why this issue is being filed now
PR #388 + #389 fixed the surrounding architecture for handling this failure:
But all of those fixes address the SYMPTOM — making the verdict durable and the balance honest. The root cause — why does a fresh alice→bob send produce a token bob cannot bind to — is untouched. The PR #388 soak run (now in manual-test-full-recovery.sh) PASSES because it exercises the invoice payment path (invoice create + invoice pay), which constructs the recipient predicate from invoice terms inside AccountingModule. The direct-send path (sphere payments send) goes through PaymentsModule.send and hits this bug.
Minimal reproduction
# Run against testnet with both clones in sync
cd /home/vrogojin/sphere-sdk # checkout fix/issue-387-v6-recover-invalid-persistence + build
SUFFIX="$(date +%s | tail -c 5)$(printf '%04x' $((RANDOM % 65536)))"
ALICE="alice-$SUFFIX"; BOB="bob-$SUFFIX"
WORK="$(mktemp -d)"; mkdir -p "$WORK/alice" "$WORK/bob"
# --- Section 1+2: create + init both ---
( cd "$WORK/alice"
sphere wallet create alice && sphere wallet use alice
SPHERE_ALLOW_MNEMONIC_NON_TTY=1 sphere init --network testnet --nametag "$ALICE"
)
( cd "$WORK/bob"
sphere wallet create bob && sphere wallet use bob
SPHERE_ALLOW_MNEMONIC_NON_TTY=1 sphere init --network testnet --nametag "$BOB"
)
# --- Section 3: faucet alice + finalize so baseline is confirmed ---
( cd "$WORK/alice"
sphere wallet use alice
sphere faucet && sphere payments sync
sphere payments receive --finalize
sphere balance # alice: UCT 100 (1 token) -- confirmed
)
# --- Section 4: snapshot bob baseline ---
( cd "$WORK/bob"
sphere wallet use bob
sphere payments sync && sphere payments receive --finalize
sphere balance # bob: No tokens found
)
# --- Section 5: alice sends 10 UCT to @bob (instant, auto addressMode) ---
( cd "$WORK/alice"
sphere wallet use alice
sphere payments send "@${BOB}" 10 UCT
# → Transfer successful! Status: submitted
)
# --- Section 6: bob receive + finalize ---
( cd "$WORK/bob"
sphere wallet use bob
sphere payments sync
sphere payments receive --finalize
# → [Payments] [V6-RECOVER] Stranded receive <id> hit permanent
# recipient-address mismatch (HD-index recovery exhausted) (no retry):
# VerificationError: Transaction verification failed
# verificationResult: { status: 1, message: 'Recipient address mismatch' }
sphere balance # bob: STILL "No tokens found"
)
# --- Section 7: alice snapshot ---
( cd "$WORK/alice"
sphere wallet use alice
sphere payments sync
sphere balance # alice: UCT 90 (1 token) -- 10 dropped, but bob has 0
)
Observed output
Section 5 — alice's send (looks successful at the source):
Sending 10 UCT to @bob-56484857...
[PaymentsModule] Connectivity gate reports aggregator 'down'; proceeding with send and letting transport surface any real failure
✓ Transfer successful!
Transfer ID: dfa70fde-4bb6-4e29-bc40-13a1caa9edd2
Status: submitted
Section 6 — bob's receive (finalize fails):
Syncing...
[Payments] [V6-RECOVER] Stranded receive ead1f1a89d20 hit permanent
recipient-address mismatch (HD-index recovery exhausted) (no retry):
VerificationError: Transaction verification failed
at Token.update (state-transition-sdk/lib/token/Token.js:124:19)
at _PaymentsModule.finalizeStrandedReceivedToken (dist/index.js:34674:33)
at Object.onProofReceived (dist/index.js:34574:13)
at _PaymentsModule.resolveLegacyReceivedTokenViaGetProof (dist/index.js:35003:7)
at _PaymentsModule.resolveLegacyReceivedToken (dist/index.js:34931:14)
at _PaymentsModule.resolveUnconfirmed (dist/index.js:33909:26)
at _PaymentsModule.receive (dist/index.js:33331:29)
verificationResult: {
status: 1,
message: 'Recipient address mismatch',
results: []
}
Received 1 transfer(s)
Section 8 — verification:
alice CONFIRMED before: 10000000000 (smallest units)
alice CONFIRMED after: 9000000000 (smallest units) → -10 UCT ✓
bob CONFIRMED before: 0
bob CONFIRMED after: 0 → +0 UCT ✗
expected delta: 1000000000 (10 UCT × 10^8)
ASSERT OK (alice-confirmed-drop-10-UCT)
ASSERT FAIL (bob-confirmed-rise-10-UCT) expected delta 1000000000, got 0
ASSERT OK (alice-balance-after) no unconfirmed residue
ASSERT OK (bob-balance-after) no unconfirmed residue
The "no unconfirmed residue" on bob's side is exactly what #389 #2 verifies — the V6-RECOVER ledger correctly absorbs the token so it doesn't pollute balance display. But the underlying transfer never delivers value.
Locus
Per stack trace, the failure is wholly in the SDK side:
Token.update (in state-transition-sdk) — predicate.isOwner returns false → 'Recipient address mismatch'
_PaymentsModule.finalizeStrandedReceivedToken — classifies as permanent, stamps ledger
- The SEND side that minted this token is in
_PaymentsModule.send (or one of its split/finalize helpers)
CLI side (legacy-cli.ts:2702-2780) is a pass-through — resolves coin symbol, converts amount to smallest units, and calls sphere.payments.send({ recipient: '@bob-XX', amount, coinId, addressMode: 'auto', transferMode: 'instant' }). No predicate construction in CLI.
Recommended investigation strategy — diff the SUCCESS path against the FAILURE path
The invoice-payment path (sphere invoice create + invoice pay) works end-to-end across two fresh testnet wallets — the PR #388 soak proves it in manual-test-full-recovery.sh section §D. The direct-send path (sphere payments send) fails on the simplest possible scenario. Diff the two execution paths inside the SDK to localize the divergence:
-
Trace AccountingModule.payInvoice (invoice path — works) → which constructs a recipient predicate from invoice terms (InvoiceTerms.targets[i].address), splits source tokens, mints to that predicate, and ships via Nostr.
-
Trace PaymentsModule.send with addressMode: 'auto' (direct path — broken) → which resolves the @nametag via transport.resolve(...), picks a mode from the resolved PeerInfo, derives a recipient predicate from chainPubkey/directAddress, splits source tokens, mints, ships.
-
The two paths should produce equivalent recipient predicates when the invoice's target address equals the resolved nametag's directAddress. If they diverge, the failure is in PaymentsModule.send's predicate construction. If they're identical, the failure must be in something else upstream (resolver, mode-picker, or HD-walk on the receiver) — the SDK send-side mint is the same code that invoice payment exercises.
Concrete prediction: the invoice path uses targets[i].address literally (a DIRECT://... or PROXY://... string), bypassing nametag resolution entirely. The direct-send path resolves @nametag to PeerInfo via the relay; the divergence is most likely (a) the resolver returning a directAddress that's structurally valid but key-derived from a different epoch / index than bob's HD walk recovers, OR (b) addressMode: 'auto' picking 'proxy' (predicate from H(nametag)) when bob's tracked addresses store only the 'direct' shape.
Hypotheses to investigate
-
addressMode: 'auto' divergence. Auto-mode picks 'direct' if recipient PeerInfo has a directAddress, else 'proxy'. PROXY-mode predicates are constructed from H(nametag) whereas DIRECT-mode predicates use the resolved chainPubkey. If the sender picks one mode and the receiver's HD walk only covers the other (or vice versa), the predicate match fails.
-
Stale relay nametag binding. transport.resolve('@bob-XX') on the relay may return a binding from a prior test run if the nametag is somehow reused — but $SUFFIX is fresh per test, so this is unlikely. Worth verifying that the resolved peer's chainPubkey EXACTLY equals the freshly-created bob's chainPubkey from sphere init.
-
Predicate-derivation formula divergence between mint and verify. The mint side (sender's PaymentsModule.send → state-transition-sdk's recipient-predicate constructor) and the verify side (receiver's latestStatePredicateMatchesWallet walk → predicate.isOwner in Token.update) may apply different hash inputs or different masking. This would manifest deterministically across all fresh sends.
-
HD index activation gap on the receiver. The receiver walks MAX_HD_RECOVERY_INDEXES from index 0. The sender may target a derived predicate that uses a higher index (e.g., one keyed by nonce, salt, or epoch) that the receiver doesn't activate by default.
Acceptance criteria
Relation to other work
Reproduction script
A self-contained reproduction script lives in the repo at manual-test-simple-send.sh on branch fix/issue-387-v6-recover-invalid-persistence (committed alongside the issue filing). It uses integer-only verification (extracts UCT amount in smallest units from sphere balance output via string-pad-and-concat, no float coercion) and reuses the decimal-aware unconfirmed-residue check from manual-test-full-recovery.sh. To reproduce:
cd /home/vrogojin/uxf
bash manual-test-simple-send.sh
# Expected: ASSERT FAIL (bob-confirmed-rise-10-UCT) — exact reproduction of this issue.
Context for next session (post context-clearup)
Branch fix/issue-387-v6-recover-invalid-persistence at commit bc28737 carries the symptom-handling work. The reproduction is deterministic across fresh nametag suffixes. Investigate the SDK send-side predicate construction first (start at modules/payments/PaymentsModule.ts send() and trace into the recipient-predicate builder); the CLI is a pure pass-through.
Symptom
sphere payments send <amount> UCT @<recipient>(the direct-send path) on two freshly-created testnet wallets produces a token whose recipient predicate does NOT bind to the recipient's HD signers, despite the recipient nametag resolving correctly via the relay. The recipient'spayments receive --finalizeruns throughfinalizeStrandedReceivedToken, hitsVerificationError: Recipient address mismatch, and stamps the token with a permanent V6-RECOVER verdict (HD-index recovery exhausted).End-to-end effect: alice's CONFIRMED balance correctly drops by 10 UCT (the source is spent), bob's CONFIRMED balance stays at 0 (the token is durably classified as
'invalid'). The funds are effectively lost to bob.Why this issue is being filed now
PR #388 + #389 fixed the surrounding architecture for handling this failure:
'invalid'in the persistent ledger (V6-RECOVER permanent-mismatch does not durably mark token invalid; balance polluted by unspendable token #387)finalizeReceivedToken+ the load() ordering can't re-confirm a ledgered token across restart (PR #388 review follow-up: V6-RECOVER ledger consult gaps + soak gate regex misses decimals #389 Sphere SDK #1, fix(storage): separate token storage formats to prevent duplication #6)But all of those fixes address the SYMPTOM — making the verdict durable and the balance honest. The root cause — why does a fresh alice→bob send produce a token bob cannot bind to — is untouched. The PR #388 soak run (now in
manual-test-full-recovery.sh) PASSES because it exercises the invoice payment path (invoice create + invoice pay), which constructs the recipient predicate from invoice terms insideAccountingModule. The direct-send path (sphere payments send) goes throughPaymentsModule.sendand hits this bug.Minimal reproduction
Observed output
The "no unconfirmed residue" on bob's side is exactly what #389 #2 verifies — the V6-RECOVER ledger correctly absorbs the token so it doesn't pollute balance display. But the underlying transfer never delivers value.
Locus
Per stack trace, the failure is wholly in the SDK side:
Token.update(in state-transition-sdk) — predicate.isOwner returns false →'Recipient address mismatch'_PaymentsModule.finalizeStrandedReceivedToken— classifies as permanent, stamps ledger_PaymentsModule.send(or one of its split/finalize helpers)CLI side (legacy-cli.ts:2702-2780) is a pass-through — resolves coin symbol, converts amount to smallest units, and calls
sphere.payments.send({ recipient: '@bob-XX', amount, coinId, addressMode: 'auto', transferMode: 'instant' }). No predicate construction in CLI.Recommended investigation strategy — diff the SUCCESS path against the FAILURE path
The invoice-payment path (
sphere invoice create + invoice pay) works end-to-end across two fresh testnet wallets — the PR #388 soak proves it inmanual-test-full-recovery.shsection §D. The direct-send path (sphere payments send) fails on the simplest possible scenario. Diff the two execution paths inside the SDK to localize the divergence:Trace
AccountingModule.payInvoice(invoice path — works) → which constructs a recipient predicate from invoice terms (InvoiceTerms.targets[i].address), splits source tokens, mints to that predicate, and ships via Nostr.Trace
PaymentsModule.sendwithaddressMode: 'auto'(direct path — broken) → which resolves the@nametagviatransport.resolve(...), picks a mode from the resolvedPeerInfo, derives a recipient predicate from chainPubkey/directAddress, splits source tokens, mints, ships.The two paths should produce equivalent recipient predicates when the invoice's target address equals the resolved nametag's directAddress. If they diverge, the failure is in
PaymentsModule.send's predicate construction. If they're identical, the failure must be in something else upstream (resolver, mode-picker, or HD-walk on the receiver) — the SDK send-side mint is the same code that invoice payment exercises.Concrete prediction: the invoice path uses
targets[i].addressliterally (a DIRECT://... or PROXY://... string), bypassing nametag resolution entirely. The direct-send path resolves@nametagto PeerInfo via the relay; the divergence is most likely (a) the resolver returning adirectAddressthat's structurally valid but key-derived from a different epoch / index than bob's HD walk recovers, OR (b)addressMode: 'auto'picking 'proxy' (predicate fromH(nametag)) when bob's tracked addresses store only the 'direct' shape.Hypotheses to investigate
addressMode: 'auto'divergence. Auto-mode picks 'direct' if recipient PeerInfo has adirectAddress, else 'proxy'. PROXY-mode predicates are constructed fromH(nametag)whereas DIRECT-mode predicates use the resolved chainPubkey. If the sender picks one mode and the receiver's HD walk only covers the other (or vice versa), the predicate match fails.Stale relay nametag binding.
transport.resolve('@bob-XX')on the relay may return a binding from a prior test run if the nametag is somehow reused — but$SUFFIXis fresh per test, so this is unlikely. Worth verifying that the resolved peer's chainPubkey EXACTLY equals the freshly-created bob's chainPubkey fromsphere init.Predicate-derivation formula divergence between mint and verify. The mint side (sender's
PaymentsModule.send→ state-transition-sdk's recipient-predicate constructor) and the verify side (receiver'slatestStatePredicateMatchesWalletwalk →predicate.isOwnerin Token.update) may apply different hash inputs or different masking. This would manifest deterministically across all fresh sends.HD index activation gap on the receiver. The receiver walks
MAX_HD_RECOVERY_INDEXESfrom index 0. The sender may target a derived predicate that uses a higher index (e.g., one keyed by nonce, salt, or epoch) that the receiver doesn't activate by default.Acceptance criteria
sphere payments send 10 UCT @bobfollowed bysphere payments receive --finalizeon bob's side leaves bob with +10 UCT CONFIRMED and 0 unconfirmed, NO V6-RECOVER permanent-verdict fires.addressMode: 'auto'(default) andaddressMode: 'direct'andaddressMode: 'proxy'reproduce the success case across two fresh wallets.--conservative) — the only legitimate divergence between modes is timing of confirmation, NOT whether bob can claim it.tests/unit/modules/PaymentsModule.*.test.tsthat pins the recipient-predicate-derivation invariant: whatever the sender mints must bepredicate.isOwner(receiver.signingService)for the receiver's tracked HD signers.tests/integration/payments/v6-recover-real-sdk-recovery.test.tsbut for the fresh-send path (no stranded receive, no recovery scenario).Relation to other work
fix/issue-387-v6-recover-invalid-persistencecommitbc28737).sphere invoice create + invoice pay) appears to NOT be affected —manual-test-full-recovery.shruns successfully end-to-end including section §D's send/receive cycles that operate via invoice. This narrows the bug toPaymentsModule.send's direct path.Reproduction script
A self-contained reproduction script lives in the repo at
manual-test-simple-send.shon branchfix/issue-387-v6-recover-invalid-persistence(committed alongside the issue filing). It uses integer-only verification (extracts UCT amount in smallest units fromsphere balanceoutput via string-pad-and-concat, no float coercion) and reuses the decimal-aware unconfirmed-residue check frommanual-test-full-recovery.sh. To reproduce:Context for next session (post context-clearup)
Branch
fix/issue-387-v6-recover-invalid-persistenceat commitbc28737carries the symptom-handling work. The reproduction is deterministic across fresh nametag suffixes. Investigate the SDK send-side predicate construction first (start atmodules/payments/PaymentsModule.tssend()and trace into the recipient-predicate builder); the CLI is a pure pass-through.