Skip to content

fix(payment-link): validate Solana OCP tx recipient/amount/asset (BUG-1260) - #4453

Open
davidleomay wants to merge 3 commits into
developfrom
fix/paymentlink-solana-anonymous-completion
Open

fix(payment-link): validate Solana OCP tx recipient/amount/asset (BUG-1260)#4453
davidleomay wants to merge 3 commits into
developfrom
fix/paymentlink-solana-anonymous-completion

Conversation

@davidleomay

Copy link
Copy Markdown
Member

Problem (High — anonymous payment completion)

Solana OCP tx-completion (?tx=<hash> on `payment-link` payments) accepted any real, finalized Solana tx. The switch case routed through `doVerifiedTxIdPayment`, which only called `SolanaClient.isTxComplete` — finality + no error, no recipient/amount/asset binding. Any unrelated finalized Solana tx flipped a real payment obligation to `TX_BLOCKCHAIN` (settlement).

Reported by external hunter (BUG-1260, Compass Security).

Fix

New `doSolanaTxIdPayment` mirrors the EVM/Firo path:

  • Fetches the parsed tx via `SolanaService.getTransaction`.
  • Iterates the quote's Solana activations and matches via new `TxValidationService.validateSolanaTransaction` — required: destination pays the expected wallet-owner address for the expected asset (mint disambiguates SPL from SOL), amount ≥ activation.amount. Overpayment accepted; wrong owner / wrong mint / underpayment fail.
  • Owner-equality works uniformly because `SolanaTransactionDto.destinations[].to` is populated with the wallet owner in every branch of the parser (`create`/`closeAccount`/`transfer`/`transferChecked` via `updateTokenInstruction`) — no ATA derivation is needed.

Also closes a secondary tx-replay class implied by the report but not exercised in the PoC: without a unique index on `PaymentQuote.txId`, a legitimate DFX-bound tx could satisfy an unrelated future quote for the same asset+amount. New `getEarliestQuoteClaimingTx` returns the oldest (ORDER BY id ASC) quote in any tx-active state OR `TX_FAILED` — the latter matters because `txFailed()` does not clear `txId`, so a failed quote still lays claim. `doSolanaTxIdPayment` rejects a submission whose earliest claimant is a different quote (`uniqueId` mismatch); self-match (retry of same quote) passes.

Out of scope (follow-ups)

`SolanaClient.createTransactionDto` mis-classifies native+SPL mixed txs (any system-program instruction routes the whole tx into the native branch and drops SPL) and merges multi-SPL transfers via last-wins in `getTokenInstructions`/`updateTokenInstruction`. Pre-existing DoS risks on legit payments, not payment-side security bypasses. Should be filed as separate parser hardening.

PR completeness

  • Migration: none (no entity/column change)
  • Environment / Infrastructure: none
  • Service / frontend sync: none (public DTO shape unchanged)
  • Impact on GS: none

Test plan

  • `npx tsc --noEmit` full project: clean
  • `npx eslint` on all changed files: clean
  • `npx jest --testPathPattern='payment-link|tx-validation|solana'`: 10 suites, 81/81 passing
  • Repro the hunter's Solana curl on a running instance with a foreign tx (dev)

Related

…-1260)

Solana routed through `doVerifiedTxIdPayment`, which only checked that the
submitted txId was finalized without an error — recipient, amount, and asset
were never validated. Any real finalized Solana tx passed as `?tx=<hash>` was
enough to flip a quote to TX_BLOCKCHAIN, letting an unrelated tx settle a
payment obligation.

New `doSolanaTxIdPayment` mirrors the EVM/Firo model:

- Wait for finality, then fetch the parsed tx via `SolanaService.getTransaction`.
- For each Solana activation on the quote, call the new
  `TxValidationService.validateSolanaTransaction` — matches a destination that
  pays the expected owner address for the expected asset (mint disambiguates
  SPL from SOL) with amount ≥ activation.amount. Overpayment accepted; wrong
  owner / wrong mint / underpayment fail.
- `SolanaTransactionDto.destinations[].to` carries the wallet owner in every
  branch of the parser (create/closeAccount/transfer/transferChecked via
  updateTokenInstruction), so the same owner-equality check works for both
  native SOL and SPL — no ATA derivation needed.

Also closes a secondary tx-replay class the report implied but didn't
exercise: without a unique index on `PaymentQuote.txId`, a legitimate
DFX-bound tx could satisfy an unrelated future quote. New
`getEarliestQuoteClaimingTx` returns the oldest (ORDER BY id ASC) quote in any
tx-active state OR TX_FAILED — the latter matters because `txFailed()` does
not clear `txId`, so a failed quote still lays claim. `doSolanaTxIdPayment`
now rejects a submission whose earliest claimant is a different quote.

Two spec files updated: added SolanaService mock provider (new constructor
dep on PaymentQuoteService).

Out of scope, worth follow-ups: `SolanaClient.createTransactionDto` still
mis-classifies native+SPL mixed txs (any system-program instruction routes
the whole tx into the native branch and drops SPL) and merges multi-SPL
transfers via last-wins in `getTokenInstructions/updateTokenInstruction`.
Both are pre-existing parser DoS risks on legit payments, not payment-side
security bypasses.

Verified: lint, type-check, payment-link jest suite (10 files / 81 tests) pass.
@davidleomay

Copy link
Copy Markdown
Member Author

3 review passes to reach zero findings (two independent lenses per pass: security/correctness and conformance/regression, each re-run from scratch on the then-current HEAD against develop).

Round 1 was a self-consistent but wrong draft — I derived the recipient's ATA and matched it against SolanaTransactionDto.destinations[].to. Both lenses in round 2 flagged that the parser (SolanaClient.getTokenInstructions + updateTokenInstruction) actually populates to with the wallet owner in every branch — the ATA I was deriving would never match. Round 1's fix would have DoS'd every legitimate SPL payment.

Round 2 replaced ATA derivation with owner-equality on both native and SPL paths, removed the now-dead getAssociatedTokenAddress wrappers on SolanaClient/SolanaService, and added an activation.asset.chainId guard. Round-2 security also surfaced a tx-replay class the original report implied but did not exercise: without a unique index on PaymentQuote.txId, a legitimate DFX-bound tx could satisfy an unrelated future quote for the same asset+amount. Added the initial guard using the existing getQuoteByTxId.

Round 3 surfaced two more issues on that guard:

  1. DB row-ordering non-determinism. findOne without ORDER BY on SQL Server can return either the earlier owner or the current quote (both match after txReceived saves the current row with the same txId), which lets a replay silently pass. Replaced with a dedicated getEarliestQuoteClaimingTx that adds order: { id: 'ASC' } — first-writer wins deterministically.

  2. TX_FAILED quotes retain txId but were outside the status filter. txFailed() on the entity does not clear txId, so a failed quote still lays claim to its tx; excluding TX_FAILED from the guard's status list would let a foreign quote replay the same tx as if it were fresh. Widened the filter to include TX_FAILED alongside PaymentQuoteTxStates.

Round 3 also confirmed the isolation-race bound: with saveTransaction awaited before the per-chain handler runs, each request's own row is committed before its own guard fires, so concurrent submissions of the same tx from different quotes both see BOTH rows and the id-ASC tie-break gives them the same earliest quote — first-writer wins, no double-credit.

Two pre-existing SolanaClient parser issues surfaced by round 2 are out of scope but worth follow-ups:

  • createTransactionDto routes any tx containing a system-program instruction into the native branch and drops SPL — a native+SPL mixed tx would DoS a legitimate SPL payment.
  • getTokenInstructions/updateTokenInstruction merges multi-SPL transfers via last-wins, so two SPL transfers in one tx produce a non-deterministic single destination.

Both are parser DoS on legit inputs, not payment-side security bypasses; documented for a separate parser-hardening PR.

Coverage gap noted, not blocking: no unit test exists for doSolanaTxIdPayment, validateSolanaTransaction, or the new replay-guard behavior. payment-quote-firo.spec.ts is the natural template for a follow-up spec.

…→owner via postTokenBalances

Follow-up on the BUG-1260 fix. Full review surfaced a CONFIRMED HIGH bypass in
the parser this fix relies on:

`SolanaClient.getTokenInstructions` merged fields across a single tx's parsed
token instructions into ONE `Partial<SolanaTokenInstructionsDto>` via a switch:
`create` set `{mint, source, destination}`, `transfer`/`transferChecked` set
`{authority, amount}`. A single tx bundling both let an attacker glue DFX's
destination + mint (from a `createAssociatedTokenAccountIdempotent(DFX, mint)`,
rent ~$0.005) to an unrelated transfer's amount (a `transferChecked` from
attacker to attacker for the required amount). The merged DTO looked like a
real payment to DFX; `validateSolanaTransaction` accepted; quote flipped to
`TX_BLOCKCHAIN`. DFX received zero.

Rewrote the SPL parser path:

- New `getTokenTransactionDestinations` iterates ALL parsed instructions and
  emits one destination per `transfer` / `transferChecked` — not one merged
  destination for the whole tx. `create` / `closeAccount` are ignored (no
  value transfer; real "create and fund" txs also carry the transfer that
  provides the destination + amount).
- Each transfer's destination ATA is resolved to its wallet owner by looking
  up `postTokenBalances[b].accountIndex → accountKeys[..].pubkey`, and its
  mint is read from the same balance entry. Callers still match on
  wallet-owner equality (the model `validateSolanaTransaction` already
  expects).
- Removed the now-dead `getTokenInstructions`, `updateTokenInstruction`, and
  `getTokenTransactionDestination`, plus the unused `SolanaTokenInstructionsDto`
  import in the client (the DTO stays exported for public API stability).
- Updated the two stale "getTokenInstructions/updateTokenInstruction" comment
  references in `tx-validation.service.ts` and `payment-quote.service.ts` to
  describe the new resolution path.

Verified against six scenarios (original exploit, legit create+fund, plain
transfer, multi-transfer, cross-mint substitution, missing postTokenBalances)
— every scenario resolves correctly or fails closed. CPI-hidden transfers
remain invisible to the outer-instruction loop (pre-existing behavior); a
hidden transfer produces no DFX destination → validator rejects.

`tsc`, `eslint`, `prettier` clean. `payment-link` (81/81) and
`solana-client.spec.ts` (5/5) pass.
@davidleomay

davidleomay commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Update after a full review pass: one more round was needed. 4 review passes total now (two independent lenses per pass: security/correctness and conformance/regression, each re-run from scratch against develop).

Round 4 caught a CONFIRMED HIGH bypass that survived rounds 1–3 because I had scoped it out of the earlier review as "pre-existing SolanaClient parser DoS" — it isn't just DoS, it's the same bypass class BUG-1260 is meant to close:

Parser instruction-merge bypass. SolanaClient.getTokenInstructions merged fields across a single tx's parsed token instructions into ONE Partial<SolanaTokenInstructionsDto> via a switch: create set {mint, source, destination}, transfer/transferChecked set {authority, amount}. In a single tx bundling both, the returned DTO glued DFX's destination + mint (from a createAssociatedTokenAccountIdempotent(DFX, USDC_mint) — rent ~$0.005) to an unrelated transfer's amount (a transferChecked from attacker to attacker for the required amount). validateSolanaTransaction saw to === DFX_owner, tokenInfo.address === USDC_mint, amount ≥ activation.amount and returned isValid; quote flipped to TX_BLOCKCHAIN; DFX received zero USDC. Exploit cost: ~$0.005 + gas.

Fix rewrote the SPL parser path (getTokenTransactionDestinations) to emit ONE destination per transfer/transferChecked — not one merged destination for the whole tx. Each destination ATA is resolved to its wallet owner via postTokenBalances[b].accountIndex → accountKeys[..].pubkey, and its mint is read from the same balance entry. create / closeAccount are now ignored (no value transfer; a real create+fund tx still works because it also carries the transfer that provides the destination + amount). Removed the dead getTokenInstructions, updateTokenInstruction, getTokenTransactionDestination, plus the unused DTO import.

Round 4 also verified against six scenarios: original exploit (blocked), legit create+fund (works), plain transfer (works), multi-transfer in one tx (each emitted separately, validator matches the DFX one), cross-mint substitution (rejected on mint mismatch), missing postTokenBalances (fail-closed).

Two adjacent items noted, not addressed here (pre-existing, separate follow-ups):

  • CPI-hidden transfers are invisible to the outer-instruction loop. Fail-closed today (no destination emitted → validator rejects), but worth documenting.
  • fromLamportAmount float collapse for token base units > 2^53 — pre-existing precision concern, harmless to the underpayment check.

Also from round 4, three conformance nits worth addressing in a follow-up rather than this PR: getEarliestQuoteClaimingTx could be private (no external callers), the naming reads awkwardly compared to getQuoteByTxId, and the !isCoin && !activation.asset.chainId guard in doSolanaTxIdPayment duplicates a check in validateSolanaTransaction. Test coverage gap (no unit tests for the new handler / validator / guard) also carries forward — payment-quote-solana.spec.ts mirroring payment-quote-firo.spec.ts would materially strengthen this.

davidleomay added a commit that referenced this pull request Jul 30, 2026
…sender-less fallback (BUG-1260 sibling)

Same class as the Solana fix (BUG-1260). `doIcpPayment` falls back to a "no
sender" branch whenever the client cannot use the ICRC-2 approve/pull flow — it
posts back a raw txId and the server settles from that. That fallback used to
route through `doVerifiedTxIdPayment`, which only checked finality via
`isTxComplete` — recipient, amount, and asset were never validated, so any
authenticated caller could submit any real finalized ICP tx (`?tx=<hash>` and
omit `sender`) and mark a payment as settled.

New `doIcpTxIdPayment` mirrors the Solana handler:

- Same replay guard via `getEarliestQuoteClaimingTx` (rejects a tx already
  claimed by a different quote, TX_* + TX_FAILED, ORDER BY id ASC).
- Waits for finality.
- Iterates the ICP activations and, for ICRC-3, rejects up-front when the
  txId's canister id (`canisterId:blockIndex`) does not match
  `activation.asset.chainId` — without this, a legit transfer of N units of a
  cheap ICRC-3 token to the DFX principal would satisfy a quote for N units of
  a different (expensive) ICRC-3 token (same recipient, same amount).
- Fetches the transfer via new `InternetComputerClient.getTransferByTxId`,
  which handles all three ICP txId formats (Rosetta 64-hex hash, ICRC-3
  `canisterId:blockIndex`, native block-index) and normalizes them into
  `IcpTransfer`. Asserts the returned block index matches the requested one
  (fail-closed against archive-skip in `getIcrcTransfers`/`getTransfers` that
  could otherwise substitute a later block into a foreign quote).
- Normalizes the expected recipient to match the format the transfer carries
  (native = account-identifier hex via `InternetComputerUtil.accountIdentifier`;
  ICRC-3 = raw principal text) and calls new
  `TxValidationService.validateIcpTransfer`. Overpayment accepted, wrong
  recipient / underpayment fails.

Removes the now-dead `doVerifiedTxIdPayment` — the ICP fallback was its only
remaining caller after the Solana fix.

Depends on the Solana PR (#4453) for `getEarliestQuoteClaimingTx` and the
validator/service seams; branch is stacked on it.

Verified: lint, type-check, payment-link + icp + solana jest suites (12 files
/ 102 tests) pass.
davidleomay added a commit that referenced this pull request Jul 30, 2026
…sender-less fallback (BUG-1260 sibling)

Same class as the Solana fix (BUG-1260). `doIcpPayment` falls back to a "no
sender" branch whenever the client cannot use the ICRC-2 approve/pull flow — it
posts back a raw txId and the server settles from that. That fallback used to
route through `doVerifiedTxIdPayment`, which only checked finality via
`isTxComplete` — recipient, amount, and asset were never validated, so any
authenticated caller could submit any real finalized ICP tx (`?tx=<hash>` and
omit `sender`) and mark a payment as settled.

New `doIcpTxIdPayment` mirrors the Solana handler:

- Same replay guard via `getEarliestQuoteClaimingTx` (rejects a tx already
  claimed by a different quote, TX_* + TX_FAILED, ORDER BY id ASC).
- Waits for finality.
- Iterates the ICP activations and, for ICRC-3, rejects up-front when the
  txId's canister id (`canisterId:blockIndex`) does not match
  `activation.asset.chainId` — without this, a legit transfer of N units of a
  cheap ICRC-3 token to the DFX principal would satisfy a quote for N units of
  a different (expensive) ICRC-3 token (same recipient, same amount).
- Fetches the transfer via new `InternetComputerClient.getTransferByTxId`,
  which handles all three ICP txId formats (Rosetta 64-hex hash, ICRC-3
  `canisterId:blockIndex`, native block-index) and normalizes them into
  `IcpTransfer`. Asserts the returned block index matches the requested one
  (fail-closed against archive-skip in `getIcrcTransfers`/`getTransfers` that
  could otherwise substitute a later block into a foreign quote).
- Normalizes the expected recipient to match the format the transfer carries
  (native = account-identifier hex via `InternetComputerUtil.accountIdentifier`;
  ICRC-3 = raw principal text) and calls new
  `TxValidationService.validateIcpTransfer`. Overpayment accepted, wrong
  recipient / underpayment fails.

Removes the now-dead `doVerifiedTxIdPayment` — the ICP fallback was its only
remaining caller after the Solana fix.

Depends on the Solana PR (#4453) for `getEarliestQuoteClaimingTx` and the
validator/service seams; branch is stacked on it.

Verified: lint, type-check, payment-link + icp + solana jest suites (12 files
/ 102 tests) pass.
@davidleomay

Copy link
Copy Markdown
Member Author

Follow-up to the previous comment, after the R4 parser rewrite plus a prettier line-wrap: one more full pass ran (two independent lenses, each re-run from scratch against develop on the current HEAD c533db8ac). 6 review passes in total.

Fresh security probes on the rewritten parser and the still-present replay guard — all fail-closed:

  • ATA forgery: an ATA's owner is baked into address derivation, enforced by the SPL Token program at the runtime layer, not by DFX. An attacker cannot mint an ATA claiming DFX as owner for a key they control.
  • postTokenBalances injection: validator-computed post-execution, not user-supplied. Immune.
  • accountIndex out of range: accountKeys[b.accountIndex]?.pubkey.toBase58() short-circuits to undefined, string equality returns false. No throw path.
  • Multi-transfer selection: .find() first-match wins, but the amount + owner + mint checks are still authoritative. Underpayment against a matching DFX destination rejected on match.amount < expectedAmount.
  • Token 2022 transferCheckedWithFee: parser type filter fails-closed on it (feature gap, not a vulnerability).
  • Combined native+SPL tx: else if selection means the SPL branch is skipped when a SystemProgram transfer is present in the same tx — activation match then falls through to fail. Pre-existing DoS on that specific tx shape, not a security bypass.
  • System transfer bleed into SPL loop: System instructions use lamports, not amount; the rawAmount == null filter drops them.
  • Cross-blockchain txId collision: txBlockchain: Equal(...) in the replay guard prevents any cross-chain match. Solana signatures are base58 (case-sensitive, Number(...) = NaN), so no numeric aliasing class analogous to the ICP one (fix(payment-link): validate ICP OCP tx recipient/amount/asset in the sender-less fallback (BUG-1260 sibling) #4490).
  • Concurrent aliased submissions: base58 has no aliases; ordering-race remains handled by the id-ASC tiebreak from round 3.
  • CPI-hidden create+transfer: inner instructions are outside message.instructions scope, parser never sees them; no destination emitted, validator rejects — fail-closed.

Pre-existing weakness noted (not introduced or worsened by this PR, worth a separate follow-up ticket): an attacker can InitializeAccount a non-ATA token account with owner = DFX_wallet, transfer tokens to it, and the parser would still match destinationBalance.owner === DFX_wallet. Impact: laundering / accounting-mismatch vector, not a free-money exploit — DFX's outbound createTokenTransaction only spends from the ATA (getAssociatedTokenAddress), so the non-ATA balance is stranded on the receive side. Same weakness existed in the old parser (updateTokenInstruction used the same postTokenBalances.owner lookup). To close it: verify destinationAta === getAssociatedTokenAddressSync(mint, DFX_wallet) before accepting.

Conformance pass surfaced two minor items and a doc drift, none blocking:

  1. SolanaTokenInstructionsDto (src/integration/blockchain/solana/dto/solana.dto.ts:33) is now a dead export after the R4 rewrite removed its only consumers (getTokenInstructions, updateTokenInstruction, getTokenTransactionDestination). Grep-confirmed no other references. Kept for now to avoid API-shape churn; drop in a follow-up cleanup.
  2. getEarliestQuoteClaimingTx (payment-quote.service.ts:158) could be private — only in-class callers, matches getActualQuoteByUniqueId's visibility.
  3. PR description was last updated before the R4 rewrite; the "no ATA derivation needed" line no longer matches shipped code (the rewrite DOES resolve destination ATA to owner via postTokenBalances). Description accuracy nit, not a code issue.

CI: all 12 checks green after the prettier line-wrap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant