feat(packages): validate depositor-graph psbt inputs against authoritative parent txs#1414
feat(packages): validate depositor-graph psbt inputs against authoritative parent txs#1414
Conversation
🔐 Commit Signature Verification✅ All 1 commit(s) passed verification
Summary
Required key type: Last verified: 2026-04-17 14:16 UTC |
Greptile SummaryThis PR hardens the depositor-graph PSBT signing flow by replacing VP-trusted prevout arrays with values derived directly from authoritative parent transactions (
Confidence Score: 4/5Safe to merge after fixing the conditional skip on payout input-1 verification, which can silently bypass the Assert:0 prevout check for VP-supplied single-input payout transactions. One P1 logic issue: the packages/babylon-ts-sdk/src/tbv/core/services/deposit/signDepositorGraph.ts — the conditional Assert:0 check at lines 238-247 Important Files Changed
Sequence DiagramsequenceDiagram
participant UI as usePayoutSigningState
participant SAP as signAndSubmitPayouts
participant SDG as signDepositorGraph
participant VPRes as VP Response
participant OnChain as On-chain peginTx
UI->>SAP: pollAndSignPayouts(signingContext)
SAP->>OnChain: signingContext.peginTxHex (authoritative)
SAP->>VPRes: response.depositor_graph
SAP->>SDG: signDepositorGraph({ depositorGraph, peginTxHex })
SDG->>SDG: Transaction.fromHex(peginTxHex) → peginTx
SDG->>VPRes: depositorGraph.assert_tx.tx_hex → graphAssertTx
SDG->>SDG: validateAndParsePsbt(payout_psbt, payout_tx.tx_hex)
SDG->>SDG: verifyInputAgainstParent(input0, peginTx, vout=0)
SDG->>SDG: verifyInputAgainstParent(input1, graphAssertTx, vout=0) [if ins.length > 1]
SDG->>SDG: verifyInputAgainstParent(noPayoutInput0, graphAssertTx, vout=0)
SDG->>SDG: sanitizePsbtForScriptPathSigning
SDG-->>UI: DepositorAsClaimerPresignatures
Reviews (1): Last reviewed commit: "feat(packages): validate depositor-graph..." | Re-trigger Greptile |
| if (payoutValidated.childTx.ins.length > 1) { | ||
| verifyInputAgainstParent( | ||
| payoutValidated.psbt, | ||
| payoutValidated.childTx, | ||
| 1, | ||
| graphAssertTx, | ||
| ASSERT_PAYOUT_OUTPUT_INDEX, | ||
| payoutLabel, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Conditional skip silently bypasses Assert:0 verification
If the VP supplies a payout transaction with only 1 input, the if guard skips the Assert:0 prevout check entirely instead of throwing. This contradicts both the protocol requirement (payout MUST have 2 inputs, as enforced in buildDepositorPayoutPsbt) and the stated security goal of this PR: a malicious VP can present a 1-input payout to avoid the graphAssertTx binding check. Should assert exactly 2 inputs first:
| if (payoutValidated.childTx.ins.length > 1) { | |
| verifyInputAgainstParent( | |
| payoutValidated.psbt, | |
| payoutValidated.childTx, | |
| 1, | |
| graphAssertTx, | |
| ASSERT_PAYOUT_OUTPUT_INDEX, | |
| payoutLabel, | |
| ); | |
| } | |
| if (payoutValidated.childTx.ins.length !== 2) { | |
| throw new Error( | |
| `Depositor Payout transaction must have exactly 2 inputs, got ${payoutValidated.childTx.ins.length}`, | |
| ); | |
| } | |
| verifyInputAgainstParent( | |
| payoutValidated.psbt, | |
| payoutValidated.childTx, | |
| 1, | |
| graphAssertTx, | |
| ASSERT_PAYOUT_OUTPUT_INDEX, | |
| payoutLabel, | |
| ); |
| function inputTxidHex(input: { hash: Buffer | Uint8Array }): string { | ||
| return uint8ArrayToHex(new Uint8Array(input.hash).slice().reverse()); | ||
| } |
There was a problem hiding this comment.
inputTxidHex duplicated across 4 files
The function is defined identically in challengeAssert.ts, depositorPayout.ts, noPayout.ts, and here. Per the project's zero-dead-code / no-duplication policy, this helper belongs in the shared primitives/utils/bitcoin module alongside uint8ArrayToHex (which it already depends on).
| /** PegIn vault output index spent by the depositor's Payout input 0. */ | ||
| const PEGIN_VAULT_OUTPUT_INDEX = 0; | ||
| /** Assert output index spent by the depositor's Payout input 1 (NOT signed). */ | ||
| const ASSERT_PAYOUT_OUTPUT_INDEX = 0; | ||
| /** Assert output index spent by NoPayout input 0 (signed). */ | ||
| const ASSERT_NOPAYOUT_OUTPUT_INDEX = 0; |
There was a problem hiding this comment.
Protocol-index constants duplicated across 3 files
PEGIN_VAULT_OUTPUT_INDEX, ASSERT_PAYOUT_OUTPUT_INDEX, and ASSERT_NOPAYOUT_OUTPUT_INDEX are all 0 and defined independently in depositorPayout.ts, noPayout.ts, and here. Because these constants encode a protocol invariant (which output of PegIn/Assert each transaction spends), they should live in one shared location — a drift between them would silently change validation behaviour.
closes https://github.com/babylonlabs-io/vault-provider-proxy/issues/17