Skip to content

fresh alice→bob direct send: recipient predicate doesn't bind to recipient HD signers (root cause behind #387/#388/#389) #390

@vrogojin

Description

@vrogojin

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:

  1. 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.

  2. 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.

  3. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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

  • A fresh alice → fresh bob sphere payments send 10 UCT @bob followed by sphere payments receive --finalize on bob's side leaves bob with +10 UCT CONFIRMED and 0 unconfirmed, NO V6-RECOVER permanent-verdict fires.
  • alice's CONFIRMED balance drops by exactly 10 UCT (smallest-unit integer comparison — no float coercion).
  • Both addressMode: 'auto' (default) and addressMode: 'direct' and addressMode: 'proxy' reproduce the success case across two fresh wallets.
  • Same expectation for the conservative transferMode (--conservative) — the only legitimate divergence between modes is timing of confirmation, NOT whether bob can claim it.
  • Regression unit test in tests/unit/modules/PaymentsModule.*.test.ts that pins the recipient-predicate-derivation invariant: whatever the sender mints must be predicate.isOwner(receiver.signingService) for the receiver's tracked HD signers.
  • Regression integration test analogous to tests/integration/payments/v6-recover-real-sdk-recovery.test.ts but for the fresh-send path (no stranded receive, no recovery scenario).
  • Manual reproduction (above) gates an end-to-end clean run.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions