test: add P2P exchange escrow integration tests - #24
Conversation
ghost
left a comment
There was a problem hiding this comment.
Review — P2P Exchange Escrow Integration Tests
Thorough and well-structured PR. The escrow contract design is solid, and the test coverage for the three Arkade-scripted leaves is good. A few observations:
pkg/arkade/script.go — closurePubKeys + ReadArkadeScript
The refactor from hardcoded MultisigClosure.Decode() to scriptlib.DecodeClosure() is clean and correct. The closurePubKeys type switch covers all current closure types. One minor note: if a new closure type is added to scriptlib that also embeds MultisigClosure, this function will silently return nil — might be worth a comment or a compile-time interface check, but not blocking.
Security Review
SellerConfirm (Leaf 0) ✅
- CSFS attestation correctly binds seller identity to RELEASE message
OP_INSPECTNUMINPUTS 1 OP_EQUALVERIFYprevents input-stuffing attacks — good- Fee computation via
OP_MUL64/OP_DIV64with overflow/div-zero guards (OP_VERIFYafter each) is correct - Pre-approved destination addresses prevent fund diversion even if CSFS keys are compromised
OP_GREATERTHANOREQUAL64allows overpaying fees (not just exact match) — reasonable design choice
BuyerRefund (Leaf 2) ✅
- No
OP_INSPECTNUMINPUTScheck here, but since output is locked to pre-approved seller address, extra inputs would only donate funds to the seller — not exploitable - No output value enforcement (could send less than full escrow minus miner fee), but since seller + introspector + operator all co-sign, this requires 3-party collusion to underpay — acceptable
- Full refund to seller with no fee deduction on cancel is correct behavior
Topup (Leaf 5) ✅
- Recursive covenant correctly enforces
output[0].spk == input[current].spk OP_LESSTHAN64enforcesinput_value < output_value— strictly increasing, prevents no-op spends- No input count restriction is intentional — topups may consolidate multiple inputs
- CASE 2 test correctly validates that equal-value spends fail the
OP_LESSTHAN64check
Test Quality
- 10 test cases across 3 functions with both positive and negative paths
submitAndExpectFailurehelper reduces boilerplate nicely- Negative tests cover the right attack vectors: wrong CSFS key, fee underpayment, wrong destinations, wrong scriptPubKey, same-value rejection
Minor Nits (Non-blocking)
-
Topup test
feeSpk: []byte{0x6a}— This OP_RETURN scriptPubKey is fine since the topup leaf never checks it, but other leaves in this same VTXO tree (SellerConfirm) would failextractWitnessInfoif someone tried to use them. Fine for test isolation, but worth a comment noting this VTXO is only valid for the topup path. -
TODO: Arbitrator path tests — Acknowledged in the PR body. These are pure multisig paths (no Arkade script), so lower risk, but would complete the coverage.
Overall: clean design, correct security properties, good test coverage. 👍
ghost
left a comment
There was a problem hiding this comment.
🔍 Follow-up Review — Arkana (post-7145498)
New commits since last review: merge conflict resolution (d406c61) and test/error message adaptation (7145498).
Changes
-
Error message expectation updated from
"spendingtapscript is not a MultisigClosure"→"unexpected error while decoding tapscript"— matches theDecodeClosure()refactor. Correct. -
Pre-approved destination enforcement —
SellerConfirmnow enforcesoutput[0] → buyerSpk,BuyerRefundenforcesoutput[0] → sellerSpk. This was already described in the PR design but now implemented in the Arkade scripts. Security-positive change — prevents fund diversion even if CSFS keys are compromised. -
Witness field type adapted from
[]bytetowire.TxWitness— upstream API change.
⚠️ Cross-PR Conflict with #30
This PR's closurePubKeys helper function and the inline type switch in #30 implement the same logic differently. #30 also removes the inputIndex parameter from ReadArkadeScript. After #30 merges, this PR will need a rebase to:
- Drop the
closurePubKeysfunction (replaced by #30's inline switch) - Update call sites to the new 3-param signature
Verdict
No new security concerns. The pre-approved destination enforcement strengthens the contract. Merge #30 first, rebase this, then ship. 👍
Implements a 6-leaf Taproot escrow contract using CSFS attestations and transaction introspection opcodes. Tests cover: - Leaf 0 (SellerConfirm): CSFS seller attestation + fee enforcement - Leaf 1 (ArbitratorToBuyer): CSFS server attestation + fee enforcement - Leaf 2 (BuyerRefund): CSFS buyer CANCEL attestation - Leaf 3 (ArbitratorToSeller): CSFS server CANCEL attestation - Leaf 5 (TopupPath): recursive covenant (same spk, strictly more value) Negative tests: wrong CSFS message, wrong party attestation, fee too low, wrong fee address, output value not greater, wrong scriptPubKey.
- Fix errcheck on conn.Close() with nolint directive - Fix trailing whitespace in comment alignment - Add TestP2PEscrowArbitratorToBuyer (Leaf 1) and TestP2PEscrowArbitratorToSeller (Leaf 3) to exercise all builders
schnorr.Sign requires a 32-byte message hash. The oracle messages (0x01/0x02 || trade_id) are 33 bytes, so we SHA256 hash them first.
- Replace wrong-message test with wrong-key test (CSFS doesn't verify message content, only signature validity) - Simplify topup test to use local script execution since multi-input txs are needed for the value increase but BuildTxs enforces balance - Remove unused variables from topup test
- Change fee enforcement from flat minFeeSats to percentage-based using OP_MUL64/OP_DIV64 with basis points (e.g. 200 = 2%) - Make tradeID an external []byte parameter instead of deterministically computed from pubkeys - Add unilateral CSV-only exit paths (seller-only and buyer-only) as additional closures in the VTXO taproot tree, alongside the existing collaborative MultisigClosure path
- Add arbitratorPubKey field to escrowParams (distinct from serverPubKey) - serverPubKey = Arkade operator (signs collaborative MultisigClosure) - arbitratorPubKey = dispute resolver (CSFS attestations in Leaf 1/3) - Update unilateral exits: buyer+seller CSV, seller-only CSV*2
Use vtxoScript.Encode() to provide all closure scripts in the RevealedTapscripts field, not just the collaborative closure. The Ark server needs the complete taproot tree to verify the VTXO.
The collaborative MultisigClosure path now uses CLTVMultisigClosure with an absolute block height locktime, while unilateral exit paths retain CSV relative timelocks.
…or, introspector) - Remove unused serverPubKey from escrowParams (operator key comes from Ark address) - Rename ownerPubKey → buyerPubKey, serverSigner → operatorSigner in createEscrowVtxoScript - Fix comments: "server attests" → "arbitrator attests" - Rename serverPrivKey → wrongPrivKey in negative test (was only used as random wrong key)
…e unsupported) The introspector's ReadArkadeScript only decodes MultisigClosure (see pkg/arkade/script.go:55 TODO). Using CLTVMultisigClosure causes the introspector to silently skip validation (negative tests pass) and the Ark server to reject signatures (INVALID_SIGNATURE). Keep the CSV unilateral exit paths unchanged since those are not used by the collaborative spend path.
…ations - Fix non-multisig tapscript test: error message changed from "spendingtapscript is not a MultisigClosure" to "unexpected error while decoding tapscript" after switching to DecodeClosure() in ReadArkadeScript. - Add pre-approved buyer/seller destination address enforcement: SellerConfirm enforces output[0] → buyerSpk, BuyerRefund enforces output[0] → sellerSpk. - Adapt Witness field from []byte to wire.TxWitness (upstream change).
…ripts - Add buyerSpk/sellerSpk to escrowParams for pre-approved destinations - SellerConfirm: enforce output[0] → buyerSpk, output[0].value >= inputValue - fee, OP_INSPECTNUMOUTPUTS 2 (exactly buyer + fee outputs) - BuyerRefund: enforce output[0] → sellerSpk, output[0].value >= inputValue, OP_INSPECTNUMOUTPUTS 1 (single seller output) - Switch arbitrator from CSFS to OP_CHECKSIG (signs tx directly) - Drop introspector from arbitrator paths - Add CLTV seller self-release path (seller + operator, no introspector) - 7-closure taproot tree with buyer/seller/arbitrator/CLTV/CSV paths - Adapt Witness field from []byte to wire.TxWitness (upstream change) - Fix ReadArkadeScript call signature in tx_test.go
7145498 to
f550e85
Compare
BuildTxs adds 2 system outputs (OP_RETURN extension + P2A anchor) that the Arkade script cannot predict. Output destinations and amounts are still enforced at specific indices, so extra outputs cannot divert funds.
ghost
left a comment
There was a problem hiding this comment.
🔍 Follow-up Review — Arkana (post-787e7e2)
New commits since last review add arbitrator leaf tests, enforce pre-approved destinations + output amounts, then remove output count enforcement (OP_INSPECTNUMOUTPUTS).
Output Count Removal — Security Analysis ✅
The removal of OP_INSPECTNUMOUTPUTS is safe. Reasoning:
SellerConfirm: The script enforces:
output[0].spk == buyerSpk(destination locked)output[1].spk == feeSpk(fee destination locked)output[1].value >= inputValue * bp / 10000(minimum fee)output[0].value >= inputValue - output[1].value(buyer gets remainder)
Extra outputs (OP_RETURN, P2A anchor from BuildTxs) cannot divert funds because Bitcoin consensus prevents creating value. If total output value exceeds input value, the transaction is invalid. Extra outputs can only reduce output[0] or output[1] values, which would violate the enforced constraints.
BuyerRefund: The script enforces output[0].value >= inputValue and output[0].spk == sellerSpk. Extra outputs would need to source their value from somewhere — they can't, because output[0] already claims the full input value. Any extra output would push total outputs above input value → invalid tx.
Topup: No output count was ever enforced — the script only checks output[0].spk == input[current].spk and output[0].value > input.value.
New Additions Since Last Review
- Arbitrator tests (Leaf 1 & 3) now implemented — pure multisig paths exercised ✅
- CSFS oracle message hashing — SHA256(prefix || tradeID) to get 32-byte schnorr message ✅
- Arbitrator design change — switched from CSFS to direct OP_CHECKSIG. This is simpler and correct: the arbitrator signs the transaction itself, controlling exact outputs. No Arkade script needed for dispute resolution paths.
- CLTV seller self-release — pure seller+operator with absolute timelock, no introspector. Correct fallback for absent buyer+arbitrator.
Cross-PR Status
PR #30 has merged. This PR's latest force-push (commits starting 17893fb) appears to be rebased on top. The closurePubKeys helper and ReadArkadeScript signatures should be aligned — confirmed by the updated error expectation in tx_test.go.
Verdict
No security concerns with the output count removal — the value + destination constraints are sufficient. Ship it. 👍
🔍 Arkana PR ReviewTest-only PR — P2P exchange escrow integration tests SummaryComprehensive integration test suite for P2P escrow using Arkade introspection opcodes (CSFS, Analysis✅ Good coverage of attack vectors:
✅ Escrow architecture looks sound:
Observations
VerdictWell-structured test suite. No production code changes, so no security risk from merge. The tests themselves validate important security invariants of the escrow contract. |
arkana-ai-bot
left a comment
There was a problem hiding this comment.
Review — arkade-os/emulator PR #24
All findings cite file:line in the PR diff. CI is green but several correctness gaps need resolution before merge.
H1 — Missing OP_INSPECTNUMINPUTS in buildLeaf2BuyerRefund
test/p2p_escrow_test.go:184–210
buildLeaf0SellerConfirm enforces a single-input transaction (OP_INSPECTNUMINPUTS 1 OP_EQUALVERIFY), but buildLeaf2BuyerRefund has no equivalent check. The value guard output[0] >= input[current].value does not prevent an additional co-spent input from having its value claimed by a third output. If a buyer and a colluding counter-party can arrange a multi-input spend (or if the operator crafts one), the buyer-refund path can leak value from other VTXOs in the same transaction.
SellerConfirm added this check deliberately; BuyerRefund must match it for consistency and safety.
H2 — No OP_INSPECTNUMOUTPUTS in any Arkade script
test/p2p_escrow_test.go:103–165, 184–210, 217–236
None of the three Arkade scripts bounds the number of outputs. SellerConfirm's value constraints (output[0] >= input - actual_fee and output[1] >= min_fee) do not sum to input value, so a third output can silently absorb the gap. BuyerRefund (output[0] >= input) conserves value only when fee is zero and no third output exists. Topup checks only output[0].
A malicious operator co-signing one of these paths can add extra outputs. The Arkade script is the only layer that constrains operator behaviour; it must close this.
H3 — CSFS message content is not enforced on-chain
test/p2p_escrow_test.go:80–102, 175–183
<seller_pk> OP_CHECKSIGFROMSTACK OP_VERIFY
The message is taken from the witness stack; the script does not check that the message equals SHA256(0x01 || tradeID) (RELEASE) or SHA256(0x02 || tradeID) (CANCEL). Any 32-byte value signed with the correct key passes. A seller can sign an arbitrary nonce and spend the SellerConfirm path; a buyer can do the same for BuyerRefund. The oracle message semantics described in the PR description are not enforced on-chain.
If this is intentional (the message is only for external auditability), it must be prominently documented. If it is not intentional, add:
OP_SHA256 # hash the top-of-stack message
<expected_release_hash> OP_EQUALVERIFY # bind to RELEASE
before the CSFS check. The test suite has no case that demonstrates a wrong message fails (because it wouldn't), which is a coverage gap that masks this behaviour.
H4 — Single Arkade hash per VTXO; multi-path production escrow is untested
test/p2p_escrow_test.go:255–321, 408–411, 705–708, 973–976
createEscrowVtxoScript takes a single arkadeScriptHash and applies it to every buyer-collab closure in the tapscript tree. Each of the three integration tests funds a separate VTXO with a single Arkade hash:
TestP2PEscrowSellerConfirm→ VTXO tweaked withArkadeScriptHash(sellerConfirmScript)TestP2PEscrowBuyerRefund→ VTXO tweaked withArkadeScriptHash(buyerRefundScript)TestP2PEscrowTopupPath→ VTXO tweaked withArkadeScriptHash(topupScript)
A production P2P escrow requires one VTXO address that supports all three spending paths simultaneously, with each leaf's introspector key tweaked with the hash of that leaf's Arkade script. The current test suite does not demonstrate this and createEscrowVtxoScript cannot express it. This gap must be addressed before this can be called a complete escrow integration test.
M1 — Unchecked index into signedByIntrospectorCheckpoints
test/p2p_escrow_test.go:616, 893
introspectorCheckpointPtx, err := psbt.NewFromRawBytes(
strings.NewReader(signedByIntrospectorCheckpoints[i]), true)If the introspector returns fewer checkpoint entries than the server (len(signedByIntrospectorCheckpoints) < len(signedByServerCheckpoints)), this is a panic in both TestP2PEscrowSellerConfirm and TestP2PEscrowBuyerRefund. Add:
require.Len(t, signedByIntrospectorCheckpoints, len(signedByServerCheckpoints),
"introspector checkpoint count mismatch")before the loop.
M2 — out.PkScript[2:] slice without length guard
test/p2p_escrow_test.go:443, 738, 1011
if bytes.Equal(out.PkScript[2:], schnorr.SerializePubKey(escrowAddr.VtxoTapKey)) {If any output in the funding transaction has a PkScript shorter than 3 bytes (e.g., an OP_RETURN with no payload), this panics. Guard with:
if len(out.PkScript) > 2 && bytes.Equal(out.PkScript[2:], ...)M3 — Missing negative test: wrong CSFS message
test/p2p_escrow_test.go:522–531, 788–817
TestP2PEscrowSellerConfirm covers wrong-key but not wrong-message (e.g., seller signing cancelMsg with their key). Because message content is not enforced (see H3), this case would pass — and the test suite never exposes that. Add a case that calls signCSFS(sellerPrivKey, cancelMsg) and asserts failure. If it passes, that is itself the bug report for H3.
L1 — Stale comment at p2p_escrow_test.go:233
// OP_GREATERTHAN64 pops b then a, checks a < b (i.e. input < output)
AddOp(arkade.OP_LESSTHAN64).Comment names the wrong opcode. Should read OP_LESSTHAN64.
L2 — Misleading test label in Topup CASE 2
test/p2p_escrow_test.go:1078–1100
Comment header says "CASE 2: Valid — output[0] has same scriptPubKey (same value, passes spk check)" but the test asserts require.Error. The label should say "CASE 2: Invalid — same value fails the topup value check".
pkg/arkade/script.go change — no issues
The extension from hardcoded MultisigClosure.Decode() to scriptlib.DecodeClosure() is correct. All five closure types extract PubKeys identically. The default case returns a typed error. The new tx_test.go case exercising OP_TRUE → "failed to decode tapscript" correctly covers the new code path.
Cross-repo
ReadArkadeScript is consumed by go-sdk (vhtlc/noninteractive.go). The new closure-type support is additive (no existing paths removed), so no breakage. No changes to proto, wire format, or exported types.
Summary: Changes needed on H1–H4 before merge. M1–M2 are test-only panics but real; fix in the same pass. The script.go change is fine to ship as-is.
Summary
Closes #22
Implements the P2P exchange escrow contract as integration tests with 3 Arkade script leaves and a full taproot VTXO tree (7 closures). Also extends
ReadArkadeScriptto support all closure types (was previously hardcoded toMultisigClosureonly).Parameters
sellerPubKeybuyerPubKeyarbitratorPubKeybuyerSpkOP_INSPECTOUTPUTSCRIPTPUBKEYon SellerConfirmsellerSpkOP_INSPECTOUTPUTSCRIPTPUBKEYon BuyerRefundfeeSpkOP_INSPECTOUTPUTSCRIPTPUBKEYfeeBasisPointsOP_MUL64/OP_DIV64computationcltvTimeoutcsvTimeouttradeIDOracle messages
SHA256(0x01 || tradeID)— attested by seller (payment received) via CSFSSHA256(0x02 || tradeID)— attested by buyer (voluntary refund) via CSFSVTXO taproot tree
Each row is one spendable path. Tapscript opcodes are the Bitcoin Script in the tapscript itself. Arkade script opcodes are validated by the introspector engine inside that tapscript.
<buyer> OP_CHECKSIGVERIFY <introspector> OP_CHECKSIGVERIFY <operator> OP_CHECKSIG<seller> OP_CHECKSIGFROMSTACK(RELEASE) OP_VERIFYOP_INSPECTNUMINPUTS 1 OP_EQUALVERIFY0 OP_INSPECTOUTPUTSCRIPTPUBKEY <buyer_ver> OP_EQUALVERIFY <buyer_prog> OP_EQUALVERIFY1 OP_INSPECTOUTPUTSCRIPTPUBKEY <fee_ver> OP_EQUALVERIFY <fee_prog> OP_EQUALVERIFYOP_PUSHCURRENTINPUTINDEX OP_INSPECTINPUTVALUE <bp> OP_MUL64 OP_VERIFY <10000> OP_DIV64 OP_VERIFY OP_SWAP OP_DROP1 OP_INSPECTOUTPUTVALUE OP_SWAP OP_GREATERTHANOREQUAL64 OP_VERIFYOP_PUSHCURRENTINPUTINDEX OP_INSPECTINPUTVALUE 1 OP_INSPECTOUTPUTVALUE OP_SUB64 OP_VERIFY0 OP_INSPECTOUTPUTVALUE OP_SWAP OP_GREATERTHANOREQUAL64<seller> OP_CHECKSIGVERIFY <introspector> OP_CHECKSIGVERIFY <operator> OP_CHECKSIG<buyer> OP_CHECKSIGFROMSTACK(CANCEL) OP_VERIFY0 OP_INSPECTOUTPUTSCRIPTPUBKEY <seller_ver> OP_EQUALVERIFY <seller_prog> OP_EQUALVERIFYOP_PUSHCURRENTINPUTINDEX OP_INSPECTINPUTVALUE 0 OP_INSPECTOUTPUTVALUE OP_SWAP OP_GREATERTHANOREQUAL64<buyer> OP_CHECKSIGVERIFY <introspector> OP_CHECKSIGVERIFY <operator> OP_CHECKSIGOP_PUSHCURRENTINPUTINDEX OP_INSPECTINPUTSCRIPTPUBKEY OP_1 OP_EQUALVERIFY0 OP_INSPECTOUTPUTSCRIPTPUBKEY OP_1 OP_EQUALVERIFY OP_EQUALVERIFYOP_PUSHCURRENTINPUTINDEX OP_INSPECTINPUTVALUE 0 OP_INSPECTOUTPUTVALUE OP_LESSTHAN64<buyer> OP_CHECKSIGVERIFY <arbitrator> OP_CHECKSIGVERIFY <operator> OP_CHECKSIG<seller> OP_CHECKSIGVERIFY <arbitrator> OP_CHECKSIGVERIFY <operator> OP_CHECKSIG<locktime> OP_CHECKLOCKTIMEVERIFY OP_DROP<seller> OP_CHECKSIGVERIFY <operator> OP_CHECKSIG<sequence> OP_CHECKSEQUENCEVERIFY OP_DROP<buyer> OP_CHECKSIGVERIFY <seller> OP_CHECKSIG<sequence> OP_CHECKSEQUENCEVERIFY OP_DROP<seller> OP_CHECKSIGIntrospector changes
ReadArkadeScriptnow usesscriptlib.DecodeClosure()instead of hardcodingMultisigClosure.Decode(), supporting all closure typesDesign decisions
input_value x basis_points / 10000computed on-chain viaOP_MUL64/OP_DIV64Test coverage (3 test functions, 10 cases)
TestP2PEscrowSellerConfirm— wrong key, fee too low, wrong fee address, wrong buyer destination, validTestP2PEscrowBuyerRefund— wrong key rejection, wrong seller destination, valid buyer CANCELTestP2PEscrowTopupPath— wrong scriptPubKey, same-value rejectionTest plan
go test ./test/ -run TestP2PEscrow -v— all 3 test functions pass