feat(core): delivery protection v2 — OrConditions + PaymentIndexRecorder - #105
Conversation
Release: OrCondition([SAC(arbiter), PayerCondition]) — arbiter or payer
RefundInEscrow: OrCondition([EscrowPeriod, ReceiverCondition, SAC(arbiter)])
— after escrow window, or receiver, or arbiter (immediate on FAIL)
AuthorizeRecorder: RecorderCombinator([EscrowPeriod, PaymentIndexRecorder])
— graceful fallback to EscrowPeriod-only when PaymentIndexRecorder not deployed
Config additions:
- RecorderSingletonAddresses (paymentIndexRecorder)
- recorderCombinatorCodehash (Hex)
- getRecorderSingletons() getter
- Both set to zeroAddress/pad('0x00') until infra deployment
Deploy changes:
- 5 contracts (was 3): EscrowPeriod, SAC(arbiter), OrCondition(release),
OrCondition(refund), Operator (+ RecorderCombinator when PIR available)
- New return fields: releaseConditionAddress, refundInEscrowConditionAddress,
authorizeRecorderAddress, paymentIndexRecorderAddress
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…preset PaymentIndexRecorder deployed on Base Sepolia: 0x3134920b77565767adf9559E747bED01918B0763 RecorderCombinator runtime codehash: 0x489c83194f171a41ed97057e542ffb877d7a787f7888341ee379288f4f02691e Both presets (delivery protection + marketplace) now use RecorderCombinator([EscrowPeriod, PaymentIndexRecorder]) as authorizeRecorder when PaymentIndexRecorder is available. Contracts PR: BackTrackCo/x402r-contracts#32 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both marketplace and delivery protection presets now deploy RecorderCombinator([EscrowPeriod, PaymentIndexRecorder]) as the authorizeRecorder when PaymentIndexRecorder is available. Marketplace deploy function changes: - Existence check for RecorderCombinator - trackDeploy for RecorderCombinator - authorizeRecorderAddress + paymentIndexRecorderAddress in return type - Early return includes combinator in existing deployments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This reverts commit 2984c0a.
Reverts marketplace preview/deploy RecorderCombinator additions. The marketplace preset keeps its existing authorizeRecorder: escrowPeriodAddress. Marketplace PaymentIndexRecorder support will be a separate PR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… assertions PaymentIndexRecorder hasn't been deployed yet — config had a pre-computed CREATE3 address which caused RecorderCombinator to be deployed referencing a non-existent contract, breaking authorize flow on fork tests. - Config: paymentIndexRecorder → zeroAddress (graceful degradation path) - Unit tests: fix orCondAddr checksum (EIP-55 validation in encodeFunctionData) - Fork tests: update preview assertions for OrCondition addresses, deploy counts from 3 → 5 (escrowPeriod + SAC + 2 OrConditions + operator) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Cover the hasCombinator branches in delivery protection preset: - preview: computeRecorderCombinatorAddress path - deploy fresh: 6 contracts (includes RecorderCombinator) - deploy existing: 6 existing when all already deployed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PaymentIndexRecorder is now deployed. Set config address to 0x3134920b77565767adf9559E747bED01918B0763 and update all tests to expect 6 contracts (with RecorderCombinator) as the default path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PaymentIndexRecorder was deployed at block 39_626_007 on Base Sepolia. The previous fork block (39_413_000) predated this, causing the RecorderCombinator to call an empty address during authorize. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SDK ReviewFound 7 issues (reviewed: tests, conventions, dead code, SDK design):
Generated with Claude Code using review-sdk skill |
Add receipt status check and codehash logging to diagnose why authorize reverts with the new RecorderCombinator config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Replace mutable idx counter with named entries + existsMap pattern 2. Remove recorderCombinatorCodehash from X402rChainConfig (keep as module export — same on every chain, not a per-chain address) 3. Add payer release fork test (OrCondition payer leg) 4. Hoist getConditionSingletons to top of deployDeliveryProtectionOperator 5. Add missing address assertions in fork deploy test 6. Add operatorConfig.authorizeRecorder assertions in unit tests 7. Add authorizedCodehash default + override test coverage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
All 7 fixed in a9fdbec:
|
Diagnostics revealed the codehash mismatch causing authorize reverts: - Config had 0x489c... (from local forge script against updated source) - On-chain factory deploys instances with codehash 0xeb39... The factory was deployed with an older RecorderCombinator version. Updated config to the on-chain value verified via EXTCODEHASH. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Protocol Analysis: Delivery Protection v2Deep review of the condition matrix changes and protocol implications. Condition Matrix (v1 → v2)
What works well
Concerns to discuss1. Arbiter trust surface doubled
A compromised/buggy arbiter now controls fund movement in both directions. A rogue arbiter could systematically refund all payments (denying receiver revenue) or release all payments (denying payer refunds). Probably acceptable for the EigenCloud verifiable arbiter model, but this is a trust assumption change worth documenting. 2. Payer self-release partially undermines the protection guarantee v1: arbiter is the sole quality gate — every payment goes through quality inspection. In the HTTP 402 flow where the payer is automated, client-side code could skip quality verification entirely. The "delivery protection" becomes opt-in from the payer's perspective rather than enforced. Is this intentional? 3. Both Missing fork test coverageThe 3 most important new paths have no fork tests:
The arbiter-immediate-refund is the core motivation for this PR and has no fork test. Recommended additions:
Generated with Claude Code using review-sdk skill |
…IndexRecorder - Arbiter immediate refund: refundInEscrow without time-forwarding - Receiver voluntary refund: merchant refunds during escrow - PaymentIndexRecorder: verify on-chain payment indexing via getPayerPayments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Protocol Analysis ResponseConcerns1. Arbiter trust surface doubled — The escrow period for delivery protection is 2 minutes. A rogue arbiter refunding during that window is operationally equivalent to the escrow expiring — the payer gets funds back either way. The arbiter shortcut just removes the 2-minute delay for legitimate FAIL verdicts. In practice the arbiter verdict lands in <5s, well before any receiver could capture. The EigenCloud arbiter is verifiable compute (deterministic + attestable) so this is a low-risk tradeoff for a significant UX win. 2. Payer self-release — Intentional. The HTTP 402 flow has two actors: automated client and automated arbiter. Payer self-release handles the case where the payer inspects content client-side and is satisfied before the arbiter round-trip completes. The protection guarantee is that someone (arbiter or payer) validates quality, not that the arbiter is the sole gate. 3. Missing fork testsAdded in 77d00a0:
|
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
049cf22 to
9ee6003
Compare
PaymentIndexRecorder redeployed at 0xA9D08f46... with correct authorizedCodehash (0xeb3902c8...). Previous instance at 0x3134920b... was deployed with stale codehash (0x489c8319...) that didn't match the on-chain RecorderCombinator runtime codehash. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New PaymentIndexRecorder at 0xA9D08f46... deployed at block 39_632_073. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ploy Redeployed PaymentIndexRecorder with correct IRecorder interface (4-param record() with bytes calldata data). Previous deploys were compiled from a branch missing the data passthrough commit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Configurability gapThe OrCondition compositions are hardcoded — deployers cannot opt out of individual legs:
A deployer who wants arbiter-only release (v1 behavior) or doesn't want receiver refund during escrow has no option — they'd have to skip the preset entirely. Suggestion: Add optional flags to /** Allow payer to release without arbiter (default: true) */
allowPayerRelease?: boolean
/** Allow receiver to refund during escrow (default: true) */
allowReceiverRefund?: boolean
/** Allow arbiter to refund immediately during escrow (default: true) */
allowArbiterRefund?: booleanWhen Generated with Claude Code using review-sdk skill |
Only arbiter immediate refund is configurable — payer self-release and receiver voluntary refund are core to the delivery protection model. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Added Payer self-release and receiver voluntary refund are not optional — they're core to the delivery protection model:
|
|
Follow-up: flipped |
## Summary - Flips `allowArbiterRefund` default from `true` to `false` — arbiter immediate refund is now opt-in - Deployers must explicitly pass `allowArbiterRefund: true` to grant arbiter refund power during escrow - Makes the trust surface explicit rather than implicit Follows up on #105 review feedback (configurability gap). ## Test plan - [x] 341 unit tests passing - [x] Typecheck passes - [x] Biome check passes - [x] Fork test updated to explicitly opt in (`allowArbiterRefund: true`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Updates
deployDeliveryProtectionOperatorwith proper access control and payment indexing.New Operator Config
SAC(arbiter)onlyOrCondition([SAC(arbiter), PayerCondition])EscrowPeriodonlyOrCondition([EscrowPeriod, ReceiverCondition, SAC(arbiter)])EscrowPeriodonlyRecorderCombinator([EscrowPeriod, PaymentIndexRecorder])Why
refundInEscrow()without waiting for escrow periodChanges
config/index.ts: AddRecorderSingletonAddresses(paymentIndexRecorder: 0x3134...),recorderCombinatorCodehash,getRecorderSingletons()deploy/presets.ts: Rewrite preview + deploy with batched OrCondition/RecorderCombinator computationpaymentIndexRecorderAddress: zeroAddress, 5 contracts) pathsDeploy count
6 contracts (was 3): EscrowPeriod, SAC(arbiter), OrCondition(release), OrCondition(refund), RecorderCombinator, Operator.
Test plan
🤖 Generated with Claude Code