feat: add Tempo charge sender validation hook#452
Conversation
commit: |
tempoxyz-cyclops-bot
left a comment
There was a problem hiding this comment.
👁️ Cyclops Review
PR #452 introduces an opt-in validateSender hook on tempo.server.charge() so operators can accept TIP-20 hash credentials whose on-chain Transfer.from differs from the address mppx would otherwise require. The default no-hook behavior remains bit-for-bit equivalent to the prior strict matcher, and replay protection / memo binding / source-DID validation continue to run.
The hook is well scoped, but it surfaces a pre-existing weakness in assertTransferLogs(): it consumes decoded log entries by array index instead of by economic TIP-20 payment effect, so a permissive validateSender can let one underlying debit satisfy two distinct expected transfers. Two manifestations were verified end-to-end with PoC tests (TIP-1022 virtual-address forwarding hop, and same-recipient split with one transferWithMemo). Inline comments below.
Full consolidated report: findings/pr-452/pr-452-consolidated.md.
Reviewer Callouts
- ⚡
credential.sourcetrust boundary: WithvalidateSender,credential.sourcecan intentionally differ from the actualTransfer.from.onPaymentSuccesshandlers, custom transports, and any merchant code that readscredential.sourceshould not treat it as authenticated payer identity unless their hook explicitly verifies thesource -> senderrelationship. - ⚡ Hook context is narrow: The hook sees only
{ expectedSender, sender, source }— no log kind, recipient, amount, memo, receipt hash, or block number. This makes it impossible for policy code to distinguish real third-party debits from synthetic/upstream attribution events. - ⚡ Memo binding is "at least one matched memo": Safe for normal client flows but fragile when one upstream movement produces multiple matchable logs and
validateSenderis permissive — the memo half satisfies binding while the paired plain log carries no challenge attribution. - ⚡ Explicit-memo + permissive
validateSender: When the server sets an explicit staticmemo, no challenge binding is enforced. Combined with a permissivevalidateSender, any on-chainTransfermatching(currency, recipient, amount, memo)from any sender can be claimed by any hash credential. Worth amplifying in user-facing docs. - ⚡ Greedy matching with looser
frompredicate: Consider adding a test that exercises N splits + N+M logs with mixed senders to confirm the algorithm still picks a valid combination when one exists.
| expectedSender: parameters.sender, | ||
| sender: log.args.from, | ||
| source: parameters.source, | ||
| validateSender: parameters.validateSender, |
There was a problem hiding this comment.
🚨 [SECURITY] assertTransferLogs matches by raw decoded log entries instead of by economic payment effects
Severity: Medium
The matcher decodes Transfer and TransferWithMemo events as independent entries (logs = [...memoLogs, ...transferLogs]) and tracks consumption via used keyed by the concatenated array index. A single TIP-20 movement can produce multiple decoded entries in that array, so two distinct expected transfers can be satisfied by one underlying debit — but only after this new validateSender hook lets the mismatched Transfer.from through. Two PoC-verified manifestations:
- TIP-1022 virtual-address forwarding. A
transferWithMemoto a virtual address emitsTransfer(payer, virtual, amount)+TransferWithMemo(payer, virtual, amount, memo)+ the synthetic forwarding logTransfer(virtual, master, amount). With a permissive hook (e.g. an allowlist that approves the virtual address), the primary expected transfer to the virtual address consumes the memo log and the split to the master address consumes the forwarding log. One paid token satisfies a challenge requiring two. - Same-recipient split with one
transferWithMemo. A schema-valid charge withamount: '2'andsplits: [{ recipient: merchant, amount: '1' }]produces two indistinguishable expected transfers(merchant, 1). OnetransferWithMemo(thirdParty -> merchant, 1, challengeMemo)emits oneTransferand oneTransferWithMemofor the same debit. After the hook approvesthirdParty, the first expected transfer consumes the memo log and the second consumes the paired plainTransfer.assertChallengeBoundMemo()(Charge.ts:974-985) only requires one matched log to be challenge-bound, so the memo half is sufficient.
In both cases the hook itself cannot detect the issue — it receives no log kind, recipient, amount, memo, or log index. Both flows were reproduced with bun test PoCs on this commit; without validateSender the strict equality check still rejects the same receipts.
Recommended Fix:
- Collapse adjacent
Transfer(from, to, amount)andTransferWithMemo(from, to, amount, memo)for the same debit into one matchable effect carrying optional memo metadata; mark both indices used when either is matched. - For TIP-1022 virtual-address forwarding, refuse to invoke
validateSenderon the syntheticTransfer(virtual, master, amount)forwarding hop (or pair it with the precedingpayer -> virtuallog as one effect). - Either reject duplicate
(recipient, amount, allowAnyMemo)expected transfers ingetExpectedTransfers(), or require the receipt contains that many distinct collapsed effects before accepting. - Optionally broaden
ValidateSenderParametersso policy code can see log kind / recipient / amount / receipt context.
There was a problem hiding this comment.
will fix in another pr
| * Validates a TIP-20 transfer sender when it differs from the credential | ||
| * source. Core verification still validates amount, currency, recipient, | ||
| * memo binding, transaction success, and replay protection. | ||
| */ |
There was a problem hiding this comment.
💡 [SUGGESTION] Tighten validateSender JSDoc to match actual trigger conditions
Severity: Low
The doc says the hook validates a sender "when it differs from the credential source," but the actual trigger is "differs from source?.address ?? receipt.from." The hook also fires when no source field is present (smart-account / EOA-only flows), and it can be invoked multiple times per credential — once per candidate matching log — without that being documented. An operator writing validateSender: ({source, sender}) => isAuthorized(source!.address, sender) will throw at runtime when a credential omits source; operators implementing metrics/rate-limiting inside the hook will observe duplicate invocations.
Additionally, the hash-credential source field is a self-asserted string and is not cryptographically authenticated. The doc should warn that validateSender cannot be used to restrict otherwise valid payers (an attacker can supply their own address as source to bypass an app-level allowlist implemented in the hook); it can only expand the set of accepted senders for a given source.
Recommended Fix: Update the JSDoc on charge.Parameters.validateSender and on ValidateSenderParameters.source to call out (1) the actual trigger condition including source = undefined, (2) potential multi-invocation per verification (hook should be side-effect-free / idempotent), and (3) that source is a client-supplied claim, so the hook is expand-only and not usable as a deny-list.
| }) | ||
| const matchedLogs = assertTransferLogs(receipt, { | ||
| const matchedLogs = await assertTransferLogs(receipt, { | ||
| currency, |
There was a problem hiding this comment.
🛡️ [DEFENSE-IN-DEPTH] Pull-mode assertTransferLogs call omits validateSender and source
Severity: Low (not currently exploitable)
This pull-mode (transaction credential) call site does not thread validateSender / source into assertTransferLogs(). Today this is not exploitable: pre-broadcast assertTransferCalls() / getTransferCalls() (Charge.ts:650-690) restrict accepted calldata to direct transfer / transferWithMemo (and the approve + swapExactAmountOut prefix) on the configured currency, so Transfer.from == transaction.from is structurally forced and the hook would never be consulted.
If Tempo later supports operations where msg.sender differs from the signing EOA (session-key relays, smart-account batched ops, account-abstraction calls, vault-mediated transfers), the omission becomes a real "user paid on-chain but server returns 402" bug because the on-chain Transfer.from would no longer equal transaction.from.
Recommended Fix: For API symmetry and forward-compatibility, thread validateSender (and source when available) into this call as well. Mechanical change that prevents a future regression once richer transaction-credential flows are supported.
Summary
Added a Tempo charge sender validation hook for accepting authorized third-party transfer senders while preserving core payment verification checks.