From 7508d1cb4f042619298187976db9017eee67f8bd Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Sat, 30 May 2026 16:06:44 +0200 Subject: [PATCH] test(payments/transfer)(audit-333-v6-recover-gap): close the V6-RECOVER test-coverage gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The soak (manual-test-full-recovery.sh) routinely surfaces `[ERROR] [V6-RECOVER] Stranded receive hit permanent recipient- address mismatch (HD-index recovery exhausted) (no retry): VerificationError: Recipient address mismatch` at §C → §D. The existing unit tests cover helpers in isolation (PaymentsModule.recipient-address-mismatch-recovery.test.ts) and the error CLASSIFIER (PaymentsModule.proof-polling-persistence.test.ts #269), but neither proves the composition: "given a sibling HD index in the recipient's inventory, tryRecover finds it AND the SDK's verifyRecipient agrees on the derived address". This left CI blind to the regression mode where tryRecover returns a false-positive candidate that Token.update later rejects. This commit closes the gap at three layers: L1. Real-SDK integration test (tests/integration/payments/v6-recover-real-sdk-recovery.test.ts): - Replaces the file-level vi.mock for SigningService / UnmaskedPredicate / TokenId / TokenType / HashAlgorithm with REAL SDK imports. - Builds two real HD-derived signing services. - Constructs the sender-targeted DIRECT address via real UnmaskedPredicate.create → reference.toAddress (the EXACT path the SDK's verifyRecipient uses). - Drives `tryRecoverSigningServiceForRecipient` against real TokenId / TokenType / Uint8Array salt. - 4 tests: happy path, no-match negative, deriveAddressInfo throws, and (L2 composition) tryRecover + predicate construction round-trip equals the transferTx target address. L3. CI workflow (.github/workflows/soak-nightly.yml): - Nightly cron + workflow_dispatch trigger. - Probes testnet aggregator + Nostr relay; skip-not-fail when either is unreachable (third-party outages cannot block releases). - Checks out sphere-cli alongside sphere-sdk, links the local build, runs manual-test-full-recovery.sh with SPHERE_DEBUG=*. - Captures soak.log + workspace as artifacts (30-day retention on failure, 7-day on success). - Surfaces metrics in the workflow summary: V6-RECOVER count, Stranded receive count, POINTER_MONOTONICITY_VIOLATION count, bcast_pub>0 count, exit code. What's tested by what (audit roll-up): - SDK address-derivation regression in tryRecover → L1 (real SDK). - tryRecover ↔ predicate-construction composition → L1 composition test (proves verifyRecipient cannot throw against the recovered state). - Cross-process / Nostr / IPFS / multi-daemon regressions → L3 soak. - Helper-level edge cases (mock-friendly) → existing PaymentsModule.recipient-address-mismatch-recovery.test.ts. - Error-classification on hard fail → existing #269 test in PaymentsModule.proof-polling-persistence.test.ts. Tests pass: 4/4 new integration tests + all 8185 unit tests. tsc clean. Refs: #333 (V6-RECOVER test-coverage gap, follow-up to H1-H7 stack). Stacked on #355 (H7) → #354 (H5) → #353 (H4) → #352 (H3) → #351 (H2) → #349 (H1) → #348 (C3) → #347 (C2) → #346 (C1). --- .github/workflows/soak-nightly.yml | 207 +++++++ .../v6-recover-real-sdk-recovery.test.ts | 562 ++++++++++++++++++ 2 files changed, 769 insertions(+) create mode 100644 .github/workflows/soak-nightly.yml create mode 100644 tests/integration/payments/v6-recover-real-sdk-recovery.test.ts diff --git a/.github/workflows/soak-nightly.yml b/.github/workflows/soak-nightly.yml new file mode 100644 index 00000000..1a5442c2 --- /dev/null +++ b/.github/workflows/soak-nightly.yml @@ -0,0 +1,207 @@ +# V6-RECOVER Soak (nightly + on-demand) +# +# Layer-3 deliverable of the V6-RECOVER test-coverage gap (companion to +# tests/integration/payments/v6-recover-real-sdk-recovery.test.ts). +# +# Why this exists +# --------------- +# The V6-RECOVER "Stranded receive ... Recipient address mismatch" failure +# mode is currently only catchable end-to-end by running +# `manual-test-full-recovery.sh` — a multi-process, cross-network soak that +# drives two daemons (peer1, peer2), real testnet aggregator, real Nostr +# relay, and real IPFS. Unit tests (the L1 file referenced above; plus the +# existing PaymentsModule.recipient-address-mismatch-recovery.test.ts and +# PaymentsModule.proof-polling-persistence.test.ts #269 tests) cover the +# helper logic and the error classifier, but only the soak exercises the +# §C → §D handoff where the regression manifests. +# +# Running the soak under CI: +# - schedule: nightly at 06:00 UTC (off-peak for testnet aggregator) +# - workflow_dispatch: on-demand for triage / pre-merge verification +# +# External dependencies +# --------------------- +# The soak requires the @unicity-sphere/cli tool installed globally. The +# CLI is a separate repository (https://github.com/unicity-sphere/sphere-cli) +# that vendors a built version of THIS sphere-sdk repo via npm link. The +# `Prepare CLI` step below clones, builds, and links it. +# +# Skip-not-fail policy +# -------------------- +# Testnet aggregator and Nostr relay are external to this repo. When either +# is unreachable we mark the job as PASS with a clear "external infra down" +# message rather than failing — a flake on a third-party service must NOT +# block a release. +# +# Artifacts +# --------- +# On any non-skip exit, we upload the full soak workspace + log so a +# developer can inspect snapshots, daemon state, and the verbose-debug log. + +name: V6-RECOVER Soak + +on: + schedule: + # 06:00 UTC daily — well off-peak for the testnet aggregator. + - cron: '0 6 * * *' + workflow_dispatch: + inputs: + debug: + description: 'SPHERE_DEBUG value (use "*" for full verbose)' + required: false + default: '*' + timeout-minutes: + description: 'Hard timeout for the soak script (minutes)' + required: false + default: '30' + +permissions: + contents: read + +jobs: + soak: + name: manual-test-full-recovery.sh (testnet) + runs-on: ubuntu-latest + # Default 35 min: 5 min headroom over the workflow_dispatch input. + # Override via the dispatch input when triaging hangs. + timeout-minutes: ${{ fromJSON(github.event.inputs.timeout-minutes || '30') }} + + steps: + - name: Checkout sphere-sdk + uses: actions/checkout@v4 + with: + path: sphere-sdk + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: sphere-sdk/package-lock.json + + - name: Probe external dependencies (skip-not-fail when down) + id: probe + run: | + set -u + # Probe 1 — testnet aggregator HTTPS endpoint. + if ! curl -fsSL --max-time 10 -o /dev/null \ + https://goggregator-test.unicity.network/health 2>/dev/null \ + && ! curl -fsSL --max-time 10 -o /dev/null \ + https://goggregator-test.unicity.network/ 2>/dev/null; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "reason=testnet aggregator unreachable" >> "$GITHUB_OUTPUT" + echo "::warning::testnet aggregator at goggregator-test.unicity.network is unreachable — skipping soak (not a sphere-sdk regression)" + exit 0 + fi + # Probe 2 — testnet Nostr relay (WebSocket; HEAD on the HTTPS + # form of the URL is sufficient to confirm DNS + TLS reach). + if ! curl -fsSL --max-time 10 -o /dev/null \ + https://nostr-relay.testnet.unicity.network/ 2>/dev/null; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "reason=testnet Nostr relay unreachable" >> "$GITHUB_OUTPUT" + echo "::warning::testnet Nostr relay at nostr-relay.testnet.unicity.network is unreachable — skipping soak (not a sphere-sdk regression)" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "reason=" >> "$GITHUB_OUTPUT" + + - name: Build sphere-sdk + if: steps.probe.outputs.skip != 'true' + working-directory: sphere-sdk + run: | + npm install --include=optional --ignore-scripts + npm rebuild + npm run build + + - name: Checkout sphere-cli + if: steps.probe.outputs.skip != 'true' + uses: actions/checkout@v4 + with: + repository: unicity-sphere/sphere-cli + path: sphere-cli + + - name: Prepare sphere-cli (link to local sphere-sdk) + if: steps.probe.outputs.skip != 'true' + working-directory: sphere-cli + run: | + # sphere-cli depends on a built sphere-sdk via file: link. + # The expected layout (see sphere-cli's package.json) is: + # sphere-cli/node_modules/@unicitylabs/sphere-sdk → ../../sphere-sdk + mkdir -p node_modules/@unicitylabs + ln -sf "${GITHUB_WORKSPACE}/sphere-sdk" node_modules/@unicitylabs/sphere-sdk + npm install --ignore-scripts + # Make the CLI binary discoverable on PATH via a wrapper. + mkdir -p "${HOME}/.local/bin" + ln -sf "$(pwd)/bin/sphere.mjs" "${HOME}/.local/bin/sphere" + chmod +x "$(pwd)/bin/sphere.mjs" + echo "${HOME}/.local/bin" >> "$GITHUB_PATH" + + - name: Run soak (SPHERE_DEBUG=${{ github.event.inputs.debug || '*' }}) + if: steps.probe.outputs.skip != 'true' + id: soak + env: + # Verbose debug surfaces V6-RECOVER, Pointer, Profile-TokenStorage + # error/warn lines so artifacts contain the full failure context + # rather than just the final exit code. + SPHERE_DEBUG: ${{ github.event.inputs.debug || '*' }} + SPHERE_FULL_TEST_DIR: ${{ github.workspace }}/soak-workspace + working-directory: sphere-sdk + run: | + set +e + mkdir -p "${SPHERE_FULL_TEST_DIR}" + bash manual-test-full-recovery.sh > "${{ github.workspace }}/soak.log" 2>&1 + EXIT=$? + echo "exit_code=${EXIT}" >> "$GITHUB_OUTPUT" + # Emit summary metrics whether the soak passed or failed — + # operators want to see V6-RECOVER counts even on green runs. + V6_RECOVER_COUNT=$(grep -c 'V6-RECOVER' "${{ github.workspace }}/soak.log" || true) + STRANDED_COUNT=$(grep -c 'Stranded receive' "${{ github.workspace }}/soak.log" || true) + MONOTONICITY_COUNT=$(grep -c 'POINTER_MONOTONICITY_VIOLATION' "${{ github.workspace }}/soak.log" || true) + BCAST_PUB_COUNT=$(grep -cE 'bcast_pub[^0]' "${{ github.workspace }}/soak.log" || true) + echo "v6_recover_count=${V6_RECOVER_COUNT}" >> "$GITHUB_OUTPUT" + echo "stranded_count=${STRANDED_COUNT}" >> "$GITHUB_OUTPUT" + echo "monotonicity_count=${MONOTONICITY_COUNT}" >> "$GITHUB_OUTPUT" + echo "bcast_pub_count=${BCAST_PUB_COUNT}" >> "$GITHUB_OUTPUT" + # Report to the workflow summary. + { + echo "## Soak metrics" + echo "" + echo "| Signal | Count |" + echo "|---|---|" + echo "| V6-RECOVER lines | ${V6_RECOVER_COUNT} |" + echo "| Stranded receive lines | ${STRANDED_COUNT} |" + echo "| POINTER_MONOTONICITY_VIOLATION | ${MONOTONICITY_COUNT} |" + echo "| bcast_pub > 0 | ${BCAST_PUB_COUNT} |" + echo "| Script exit code | ${EXIT} |" + echo "" + if [ "${EXIT}" -ne 0 ]; then + echo "**FAILED** — workspace + log artifacts uploaded; see the \"soak-artifacts-*\" archive." + else + echo "PASS" + fi + } >> "$GITHUB_STEP_SUMMARY" + exit ${EXIT} + + - name: Upload soak artifacts (on any non-skip exit) + if: always() && steps.probe.outputs.skip != 'true' + uses: actions/upload-artifact@v4 + with: + name: soak-artifacts-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ github.workspace }}/soak.log + ${{ github.workspace }}/soak-workspace + # Retain failures longer than passes so triage has a generous + # window; passes auto-prune sooner to keep storage cost down. + retention-days: ${{ steps.soak.outputs.exit_code == '0' && 7 || 30 }} + if-no-files-found: warn + + - name: Skip summary + if: steps.probe.outputs.skip == 'true' + run: | + { + echo "## Soak skipped" + echo "" + echo "${{ steps.probe.outputs.reason }}" + echo "" + echo "_This is not a sphere-sdk regression — external infrastructure was unreachable during the probe step._" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/integration/payments/v6-recover-real-sdk-recovery.test.ts b/tests/integration/payments/v6-recover-real-sdk-recovery.test.ts new file mode 100644 index 00000000..d9902edb --- /dev/null +++ b/tests/integration/payments/v6-recover-real-sdk-recovery.test.ts @@ -0,0 +1,562 @@ +/** + * V6-RECOVER real-SDK integration tests. + * + * Test-coverage gap + * ----------------- + * `tests/unit/modules/PaymentsModule.recipient-address-mismatch-recovery.test.ts` + * exercises `tryRecoverSigningServiceForRecipient` and friends with the + * SDK fully mocked at the import boundary (vi.mock for SigningService, + * UnmaskedPredicate, TokenState, HashAlgorithm, ...). Those tests prove + * the helper's iteration logic but NOT that the helper's notion of + * "derived recipient address" agrees with the real SDK's notion when + * `verifyRecipient` runs on the same inputs. + * + * `tests/unit/modules/PaymentsModule.proof-polling-persistence.test.ts` + * issue-#269 tests cover the error-classification side: GIVEN the SDK + * throws `VerificationError(Recipient address mismatch)`, the worker + * routes to permanent-fail (not transient). That test mocks the throw + * source — it does NOT prove the throw shouldn't have fired. + * + * The soak (`manual-test-full-recovery.sh`) routinely surfaces + * `[ERROR] [V6-RECOVER] Stranded receive hit permanent recipient- + * address mismatch (HD-index recovery exhausted) (no retry): + * VerificationError: Recipient address mismatch` at §C → §D. With + * only mocked-SDK helper tests + a mocked-throw classifier test, the + * regression mode "tryRecover returns a candidate but the SDK still + * rejects it" is not catchable by CI. + * + * What this file covers (Audit #333 V6-RECOVER test-gap layer 1) + * -------------------------------------------------------------- + * - Real SDK end-to-end: SigningService.createFromSecret → + * UnmaskedPredicate.create → reference.toAddress() — no mocks. + * - Happy path: sender targeted a sibling HD index that IS in the + * recipient's tracked-addresses inventory; tryRecover finds the + * matching signer; the recovered signer's predicate constructs + * the EXACT address the sender computed. + * - Negative path: sender targeted an HD index NOT in the + * recipient's inventory; tryRecover returns null (no false-positive + * candidate that the SDK would later reject). + * - Negative path: deriveAddressInfo() throws for one index but + * succeeds for another; iteration continues and finds the match. + */ + +import { describe, expect, it, vi } from 'vitest'; + +// Real SDK — NO vi.mock for these imports. +import { SigningService } from '@unicitylabs/state-transition-sdk/lib/sign/SigningService'; +import { UnmaskedPredicate } from '@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate'; +import { TokenId } from '@unicitylabs/state-transition-sdk/lib/token/TokenId'; +import { TokenType } from '@unicitylabs/state-transition-sdk/lib/token/TokenType'; +import { HashAlgorithm } from '@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm'; + +import { + createPaymentsModule, + type PaymentsModuleDependencies, +} from '../../../modules/payments/PaymentsModule'; +import type { AddressInfo } from '../../../core/crypto'; +import type { + FullIdentity, + TrackedAddress, + TxfStorageDataBase, +} from '../../../types'; +import type { StorageProvider } from '../../../storage/storage-provider'; +import type { TransportProvider } from '../../../transport'; +import type { OracleProvider } from '../../../oracle/oracle-provider'; +import type { TokenStorageProvider } from '../../../storage/token-storage-provider'; + +// --------------------------------------------------------------------------- +// Minimal stubs for the non-recovery dependencies +// --------------------------------------------------------------------------- + +function makeStubStorage(): StorageProvider { + const m = new Map(); + return { + id: 'mock-storage', + name: 'Mock Storage', + type: 'local' as const, + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(true), + getStatus: vi.fn().mockReturnValue('connected'), + setIdentity: vi.fn(), + get: vi.fn(async (k: string) => m.get(k) ?? null), + set: vi.fn(async (k: string, v: string) => { m.set(k, v); }), + remove: vi.fn(async (k: string) => { m.delete(k); }), + has: vi.fn(async (k: string) => m.has(k)), + keys: vi.fn(async () => Array.from(m.keys())), + clear: vi.fn(async () => { m.clear(); }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +function makeStubs(): { + storage: StorageProvider; + transport: TransportProvider; + oracle: OracleProvider; +} { + return { + storage: makeStubStorage(), + transport: { + id: 'mock-transport', + name: 'Mock Transport', + type: 'p2p', + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(true), + getStatus: vi.fn().mockReturnValue('connected'), + setIdentity: vi.fn(), + sendTokenTransfer: vi.fn(), + onTokenTransfer: vi.fn().mockReturnValue(() => {}), + onPaymentRequest: vi.fn().mockReturnValue(() => {}), + onPaymentRequestResponse: vi.fn().mockReturnValue(() => {}), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + oracle: { + id: 'mock-oracle', + name: 'Mock Oracle', + type: 'network', + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(true), + getStatus: vi.fn().mockReturnValue('connected'), + initialize: vi.fn().mockResolvedValue(undefined), + getProof: vi.fn().mockResolvedValue(null), + waitForProofSdk: vi.fn().mockResolvedValue(null), + getStateTransitionClient: vi.fn().mockReturnValue({}), + getTrustBase: vi.fn().mockReturnValue({}), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }; +} + +function hexFromBytes(b: Uint8Array): string { + return Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join(''); +} + +function makeTracked( + index: number, + chainPubkey: string, + directAddress: string, +): TrackedAddress { + return { + index, + hidden: false, + createdAt: 1700000000000, + updatedAt: 1700000000000, + addressId: `DIRECT_idx${index}`, + l1Address: `alpha1real-sdk-${index}`, + directAddress, + chainPubkey, + }; +} + +function makeAddressInfo( + index: number, + privateKey: Uint8Array, + publicKey: Uint8Array, + directAddress: string, +): AddressInfo { + return { + privateKey: hexFromBytes(privateKey), + publicKey: hexFromBytes(publicKey), + address: directAddress, + path: `m/44'/0'/0'/0/${index}`, + index, + }; +} + +function makeDeps(input: { + identity: FullIdentity; + derivations: Map; + trackedAddresses: ReadonlyArray; +}): PaymentsModuleDependencies { + const stubs = makeStubs(); + return { + identity: input.identity, + storage: stubs.storage, + tokenStorageProviders: new Map< + string, + TokenStorageProvider + >(), + transport: stubs.transport, + oracle: stubs.oracle, + emitEvent: vi.fn(), + getActiveAddresses: () => input.trackedAddresses, + deriveAddressInfo: (idx: number) => { + const info = input.derivations.get(idx); + if (!info) { + throw new Error(`No derivation fixture for index ${idx}`); + } + return info; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +// Reach the private recovery helpers without losing static types. +interface RecoveryInternals { + tryRecoverSigningServiceForRecipient: ( + sourceToken: { id: TokenId; type: TokenType }, + transferSalt: Uint8Array, + expectedTransactionAddress: string, + ) => Promise<{ signer: SigningService; index: number } | null>; +} +function recovery(m: ReturnType): RecoveryInternals { + return m as unknown as RecoveryInternals; +} + +// --------------------------------------------------------------------------- +// Setup helper — build N real HD-index keypairs. +// --------------------------------------------------------------------------- + +interface RealKey { + privateKey: Uint8Array; + signer: SigningService; + chainPubkeyHex: string; +} + +async function buildHdKey(seedSuffix: number): Promise { + // Deterministic per index so failures are reproducible. We do not need + // BIP32 derivation for this test — only "two distinct keys that the + // SDK accepts" so the iteration logic can find one of them. + const privateKey = new Uint8Array(32).fill(0xa0 + seedSuffix); + const signer = await SigningService.createFromSecret(privateKey); + // SigningService exposes `publicKey` as Uint8Array; the wallet stores + // it as 33-byte compressed hex (the `chainPubkey` field). + const pubBytes = signer.publicKey as Uint8Array; + const chainPubkeyHex = hexFromBytes(pubBytes); + return { privateKey, signer, chainPubkeyHex }; +} + +async function deriveDirectAddress( + signer: SigningService, + tokenId: TokenId, + tokenType: TokenType, + transferSalt: Uint8Array, +): Promise { + const predicate = await UnmaskedPredicate.create( + tokenId, + tokenType, + signer, + HashAlgorithm.SHA256, + transferSalt, + ); + const reference = await predicate.getReference(); + const address = await reference.toAddress(); + return address.address; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('V6-RECOVER — real-SDK HD-index recovery integration', () => { + describe('happy path: sender targeted sibling HD index in inventory', () => { + it('tryRecover finds the matching signer AND the recovered signer derives the EXACT same address', async () => { + // Two real HD keys. + const keyZero = await buildHdKey(0); + const keyOne = await buildHdKey(1); + + // Real tokenId + tokenType the source token is identified by. + const tokenId = TokenId.fromJSON('aa'.repeat(32)); + const tokenType = TokenType.fromJSON('bb'.repeat(32)); + const transferSalt = new Uint8Array(32).fill(0xdd); + + // What the sender computed: the recipient's DIRECT address for + // HD index 1, using REAL SDK predicate construction. + const senderTargetedAddress = await deriveDirectAddress( + keyOne.signer, + tokenId, + tokenType, + transferSalt, + ); + + // Recipient's wallet state: active at index 0, but tracks both + // index 0 and index 1 in its address book. + const trackedZero = makeTracked( + 0, + keyZero.chainPubkeyHex, + await deriveDirectAddress(keyZero.signer, tokenId, tokenType, transferSalt), + ); + const trackedOne = makeTracked(1, keyOne.chainPubkeyHex, senderTargetedAddress); + + const derivations = new Map(); + derivations.set(0, makeAddressInfo( + 0, keyZero.privateKey, keyZero.signer.publicKey as Uint8Array, trackedZero.directAddress, + )); + derivations.set(1, makeAddressInfo( + 1, keyOne.privateKey, keyOne.signer.publicKey as Uint8Array, trackedOne.directAddress, + )); + + const identity: FullIdentity = { + chainPubkey: keyZero.chainPubkeyHex, + l1Address: 'alpha1active', + directAddress: trackedZero.directAddress, + privateKey: hexFromBytes(keyZero.privateKey), + }; + + const module = createPaymentsModule(); + module.initialize(makeDeps({ + identity, + derivations, + trackedAddresses: [trackedZero, trackedOne], + })); + + // The actual SUT call — a real SDK predicate is constructed + // inside `tryRecoverSigningServiceForRecipient` for each + // candidate and compared against `senderTargetedAddress`. + const result = await recovery(module).tryRecoverSigningServiceForRecipient( + { id: tokenId, type: tokenType }, + transferSalt, + senderTargetedAddress, + ); + + expect(result).not.toBeNull(); + expect(result!.index).toBe(1); + + // The recovered signer's public key MUST match keyOne — this is + // the layer the existing mock-based tests cannot prove. + expect(hexFromBytes(result!.signer.publicKey as Uint8Array)).toBe( + keyOne.chainPubkeyHex, + ); + + // Most importantly: using the recovered signer to construct the + // same predicate the SDK's verifyRecipient uses gives back the + // EXACT same address the sender targeted. If this assertion + // holds, the SDK's downstream Token.update verifyRecipient + // step CANNOT throw "Recipient address mismatch" — the address + // the SDK computes from this signer + (tokenId, tokenType, salt) + // is bit-for-bit identical to the sender's target. + const rederived = await deriveDirectAddress( + result!.signer, + tokenId, + tokenType, + transferSalt, + ); + expect(rederived).toBe(senderTargetedAddress); + }); + }); + + describe('negative path: sender targeted an HD index NOT in inventory', () => { + it('tryRecover returns null (no false-positive that SDK would later reject)', async () => { + const keyZero = await buildHdKey(0); + const keyOne = await buildHdKey(1); + const keyTwo = await buildHdKey(2); + + const tokenId = TokenId.fromJSON('aa'.repeat(32)); + const tokenType = TokenType.fromJSON('bb'.repeat(32)); + const transferSalt = new Uint8Array(32).fill(0xdd); + + // Sender targeted index 2's address — but recipient only tracks + // indices 0 and 1. + const senderTargetedAddress = await deriveDirectAddress( + keyTwo.signer, + tokenId, + tokenType, + transferSalt, + ); + + const trackedZero = makeTracked(0, keyZero.chainPubkeyHex, + await deriveDirectAddress(keyZero.signer, tokenId, tokenType, transferSalt)); + const trackedOne = makeTracked(1, keyOne.chainPubkeyHex, + await deriveDirectAddress(keyOne.signer, tokenId, tokenType, transferSalt)); + + const derivations = new Map(); + derivations.set(0, makeAddressInfo( + 0, keyZero.privateKey, keyZero.signer.publicKey as Uint8Array, trackedZero.directAddress, + )); + derivations.set(1, makeAddressInfo( + 1, keyOne.privateKey, keyOne.signer.publicKey as Uint8Array, trackedOne.directAddress, + )); + + const identity: FullIdentity = { + chainPubkey: keyZero.chainPubkeyHex, + l1Address: 'alpha1active', + directAddress: trackedZero.directAddress, + privateKey: hexFromBytes(keyZero.privateKey), + }; + + const module = createPaymentsModule(); + module.initialize(makeDeps({ + identity, + derivations, + trackedAddresses: [trackedZero, trackedOne], + })); + + const result = await recovery(module).tryRecoverSigningServiceForRecipient( + { id: tokenId, type: tokenType }, + transferSalt, + senderTargetedAddress, + ); + + // No tracked index produces the sender's target address — the + // helper MUST return null. (A false-positive candidate would + // surface as the soak's V6-RECOVER ERROR a step later when + // Token.update runs verifyRecipient.) + expect(result).toBeNull(); + }); + }); + + describe('composition: tryRecover signer + real predicate matches transferTx recipient', () => { + // The full `finalizeTransferToken` path is: + // 1. recipientAddress = transferTx.data.recipient + // 2. expectedTransactionAddress = resolveExpectedTransactionAddress(recipientAddress) + // 3. primaryDerivedAddress = deriveRecipientAddressFor(currentSigner, sourceToken, salt) + // 4. IF primary ≠ expected: chosenSigner = tryRecover(sourceToken, salt, expected).signer + // 5. recipientPredicate = UnmaskedPredicate.create(sourceToken.id, sourceToken.type, + // chosenSigner, SHA256, salt) + // 6. recipientState = new TokenState(recipientPredicate, null) + // 7. stClient.finalizeTransaction(trustBase, sourceToken, recipientState, transferTx, ...) + // + // The SDK's verifyRecipient inside step 7 compares the predicate's + // derived address against transferTx.data.recipient.address. If + // step 5's `chosenSigner` derives an address matching `expected`, + // and `expected` matches `transferTx.data.recipient.address` (true + // for DIRECT scheme), then verifyRecipient cannot throw "Recipient + // address mismatch". + // + // This test exercises steps 3–5 with real SDK objects and asserts + // the predicate derived at step 5 matches the transferTx target. + // The previous test covers step 4 in isolation; this test proves + // the COMPOSITION across steps 3–5 holds end-to-end at the real- + // address layer, closing the "tryRecover returns a candidate but + // the SDK still rejects it" risk. + it('produces a recipientPredicate whose address === transferTx.data.recipient.address (DIRECT scheme)', async () => { + const keyZero = await buildHdKey(0); + const keyOne = await buildHdKey(1); + + const tokenId = TokenId.fromJSON('aa'.repeat(32)); + const tokenType = TokenType.fromJSON('bb'.repeat(32)); + const transferSalt = new Uint8Array(32).fill(0xdd); + + // Sender computed `transferTx.data.recipient.address` from index 1. + const transferTxRecipientAddress = await deriveDirectAddress( + keyOne.signer, + tokenId, + tokenType, + transferSalt, + ); + + // Step 3 — primary derived address from the current (index 0) signer. + const primaryDerived = await deriveDirectAddress( + keyZero.signer, + tokenId, + tokenType, + transferSalt, + ); + // Pre-condition for the recovery branch: primary ≠ expected. + expect(primaryDerived).not.toBe(transferTxRecipientAddress); + + // Set up wallet for steps 4–5. + const trackedZero = makeTracked(0, keyZero.chainPubkeyHex, primaryDerived); + const trackedOne = makeTracked(1, keyOne.chainPubkeyHex, transferTxRecipientAddress); + const derivations = new Map(); + derivations.set(0, makeAddressInfo( + 0, keyZero.privateKey, keyZero.signer.publicKey as Uint8Array, primaryDerived, + )); + derivations.set(1, makeAddressInfo( + 1, keyOne.privateKey, keyOne.signer.publicKey as Uint8Array, transferTxRecipientAddress, + )); + const identity: FullIdentity = { + chainPubkey: keyZero.chainPubkeyHex, + l1Address: 'alpha1active', + directAddress: primaryDerived, + privateKey: hexFromBytes(keyZero.privateKey), + }; + const module = createPaymentsModule(); + module.initialize(makeDeps({ + identity, + derivations, + trackedAddresses: [trackedZero, trackedOne], + })); + + // Step 4 — tryRecover. + const recovered = await recovery(module).tryRecoverSigningServiceForRecipient( + { id: tokenId, type: tokenType }, + transferSalt, + transferTxRecipientAddress, // DIRECT scheme → expected === transferTx.recipient.address + ); + expect(recovered).not.toBeNull(); + expect(recovered!.index).toBe(1); + + // Step 5 — build recipientPredicate from the recovered signer with + // the REAL SDK and re-derive its address. + const recipientPredicate = await UnmaskedPredicate.create( + tokenId, + tokenType, + recovered!.signer, + HashAlgorithm.SHA256, + transferSalt, + ); + const reference = await recipientPredicate.getReference(); + const recipientAddress = (await reference.toAddress()).address; + + // Composition assertion: the recipientPredicate that + // finalizeTransferToken would pass into stClient.finalizeTransaction + // derives an address byte-equal to transferTx.data.recipient.address. + // The SDK's verifyRecipient inside finalizeTransaction CANNOT throw + // "Recipient address mismatch" against this state. + expect(recipientAddress).toBe(transferTxRecipientAddress); + }); + }); + + describe('iteration tolerates per-index deriveAddressInfo throws', () => { + it('skips a throwing index and continues — finds the match further down', async () => { + const keyZero = await buildHdKey(0); + const keyOne = await buildHdKey(1); + const keyTwo = await buildHdKey(2); + + const tokenId = TokenId.fromJSON('aa'.repeat(32)); + const tokenType = TokenType.fromJSON('bb'.repeat(32)); + const transferSalt = new Uint8Array(32).fill(0xdd); + + // Sender targeted index 2. + const senderTargetedAddress = await deriveDirectAddress( + keyTwo.signer, + tokenId, + tokenType, + transferSalt, + ); + + const trackedZero = makeTracked(0, keyZero.chainPubkeyHex, + await deriveDirectAddress(keyZero.signer, tokenId, tokenType, transferSalt)); + const trackedOne = makeTracked(1, keyOne.chainPubkeyHex, + await deriveDirectAddress(keyOne.signer, tokenId, tokenType, transferSalt)); + const trackedTwo = makeTracked(2, keyTwo.chainPubkeyHex, senderTargetedAddress); + + const derivations = new Map(); + derivations.set(0, makeAddressInfo( + 0, keyZero.privateKey, keyZero.signer.publicKey as Uint8Array, trackedZero.directAddress, + )); + // Index 1's derivation FAILS — the helper must continue. + // (Map.get returns undefined → the deriveAddressInfo throws in makeDeps.) + derivations.set(2, makeAddressInfo( + 2, keyTwo.privateKey, keyTwo.signer.publicKey as Uint8Array, trackedTwo.directAddress, + )); + + const identity: FullIdentity = { + chainPubkey: keyZero.chainPubkeyHex, + l1Address: 'alpha1active', + directAddress: trackedZero.directAddress, + privateKey: hexFromBytes(keyZero.privateKey), + }; + + const module = createPaymentsModule(); + module.initialize(makeDeps({ + identity, + derivations, + trackedAddresses: [trackedZero, trackedOne, trackedTwo], + })); + + const result = await recovery(module).tryRecoverSigningServiceForRecipient( + { id: tokenId, type: tokenType }, + transferSalt, + senderTargetedAddress, + ); + + // Index 1 threw — the loop continued to index 2 and found the match. + expect(result).not.toBeNull(); + expect(result!.index).toBe(2); + }); + }); +});