From 9a508f4639d98de02cfd7e9c19941a6995973abb Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 17 Mar 2026 01:37:12 +0400 Subject: [PATCH 01/33] docs(tip): draft TIP-1034 for enshrined MPP channels Amp-Thread-ID: https://ampcode.com/threads/T-019cf889-ace3-7128-8bd1-04d5d8fb2783 --- tips/tip-1034.md | 235 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tips/tip-1034.md diff --git a/tips/tip-1034.md b/tips/tip-1034.md new file mode 100644 index 0000000000..8f149d4ddf --- /dev/null +++ b/tips/tip-1034.md @@ -0,0 +1,235 @@ +--- +id: TIP-1034 +title: Enshrined MPP Channels Precompile +description: Enshrines MPP auth-and-capture payment channels as a Tempo precompile with explicit expiry and partial capture semantics. +authors: Tanishk Goyal +status: Draft +related: TIP-20, TIP-1000, mpp-specs (tempo session/auth-capture) +protocolVersion: TBD +--- + +# TIP-1034: Enshrined MPP Channels Precompile + +## Abstract + +This TIP enshrines MPP payment channels in Tempo as a native precompile. The precompile preserves the existing unidirectional channel model (`open`, `settle`, `topUp`, `requestClose`, `withdraw`) while adding two auth-and-capture requirements from recent design discussions: explicit authorization expiry and explicit partial capture on final close. + +The goal is to provide a protocol-native escrow primitive that supports single capture, multi-capture, incremental authorization, void, and delegated signing flows with lower friction than a pure Solidity deployment. + +## Motivation + +Current MPP channel behavior exists as a Solidity reference contract (`TempoStreamChannel`). It proves the core flow but leaves key production constraints unsolved for Tempo-native auth-and-capture: + +1. `open` and `topUp` require token `approve` + `transferFrom` UX. +2. Channel state is contract storage that remains after finalization unless manually pruned. +3. There is no explicit channel expiry field for authorization-hold use cases. +4. Partial capture at close should be explicit (`capture <= authorized`), not implicit in client-side voucher selection. + +From product and partner feedback (Shopify/Stripe-style auth-and-capture), Tempo needs a first-class escrow primitive with explicit expiry and clean close semantics that can be composed directly by SDKs, gateways, and merchants. + +--- + +# Specification + +## Precompile Address + +This TIP introduces a new system precompile, referred to as `MPP_CHANNEL_PRECOMPILE` in this document. The final canonical address is assigned at implementation time and activated in the protocol version listed in this TIP. + +## Channel Model + +Channels are unidirectional (`payer -> payee`) and token-specific. + +```solidity +struct Channel { + address payer; + address payee; + address token; + address authorizedSigner; // 0 => payer signs vouchers + uint128 deposit; // total authorized funds escrowed + uint128 settled; // total amount already paid to payee + uint64 expiresAt; // hard expiry for capture authorization + uint64 closeRequestedAt; // payer-initiated close timer start (0 if not requested) + bool finalized; +} +``` + +`channelId` is deterministic and MUST include chain and precompile domain separation: + +```solidity +channelId = keccak256( + abi.encode( + payer, + payee, + token, + salt, + authorizedSigner, + MPP_CHANNEL_PRECOMPILE, + block.chainid + ) +); +``` + +## Interface + +```solidity +interface IMPPChannel { + error ChannelAlreadyExists(); + error ChannelNotFound(); + error ChannelFinalized(); + error NotPayer(); + error NotPayee(); + error InvalidPayee(); + error InvalidExpiry(); + error ChannelExpired(); + error InvalidSignature(); + error AmountExceedsDeposit(); + error AmountNotIncreasing(); + error CaptureAmountInvalid(); + error CloseNotReady(); + error DepositOverflow(); + error TransferFailed(); + + event ChannelOpened( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + address token, + address authorizedSigner, + bytes32 salt, + uint128 deposit, + uint64 expiresAt + ); + + event Settled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint128 cumulativeAuthorized, + uint128 deltaPaid, + uint128 newSettled + ); + + event TopUp( + bytes32 indexed channelId, + uint128 additionalDeposit, + uint128 newDeposit, + uint64 newExpiresAt + ); + + event CloseRequested(bytes32 indexed channelId, uint64 closeGraceEnd); + + event ChannelClosed( + bytes32 indexed channelId, + uint128 finalCaptured, + uint128 refundedToPayer + ); + + function open( + address payee, + address token, + uint128 deposit, + bytes32 salt, + address authorizedSigner, + uint64 expiresAt + ) external returns (bytes32 channelId); + + function settle( + bytes32 channelId, + uint128 cumulativeAuthorized, + bytes calldata signature + ) external; + + function topUp( + bytes32 channelId, + uint128 additionalDeposit, + uint64 newExpiresAt + ) external; + + /// @notice Final close with explicit partial capture. + /// @dev captureAmount MUST satisfy: settled <= captureAmount <= cumulativeAuthorized. + function close( + bytes32 channelId, + uint128 cumulativeAuthorized, + uint128 captureAmount, + bytes calldata signature + ) external; + + function requestClose(bytes32 channelId) external; + function withdraw(bytes32 channelId) external; + function getChannel(bytes32 channelId) external view returns (Channel memory); +} +``` + +## Voucher Semantics + +Voucher signatures authorize a maximum cumulative amount: + +```solidity +Voucher(bytes32 channelId,uint128 cumulativeAuthorized) +``` + +Rules: + +1. `cumulativeAuthorized` MUST be `>= settled` and `<= deposit`. +2. `settle` moves `delta = cumulativeAuthorized - settled` to payee and sets `settled = cumulativeAuthorized`. +3. `close` allows `captureAmount <= cumulativeAuthorized` so the payee can do explicit partial capture at finalization time. +4. Signer MUST be `authorizedSigner` when set, otherwise `payer`. + +## Expiry Semantics + +`expiresAt` is an explicit authorization deadline for capture: + +1. `open` MUST revert if `expiresAt <= block.timestamp`. +2. `settle` and `close` that increase `settled` MUST revert after expiry. +3. After expiry, payer MAY call `withdraw` immediately (no grace timer required). +4. `topUp` MAY extend expiry with `newExpiresAt`; when non-zero, it MUST be strictly greater than the current block timestamp. + +This provides canonical auth-hold behavior (`authorize now, capture before deadline, void after deadline`). + +## Close And Void Flows + +1. **Payee final close:** `close(channelId, cumulativeAuthorized, captureAmount, signature)`. +2. **Payer forced close:** `requestClose` starts grace timer; `withdraw` succeeds after timer. +3. **Natural expiry void:** once expired, payer can withdraw remainder. +4. **Top-up cancelation:** successful `topUp` clears an active close request. + +## Enshrined Execution Rules + +The precompile MUST enforce the following protocol-level behavior: + +1. `open` and `topUp` escrow transfers use TIP-20 system movement (equivalent to `systemTransferFrom`) so users do not need an explicit `approve` transaction. +2. On finalization, channel storage MUST be fully deleted (or tombstoned with equivalent trie-pruning semantics) so completed channels do not accumulate permanent state. +3. Calls to this precompile remain regular EVM calls, but builders MAY classify these selectors as payment-lane eligible in admission policy. + +## Supported Auth-And-Capture Verbs + +This single interface supports: + +1. **Single capture:** `open` then one `close`. +2. **Multi-capture:** multiple `settle` calls then `close`. +3. **Incremental auth:** `topUp` (optional expiry extension). +4. **Void:** payer `withdraw` after forced close timer or expiry. +5. **Delegation:** vouchers signed by `authorizedSigner`. + +## Out Of Scope + +The following are explicitly out of scope for this TIP revision: + +1. Rewards on escrowed channel balances. +2. Multi-party channels. +3. Generalized conditional execution language inside the channel precompile. + +--- + +# Invariants + +1. `settled <= deposit` MUST hold in all reachable states. +2. `settled` is monotonic and can never decrease. +3. Any successful capture (`settle` or `close`) MUST be authorized by a valid voucher signature from the expected signer. +4. In `close`, `captureAmount` MUST satisfy `previousSettled <= captureAmount <= cumulativeAuthorized <= deposit`. +5. No operation may increase captured amount after `expiresAt`. +6. Only payer can `topUp`, `requestClose`, and `withdraw`. +7. Only payee can `settle` and `close`. +8. Once finalized, all state-changing methods MUST revert for that channel. +9. Finalization must conserve funds: `finalCaptured + refundedToPayer == deposit`. +10. Finalized channels MUST be removable from active state storage. From ceddc4eee9b8d9d7dd2381edff889bf1d828b77c Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 17 Mar 2026 01:44:47 +0400 Subject: [PATCH 02/33] docs(tip): align TIP-1034 with PR3136 and payment-lane plan Amp-Thread-ID: https://ampcode.com/threads/T-019cf889-ace3-7128-8bd1-04d5d8fb2783 --- tips/tip-1034.md | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 8f149d4ddf..b96b1332a8 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -21,9 +21,13 @@ The goal is to provide a protocol-native escrow primitive that supports single c Current MPP channel behavior exists as a Solidity reference contract (`TempoStreamChannel`). It proves the core flow but leaves key production constraints unsolved for Tempo-native auth-and-capture: 1. `open` and `topUp` require token `approve` + `transferFrom` UX. -2. Channel state is contract storage that remains after finalization unless manually pruned. -3. There is no explicit channel expiry field for authorization-hold use cases. -4. Partial capture at close should be explicit (`capture <= authorized`), not implicit in client-side voucher selection. +2. There is no explicit channel expiry field for authorization-hold use cases. +3. Partial capture at close should be explicit (`capture <= authorized`), not implicit in client-side voucher selection. +4. Channel operations are not currently first-class payment-lane transactions, so under congestion they can be crowded out by general traffic. + +PR #3136 already hardens the Solidity reference contract around tombstoning/finalization and validation guardrails (invalid token and zero-deposit reverts). This TIP treats those semantics as baseline behavior and extends the design with explicit expiry, explicit final partial capture, and lane admission. + +For payment-lane integration, the implementation should follow the existing Tempo lane architecture: transactions classified as payment are exempt from the non-payment `general_gas_limit` check in the payload builder. This TIP adds MPP channel calls to that payment classifier so channel lifecycle operations (`open`, `settle`, `topUp`, `close`, `requestClose`, `withdraw`) continue to make progress during volatile-fee or spam conditions. From product and partner feedback (Shopify/Stripe-style auth-and-capture), Tempo needs a first-class escrow primitive with explicit expiry and clean close semantics that can be composed directly by SDKs, gateways, and merchants. @@ -41,6 +45,8 @@ Channels are unidirectional (`payer -> payee`) and token-specific. ```solidity struct Channel { + bool finalized; // tombstone marker + uint64 closeRequestedAt; // payer-initiated close timer start (0 if not requested) address payer; address payee; address token; @@ -48,8 +54,6 @@ struct Channel { uint128 deposit; // total authorized funds escrowed uint128 settled; // total amount already paid to payee uint64 expiresAt; // hard expiry for capture authorization - uint64 closeRequestedAt; // payer-initiated close timer start (0 if not requested) - bool finalized; } ``` @@ -79,6 +83,8 @@ interface IMPPChannel { error NotPayer(); error NotPayee(); error InvalidPayee(); + error InvalidToken(); + error ZeroDeposit(); error InvalidExpiry(); error ChannelExpired(); error InvalidSignature(); @@ -175,6 +181,16 @@ Rules: 3. `close` allows `captureAmount <= cumulativeAuthorized` so the payee can do explicit partial capture at finalization time. 4. Signer MUST be `authorizedSigner` when set, otherwise `payer`. +## Baseline Validation Semantics (PR #3136 Compatibility) + +The precompile MUST preserve the latest `TempoStreamChannel` interface behavior from PR #3136: + +1. `open` MUST revert with `InvalidToken` when `token` is not a TIP-20 token. +2. `open` MUST revert with `ZeroDeposit` when `deposit == 0`. +3. `topUp` MUST revert with `ZeroDeposit` when `additionalDeposit == 0`. +4. `open` MUST treat tombstoned channels as existing (channel IDs are non-reusable). +5. Finalization MUST clear channel fields and keep `finalized = true` as a tombstone marker. + ## Expiry Semantics `expiresAt` is an explicit authorization deadline for capture: @@ -199,7 +215,16 @@ The precompile MUST enforce the following protocol-level behavior: 1. `open` and `topUp` escrow transfers use TIP-20 system movement (equivalent to `systemTransferFrom`) so users do not need an explicit `approve` transaction. 2. On finalization, channel storage MUST be fully deleted (or tombstoned with equivalent trie-pruning semantics) so completed channels do not accumulate permanent state. -3. Calls to this precompile remain regular EVM calls, but builders MAY classify these selectors as payment-lane eligible in admission policy. +3. Calls to this precompile remain regular EVM calls, and MUST be payment-lane eligible. + +### Payment-Lane Admission Plan + +At activation, implementations SHOULD add MPP channel classification using the same pattern used for TIP-20 payments: + +1. Add a strict channel classifier: `to == MPP_CHANNEL_PRECOMPILE` and selector is one of `{open, settle, topUp, close, requestClose, withdraw}` with exact calldata shape checks. +2. Include that classifier in the transaction pool payment flag (the strict builder/pool path) so DoS-resistant lane admission remains intact. +3. Include the same classifier in the payload builder path that applies `general_gas_limit`, so channel transactions are treated as payment traffic and not dropped as non-payment overflow. +4. Keep consensus-level and builder-level payment classification aligned for fork activation to avoid classification drift. ## Supported Auth-And-Capture Verbs @@ -233,3 +258,6 @@ The following are explicitly out of scope for this TIP revision: 8. Once finalized, all state-changing methods MUST revert for that channel. 9. Finalization must conserve funds: `finalCaptured + refundedToPayer == deposit`. 10. Finalized channels MUST be removable from active state storage. +11. `open` and `topUp` MUST revert when deposit input is zero. +12. `open` MUST revert when `token` is not a TIP-20 token. +13. Finalized/tombstoned channel IDs MUST NOT be reopenable. From f3fd9eb348effc322983c0987b4ff7c51ee24e1f Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 17 Mar 2026 01:50:36 +0400 Subject: [PATCH 03/33] docs(tip): rewrite TIP-1034 around precompile baseline and lane admission Amp-Thread-ID: https://ampcode.com/threads/T-019cf889-ace3-7128-8bd1-04d5d8fb2783 --- tips/tip-1034.md | 224 ++++++++++++++++++++++++----------------------- 1 file changed, 116 insertions(+), 108 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index b96b1332a8..1450c97e69 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -1,7 +1,7 @@ --- id: TIP-1034 title: Enshrined MPP Channels Precompile -description: Enshrines MPP auth-and-capture payment channels as a Tempo precompile with explicit expiry and partial capture semantics. +description: Enshrines MPP payment channels as a Tempo precompile with payment-lane admission and native escrow transfer semantics. authors: Tanishk Goyal status: Draft related: TIP-20, TIP-1000, mpp-specs (tempo session/auth-capture) @@ -12,24 +12,19 @@ protocolVersion: TBD ## Abstract -This TIP enshrines MPP payment channels in Tempo as a native precompile. The precompile preserves the existing unidirectional channel model (`open`, `settle`, `topUp`, `requestClose`, `withdraw`) while adding two auth-and-capture requirements from recent design discussions: explicit authorization expiry and explicit partial capture on final close. +This TIP enshrines MPP payment channels in Tempo as a native precompile. The implementation baseline follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow. -The goal is to provide a protocol-native escrow primitive that supports single capture, multi-capture, incremental authorization, void, and delegated signing flows with lower friction than a pure Solidity deployment. +The precompile is introduced to reduce execution overhead, remove the separate `approve` UX via native escrow movement, and make channel operations first-class payment-lane traffic under congestion. ## Motivation -Current MPP channel behavior exists as a Solidity reference contract (`TempoStreamChannel`). It proves the core flow but leaves key production constraints unsolved for Tempo-native auth-and-capture: +MPP channels are currently specified as a Solidity contract reference implementation. Enshrining that behavior as a precompile is motivated by three protocol-level goals: -1. `open` and `topUp` require token `approve` + `transferFrom` UX. -2. There is no explicit channel expiry field for authorization-hold use cases. -3. Partial capture at close should be explicit (`capture <= authorized`), not implicit in client-side voucher selection. -4. Channel operations are not currently first-class payment-lane transactions, so under congestion they can be crowded out by general traffic. +1. **Efficiency**: Channel operations become native precompile execution instead of generic contract execution, reducing overhead and making gas behavior more predictable for high-frequency payment flows. +2. **Payment-Lane Access**: Channel operations become payment-lane transactions, so they are not throttled by the non-payment `general_gas_limit` path during block contention. +3. **Approve-less Escrow UX**: `open` and `topUp` can escrow TIP-20 funds through native system transfer (`systemTransferFrom` semantics), removing the extra `approve` transaction from the user flow. -PR #3136 already hardens the Solidity reference contract around tombstoning/finalization and validation guardrails (invalid token and zero-deposit reverts). This TIP treats those semantics as baseline behavior and extends the design with explicit expiry, explicit final partial capture, and lane admission. - -For payment-lane integration, the implementation should follow the existing Tempo lane architecture: transactions classified as payment are exempt from the non-payment `general_gas_limit` check in the payload builder. This TIP adds MPP channel calls to that payment classifier so channel lifecycle operations (`open`, `settle`, `topUp`, `close`, `requestClose`, `withdraw`) continue to make progress during volatile-fee or spam conditions. - -From product and partner feedback (Shopify/Stripe-style auth-and-capture), Tempo needs a first-class escrow primitive with explicit expiry and clean close semantics that can be composed directly by SDKs, gateways, and merchants. +This produces a simpler and more reliable path for session and auth/capture style integrations without changing the core channel model developers already use. --- @@ -39,25 +34,29 @@ From product and partner feedback (Shopify/Stripe-style auth-and-capture), Tempo This TIP introduces a new system precompile, referred to as `MPP_CHANNEL_PRECOMPILE` in this document. The final canonical address is assigned at implementation time and activated in the protocol version listed in this TIP. -## Channel Model +## Baseline Implementation + +Unless explicitly changed in `Changes Proposed`, the precompile MUST match the reference implementation behavior and interface in: + +1. `tips/ref-impls/src/interfaces/ITempoStreamChannel.sol` +2. `tips/ref-impls/src/TempoStreamChannel.sol` Channels are unidirectional (`payer -> payee`) and token-specific. ```solidity struct Channel { - bool finalized; // tombstone marker - uint64 closeRequestedAt; // payer-initiated close timer start (0 if not requested) + bool finalized; + uint64 closeRequestedAt; address payer; address payee; address token; - address authorizedSigner; // 0 => payer signs vouchers - uint128 deposit; // total authorized funds escrowed - uint128 settled; // total amount already paid to payee - uint64 expiresAt; // hard expiry for capture authorization + address authorizedSigner; + uint128 deposit; + uint128 settled; } ``` -`channelId` is deterministic and MUST include chain and precompile domain separation: +`channelId` MUST use the same deterministic domain-separated construction used in the reference implementation: ```solidity channelId = keccak256( @@ -73,7 +72,7 @@ channelId = keccak256( ); ``` -## Interface +The baseline callable interface is: ```solidity interface IMPPChannel { @@ -85,12 +84,9 @@ interface IMPPChannel { error InvalidPayee(); error InvalidToken(); error ZeroDeposit(); - error InvalidExpiry(); - error ChannelExpired(); error InvalidSignature(); error AmountExceedsDeposit(); error AmountNotIncreasing(); - error CaptureAmountInvalid(); error CloseNotReady(); error DepositOverflow(); error TransferFailed(); @@ -102,63 +98,70 @@ interface IMPPChannel { address token, address authorizedSigner, bytes32 salt, - uint128 deposit, - uint64 expiresAt + uint128 deposit ); event Settled( bytes32 indexed channelId, address indexed payer, address indexed payee, - uint128 cumulativeAuthorized, + uint128 cumulativeAmount, uint128 deltaPaid, uint128 newSettled ); event TopUp( bytes32 indexed channelId, + address indexed payer, + address indexed payee, uint128 additionalDeposit, - uint128 newDeposit, - uint64 newExpiresAt + uint128 newDeposit ); - event CloseRequested(bytes32 indexed channelId, uint64 closeGraceEnd); + event CloseRequested( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint256 closeGraceEnd + ); event ChannelClosed( bytes32 indexed channelId, - uint128 finalCaptured, + address indexed payer, + address indexed payee, + uint128 settledToPayee, uint128 refundedToPayer ); + event CloseRequestCancelled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee + ); + + event ChannelExpired( + bytes32 indexed channelId, + address indexed payer, + address indexed payee + ); + function open( address payee, address token, uint128 deposit, bytes32 salt, - address authorizedSigner, - uint64 expiresAt + address authorizedSigner ) external returns (bytes32 channelId); function settle( bytes32 channelId, - uint128 cumulativeAuthorized, + uint128 cumulativeAmount, bytes calldata signature ) external; - function topUp( - bytes32 channelId, - uint128 additionalDeposit, - uint64 newExpiresAt - ) external; + function topUp(bytes32 channelId, uint256 additionalDeposit) external; - /// @notice Final close with explicit partial capture. - /// @dev captureAmount MUST satisfy: settled <= captureAmount <= cumulativeAuthorized. - function close( - bytes32 channelId, - uint128 cumulativeAuthorized, - uint128 captureAmount, - bytes calldata signature - ) external; + function close(bytes32 channelId, uint128 cumulativeAmount, bytes calldata signature) external; function requestClose(bytes32 channelId) external; function withdraw(bytes32 channelId) external; @@ -166,83 +169,90 @@ interface IMPPChannel { } ``` -## Voucher Semantics +## Baseline Channel Semantics -Voucher signatures authorize a maximum cumulative amount: +Voucher signatures use the same EIP-712 voucher shape: ```solidity -Voucher(bytes32 channelId,uint128 cumulativeAuthorized) +Voucher(bytes32 channelId,uint128 cumulativeAmount) ``` -Rules: +Baseline execution rules: + +1. `settle` transfers only the positive delta from prior `settled` to the new signed cumulative amount. +2. `close` settles any final voucher delta and refunds the remainder to the payer. +3. `requestClose` starts the close grace timer for payer-driven withdrawal. +4. `withdraw` succeeds only after the grace timer in baseline behavior. +5. Signer MUST be `authorizedSigner` when set, otherwise `payer`. + +## Native Escrow Movement + +In this precompile, escrow transfers MUST use system TIP-20 movement semantics equivalent to `systemTransferFrom`. -1. `cumulativeAuthorized` MUST be `>= settled` and `<= deposit`. -2. `settle` moves `delta = cumulativeAuthorized - settled` to payee and sets `settled = cumulativeAuthorized`. -3. `close` allows `captureAmount <= cumulativeAuthorized` so the payee can do explicit partial capture at finalization time. -4. Signer MUST be `authorizedSigner` when set, otherwise `payer`. +Required behavior: -## Baseline Validation Semantics (PR #3136 Compatibility) +1. `open` escrows `deposit` from `payer` to channel escrow state without requiring a prior user `approve` transaction. +2. `topUp` escrows `additionalDeposit` the same way. +3. `settle`, `close`, and `withdraw` payout paths continue to transfer TIP-20 value using protocol-native token movement. -The precompile MUST preserve the latest `TempoStreamChannel` interface behavior from PR #3136: +## Payment-Lane Integration (Mandatory) -1. `open` MUST revert with `InvalidToken` when `token` is not a TIP-20 token. -2. `open` MUST revert with `ZeroDeposit` when `deposit == 0`. -3. `topUp` MUST revert with `ZeroDeposit` when `additionalDeposit == 0`. -4. `open` MUST treat tombstoned channels as existing (channel IDs are non-reusable). -5. Finalization MUST clear channel fields and keep `finalized = true` as a tombstone marker. +MPP channel operations MUST be treated as payment-lane transactions in consensus classification, pool admission, and payload building. -## Expiry Semantics +### Classification Rules -`expiresAt` is an explicit authorization deadline for capture: +Implementations MUST define a strict classifier `is_mpp_channel_payment(to, input)` that returns true iff: -1. `open` MUST revert if `expiresAt <= block.timestamp`. -2. `settle` and `close` that increase `settled` MUST revert after expiry. -3. After expiry, payer MAY call `withdraw` immediately (no grace timer required). -4. `topUp` MAY extend expiry with `newExpiresAt`; when non-zero, it MUST be strictly greater than the current block timestamp. +1. `to == MPP_CHANNEL_PRECOMPILE`. +2. `input` selector is one of `{open, settle, topUp, close, requestClose, withdraw}`. +3. Calldata length/encoding is valid for that selector. -This provides canonical auth-hold behavior (`authorize now, capture before deadline, void after deadline`). +For AA transactions, payment classification MUST require every call to satisfy either TIP-20 strict payment classification or `is_mpp_channel_payment`. -## Close And Void Flows +### Required Integration Points -1. **Payee final close:** `close(channelId, cumulativeAuthorized, captureAmount, signature)`. -2. **Payer forced close:** `requestClose` starts grace timer; `withdraw` succeeds after timer. -3. **Natural expiry void:** once expired, payer can withdraw remainder. -4. **Top-up cancelation:** successful `topUp` clears an active close request. +1. The consensus-level payment classifier (`is_payment`) MUST include MPP channel calls. +2. The strict builder/pool classifier (`is_payment_v2`) MUST include `is_mpp_channel_payment`. +3. The transaction pool payment flag MUST be computed from the strict classifier, so MPP channel calls are admitted in the payment lane path. +4. The payload builder non-payment gate (`general_gas_limit` enforcement) MUST treat MPP channel calls as payment, so they are not rejected by the non-payment overflow path. -## Enshrined Execution Rules +### What This Means Operationally -The precompile MUST enforce the following protocol-level behavior: +With this integration, channel lifecycle calls consume payment-lane capacity rather than non-payment capacity. Under high congestion, these transactions continue to compete in the same lane as other payment traffic instead of being excluded by non-payment limits. -1. `open` and `topUp` escrow transfers use TIP-20 system movement (equivalent to `systemTransferFrom`) so users do not need an explicit `approve` transaction. -2. On finalization, channel storage MUST be fully deleted (or tombstoned with equivalent trie-pruning semantics) so completed channels do not accumulate permanent state. -3. Calls to this precompile remain regular EVM calls, and MUST be payment-lane eligible. +## Changes Proposed -### Payment-Lane Admission Plan +This section lists behavior changes beyond the baseline reference implementation. -At activation, implementations SHOULD add MPP channel classification using the same pattern used for TIP-20 payments: +### Change 1: Explicit Authorization Expiry -1. Add a strict channel classifier: `to == MPP_CHANNEL_PRECOMPILE` and selector is one of `{open, settle, topUp, close, requestClose, withdraw}` with exact calldata shape checks. -2. Include that classifier in the transaction pool payment flag (the strict builder/pool path) so DoS-resistant lane admission remains intact. -3. Include the same classifier in the payload builder path that applies `general_gas_limit`, so channel transactions are treated as payment traffic and not dropped as non-payment overflow. -4. Keep consensus-level and builder-level payment classification aligned for fork activation to avoid classification drift. +Add `expiresAt` to channel state and enforce authorization expiry semantics: -## Supported Auth-And-Capture Verbs +1. `open` requires a valid future `expiresAt`. +2. `settle` and `close` that increase captured amount MUST fail after expiry. +3. After expiry, payer MAY reclaim remaining funds via `withdraw` without waiting for close grace. +4. `topUp` MAY extend expiry by setting a new future deadline. -This single interface supports: +### Change 2: Explicit Final Partial Capture -1. **Single capture:** `open` then one `close`. -2. **Multi-capture:** multiple `settle` calls then `close`. -3. **Incremental auth:** `topUp` (optional expiry extension). -4. **Void:** payer `withdraw` after forced close timer or expiry. -5. **Delegation:** vouchers signed by `authorizedSigner`. +Extend `close` to make final partial capture explicit: -## Out Of Scope +```solidity +function close( + bytes32 channelId, + uint128 cumulativeAmount, + uint128 captureAmount, + bytes calldata signature +) external; +``` + +Rules: -The following are explicitly out of scope for this TIP revision: +1. `captureAmount` MUST satisfy `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. +2. Signature still authorizes `cumulativeAmount` (the upper bound). +3. `captureAmount` determines final payee payout, with `deposit - captureAmount` refunded to payer. -1. Rewards on escrowed channel balances. -2. Multi-party channels. -3. Generalized conditional execution language inside the channel precompile. +This keeps cumulative vouchers while making “authorize up to X, capture Y” explicit on finalization. --- @@ -251,13 +261,11 @@ The following are explicitly out of scope for this TIP revision: 1. `settled <= deposit` MUST hold in all reachable states. 2. `settled` is monotonic and can never decrease. 3. Any successful capture (`settle` or `close`) MUST be authorized by a valid voucher signature from the expected signer. -4. In `close`, `captureAmount` MUST satisfy `previousSettled <= captureAmount <= cumulativeAuthorized <= deposit`. -5. No operation may increase captured amount after `expiresAt`. -6. Only payer can `topUp`, `requestClose`, and `withdraw`. -7. Only payee can `settle` and `close`. -8. Once finalized, all state-changing methods MUST revert for that channel. -9. Finalization must conserve funds: `finalCaptured + refundedToPayer == deposit`. -10. Finalized channels MUST be removable from active state storage. -11. `open` and `topUp` MUST revert when deposit input is zero. -12. `open` MUST revert when `token` is not a TIP-20 token. -13. Finalized/tombstoned channel IDs MUST NOT be reopenable. +4. Only payer can `topUp`, `requestClose`, and `withdraw`. +5. Only payee can `settle` and `close`. +6. Once finalized, all state-changing methods MUST revert for that channel. +7. Fund conservation MUST hold at all terminal states. +8. MPP channel calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers. +9. `open` and `topUp` MUST not require a prior user `approve` transaction. +10. If `Changes Proposed` are activated, no capture-increasing operation may succeed past `expiresAt`. +11. If `Changes Proposed` are activated, `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. From 5a3e761c4ca55486b041bd071b94a02dc410f7ae Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 17 Mar 2026 01:56:30 +0400 Subject: [PATCH 04/33] docs(tip): set MPP precompile address in TIP-1034 Amp-Thread-ID: https://ampcode.com/threads/T-019cf889-ace3-7128-8bd1-04d5d8fb2783 --- tips/tip-1034.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 1450c97e69..36fce10ecb 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -32,7 +32,13 @@ This produces a simpler and more reliable path for session and auth/capture styl ## Precompile Address -This TIP introduces a new system precompile, referred to as `MPP_CHANNEL_PRECOMPILE` in this document. The final canonical address is assigned at implementation time and activated in the protocol version listed in this TIP. +This TIP introduces a new system precompile at: + +```solidity +address constant MPP_CHANNEL_PRECOMPILE = 0x4D50500000000000000000000000000000000000; +``` + +`MPP_CHANNEL_PRECOMPILE` MUST refer to this address throughout this specification. ## Baseline Implementation From 83b6a8025d48965354c7845faf1196a1498790b1 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 17 Mar 2026 01:59:11 +0400 Subject: [PATCH 05/33] docs(tip): integrate final struct and inline channel changes Amp-Thread-ID: https://ampcode.com/threads/T-019cf889-ace3-7128-8bd1-04d5d8fb2783 --- tips/tip-1034.md | 100 ++++++++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 54 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 36fce10ecb..4bb7073d2a 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -12,7 +12,7 @@ protocolVersion: TBD ## Abstract -This TIP enshrines MPP payment channels in Tempo as a native precompile. The implementation baseline follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow. +This TIP enshrines MPP payment channels in Tempo as a native precompile. The implementation follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow, with expiry and explicit partial-capture updates defined in this spec. The precompile is introduced to reduce execution overhead, remove the separate `approve` UX via native escrow movement, and make channel operations first-class payment-lane traffic under congestion. @@ -40,21 +40,26 @@ address constant MPP_CHANNEL_PRECOMPILE = 0x4D5050000000000000000000000000000000 `MPP_CHANNEL_PRECOMPILE` MUST refer to this address throughout this specification. -## Baseline Implementation +## Implementation Details -Unless explicitly changed in `Changes Proposed`, the precompile MUST match the reference implementation behavior and interface in: +This precompile MUST follow the current reference implementation in: 1. `tips/ref-impls/src/interfaces/ITempoStreamChannel.sol` 2. `tips/ref-impls/src/TempoStreamChannel.sol` +with updates integrated directly in the sections below (expiry, explicit partial capture, native escrow movement, and payment-lane integration). + Channels are unidirectional (`payer -> payee`) and token-specific. +### Channel State Layout + ```solidity struct Channel { bool finalized; uint64 closeRequestedAt; address payer; address payee; + uint64 expiresAt; address token; address authorizedSigner; uint128 deposit; @@ -62,7 +67,9 @@ struct Channel { } ``` -`channelId` MUST use the same deterministic domain-separated construction used in the reference implementation: +Compared to the reference implementation, this adds `expiresAt` and places it after `payee` for tight packing. This layout uses 5 storage slots total. + +`channelId` MUST use the deterministic domain-separated construction from the reference implementation: ```solidity channelId = keccak256( @@ -78,7 +85,7 @@ channelId = keccak256( ); ``` -The baseline callable interface is: +### Interface ```solidity interface IMPPChannel { @@ -90,9 +97,12 @@ interface IMPPChannel { error InvalidPayee(); error InvalidToken(); error ZeroDeposit(); + error InvalidExpiry(); + error ChannelExpired(); error InvalidSignature(); error AmountExceedsDeposit(); error AmountNotIncreasing(); + error CaptureAmountInvalid(); error CloseNotReady(); error DepositOverflow(); error TransferFailed(); @@ -104,7 +114,8 @@ interface IMPPChannel { address token, address authorizedSigner, bytes32 salt, - uint128 deposit + uint128 deposit, + uint64 expiresAt ); event Settled( @@ -121,7 +132,8 @@ interface IMPPChannel { address indexed payer, address indexed payee, uint128 additionalDeposit, - uint128 newDeposit + uint128 newDeposit, + uint64 newExpiresAt ); event CloseRequested( @@ -156,7 +168,8 @@ interface IMPPChannel { address token, uint128 deposit, bytes32 salt, - address authorizedSigner + address authorizedSigner, + uint64 expiresAt ) external returns (bytes32 channelId); function settle( @@ -165,9 +178,18 @@ interface IMPPChannel { bytes calldata signature ) external; - function topUp(bytes32 channelId, uint256 additionalDeposit) external; + function topUp( + bytes32 channelId, + uint256 additionalDeposit, + uint64 newExpiresAt + ) external; - function close(bytes32 channelId, uint128 cumulativeAmount, bytes calldata signature) external; + function close( + bytes32 channelId, + uint128 cumulativeAmount, + uint128 captureAmount, + bytes calldata signature + ) external; function requestClose(bytes32 channelId) external; function withdraw(bytes32 channelId) external; @@ -175,21 +197,25 @@ interface IMPPChannel { } ``` -## Baseline Channel Semantics +Compared to the reference interface, this adds `expiresAt` to channel lifecycle (`open`, `topUp`) and adds explicit final `captureAmount` to `close`. + +### Execution Semantics -Voucher signatures use the same EIP-712 voucher shape: +Voucher signatures keep the reference EIP-712 shape: ```solidity Voucher(bytes32 channelId,uint128 cumulativeAmount) ``` -Baseline execution rules: +Execution semantics are the same as the reference implementation except: -1. `settle` transfers only the positive delta from prior `settled` to the new signed cumulative amount. -2. `close` settles any final voucher delta and refunds the remainder to the payer. -3. `requestClose` starts the close grace timer for payer-driven withdrawal. -4. `withdraw` succeeds only after the grace timer in baseline behavior. -5. Signer MUST be `authorizedSigner` when set, otherwise `payer`. +1. `open` MUST validate `expiresAt > block.timestamp` and persist `expiresAt`. +2. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST be strictly in the future. +3. `settle` and `close` MUST reject any capture-increasing action after `expiresAt`. +4. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. +5. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. +6. `withdraw` MUST be allowed after close grace OR after expiry. +7. Signer MUST be `authorizedSigner` when set, otherwise `payer`. ## Native Escrow Movement @@ -226,40 +252,6 @@ For AA transactions, payment classification MUST require every call to satisfy e With this integration, channel lifecycle calls consume payment-lane capacity rather than non-payment capacity. Under high congestion, these transactions continue to compete in the same lane as other payment traffic instead of being excluded by non-payment limits. -## Changes Proposed - -This section lists behavior changes beyond the baseline reference implementation. - -### Change 1: Explicit Authorization Expiry - -Add `expiresAt` to channel state and enforce authorization expiry semantics: - -1. `open` requires a valid future `expiresAt`. -2. `settle` and `close` that increase captured amount MUST fail after expiry. -3. After expiry, payer MAY reclaim remaining funds via `withdraw` without waiting for close grace. -4. `topUp` MAY extend expiry by setting a new future deadline. - -### Change 2: Explicit Final Partial Capture - -Extend `close` to make final partial capture explicit: - -```solidity -function close( - bytes32 channelId, - uint128 cumulativeAmount, - uint128 captureAmount, - bytes calldata signature -) external; -``` - -Rules: - -1. `captureAmount` MUST satisfy `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. -2. Signature still authorizes `cumulativeAmount` (the upper bound). -3. `captureAmount` determines final payee payout, with `deposit - captureAmount` refunded to payer. - -This keeps cumulative vouchers while making “authorize up to X, capture Y” explicit on finalization. - --- # Invariants @@ -273,5 +265,5 @@ This keeps cumulative vouchers while making “authorize up to X, capture Y” e 7. Fund conservation MUST hold at all terminal states. 8. MPP channel calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers. 9. `open` and `topUp` MUST not require a prior user `approve` transaction. -10. If `Changes Proposed` are activated, no capture-increasing operation may succeed past `expiresAt`. -11. If `Changes Proposed` are activated, `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. +10. No capture-increasing operation may succeed past `expiresAt`. +11. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. From 2702b5f72b66c4aa6d0a087e155564baedfe50b2 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 17 Mar 2026 02:03:24 +0400 Subject: [PATCH 06/33] docs(tip): keep only updated interface functions in TIP-1034 Amp-Thread-ID: https://ampcode.com/threads/T-019cf889-ace3-7128-8bd1-04d5d8fb2783 --- tips/tip-1034.md | 88 +++--------------------------------------------- 1 file changed, 4 insertions(+), 84 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 4bb7073d2a..cd11d1bdfe 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -87,82 +87,12 @@ channelId = keccak256( ### Interface -```solidity -interface IMPPChannel { - error ChannelAlreadyExists(); - error ChannelNotFound(); - error ChannelFinalized(); - error NotPayer(); - error NotPayee(); - error InvalidPayee(); - error InvalidToken(); - error ZeroDeposit(); - error InvalidExpiry(); - error ChannelExpired(); - error InvalidSignature(); - error AmountExceedsDeposit(); - error AmountNotIncreasing(); - error CaptureAmountInvalid(); - error CloseNotReady(); - error DepositOverflow(); - error TransferFailed(); - - event ChannelOpened( - bytes32 indexed channelId, - address indexed payer, - address indexed payee, - address token, - address authorizedSigner, - bytes32 salt, - uint128 deposit, - uint64 expiresAt - ); +For unchanged functions, events, and errors, see the reference interface: [`ref-impls/src/interfaces/ITempoStreamChannel.sol`](ref-impls/src/interfaces/ITempoStreamChannel.sol). - event Settled( - bytes32 indexed channelId, - address indexed payer, - address indexed payee, - uint128 cumulativeAmount, - uint128 deltaPaid, - uint128 newSettled - ); - - event TopUp( - bytes32 indexed channelId, - address indexed payer, - address indexed payee, - uint128 additionalDeposit, - uint128 newDeposit, - uint64 newExpiresAt - ); - - event CloseRequested( - bytes32 indexed channelId, - address indexed payer, - address indexed payee, - uint256 closeGraceEnd - ); - - event ChannelClosed( - bytes32 indexed channelId, - address indexed payer, - address indexed payee, - uint128 settledToPayee, - uint128 refundedToPayer - ); - - event CloseRequestCancelled( - bytes32 indexed channelId, - address indexed payer, - address indexed payee - ); - - event ChannelExpired( - bytes32 indexed channelId, - address indexed payer, - address indexed payee - ); +This TIP updates only the following interface functions: +```solidity +interface IMPPChannel { function open( address payee, address token, @@ -172,12 +102,6 @@ interface IMPPChannel { uint64 expiresAt ) external returns (bytes32 channelId); - function settle( - bytes32 channelId, - uint128 cumulativeAmount, - bytes calldata signature - ) external; - function topUp( bytes32 channelId, uint256 additionalDeposit, @@ -190,10 +114,6 @@ interface IMPPChannel { uint128 captureAmount, bytes calldata signature ) external; - - function requestClose(bytes32 channelId) external; - function withdraw(bytes32 channelId) external; - function getChannel(bytes32 channelId) external view returns (Channel memory); } ``` From dae40fa9d25389687ae32af426093c5d0c1ccd50 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 17 Mar 2026 02:05:49 +0400 Subject: [PATCH 07/33] docs(tip): show only updated interface signatures Amp-Thread-ID: https://ampcode.com/threads/T-019cf889-ace3-7128-8bd1-04d5d8fb2783 --- tips/tip-1034.md | 48 +++++++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index cd11d1bdfe..2abfdecfb9 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -92,29 +92,31 @@ For unchanged functions, events, and errors, see the reference interface: [`ref- This TIP updates only the following interface functions: ```solidity -interface IMPPChannel { - function open( - address payee, - address token, - uint128 deposit, - bytes32 salt, - address authorizedSigner, - uint64 expiresAt - ) external returns (bytes32 channelId); - - function topUp( - bytes32 channelId, - uint256 additionalDeposit, - uint64 newExpiresAt - ) external; - - function close( - bytes32 channelId, - uint128 cumulativeAmount, - uint128 captureAmount, - bytes calldata signature - ) external; -} +function open( + address payee, + address token, + uint128 deposit, + bytes32 salt, + address authorizedSigner, + uint64 expiresAt +) external returns (bytes32 channelId); +``` + +```solidity +function topUp( + bytes32 channelId, + uint256 additionalDeposit, + uint64 newExpiresAt +) external; +``` + +```solidity +function close( + bytes32 channelId, + uint128 cumulativeAmount, + uint128 captureAmount, + bytes calldata signature +) external; ``` Compared to the reference interface, this adds `expiresAt` to channel lifecycle (`open`, `topUp`) and adds explicit final `captureAmount` to `close`. From a835bb5827f97711a0805c123a9c3840c257ca55 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 17 Mar 2026 02:07:58 +0400 Subject: [PATCH 08/33] docs(tip): require non-empty AA calls for payment classification Amp-Thread-ID: https://ampcode.com/threads/T-019cf889-ace3-7128-8bd1-04d5d8fb2783 --- tips/tip-1034.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 2abfdecfb9..c56e805a26 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -161,7 +161,12 @@ Implementations MUST define a strict classifier `is_mpp_channel_payment(to, inpu 2. `input` selector is one of `{open, settle, topUp, close, requestClose, withdraw}`. 3. Calldata length/encoding is valid for that selector. -For AA transactions, payment classification MUST require every call to satisfy either TIP-20 strict payment classification or `is_mpp_channel_payment`. +For AA transactions, payment classification MUST satisfy both: + +1. `calls.length > 0`. +2. Every call satisfies either TIP-20 strict payment classification or `is_mpp_channel_payment`. + +An AA transaction with an empty `calls` array MUST be classified as non-payment. ### Required Integration Points @@ -185,7 +190,7 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 5. Only payee can `settle` and `close`. 6. Once finalized, all state-changing methods MUST revert for that channel. 7. Fund conservation MUST hold at all terminal states. -8. MPP channel calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers. +8. MPP channel calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, and AA payment classification MUST require `calls.length > 0`. 9. `open` and `topUp` MUST not require a prior user `approve` transaction. 10. No capture-increasing operation may succeed past `expiresAt`. 11. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. From 42a6c2d25cc9573956b8e39bea83825afd191813 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 6 Apr 2026 15:17:26 +0530 Subject: [PATCH 09/33] docs(tip-1034): align ref impl with canonical interface Amp-Thread-ID: https://ampcode.com/threads/T-019d6215-0909-7769-8f3d-6e692fc2e6f4 --- tips/tip-1034.md | 80 +++++++++++++++++++----------------------------- 1 file changed, 31 insertions(+), 49 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index c56e805a26..467a527c93 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -4,7 +4,7 @@ title: Enshrined MPP Channels Precompile description: Enshrines MPP payment channels as a Tempo precompile with payment-lane admission and native escrow transfer semantics. authors: Tanishk Goyal status: Draft -related: TIP-20, TIP-1000, mpp-specs (tempo session/auth-capture) +related: TIP-20, TIP-1000, Tempo Session, Tempo Charge protocolVersion: TBD --- @@ -26,6 +26,13 @@ MPP channels are currently specified as a Solidity contract reference implementa This produces a simpler and more reliable path for session and auth/capture style integrations without changing the core channel model developers already use. +## Related + +1. [TIP-20](https://docs.tempo.xyz/protocol/tip20/spec) +2. [TIP-1000](tip-1000.md) +3. [Tempo Session Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-session-00.html) +4. [Tempo Charge Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-charge-00.html) + --- # Specification @@ -42,12 +49,12 @@ address constant MPP_CHANNEL_PRECOMPILE = 0x4D5050000000000000000000000000000000 ## Implementation Details -This precompile MUST follow the current reference implementation in: +This TIP is normative. The current reference implementations are informative and live at: -1. `tips/ref-impls/src/interfaces/ITempoStreamChannel.sol` -2. `tips/ref-impls/src/TempoStreamChannel.sol` +1. [`tips/ref-impls/src/interfaces/ITempoStreamChannel.sol`](ref-impls/src/interfaces/ITempoStreamChannel.sol) +2. [`tips/ref-impls/src/TempoStreamChannel.sol`](ref-impls/src/TempoStreamChannel.sol) -with updates integrated directly in the sections below (expiry, explicit partial capture, native escrow movement, and payment-lane integration). +Implementations SHOULD keep those reference artifacts aligned with the normative interface and execution rules defined below. Channels are unidirectional (`payer -> payee`) and token-specific. @@ -67,9 +74,9 @@ struct Channel { } ``` -Compared to the reference implementation, this adds `expiresAt` and places it after `payee` for tight packing. This layout uses 5 storage slots total. +This layout uses 5 storage slots total. -`channelId` MUST use the deterministic domain-separated construction from the reference implementation: +`channelId` MUST use the following deterministic domain-separated construction: ```solidity channelId = keccak256( @@ -87,57 +94,32 @@ channelId = keccak256( ### Interface -For unchanged functions, events, and errors, see the reference interface: [`ref-impls/src/interfaces/ITempoStreamChannel.sol`](ref-impls/src/interfaces/ITempoStreamChannel.sol). +The canonical interface for this TIP is [`tips/ref-impls/src/interfaces/ITempoStreamChannel.sol`](ref-impls/src/interfaces/ITempoStreamChannel.sol). -This TIP updates only the following interface functions: - -```solidity -function open( - address payee, - address token, - uint128 deposit, - bytes32 salt, - address authorizedSigner, - uint64 expiresAt -) external returns (bytes32 channelId); -``` - -```solidity -function topUp( - bytes32 channelId, - uint256 additionalDeposit, - uint64 newExpiresAt -) external; -``` - -```solidity -function close( - bytes32 channelId, - uint128 cumulativeAmount, - uint128 captureAmount, - bytes calldata signature -) external; -``` - -Compared to the reference interface, this adds `expiresAt` to channel lifecycle (`open`, `topUp`) and adds explicit final `captureAmount` to `close`. +Implementations MUST expose an external interface that is semantically identical to that file, including the `Channel` state layout, events, errors, and function signatures. ### Execution Semantics -Voucher signatures keep the reference EIP-712 shape: +Voucher signatures use the following EIP-712 type: ```solidity Voucher(bytes32 channelId,uint128 cumulativeAmount) ``` -Execution semantics are the same as the reference implementation except: - -1. `open` MUST validate `expiresAt > block.timestamp` and persist `expiresAt`. -2. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST be strictly in the future. -3. `settle` and `close` MUST reject any capture-increasing action after `expiresAt`. -4. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. -5. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. -6. `withdraw` MUST be allowed after close grace OR after expiry. -7. Signer MUST be `authorizedSigner` when set, otherwise `payer`. +Execution semantics are: + +1. `open` MUST reject zero deposit, invalid token address, invalid payee address, and any `expiresAt` value where `expiresAt <= block.timestamp`. +2. `open` MUST persist `expiresAt`. +3. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST satisfy `newExpiresAt > block.timestamp`. +4. If `closeRequestedAt != 0`, a successful `topUp` MUST clear it and emit `CloseRequestCancelled`. +5. `requestClose` MUST set `closeRequestedAt = block.timestamp` on the first successful call and leave it unchanged on later successful calls. +6. `settle` MUST reject when `block.timestamp >= expiresAt`. +7. `close` MUST reject when `block.timestamp >= expiresAt` and `captureAmount > previousSettled`. +8. `close` MUST validate the voucher signature for any capture-increasing close. +9. Signer MUST be `authorizedSigner` when set, otherwise `payer`. +10. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. +11. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. +12. `withdraw` MUST be allowed when either the close grace period has elapsed or `block.timestamp >= expiresAt`. ## Native Escrow Movement From 76c8d5cf926084a7a333e58e64345ff26ac9768b Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 6 Apr 2026 23:47:06 +0530 Subject: [PATCH 10/33] feat(tip-1034): use TIP-1020 for voucher verification Amp-Thread-ID: https://ampcode.com/threads/T-019d6232-ab45-76cb-8a06-f00ef94708cd --- tips/tip-1034.md | 45 +++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 467a527c93..8fc34dd7bf 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -4,12 +4,14 @@ title: Enshrined MPP Channels Precompile description: Enshrines MPP payment channels as a Tempo precompile with payment-lane admission and native escrow transfer semantics. authors: Tanishk Goyal status: Draft -related: TIP-20, TIP-1000, Tempo Session, Tempo Charge +related: TIP-20, TIP-1000, TIP-1020, Tempo Session, Tempo Charge protocolVersion: TBD --- # TIP-1034: Enshrined MPP Channels Precompile +Related: [TIP-20](https://docs.tempo.xyz/protocol/tip20/spec), [TIP-1000](tip-1000.md), [TIP-1020](https://docs.tempo.xyz/protocol/tips/tip-1020), [Tempo Session Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-session-00.html), [Tempo Charge Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-charge-00.html) + ## Abstract This TIP enshrines MPP payment channels in Tempo as a native precompile. The implementation follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow, with expiry and explicit partial-capture updates defined in this spec. @@ -26,13 +28,6 @@ MPP channels are currently specified as a Solidity contract reference implementa This produces a simpler and more reliable path for session and auth/capture style integrations without changing the core channel model developers already use. -## Related - -1. [TIP-20](https://docs.tempo.xyz/protocol/tip20/spec) -2. [TIP-1000](tip-1000.md) -3. [Tempo Session Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-session-00.html) -4. [Tempo Charge Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-charge-00.html) - --- # Specification @@ -106,6 +101,21 @@ Voucher signatures use the following EIP-712 type: Voucher(bytes32 channelId,uint128 cumulativeAmount) ``` +Voucher signatures MUST be verified via the TIP-1020 Signature Verification Precompile at +`0x5165300000000000000000000000000000000000`, using the same signature encodings and +verification rules as Tempo transaction signatures. + +This means: + +1. Implementations MUST compute the EIP-712 voucher digest and validate signatures using TIP-1020 `recover` or `verify`, not raw `ecrecover`. +2. Voucher signatures MAY use any TIP-1020-supported Tempo signature type. +3. TIP-1020 keychain wrapper signatures (`0x03` / `0x04`) MUST be rejected for direct voucher verification. +4. Delegated voucher signing MUST use `authorizedSigner`, rather than a keychain wrapper around `payer`. + +Execution semantics use exact timestamp boundaries. Future-required timestamps MUST use the +strict predicate `timestamp > block.timestamp`, and expiry checks MUST use the strict predicate +`block.timestamp >= expiresAt`. Implementations MUST NOT substitute different operators. + Execution semantics are: 1. `open` MUST reject zero deposit, invalid token address, invalid payee address, and any `expiresAt` value where `expiresAt <= block.timestamp`. @@ -115,7 +125,7 @@ Execution semantics are: 5. `requestClose` MUST set `closeRequestedAt = block.timestamp` on the first successful call and leave it unchanged on later successful calls. 6. `settle` MUST reject when `block.timestamp >= expiresAt`. 7. `close` MUST reject when `block.timestamp >= expiresAt` and `captureAmount > previousSettled`. -8. `close` MUST validate the voucher signature for any capture-increasing close. +8. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. 9. Signer MUST be `authorizedSigner` when set, otherwise `payer`. 10. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. 11. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. @@ -142,18 +152,25 @@ Implementations MUST define a strict classifier `is_mpp_channel_payment(to, inpu 1. `to == MPP_CHANNEL_PRECOMPILE`. 2. `input` selector is one of `{open, settle, topUp, close, requestClose, withdraw}`. 3. Calldata length/encoding is valid for that selector. +4. For `settle` and `close`, the trailing `signature` bytes use a valid TIP-1020 / Tempo transaction signature encoding. + +Transactions with authorization side effects MUST be classified as non-payment. + +For EIP-7702 transactions, payment classification requires `authorization_list.length == 0`. -For AA transactions, payment classification MUST satisfy both: +For AA transactions, payment classification MUST satisfy all of: 1. `calls.length > 0`. -2. Every call satisfies either TIP-20 strict payment classification or `is_mpp_channel_payment`. +2. `tempo_authorization_list.length == 0`. +3. `key_authorization` is absent. +4. Every call satisfies either TIP-20 strict payment classification or `is_mpp_channel_payment`. An AA transaction with an empty `calls` array MUST be classified as non-payment. ### Required Integration Points -1. The consensus-level payment classifier (`is_payment`) MUST include MPP channel calls. -2. The strict builder/pool classifier (`is_payment_v2`) MUST include `is_mpp_channel_payment`. +1. The consensus-level payment classifier (`is_payment`) MUST include MPP channel calls and MUST enforce the same authorization-side-effect exclusions above. +2. The strict builder/pool classifier (`is_payment_v2`) MUST include `is_mpp_channel_payment` and MUST enforce the same authorization-side-effect exclusions above. 3. The transaction pool payment flag MUST be computed from the strict classifier, so MPP channel calls are admitted in the payment lane path. 4. The payload builder non-payment gate (`general_gas_limit` enforcement) MUST treat MPP channel calls as payment, so they are not rejected by the non-payment overflow path. @@ -172,7 +189,7 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 5. Only payee can `settle` and `close`. 6. Once finalized, all state-changing methods MUST revert for that channel. 7. Fund conservation MUST hold at all terminal states. -8. MPP channel calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, and AA payment classification MUST require `calls.length > 0`. +8. MPP channel calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. 9. `open` and `topUp` MUST not require a prior user `approve` transaction. 10. No capture-increasing operation may succeed past `expiresAt`. 11. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. From 0db39b2548088ae8787b2b33ffb1ea00e9cbd591 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 6 Apr 2026 23:49:22 +0530 Subject: [PATCH 11/33] docs(tip-1034): move related links to file header Amp-Thread-ID: https://ampcode.com/threads/T-019d6232-ab45-76cb-8a06-f00ef94708cd --- tips/tip-1034.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 8fc34dd7bf..ac824867ee 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -8,10 +8,10 @@ related: TIP-20, TIP-1000, TIP-1020, Tempo Session, Tempo Charge protocolVersion: TBD --- -# TIP-1034: Enshrined MPP Channels Precompile - Related: [TIP-20](https://docs.tempo.xyz/protocol/tip20/spec), [TIP-1000](tip-1000.md), [TIP-1020](https://docs.tempo.xyz/protocol/tips/tip-1020), [Tempo Session Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-session-00.html), [Tempo Charge Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-charge-00.html) +# TIP-1034: Enshrined MPP Channels Precompile + ## Abstract This TIP enshrines MPP payment channels in Tempo as a native precompile. The implementation follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow, with expiry and explicit partial-capture updates defined in this spec. From 01c1a43241f5719ee943fd892bb95aa0cd9a0c90 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 6 Apr 2026 23:53:59 +0530 Subject: [PATCH 12/33] fix(tip-1034): tighten expiry extension and close bounds Amp-Thread-ID: https://ampcode.com/threads/T-019d6406-bd52-7686-9706-88a8a80bb8f6 --- tips/tip-1034.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index ac824867ee..467e02eab7 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -8,8 +8,6 @@ related: TIP-20, TIP-1000, TIP-1020, Tempo Session, Tempo Charge protocolVersion: TBD --- -Related: [TIP-20](https://docs.tempo.xyz/protocol/tip20/spec), [TIP-1000](tip-1000.md), [TIP-1020](https://docs.tempo.xyz/protocol/tips/tip-1020), [Tempo Session Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-session-00.html), [Tempo Charge Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-charge-00.html) - # TIP-1034: Enshrined MPP Channels Precompile ## Abstract @@ -120,16 +118,18 @@ Execution semantics are: 1. `open` MUST reject zero deposit, invalid token address, invalid payee address, and any `expiresAt` value where `expiresAt <= block.timestamp`. 2. `open` MUST persist `expiresAt`. -3. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST satisfy `newExpiresAt > block.timestamp`. +3. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST satisfy both `newExpiresAt > block.timestamp` and `newExpiresAt > current expiresAt`. 4. If `closeRequestedAt != 0`, a successful `topUp` MUST clear it and emit `CloseRequestCancelled`. 5. `requestClose` MUST set `closeRequestedAt = block.timestamp` on the first successful call and leave it unchanged on later successful calls. 6. `settle` MUST reject when `block.timestamp >= expiresAt`. 7. `close` MUST reject when `block.timestamp >= expiresAt` and `captureAmount > previousSettled`. 8. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. 9. Signer MUST be `authorizedSigner` when set, otherwise `payer`. -10. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. -11. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. -12. `withdraw` MUST be allowed when either the close grace period has elapsed or `block.timestamp >= expiresAt`. +10. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`. +11. `close` MUST reject when `captureAmount > deposit`, even if `cumulativeAmount > deposit`. +12. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. +13. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. +14. `withdraw` MUST be allowed when either the close grace period has elapsed or `block.timestamp >= expiresAt`. ## Native Escrow Movement @@ -192,4 +192,13 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 8. MPP channel calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. 9. `open` and `topUp` MUST not require a prior user `approve` transaction. 10. No capture-increasing operation may succeed past `expiresAt`. -11. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount <= deposit`. +11. `topUp` MUST NOT reduce or preserve the channel expiry when `newExpiresAt` is provided. +12. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. + +## References + +- [TIP-20](https://docs.tempo.xyz/protocol/tip20/spec) +- [TIP-1000](tip-1000.md) +- [TIP-1020](https://docs.tempo.xyz/protocol/tips/tip-1020) +- [Tempo Session Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-session-00.html) +- [Tempo Charge Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-charge-00.html) From afdf4e5154be2cf21cfe99a03b1dc9c6b2043c2f Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 6 Apr 2026 23:57:52 +0530 Subject: [PATCH 13/33] docs(tip-1034): normalize local TIP references Amp-Thread-ID: https://ampcode.com/threads/T-019d6407-305e-7779-a931-e4acc276be4c --- tips/tip-1034.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 467e02eab7..47be8c3ecb 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -199,6 +199,6 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat - [TIP-20](https://docs.tempo.xyz/protocol/tip20/spec) - [TIP-1000](tip-1000.md) -- [TIP-1020](https://docs.tempo.xyz/protocol/tips/tip-1020) +- [TIP-1020](tip-1020.md) - [Tempo Session Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-session-00.html) - [Tempo Charge Intent for HTTP Payment Authentication](https://paymentauth.org/draft-tempo-charge-00.html) From a8c3d6b9f5cc0bebb8b1e01f0108b89ac5a6d2ee Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:48:36 +0200 Subject: [PATCH 14/33] docs(tip-1034): rename precompile to TIP20ChannelEscrow (#3621) Renames `TempoStreamChannel` / `MPP_CHANNEL_PRECOMPILE` to `TIP20ChannelEscrow` / `TIP20_CHANNEL_ESCROW` across the TIP spec, reference implementation, interface, and tests. Prompted by: Tanishk Co-authored-by: Tanishk Goyal <64212892+legion2002@users.noreply.github.com> --- tips/tip-1034.md | 40 +-- tips/verify/test/TIP20ChannelEscrow.t.sol | 397 ++++++++++++++++++++++ 2 files changed, 417 insertions(+), 20 deletions(-) create mode 100644 tips/verify/test/TIP20ChannelEscrow.t.sol diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 47be8c3ecb..094f4cb0a0 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -1,24 +1,24 @@ --- id: TIP-1034 -title: Enshrined MPP Channels Precompile -description: Enshrines MPP payment channels as a Tempo precompile with payment-lane admission and native escrow transfer semantics. +title: TIP-20 Channel Escrow Precompile +description: Enshrines TIP-20 channel escrow as a Tempo precompile with payment-lane admission and native escrow transfer semantics. authors: Tanishk Goyal status: Draft related: TIP-20, TIP-1000, TIP-1020, Tempo Session, Tempo Charge protocolVersion: TBD --- -# TIP-1034: Enshrined MPP Channels Precompile +# TIP-1034: TIP-20 Channel Escrow Precompile ## Abstract -This TIP enshrines MPP payment channels in Tempo as a native precompile. The implementation follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow, with expiry and explicit partial-capture updates defined in this spec. +This TIP enshrines TIP-20 channel escrow in Tempo as a native precompile. The implementation follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow, with expiry and explicit partial-capture updates defined in this spec. The precompile is introduced to reduce execution overhead, remove the separate `approve` UX via native escrow movement, and make channel operations first-class payment-lane traffic under congestion. ## Motivation -MPP channels are currently specified as a Solidity contract reference implementation. Enshrining that behavior as a precompile is motivated by three protocol-level goals: +TIP-20 channel escrow is currently specified as a Solidity contract reference implementation. Enshrining that behavior as a precompile is motivated by three protocol-level goals: 1. **Efficiency**: Channel operations become native precompile execution instead of generic contract execution, reducing overhead and making gas behavior more predictable for high-frequency payment flows. 2. **Payment-Lane Access**: Channel operations become payment-lane transactions, so they are not throttled by the non-payment `general_gas_limit` path during block contention. @@ -35,17 +35,17 @@ This produces a simpler and more reliable path for session and auth/capture styl This TIP introduces a new system precompile at: ```solidity -address constant MPP_CHANNEL_PRECOMPILE = 0x4D50500000000000000000000000000000000000; +address constant TIP20_CHANNEL_ESCROW = 0x4D50500000000000000000000000000000000000; ``` -`MPP_CHANNEL_PRECOMPILE` MUST refer to this address throughout this specification. +`TIP20_CHANNEL_ESCROW` MUST refer to this address throughout this specification. ## Implementation Details This TIP is normative. The current reference implementations are informative and live at: -1. [`tips/ref-impls/src/interfaces/ITempoStreamChannel.sol`](ref-impls/src/interfaces/ITempoStreamChannel.sol) -2. [`tips/ref-impls/src/TempoStreamChannel.sol`](ref-impls/src/TempoStreamChannel.sol) +1. [`tips/ref-impls/src/interfaces/ITIP20ChannelEscrow.sol`](ref-impls/src/interfaces/ITIP20ChannelEscrow.sol) +2. [`tips/ref-impls/src/TIP20ChannelEscrow.sol`](ref-impls/src/TIP20ChannelEscrow.sol) Implementations SHOULD keep those reference artifacts aligned with the normative interface and execution rules defined below. @@ -79,7 +79,7 @@ channelId = keccak256( token, salt, authorizedSigner, - MPP_CHANNEL_PRECOMPILE, + TIP20_CHANNEL_ESCROW, block.chainid ) ); @@ -87,7 +87,7 @@ channelId = keccak256( ### Interface -The canonical interface for this TIP is [`tips/ref-impls/src/interfaces/ITempoStreamChannel.sol`](ref-impls/src/interfaces/ITempoStreamChannel.sol). +The canonical interface for this TIP is [`tips/ref-impls/src/interfaces/ITIP20ChannelEscrow.sol`](ref-impls/src/interfaces/ITIP20ChannelEscrow.sol). Implementations MUST expose an external interface that is semantically identical to that file, including the `Channel` state layout, events, errors, and function signatures. @@ -143,13 +143,13 @@ Required behavior: ## Payment-Lane Integration (Mandatory) -MPP channel operations MUST be treated as payment-lane transactions in consensus classification, pool admission, and payload building. +Channel escrow operations MUST be treated as payment-lane transactions in consensus classification, pool admission, and payload building. ### Classification Rules -Implementations MUST define a strict classifier `is_mpp_channel_payment(to, input)` that returns true iff: +Implementations MUST define a strict classifier `is_channel_escrow_payment(to, input)` that returns true iff: -1. `to == MPP_CHANNEL_PRECOMPILE`. +1. `to == TIP20_CHANNEL_ESCROW`. 2. `input` selector is one of `{open, settle, topUp, close, requestClose, withdraw}`. 3. Calldata length/encoding is valid for that selector. 4. For `settle` and `close`, the trailing `signature` bytes use a valid TIP-1020 / Tempo transaction signature encoding. @@ -163,16 +163,16 @@ For AA transactions, payment classification MUST satisfy all of: 1. `calls.length > 0`. 2. `tempo_authorization_list.length == 0`. 3. `key_authorization` is absent. -4. Every call satisfies either TIP-20 strict payment classification or `is_mpp_channel_payment`. +4. Every call satisfies either TIP-20 strict payment classification or `is_channel_escrow_payment`. An AA transaction with an empty `calls` array MUST be classified as non-payment. ### Required Integration Points -1. The consensus-level payment classifier (`is_payment`) MUST include MPP channel calls and MUST enforce the same authorization-side-effect exclusions above. -2. The strict builder/pool classifier (`is_payment_v2`) MUST include `is_mpp_channel_payment` and MUST enforce the same authorization-side-effect exclusions above. -3. The transaction pool payment flag MUST be computed from the strict classifier, so MPP channel calls are admitted in the payment lane path. -4. The payload builder non-payment gate (`general_gas_limit` enforcement) MUST treat MPP channel calls as payment, so they are not rejected by the non-payment overflow path. +1. The consensus-level payment classifier (`is_payment`) MUST include TIP-20 channel escrow calls and MUST enforce the same authorization-side-effect exclusions above. +2. The strict builder/pool classifier (`is_payment_v2`) MUST include `is_channel_escrow_payment` and MUST enforce the same authorization-side-effect exclusions above. +3. The transaction pool payment flag MUST be computed from the strict classifier, so channel escrow calls are admitted in the payment lane path. +4. The payload builder non-payment gate (`general_gas_limit` enforcement) MUST treat channel escrow calls as payment, so they are not rejected by the non-payment overflow path. ### What This Means Operationally @@ -189,7 +189,7 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 5. Only payee can `settle` and `close`. 6. Once finalized, all state-changing methods MUST revert for that channel. 7. Fund conservation MUST hold at all terminal states. -8. MPP channel calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. +8. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. 9. `open` and `topUp` MUST not require a prior user `approve` transaction. 10. No capture-increasing operation may succeed past `expiresAt`. 11. `topUp` MUST NOT reduce or preserve the channel expiry when `newExpiresAt` is provided. diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol new file mode 100644 index 0000000000..2b7d01910d --- /dev/null +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { TIP20 } from "../src/TIP20.sol"; +import { TIP20ChannelEscrow } from "../src/TIP20ChannelEscrow.sol"; +import { ITIP20ChannelEscrow } from "../src/interfaces/ITIP20ChannelEscrow.sol"; +import { BaseTest } from "./BaseTest.t.sol"; + +contract MockSignatureVerifier { + + error InvalidFormat(); + error InvalidSignature(); + + function recover(bytes32 hash, bytes calldata signature) + external + pure + returns (address signer) + { + return _recover(hash, signature); + } + + function verify( + address signer, + bytes32 hash, + bytes calldata signature + ) + external + pure + returns (bool) + { + return _recover(hash, signature) == signer; + } + + function _recover( + bytes32 hash, + bytes calldata signature + ) + internal + pure + returns (address signer) + { + if (signature.length != 65) revert InvalidSignature(); + + bytes32 r; + bytes32 s; + uint8 v; + + assembly { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 32)) + v := byte(0, calldataload(add(signature.offset, 64))) + } + + if (v < 27) v += 27; + if (v != 27 && v != 28) revert InvalidSignature(); + + signer = ecrecover(hash, v, r, s); + if (signer == address(0)) revert InvalidSignature(); + } + +} + +contract TIP20ChannelEscrowTest is BaseTest { + + TIP20ChannelEscrow public channel; + TIP20 public token; + + address public payer; + uint256 public payerKey; + address public payee; + + uint128 internal constant DEPOSIT = 1_000_000; + bytes32 internal constant SALT = bytes32(uint256(1)); + + function setUp() public override { + super.setUp(); + + channel = new TIP20ChannelEscrow(); + MockSignatureVerifier verifier = new MockSignatureVerifier(); + vm.etch(channel.SIGNATURE_VERIFIER_PRECOMPILE(), address(verifier).code); + token = TIP20( + factory.createToken("Stream Token", "STR", "USD", pathUSD, admin, bytes32("stream")) + ); + + (payer, payerKey) = makeAddrAndKey("payer"); + payee = makeAddr("payee"); + + vm.startPrank(admin); + token.grantRole(_ISSUER_ROLE, admin); + token.mint(payer, 20_000_000); + vm.stopPrank(); + + vm.prank(payer); + token.approve(address(channel), type(uint256).max); + } + + function _defaultExpiry() internal view returns (uint64) { + return uint64(block.timestamp + 1 days); + } + + function _openChannel() internal returns (bytes32) { + vm.prank(payer); + return channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); + } + + function _openChannelWithExpiry(uint64 expiresAt) internal returns (bytes32) { + vm.prank(payer); + return channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); + } + + function _signVoucher(bytes32 channelId, uint128 amount) internal view returns (bytes memory) { + return _signVoucher(channelId, amount, payerKey); + } + + function _signVoucher( + bytes32 channelId, + uint128 amount, + uint256 signerKey + ) + internal + view + returns (bytes memory) + { + bytes32 digest = channel.getVoucherDigest(channelId, amount); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, digest); + return abi.encodePacked(r, s, v); + } + + function test_open_success() public { + uint64 expiresAt = _defaultExpiry(); + + vm.prank(payer); + bytes32 channelId = + channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); + + TIP20ChannelEscrow.Channel memory ch = channel.getChannel(channelId); + assertFalse(ch.finalized); + assertEq(ch.closeRequestedAt, 0); + assertEq(ch.payer, payer); + assertEq(ch.payee, payee); + assertEq(ch.expiresAt, expiresAt); + assertEq(ch.token, address(token)); + assertEq(ch.authorizedSigner, address(0)); + assertEq(ch.deposit, DEPOSIT); + assertEq(ch.settled, 0); + } + + function test_open_revert_zeroPayee() public { + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.InvalidPayee.selector); + channel.open(address(0), address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); + } + + function test_open_revert_zeroToken() public { + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.InvalidToken.selector); + channel.open(payee, address(0), DEPOSIT, SALT, address(0), _defaultExpiry()); + } + + function test_open_revert_zeroDeposit() public { + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.ZeroDeposit.selector); + channel.open(payee, address(token), 0, SALT, address(0), _defaultExpiry()); + } + + function test_open_revert_invalidExpiry() public { + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.InvalidExpiry.selector); + channel.open(payee, address(token), DEPOSIT, SALT, address(0), uint64(block.timestamp)); + } + + function test_open_revert_duplicate() public { + _openChannel(); + + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); + channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); + } + + function test_settle_success() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, 500_000); + + vm.prank(payee); + channel.settle(channelId, 500_000, sig); + + assertEq(channel.getChannel(channelId).settled, 500_000); + assertEq(token.balanceOf(payee), 500_000); + } + + function test_settle_revert_afterExpiry() public { + bytes32 channelId = _openChannelWithExpiry(uint64(block.timestamp + 10)); + bytes memory sig = _signVoucher(channelId, 500_000); + + vm.warp(block.timestamp + 10); + vm.prank(payee); + vm.expectRevert(ITIP20ChannelEscrow.ChannelExpiredError.selector); + channel.settle(channelId, 500_000, sig); + } + + function test_settle_revert_invalidSignature() public { + bytes32 channelId = _openChannel(); + (, uint256 wrongKey) = makeAddrAndKey("wrong"); + bytes memory sig = _signVoucher(channelId, 500_000, wrongKey); + + vm.prank(payee); + vm.expectRevert(ITIP20ChannelEscrow.InvalidSignature.selector); + channel.settle(channelId, 500_000, sig); + } + + function test_authorizedSigner_settleSuccess() public { + (address delegateSigner, uint256 delegateKey) = makeAddrAndKey("delegate"); + + vm.prank(payer); + bytes32 channelId = + channel.open(payee, address(token), DEPOSIT, SALT, delegateSigner, _defaultExpiry()); + + bytes memory sig = _signVoucher(channelId, 500_000, delegateKey); + + vm.prank(payee); + channel.settle(channelId, 500_000, sig); + + assertEq(channel.getChannel(channelId).settled, 500_000); + } + + function test_topUp_updatesDepositAndExpiry() public { + bytes32 channelId = _openChannel(); + uint64 nextExpiry = uint64(block.timestamp + 2 days); + + vm.prank(payer); + channel.topUp(channelId, 250_000, nextExpiry); + + TIP20ChannelEscrow.Channel memory ch = channel.getChannel(channelId); + assertEq(ch.deposit, DEPOSIT + 250_000); + assertEq(ch.expiresAt, nextExpiry); + } + + function test_topUp_revert_nonIncreasingExpiry() public { + bytes32 channelId = _openChannel(); + + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.InvalidExpiry.selector); + channel.topUp(channelId, 0, _defaultExpiry()); + } + + function test_topUp_cancelsCloseRequest() public { + bytes32 channelId = _openChannel(); + + vm.prank(payer); + channel.requestClose(channelId); + + vm.prank(payer); + channel.topUp(channelId, 100_000, 0); + + assertEq(channel.getChannel(channelId).closeRequestedAt, 0); + } + + function test_close_partialCapture_success() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, 900_000); + + uint256 payeeBalanceBefore = token.balanceOf(payee); + uint256 payerBalanceBefore = token.balanceOf(payer); + + vm.prank(payee); + channel.close(channelId, 900_000, 600_000, sig); + + TIP20ChannelEscrow.Channel memory ch = channel.getChannel(channelId); + assertTrue(ch.finalized); + assertEq(ch.settled, 600_000); + assertEq(token.balanceOf(payee), payeeBalanceBefore + 600_000); + assertEq(token.balanceOf(payer), payerBalanceBefore + 400_000); + } + + function test_close_usesPreviousSettledForDelta() public { + bytes32 channelId = _openChannel(); + bytes memory settleSig = _signVoucher(channelId, 300_000); + + vm.prank(payee); + channel.settle(channelId, 300_000, settleSig); + + bytes memory closeSig = _signVoucher(channelId, 800_000); + uint256 payeeBalanceBefore = token.balanceOf(payee); + + vm.prank(payee); + channel.close(channelId, 800_000, 500_000, closeSig); + + assertEq(token.balanceOf(payee), payeeBalanceBefore + 200_000); + assertEq(channel.getChannel(channelId).settled, 500_000); + } + + function test_close_allowsVoucherAmountAboveDepositWhenCaptureWithinDeposit() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, DEPOSIT + 250_000); + + uint256 payeeBalanceBefore = token.balanceOf(payee); + + vm.prank(payee); + channel.close(channelId, DEPOSIT + 250_000, DEPOSIT, sig); + + TIP20ChannelEscrow.Channel memory ch = channel.getChannel(channelId); + assertTrue(ch.finalized); + assertEq(ch.settled, DEPOSIT); + assertEq(token.balanceOf(payee), payeeBalanceBefore + DEPOSIT); + } + + function test_close_revert_invalidCaptureAmount() public { + bytes32 channelId = _openChannel(); + bytes memory settleSig = _signVoucher(channelId, 300_000); + + vm.prank(payee); + channel.settle(channelId, 300_000, settleSig); + + vm.prank(payee); + vm.expectRevert(ITIP20ChannelEscrow.CaptureAmountInvalid.selector); + channel.close(channelId, 300_000, 200_000, ""); + } + + function test_close_afterExpiry_allowsNoAdditionalCapture() public { + bytes32 channelId = _openChannelWithExpiry(uint64(block.timestamp + 10)); + bytes memory sig = _signVoucher(channelId, 300_000); + + vm.prank(payee); + channel.settle(channelId, 300_000, sig); + + vm.warp(block.timestamp + 10); + + uint256 payerBalanceBefore = token.balanceOf(payer); + vm.prank(payee); + channel.close(channelId, 300_000, 300_000, ""); + + assertTrue(channel.getChannel(channelId).finalized); + assertEq(token.balanceOf(payer), payerBalanceBefore + (DEPOSIT - 300_000)); + } + + function test_withdraw_afterGracePeriod() public { + bytes32 channelId = _openChannel(); + + vm.prank(payer); + channel.requestClose(channelId); + + vm.warp(block.timestamp + channel.CLOSE_GRACE_PERIOD() + 1); + + uint256 payerBalanceBefore = token.balanceOf(payer); + vm.prank(payer); + channel.withdraw(channelId); + + assertTrue(channel.getChannel(channelId).finalized); + assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); + } + + function test_withdraw_afterExpiryWithoutCloseRequest() public { + bytes32 channelId = _openChannelWithExpiry(uint64(block.timestamp + 10)); + + vm.warp(block.timestamp + 10); + + uint256 payerBalanceBefore = token.balanceOf(payer); + vm.prank(payer); + channel.withdraw(channelId); + + assertTrue(channel.getChannel(channelId).finalized); + assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); + } + + function test_getChannelsBatch_success() public { + bytes32 channelId1 = _openChannel(); + + vm.prank(payer); + bytes32 channelId2 = channel.open( + payee, address(token), DEPOSIT, bytes32(uint256(2)), address(0), _defaultExpiry() + ); + + bytes memory sig = _signVoucher(channelId1, 400_000); + vm.prank(payee); + channel.settle(channelId1, 400_000, sig); + + bytes32[] memory ids = new bytes32[](2); + ids[0] = channelId1; + ids[1] = channelId2; + + TIP20ChannelEscrow.Channel[] memory states = channel.getChannelsBatch(ids); + assertEq(states.length, 2); + assertEq(states[0].settled, 400_000); + assertEq(states[1].settled, 0); + } + + function test_computeChannelId_usesFixedPrecompileAddress() public { + TIP20ChannelEscrow other = new TIP20ChannelEscrow(); + + bytes32 id1 = channel.computeChannelId(payer, payee, address(token), SALT, address(0)); + bytes32 id2 = other.computeChannelId(payer, payee, address(token), SALT, address(0)); + + assertEq(id1, id2); + assertEq(channel.domainSeparator(), other.domainSeparator()); + } + +} From 44d13c8f1f3214caea56bdc6696fc878b31d456f Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 21 Apr 2026 17:09:38 +0530 Subject: [PATCH 15/33] refactor(tip-1034): use single-slot descriptor state Amp-Thread-ID: https://ampcode.com/threads/T-019dafbf-0246-74d7-ba6d-082551e5f158 --- tips/tip-1034.md | 96 +++++++++----- tips/verify/test/TIP20ChannelEscrow.t.sol | 153 ++++++++++++++-------- 2 files changed, 164 insertions(+), 85 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 094f4cb0a0..75c69a5ec4 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -51,23 +51,53 @@ Implementations SHOULD keep those reference artifacts aligned with the normative Channels are unidirectional (`payer -> payee`) and token-specific. -### Channel State Layout +### Channel Identity And Packed State ```solidity -struct Channel { - bool finalized; - uint64 closeRequestedAt; +struct ChannelDescriptor { address payer; address payee; - uint64 expiresAt; address token; + bytes32 salt; address authorizedSigner; - uint128 deposit; - uint128 settled; +} + +struct ChannelState { + bool finalized; + uint32 closeRequestedAt; + uint32 expiresAt; + uint96 deposit; + uint96 settled; +} + +struct Channel { + ChannelDescriptor descriptor; + ChannelState state; } ``` -This layout uses 5 storage slots total. +Implementations MUST store only `ChannelState` on-chain, keyed by `channelId` and packed into a +single 32-byte storage slot. `ChannelDescriptor` is immutable channel identity and MUST NOT be +stored on-chain; it MUST instead be supplied in calldata for post-open operations and emitted in +`ChannelOpened` so indexers and counterparties can recover it. + +The packed slot layout is: + +```text +bits 0..95 settled uint96 +bits 96..191 deposit uint96 +bits 192..223 expiresAt uint32 +bits 224..255 closeData uint32 +``` + +`closeData` MUST be encoded as: + +1. `0` for an active channel with no close request. +2. `1` for a finalized channel tombstone. +3. `closeRequestedAt + 2` for an active channel with a close request. + +Implementations MUST decode `closeRequestedAt` as `0` when `closeData < 2`, otherwise as +`closeData - 2`. `channelId` MUST use the following deterministic domain-separated construction: @@ -89,14 +119,16 @@ channelId = keccak256( The canonical interface for this TIP is [`tips/ref-impls/src/interfaces/ITIP20ChannelEscrow.sol`](ref-impls/src/interfaces/ITIP20ChannelEscrow.sol). -Implementations MUST expose an external interface that is semantically identical to that file, including the `Channel` state layout, events, errors, and function signatures. +Implementations MUST expose an external interface that is semantically identical to that file, +including the `ChannelDescriptor`, `ChannelState`, and `Channel` view structs, the descriptor-based +post-open methods, the events, the errors, and the function signatures. ### Execution Semantics Voucher signatures use the following EIP-712 type: ```solidity -Voucher(bytes32 channelId,uint128 cumulativeAmount) +Voucher(bytes32 channelId,uint96 cumulativeAmount) ``` Voucher signatures MUST be verified via the TIP-1020 Signature Verification Precompile at @@ -117,19 +149,21 @@ strict predicate `timestamp > block.timestamp`, and expiry checks MUST use the s Execution semantics are: 1. `open` MUST reject zero deposit, invalid token address, invalid payee address, and any `expiresAt` value where `expiresAt <= block.timestamp`. -2. `open` MUST persist `expiresAt`. -3. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST satisfy both `newExpiresAt > block.timestamp` and `newExpiresAt > current expiresAt`. -4. If `closeRequestedAt != 0`, a successful `topUp` MUST clear it and emit `CloseRequestCancelled`. -5. `requestClose` MUST set `closeRequestedAt = block.timestamp` on the first successful call and leave it unchanged on later successful calls. -6. `settle` MUST reject when `block.timestamp >= expiresAt`. -7. `close` MUST reject when `block.timestamp >= expiresAt` and `captureAmount > previousSettled`. -8. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. -9. Signer MUST be `authorizedSigner` when set, otherwise `payer`. -10. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`. -11. `close` MUST reject when `captureAmount > deposit`, even if `cumulativeAmount > deposit`. -12. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. -13. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. -14. `withdraw` MUST be allowed when either the close grace period has elapsed or `block.timestamp >= expiresAt`. +2. `open` MUST persist only the packed `ChannelState` slot and MUST emit the full immutable descriptor in `ChannelOpened`. +3. Post-open methods (`settle`, `topUp`, `close`, `requestClose`, `withdraw`, and descriptor-based views) MUST recompute `channelId` from the supplied descriptor and use that derived id for storage lookup. +4. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST satisfy both `newExpiresAt > block.timestamp` and `newExpiresAt > current expiresAt`. +5. If `closeRequestedAt != 0`, a successful `topUp` MUST clear it and emit `CloseRequestCancelled`. +6. `requestClose` MUST encode `closeRequestedAt = block.timestamp` into the packed slot on the first successful call and leave it unchanged on later successful calls. +7. `settle` MUST reject when `block.timestamp >= expiresAt`. +8. `close` MUST reject when `block.timestamp >= expiresAt` and `captureAmount > previousSettled`. +9. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. +10. Signer MUST be `authorizedSigner` from the supplied descriptor when set, otherwise `payer` from the supplied descriptor. +11. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`. +12. `close` MUST reject when `captureAmount > deposit`, even if `cumulativeAmount > deposit`. +13. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. +14. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. +15. `withdraw` MUST be allowed when either the close grace period has elapsed or `block.timestamp >= expiresAt`. +16. Terminal `close` and `withdraw` MUST leave a non-zero finalized tombstone in the packed slot. Implementations MUST NOT delete the slot, so the same descriptor and `channelId` cannot be reopened and replay old vouchers. ## Native Escrow Movement @@ -187,13 +221,15 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 3. Any successful capture (`settle` or `close`) MUST be authorized by a valid voucher signature from the expected signer. 4. Only payer can `topUp`, `requestClose`, and `withdraw`. 5. Only payee can `settle` and `close`. -6. Once finalized, all state-changing methods MUST revert for that channel. -7. Fund conservation MUST hold at all terminal states. -8. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. -9. `open` and `topUp` MUST not require a prior user `approve` transaction. -10. No capture-increasing operation may succeed past `expiresAt`. -11. `topUp` MUST NOT reduce or preserve the channel expiry when `newExpiresAt` is provided. -12. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. +6. A channel MUST consume exactly one storage slot of mutable on-chain state. +7. Once finalized, all state-changing methods MUST revert for that channel. +8. Finalized channels MUST retain a non-zero tombstone state so the same descriptor cannot be reopened. +9. Fund conservation MUST hold at all terminal states. +10. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. +11. `open` and `topUp` MUST not require a prior user `approve` transaction. +12. No capture-increasing operation may succeed past `expiresAt`. +13. `topUp` MUST NOT reduce or preserve the channel expiry when `newExpiresAt` is provided. +14. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. ## References diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol index 2b7d01910d..6ca30322da 100644 --- a/tips/verify/test/TIP20ChannelEscrow.t.sol +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -69,7 +69,7 @@ contract TIP20ChannelEscrowTest is BaseTest { uint256 public payerKey; address public payee; - uint128 internal constant DEPOSIT = 1_000_000; + uint96 internal constant DEPOSIT = 1_000_000; bytes32 internal constant SALT = bytes32(uint256(1)); function setUp() public override { @@ -94,8 +94,8 @@ contract TIP20ChannelEscrowTest is BaseTest { token.approve(address(channel), type(uint256).max); } - function _defaultExpiry() internal view returns (uint64) { - return uint64(block.timestamp + 1 days); + function _defaultExpiry() internal view returns (uint32) { + return uint32(block.timestamp + 1 days); } function _openChannel() internal returns (bytes32) { @@ -103,18 +103,39 @@ contract TIP20ChannelEscrowTest is BaseTest { return channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); } - function _openChannelWithExpiry(uint64 expiresAt) internal returns (bytes32) { + function _openChannelWithExpiry(uint32 expiresAt) internal returns (bytes32) { vm.prank(payer); return channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); } - function _signVoucher(bytes32 channelId, uint128 amount) internal view returns (bytes memory) { + function _descriptor() internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { + return _descriptor(SALT, address(0)); + } + + function _descriptor( + bytes32 salt, + address authorizedSigner + ) + internal + view + returns (ITIP20ChannelEscrow.ChannelDescriptor memory) + { + return ITIP20ChannelEscrow.ChannelDescriptor({ + payer: payer, + payee: payee, + token: address(token), + salt: salt, + authorizedSigner: authorizedSigner + }); + } + + function _signVoucher(bytes32 channelId, uint96 amount) internal view returns (bytes memory) { return _signVoucher(channelId, amount, payerKey); } function _signVoucher( bytes32 channelId, - uint128 amount, + uint96 amount, uint256 signerKey ) internal @@ -127,22 +148,23 @@ contract TIP20ChannelEscrowTest is BaseTest { } function test_open_success() public { - uint64 expiresAt = _defaultExpiry(); + uint32 expiresAt = _defaultExpiry(); vm.prank(payer); bytes32 channelId = channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); - TIP20ChannelEscrow.Channel memory ch = channel.getChannel(channelId); - assertFalse(ch.finalized); - assertEq(ch.closeRequestedAt, 0); - assertEq(ch.payer, payer); - assertEq(ch.payee, payee); - assertEq(ch.expiresAt, expiresAt); - assertEq(ch.token, address(token)); - assertEq(ch.authorizedSigner, address(0)); - assertEq(ch.deposit, DEPOSIT); - assertEq(ch.settled, 0); + ITIP20ChannelEscrow.Channel memory ch = channel.getChannel(_descriptor()); + assertFalse(ch.state.finalized); + assertEq(ch.state.closeRequestedAt, 0); + assertEq(ch.descriptor.payer, payer); + assertEq(ch.descriptor.payee, payee); + assertEq(ch.state.expiresAt, expiresAt); + assertEq(ch.descriptor.token, address(token)); + assertEq(ch.descriptor.authorizedSigner, address(0)); + assertEq(ch.state.deposit, DEPOSIT); + assertEq(ch.state.settled, 0); + assertEq(channel.getChannelState(channelId).deposit, DEPOSIT); } function test_open_revert_zeroPayee() public { @@ -166,7 +188,7 @@ contract TIP20ChannelEscrowTest is BaseTest { function test_open_revert_invalidExpiry() public { vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidExpiry.selector); - channel.open(payee, address(token), DEPOSIT, SALT, address(0), uint64(block.timestamp)); + channel.open(payee, address(token), DEPOSIT, SALT, address(0), uint32(block.timestamp)); } function test_open_revert_duplicate() public { @@ -182,20 +204,20 @@ contract TIP20ChannelEscrowTest is BaseTest { bytes memory sig = _signVoucher(channelId, 500_000); vm.prank(payee); - channel.settle(channelId, 500_000, sig); + channel.settle(_descriptor(), 500_000, sig); - assertEq(channel.getChannel(channelId).settled, 500_000); + assertEq(channel.getChannelState(channelId).settled, 500_000); assertEq(token.balanceOf(payee), 500_000); } function test_settle_revert_afterExpiry() public { - bytes32 channelId = _openChannelWithExpiry(uint64(block.timestamp + 10)); + bytes32 channelId = _openChannelWithExpiry(uint32(block.timestamp + 10)); bytes memory sig = _signVoucher(channelId, 500_000); vm.warp(block.timestamp + 10); vm.prank(payee); vm.expectRevert(ITIP20ChannelEscrow.ChannelExpiredError.selector); - channel.settle(channelId, 500_000, sig); + channel.settle(_descriptor(), 500_000, sig); } function test_settle_revert_invalidSignature() public { @@ -205,7 +227,16 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payee); vm.expectRevert(ITIP20ChannelEscrow.InvalidSignature.selector); - channel.settle(channelId, 500_000, sig); + channel.settle(_descriptor(), 500_000, sig); + } + + function test_settle_revert_wrongDescriptor() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, 500_000); + + vm.prank(payee); + vm.expectRevert(ITIP20ChannelEscrow.ChannelNotFound.selector); + channel.settle(_descriptor(bytes32(uint256(2)), address(0)), 500_000, sig); } function test_authorizedSigner_settleSuccess() public { @@ -218,41 +249,41 @@ contract TIP20ChannelEscrowTest is BaseTest { bytes memory sig = _signVoucher(channelId, 500_000, delegateKey); vm.prank(payee); - channel.settle(channelId, 500_000, sig); + channel.settle(_descriptor(SALT, delegateSigner), 500_000, sig); - assertEq(channel.getChannel(channelId).settled, 500_000); + assertEq(channel.getChannelState(channelId).settled, 500_000); } function test_topUp_updatesDepositAndExpiry() public { bytes32 channelId = _openChannel(); - uint64 nextExpiry = uint64(block.timestamp + 2 days); + uint32 nextExpiry = uint32(block.timestamp + 2 days); vm.prank(payer); - channel.topUp(channelId, 250_000, nextExpiry); + channel.topUp(_descriptor(), 250_000, nextExpiry); - TIP20ChannelEscrow.Channel memory ch = channel.getChannel(channelId); + ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); assertEq(ch.deposit, DEPOSIT + 250_000); assertEq(ch.expiresAt, nextExpiry); } function test_topUp_revert_nonIncreasingExpiry() public { - bytes32 channelId = _openChannel(); + _openChannel(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidExpiry.selector); - channel.topUp(channelId, 0, _defaultExpiry()); + channel.topUp(_descriptor(), 0, _defaultExpiry()); } function test_topUp_cancelsCloseRequest() public { bytes32 channelId = _openChannel(); vm.prank(payer); - channel.requestClose(channelId); + channel.requestClose(_descriptor()); vm.prank(payer); - channel.topUp(channelId, 100_000, 0); + channel.topUp(_descriptor(), 100_000, 0); - assertEq(channel.getChannel(channelId).closeRequestedAt, 0); + assertEq(channel.getChannelState(channelId).closeRequestedAt, 0); } function test_close_partialCapture_success() public { @@ -263,9 +294,9 @@ contract TIP20ChannelEscrowTest is BaseTest { uint256 payerBalanceBefore = token.balanceOf(payer); vm.prank(payee); - channel.close(channelId, 900_000, 600_000, sig); + channel.close(_descriptor(), 900_000, 600_000, sig); - TIP20ChannelEscrow.Channel memory ch = channel.getChannel(channelId); + ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); assertTrue(ch.finalized); assertEq(ch.settled, 600_000); assertEq(token.balanceOf(payee), payeeBalanceBefore + 600_000); @@ -277,16 +308,16 @@ contract TIP20ChannelEscrowTest is BaseTest { bytes memory settleSig = _signVoucher(channelId, 300_000); vm.prank(payee); - channel.settle(channelId, 300_000, settleSig); + channel.settle(_descriptor(), 300_000, settleSig); bytes memory closeSig = _signVoucher(channelId, 800_000); uint256 payeeBalanceBefore = token.balanceOf(payee); vm.prank(payee); - channel.close(channelId, 800_000, 500_000, closeSig); + channel.close(_descriptor(), 800_000, 500_000, closeSig); assertEq(token.balanceOf(payee), payeeBalanceBefore + 200_000); - assertEq(channel.getChannel(channelId).settled, 500_000); + assertEq(channel.getChannelState(channelId).settled, 500_000); } function test_close_allowsVoucherAmountAboveDepositWhenCaptureWithinDeposit() public { @@ -296,9 +327,9 @@ contract TIP20ChannelEscrowTest is BaseTest { uint256 payeeBalanceBefore = token.balanceOf(payee); vm.prank(payee); - channel.close(channelId, DEPOSIT + 250_000, DEPOSIT, sig); + channel.close(_descriptor(), DEPOSIT + 250_000, DEPOSIT, sig); - TIP20ChannelEscrow.Channel memory ch = channel.getChannel(channelId); + ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); assertTrue(ch.finalized); assertEq(ch.settled, DEPOSIT); assertEq(token.balanceOf(payee), payeeBalanceBefore + DEPOSIT); @@ -309,60 +340,72 @@ contract TIP20ChannelEscrowTest is BaseTest { bytes memory settleSig = _signVoucher(channelId, 300_000); vm.prank(payee); - channel.settle(channelId, 300_000, settleSig); + channel.settle(_descriptor(), 300_000, settleSig); vm.prank(payee); vm.expectRevert(ITIP20ChannelEscrow.CaptureAmountInvalid.selector); - channel.close(channelId, 300_000, 200_000, ""); + channel.close(_descriptor(), 300_000, 200_000, ""); } function test_close_afterExpiry_allowsNoAdditionalCapture() public { - bytes32 channelId = _openChannelWithExpiry(uint64(block.timestamp + 10)); + bytes32 channelId = _openChannelWithExpiry(uint32(block.timestamp + 10)); bytes memory sig = _signVoucher(channelId, 300_000); vm.prank(payee); - channel.settle(channelId, 300_000, sig); + channel.settle(_descriptor(), 300_000, sig); vm.warp(block.timestamp + 10); uint256 payerBalanceBefore = token.balanceOf(payer); vm.prank(payee); - channel.close(channelId, 300_000, 300_000, ""); + channel.close(_descriptor(), 300_000, 300_000, ""); - assertTrue(channel.getChannel(channelId).finalized); + assertTrue(channel.getChannelState(channelId).finalized); assertEq(token.balanceOf(payer), payerBalanceBefore + (DEPOSIT - 300_000)); } + function test_close_keepsTombstoneAndBlocksReopen() public { + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, 600_000); + + vm.prank(payee); + channel.close(_descriptor(), 600_000, 600_000, sig); + + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); + channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); + } + function test_withdraw_afterGracePeriod() public { bytes32 channelId = _openChannel(); vm.prank(payer); - channel.requestClose(channelId); + channel.requestClose(_descriptor()); vm.warp(block.timestamp + channel.CLOSE_GRACE_PERIOD() + 1); uint256 payerBalanceBefore = token.balanceOf(payer); vm.prank(payer); - channel.withdraw(channelId); + channel.withdraw(_descriptor()); - assertTrue(channel.getChannel(channelId).finalized); + assertTrue(channel.getChannelState(channelId).finalized); assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); } function test_withdraw_afterExpiryWithoutCloseRequest() public { - bytes32 channelId = _openChannelWithExpiry(uint64(block.timestamp + 10)); + bytes32 channelId = _openChannelWithExpiry(uint32(block.timestamp + 10)); vm.warp(block.timestamp + 10); uint256 payerBalanceBefore = token.balanceOf(payer); vm.prank(payer); - channel.withdraw(channelId); + channel.withdraw(_descriptor()); - assertTrue(channel.getChannel(channelId).finalized); + assertTrue(channel.getChannelState(channelId).finalized); assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); } - function test_getChannelsBatch_success() public { + function test_getChannelStatesBatch_success() public { bytes32 channelId1 = _openChannel(); vm.prank(payer); @@ -372,13 +415,13 @@ contract TIP20ChannelEscrowTest is BaseTest { bytes memory sig = _signVoucher(channelId1, 400_000); vm.prank(payee); - channel.settle(channelId1, 400_000, sig); + channel.settle(_descriptor(), 400_000, sig); bytes32[] memory ids = new bytes32[](2); ids[0] = channelId1; ids[1] = channelId2; - TIP20ChannelEscrow.Channel[] memory states = channel.getChannelsBatch(ids); + ITIP20ChannelEscrow.ChannelState[] memory states = channel.getChannelStatesBatch(ids); assertEq(states.length, 2); assertEq(states[0].settled, 400_000); assertEq(states[1].settled, 0); From e16f9cc5bdf0ebefaf36e16b605fbdd92d05d2cd Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 21 Apr 2026 17:32:15 +0530 Subject: [PATCH 16/33] refactor(tip-1034): expose closeData in channel state Amp-Thread-ID: https://ampcode.com/threads/T-019dafbf-0246-74d7-ba6d-082551e5f158 --- tips/tip-1034.md | 42 ++++++++++++----------- tips/verify/test/TIP20ChannelEscrow.t.sol | 39 +++++++++++++++------ 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 75c69a5ec4..c3c6ad930e 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -63,11 +63,10 @@ struct ChannelDescriptor { } struct ChannelState { - bool finalized; - uint32 closeRequestedAt; - uint32 expiresAt; - uint96 deposit; uint96 settled; + uint96 deposit; + uint32 expiresAt; + uint32 closeData; } struct Channel { @@ -94,10 +93,10 @@ bits 224..255 closeData uint32 1. `0` for an active channel with no close request. 2. `1` for a finalized channel tombstone. -3. `closeRequestedAt + 2` for an active channel with a close request. +3. Any value `>= 2` for an active channel with a close request, where the stored value is the exact `closeRequestedAt` timestamp. -Implementations MUST decode `closeRequestedAt` as `0` when `closeData < 2`, otherwise as -`closeData - 2`. +This means timestamps `0` and `1` are reserved sentinel values and are not representable as +close-request times. `channelId` MUST use the following deterministic domain-separated construction: @@ -120,7 +119,7 @@ channelId = keccak256( The canonical interface for this TIP is [`tips/ref-impls/src/interfaces/ITIP20ChannelEscrow.sol`](ref-impls/src/interfaces/ITIP20ChannelEscrow.sol). Implementations MUST expose an external interface that is semantically identical to that file, -including the `ChannelDescriptor`, `ChannelState`, and `Channel` view structs, the descriptor-based +including the `ChannelDescriptor`, `ChannelState`, and `Channel` structs, the descriptor-based post-open methods, the events, the errors, and the function signatures. ### Execution Semantics @@ -152,8 +151,8 @@ Execution semantics are: 2. `open` MUST persist only the packed `ChannelState` slot and MUST emit the full immutable descriptor in `ChannelOpened`. 3. Post-open methods (`settle`, `topUp`, `close`, `requestClose`, `withdraw`, and descriptor-based views) MUST recompute `channelId` from the supplied descriptor and use that derived id for storage lookup. 4. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST satisfy both `newExpiresAt > block.timestamp` and `newExpiresAt > current expiresAt`. -5. If `closeRequestedAt != 0`, a successful `topUp` MUST clear it and emit `CloseRequestCancelled`. -6. `requestClose` MUST encode `closeRequestedAt = block.timestamp` into the packed slot on the first successful call and leave it unchanged on later successful calls. +5. If `closeData >= 2`, a successful `topUp` MUST clear it back to `0` and emit `CloseRequestCancelled`. +6. `requestClose` MUST set `closeData = uint32(block.timestamp)` on the first successful call and leave it unchanged on later successful calls. 7. `settle` MUST reject when `block.timestamp >= expiresAt`. 8. `close` MUST reject when `block.timestamp >= expiresAt` and `captureAmount > previousSettled`. 9. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. @@ -162,8 +161,8 @@ Execution semantics are: 12. `close` MUST reject when `captureAmount > deposit`, even if `cumulativeAmount > deposit`. 13. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. 14. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. -15. `withdraw` MUST be allowed when either the close grace period has elapsed or `block.timestamp >= expiresAt`. -16. Terminal `close` and `withdraw` MUST leave a non-zero finalized tombstone in the packed slot. Implementations MUST NOT delete the slot, so the same descriptor and `channelId` cannot be reopened and replay old vouchers. +15. `withdraw` MUST be allowed when either the close grace period has elapsed from `closeData` or `block.timestamp >= expiresAt`. +16. Terminal `close` and `withdraw` MUST set `closeData = 1` and MUST NOT delete the slot, so the same descriptor and `channelId` cannot be reopened and replay old vouchers. ## Native Escrow Movement @@ -222,14 +221,17 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 4. Only payer can `topUp`, `requestClose`, and `withdraw`. 5. Only payee can `settle` and `close`. 6. A channel MUST consume exactly one storage slot of mutable on-chain state. -7. Once finalized, all state-changing methods MUST revert for that channel. -8. Finalized channels MUST retain a non-zero tombstone state so the same descriptor cannot be reopened. -9. Fund conservation MUST hold at all terminal states. -10. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. -11. `open` and `topUp` MUST not require a prior user `approve` transaction. -12. No capture-increasing operation may succeed past `expiresAt`. -13. `topUp` MUST NOT reduce or preserve the channel expiry when `newExpiresAt` is provided. -14. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. +7. `closeData == 0` MUST mean active with no close request. +8. `closeData == 1` MUST mean finalized. +9. `closeData >= 2` MUST mean active with a close request timestamp equal to `closeData`. +10. Once finalized, all state-changing methods MUST revert for that channel. +11. Finalized channels MUST retain a non-zero tombstone state so the same descriptor cannot be reopened. +12. Fund conservation MUST hold at all terminal states. +13. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. +14. `open` and `topUp` MUST not require a prior user `approve` transaction. +15. No capture-increasing operation may succeed past `expiresAt`. +16. `topUp` MUST NOT reduce or preserve the channel expiry when `newExpiresAt` is provided. +17. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. ## References diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol index 6ca30322da..df6534dc70 100644 --- a/tips/verify/test/TIP20ChannelEscrow.t.sol +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -129,6 +129,10 @@ contract TIP20ChannelEscrowTest is BaseTest { }); } + function _channelStateSlot(bytes32 channelId) internal pure returns (bytes32) { + return keccak256(abi.encode(channelId, uint256(0))); + } + function _signVoucher(bytes32 channelId, uint96 amount) internal view returns (bytes memory) { return _signVoucher(channelId, amount, payerKey); } @@ -155,15 +159,14 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); ITIP20ChannelEscrow.Channel memory ch = channel.getChannel(_descriptor()); - assertFalse(ch.state.finalized); - assertEq(ch.state.closeRequestedAt, 0); assertEq(ch.descriptor.payer, payer); assertEq(ch.descriptor.payee, payee); - assertEq(ch.state.expiresAt, expiresAt); assertEq(ch.descriptor.token, address(token)); assertEq(ch.descriptor.authorizedSigner, address(0)); - assertEq(ch.state.deposit, DEPOSIT); assertEq(ch.state.settled, 0); + assertEq(ch.state.deposit, DEPOSIT); + assertEq(ch.state.expiresAt, expiresAt); + assertEq(ch.state.closeData, 0); assertEq(channel.getChannelState(channelId).deposit, DEPOSIT); } @@ -283,7 +286,21 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payer); channel.topUp(_descriptor(), 100_000, 0); - assertEq(channel.getChannelState(channelId).closeRequestedAt, 0); + assertEq(channel.getChannelState(channelId).closeData, 0); + } + + function test_requestClose_storesTimestampInCloseData() public { + bytes32 channelId = _openChannel(); + uint32 closeRequestedAt = uint32(block.timestamp); + + vm.prank(payer); + channel.requestClose(_descriptor()); + + ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); + assertEq(ch.closeData, closeRequestedAt); + + uint256 raw = uint256(vm.load(address(channel), _channelStateSlot(channelId))); + assertEq(uint32(raw >> 224), closeRequestedAt); } function test_close_partialCapture_success() public { @@ -297,8 +314,8 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.close(_descriptor(), 900_000, 600_000, sig); ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); - assertTrue(ch.finalized); assertEq(ch.settled, 600_000); + assertEq(ch.closeData, 1); assertEq(token.balanceOf(payee), payeeBalanceBefore + 600_000); assertEq(token.balanceOf(payer), payerBalanceBefore + 400_000); } @@ -330,8 +347,8 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.close(_descriptor(), DEPOSIT + 250_000, DEPOSIT, sig); ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); - assertTrue(ch.finalized); assertEq(ch.settled, DEPOSIT); + assertEq(ch.closeData, 1); assertEq(token.balanceOf(payee), payeeBalanceBefore + DEPOSIT); } @@ -360,7 +377,7 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payee); channel.close(_descriptor(), 300_000, 300_000, ""); - assertTrue(channel.getChannelState(channelId).finalized); + assertEq(channel.getChannelState(channelId).closeData, 1); assertEq(token.balanceOf(payer), payerBalanceBefore + (DEPOSIT - 300_000)); } @@ -371,6 +388,8 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payee); channel.close(_descriptor(), 600_000, 600_000, sig); + assertEq(channel.getChannelState(channelId).closeData, 1); + vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); @@ -388,7 +407,7 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payer); channel.withdraw(_descriptor()); - assertTrue(channel.getChannelState(channelId).finalized); + assertEq(channel.getChannelState(channelId).closeData, 1); assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); } @@ -401,7 +420,7 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payer); channel.withdraw(_descriptor()); - assertTrue(channel.getChannelState(channelId).finalized); + assertEq(channel.getChannelState(channelId).closeData, 1); assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); } From 23a8ef0886fcd39a6da18ffaf7f31dd0265de290 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 21 Apr 2026 18:34:01 +0530 Subject: [PATCH 17/33] docs(tip-1034): justify packed integer widths Amp-Thread-ID: https://ampcode.com/threads/T-019dafbf-0246-74d7-ba6d-082551e5f158 --- tips/tip-1034.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index c3c6ad930e..5b47f18345 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -89,6 +89,14 @@ bits 192..223 expiresAt uint32 bits 224..255 closeData uint32 ``` +These widths are chosen to keep the entire mutable channel state in one storage slot without +introducing any practical limit for production usage. `uint96` supports up to +`2^96 - 1 = 79,228,162,514,264,337,593,543,950,335` base units, which is far above the supply or +escrow size of any realistic TIP-20 token deployment. `uint32` stores second-resolution unix +timestamps through February 2106, which is sufficient for channel expiry and close-grace tracking +because channels are expected to live for minutes, hours, days, or months rather than many +decades. + `closeData` MUST be encoded as: 1. `0` for an active channel with no close request. From 538b8c5dbcc32f2b8825f7281a95bc1bdc96ed1b Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 18:28:32 +0530 Subject: [PATCH 18/33] docs(tip-1034): restore reference escrow artifacts Amp-Thread-ID: https://ampcode.com/threads/T-019dcee2-6079-716d-aecd-628012303ecf --- tips/tip-1034.md | 8 +- tips/verify/src/TIP20ChannelEscrow.sol | 406 ++++++++++++++++++ .../src/interfaces/ISignatureVerifier.sol | 32 ++ tips/verify/src/interfaces/ITIP20.sol | 11 + .../src/interfaces/ITIP20ChannelEscrow.sol | 170 ++++++++ 5 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 tips/verify/src/TIP20ChannelEscrow.sol create mode 100644 tips/verify/src/interfaces/ISignatureVerifier.sol create mode 100644 tips/verify/src/interfaces/ITIP20.sol create mode 100644 tips/verify/src/interfaces/ITIP20ChannelEscrow.sol diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 5b47f18345..40e99745fd 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -42,10 +42,10 @@ address constant TIP20_CHANNEL_ESCROW = 0x4D505000000000000000000000000000000000 ## Implementation Details -This TIP is normative. The current reference implementations are informative and live at: +This TIP is normative. The current reference Solidity artifacts are informative and live at: -1. [`tips/ref-impls/src/interfaces/ITIP20ChannelEscrow.sol`](ref-impls/src/interfaces/ITIP20ChannelEscrow.sol) -2. [`tips/ref-impls/src/TIP20ChannelEscrow.sol`](ref-impls/src/TIP20ChannelEscrow.sol) +1. [`tips/verify/src/interfaces/ITIP20ChannelEscrow.sol`](verify/src/interfaces/ITIP20ChannelEscrow.sol) +2. [`tips/verify/src/TIP20ChannelEscrow.sol`](verify/src/TIP20ChannelEscrow.sol) Implementations SHOULD keep those reference artifacts aligned with the normative interface and execution rules defined below. @@ -124,7 +124,7 @@ channelId = keccak256( ### Interface -The canonical interface for this TIP is [`tips/ref-impls/src/interfaces/ITIP20ChannelEscrow.sol`](ref-impls/src/interfaces/ITIP20ChannelEscrow.sol). +The canonical interface for this TIP is [`tips/verify/src/interfaces/ITIP20ChannelEscrow.sol`](verify/src/interfaces/ITIP20ChannelEscrow.sol). Implementations MUST expose an external interface that is semantically identical to that file, including the `ChannelDescriptor`, `ChannelState`, and `Channel` structs, the descriptor-based diff --git a/tips/verify/src/TIP20ChannelEscrow.sol b/tips/verify/src/TIP20ChannelEscrow.sol new file mode 100644 index 0000000000..d3ca981f45 --- /dev/null +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { ISignatureVerifier } from "./interfaces/ISignatureVerifier.sol"; +import { ITIP20 } from "./interfaces/ITIP20.sol"; +import { ITIP20ChannelEscrow } from "./interfaces/ITIP20ChannelEscrow.sol"; + +/// @title TIP20ChannelEscrow +/// @notice Reference contract for the TIP-1034 channel model. +contract TIP20ChannelEscrow is ITIP20ChannelEscrow { + + address public constant TIP20_CHANNEL_ESCROW = 0x4d50500000000000000000000000000000000000; + address public constant SIGNATURE_VERIFIER_PRECOMPILE = + 0x5165300000000000000000000000000000000000; + + bytes32 public constant VOUCHER_TYPEHASH = + keccak256("Voucher(bytes32 channelId,uint96 cumulativeAmount)"); + + uint64 public constant CLOSE_GRACE_PERIOD = 15 minutes; + + uint256 internal constant _DEPOSIT_OFFSET = 96; + uint256 internal constant _EXPIRES_AT_OFFSET = 192; + uint256 internal constant _STATUS_OFFSET = 224; + uint32 internal constant _FINALIZED_STATUS = 1; + uint32 internal constant _CLOSE_REQUEST_OFFSET = 2; + + bytes32 internal constant _EIP712_DOMAIN_TYPEHASH = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 internal constant _NAME_HASH = keccak256("TIP20 Channel Escrow"); + bytes32 internal constant _VERSION_HASH = keccak256("1"); + + mapping(bytes32 => uint256) internal channelStates; + + function open( + address payee, + address token, + uint96 deposit, + bytes32 salt, + address authorizedSigner, + uint32 expiresAt + ) + external + returns (bytes32 channelId) + { + if (payee == address(0)) revert InvalidPayee(); + if (token == address(0)) revert InvalidToken(); + if (deposit == 0) revert ZeroDeposit(); + if (expiresAt <= block.timestamp) revert InvalidExpiry(); + + channelId = computeChannelId(msg.sender, payee, token, salt, authorizedSigner); + if (channelStates[channelId] != 0) revert ChannelAlreadyExists(); + + channelStates[channelId] = _encodeChannelState( + ChannelState({ + finalized: false, + closeRequestedAt: 0, + expiresAt: expiresAt, + deposit: deposit, + settled: 0 + }) + ); + + bool success = ITIP20(token).transferFrom(msg.sender, address(this), deposit); + if (!success) revert TransferFailed(); + + emit ChannelOpened( + channelId, msg.sender, payee, token, authorizedSigner, salt, deposit, expiresAt + ); + } + + function settle( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + bytes calldata signature + ) + external + { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payee) revert NotPayee(); + if (channel.finalized) revert ChannelFinalized(); + if (_isExpired(channel.expiresAt)) revert ChannelExpiredError(); + if (cumulativeAmount > channel.deposit) revert AmountExceedsDeposit(); + if (cumulativeAmount <= channel.settled) revert AmountNotIncreasing(); + + _validateVoucher(descriptor, channelId, cumulativeAmount, signature); + + uint96 delta = cumulativeAmount - channel.settled; + channel.settled = cumulativeAmount; + channelStates[channelId] = _encodeChannelState(channel); + + bool success = ITIP20(descriptor.token).transfer(descriptor.payee, delta); + if (!success) revert TransferFailed(); + + emit Settled( + channelId, descriptor.payer, descriptor.payee, cumulativeAmount, delta, channel.settled + ); + } + + function topUp( + ChannelDescriptor calldata descriptor, + uint96 additionalDeposit, + uint32 newExpiresAt + ) + external + { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payer) revert NotPayer(); + if (channel.finalized) revert ChannelFinalized(); + + if (additionalDeposit > type(uint96).max - channel.deposit) revert DepositOverflow(); + if (newExpiresAt != 0) { + if (newExpiresAt <= block.timestamp) revert InvalidExpiry(); + if (newExpiresAt <= channel.expiresAt) revert InvalidExpiry(); + } + + if (additionalDeposit > 0) { + channel.deposit += additionalDeposit; + + bool success = + ITIP20(descriptor.token).transferFrom(msg.sender, address(this), additionalDeposit); + if (!success) revert TransferFailed(); + } + + if (newExpiresAt != 0) { + channel.expiresAt = newExpiresAt; + } + + if (channel.closeRequestedAt != 0) { + channel.closeRequestedAt = 0; + emit CloseRequestCancelled(channelId, descriptor.payer, descriptor.payee); + } + + channelStates[channelId] = _encodeChannelState(channel); + + emit TopUp( + channelId, + descriptor.payer, + descriptor.payee, + additionalDeposit, + channel.deposit, + channel.expiresAt + ); + } + + function requestClose(ChannelDescriptor calldata descriptor) external { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payer) revert NotPayer(); + if (channel.finalized) revert ChannelFinalized(); + + if (channel.closeRequestedAt == 0) { + channel.closeRequestedAt = uint32(block.timestamp); + channelStates[channelId] = _encodeChannelState(channel); + emit CloseRequested( + channelId, + descriptor.payer, + descriptor.payee, + uint256(block.timestamp) + CLOSE_GRACE_PERIOD + ); + } + } + + function close( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + uint96 captureAmount, + bytes calldata signature + ) + external + { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payee) revert NotPayee(); + if (channel.finalized) revert ChannelFinalized(); + + uint96 previousSettled = channel.settled; + if (captureAmount < previousSettled || captureAmount > cumulativeAmount) { + revert CaptureAmountInvalid(); + } + if (captureAmount > channel.deposit) revert AmountExceedsDeposit(); + + if (captureAmount > previousSettled) { + if (_isExpired(channel.expiresAt)) revert ChannelExpiredError(); + _validateVoucher(descriptor, channelId, cumulativeAmount, signature); + } + + uint96 delta = captureAmount - previousSettled; + uint96 refund = channel.deposit - captureAmount; + + channel.settled = captureAmount; + channel.finalized = true; + channelStates[channelId] = _encodeChannelState(channel); + + if (delta > 0) { + bool payeeTransferSucceeded = ITIP20(descriptor.token).transfer(descriptor.payee, delta); + if (!payeeTransferSucceeded) revert TransferFailed(); + } + + if (refund > 0) { + bool payerTransferSucceeded = + ITIP20(descriptor.token).transfer(descriptor.payer, refund); + if (!payerTransferSucceeded) revert TransferFailed(); + } + + emit ChannelClosed(channelId, descriptor.payer, descriptor.payee, captureAmount, refund); + } + + function withdraw(ChannelDescriptor calldata descriptor) external { + bytes32 channelId = _channelId(descriptor); + ChannelState memory channel = _loadChannelState(channelId); + + if (msg.sender != descriptor.payer) revert NotPayer(); + if (channel.finalized) revert ChannelFinalized(); + + bool closeGracePassed = channel.closeRequestedAt != 0 + && block.timestamp >= uint256(channel.closeRequestedAt) + CLOSE_GRACE_PERIOD; + + if (!closeGracePassed && !_isExpired(channel.expiresAt)) revert CloseNotReady(); + + uint96 refund = channel.deposit - channel.settled; + channel.finalized = true; + channelStates[channelId] = _encodeChannelState(channel); + + if (refund > 0) { + bool success = ITIP20(descriptor.token).transfer(descriptor.payer, refund); + if (!success) revert TransferFailed(); + } + + emit ChannelExpired(channelId, descriptor.payer, descriptor.payee); + emit ChannelClosed(channelId, descriptor.payer, descriptor.payee, channel.settled, refund); + } + + function getChannel(ChannelDescriptor calldata descriptor) + external + view + returns (Channel memory channel) + { + channel.descriptor = ChannelDescriptor({ + payer: descriptor.payer, + payee: descriptor.payee, + token: descriptor.token, + salt: descriptor.salt, + authorizedSigner: descriptor.authorizedSigner + }); + channel.state = _decodeChannelState(channelStates[_channelId(descriptor)]); + } + + function getChannelState(bytes32 channelId) external view returns (ChannelState memory) { + return _decodeChannelState(channelStates[channelId]); + } + + function getChannelStatesBatch(bytes32[] calldata channelIds) + external + view + returns (ChannelState[] memory states) + { + uint256 length = channelIds.length; + states = new ChannelState[](length); + + for (uint256 i = 0; i < length; ++i) { + states[i] = _decodeChannelState(channelStates[channelIds[i]]); + } + } + + function computeChannelId( + address payer, + address payee, + address token, + bytes32 salt, + address authorizedSigner + ) + public + view + returns (bytes32) + { + return keccak256( + abi.encode( + payer, payee, token, salt, authorizedSigner, TIP20_CHANNEL_ESCROW, block.chainid + ) + ); + } + + function getVoucherDigest( + bytes32 channelId, + uint96 cumulativeAmount + ) + external + view + returns (bytes32) + { + bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); + return _hashTypedData(structHash); + } + + function domainSeparator() external view returns (bytes32) { + return _domainSeparator(); + } + + function _channelId(ChannelDescriptor calldata descriptor) internal view returns (bytes32) { + return computeChannelId( + descriptor.payer, + descriptor.payee, + descriptor.token, + descriptor.salt, + descriptor.authorizedSigner + ); + } + + function _loadChannelState(bytes32 channelId) internal view returns (ChannelState memory) { + uint256 packedState = channelStates[channelId]; + if (packedState == 0) revert ChannelNotFound(); + return _decodeChannelState(packedState); + } + + function _decodeChannelState(uint256 packedState) + internal + pure + returns (ChannelState memory state) + { + if (packedState == 0) { + return state; + } + + uint32 status = uint32(packedState >> _STATUS_OFFSET); + state.finalized = status == _FINALIZED_STATUS; + state.closeRequestedAt = + status >= _CLOSE_REQUEST_OFFSET ? status - _CLOSE_REQUEST_OFFSET : 0; + state.expiresAt = uint32(packedState >> _EXPIRES_AT_OFFSET); + state.deposit = uint96(packedState >> _DEPOSIT_OFFSET); + state.settled = uint96(packedState); + } + + function _encodeChannelState(ChannelState memory state) + internal + pure + returns (uint256 packedState) + { + uint32 status; + if (state.finalized) { + status = _FINALIZED_STATUS; + } else if (state.closeRequestedAt != 0) { + status = state.closeRequestedAt + _CLOSE_REQUEST_OFFSET; + } + + packedState = uint256(state.settled); + packedState |= uint256(state.deposit) << _DEPOSIT_OFFSET; + packedState |= uint256(state.expiresAt) << _EXPIRES_AT_OFFSET; + packedState |= uint256(status) << _STATUS_OFFSET; + } + + function _isExpired(uint32 expiresAt) internal view returns (bool) { + return block.timestamp >= expiresAt; + } + + function _validateVoucher( + ChannelDescriptor calldata descriptor, + bytes32 channelId, + uint96 cumulativeAmount, + bytes calldata signature + ) + internal + view + { + bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); + bytes32 digest = _hashTypedData(structHash); + address expectedSigner = descriptor.authorizedSigner != address(0) + ? descriptor.authorizedSigner + : descriptor.payer; + + bool isValid; + try ISignatureVerifier(SIGNATURE_VERIFIER_PRECOMPILE) + .verify(expectedSigner, digest, signature) returns ( + bool valid + ) { + isValid = valid; + } catch { + revert InvalidSignature(); + } + + if (!isValid) revert InvalidSignature(); + } + + function _domainSeparator() internal view returns (bytes32) { + return keccak256( + abi.encode( + _EIP712_DOMAIN_TYPEHASH, + _NAME_HASH, + _VERSION_HASH, + block.chainid, + TIP20_CHANNEL_ESCROW + ) + ); + } + + function _hashTypedData(bytes32 structHash) internal view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + } + +} diff --git a/tips/verify/src/interfaces/ISignatureVerifier.sol b/tips/verify/src/interfaces/ISignatureVerifier.sol new file mode 100644 index 0000000000..b7b19cb273 --- /dev/null +++ b/tips/verify/src/interfaces/ISignatureVerifier.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.13 <0.9.0; + +/// @title ISignatureVerifier +/// @notice Interface for the TIP-1020 Signature Verification Precompile +/// @dev Deployed at 0x5165300000000000000000000000000000000000 +interface ISignatureVerifier { + + error InvalidFormat(); + error InvalidSignature(); + + /// @notice Recovers the signer of a Tempo signature (secp256k1, P256, WebAuthn). + /// @param hash The message hash that was signed + /// @param signature The encoded signature (see Tempo Transaction spec for formats) + /// @return signer Address of the signer if valid, reverts otherwise + function recover(bytes32 hash, bytes calldata signature) external view returns (address signer); + + /// @notice Verifies a signer against a Tempo signature (secp256k1, P256, WebAuthn). + /// @param signer The input address verified against the recovered signer + /// @param hash The message hash that was signed + /// @param signature The encoded signature (see Tempo Transaction spec for formats) + /// @return True if the input address signed, false otherwise. Reverts on invalid signatures. + function verify( + address signer, + bytes32 hash, + bytes calldata signature + ) + external + view + returns (bool); + +} diff --git a/tips/verify/src/interfaces/ITIP20.sol b/tips/verify/src/interfaces/ITIP20.sol new file mode 100644 index 0000000000..7e8ef2b57b --- /dev/null +++ b/tips/verify/src/interfaces/ITIP20.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.13 <0.9.0; + +/// @title Minimal TIP-20 interface required by the historical TempoStreamChannel reference impl. +interface ITIP20 { + + function transfer(address to, uint256 amount) external returns (bool); + + function transferFrom(address from, address to, uint256 amount) external returns (bool); + +} diff --git a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol new file mode 100644 index 0000000000..26dc21f7e6 --- /dev/null +++ b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.20 <0.9.0; + +/// @title ITIP20ChannelEscrow +/// @notice Reference interface for the TIP-1034 channel model. +interface ITIP20ChannelEscrow { + + struct ChannelDescriptor { + address payer; + address payee; + address token; + bytes32 salt; + address authorizedSigner; + } + + struct ChannelState { + bool finalized; + uint32 closeRequestedAt; + uint32 expiresAt; + uint96 deposit; + uint96 settled; + } + + struct Channel { + ChannelDescriptor descriptor; + ChannelState state; + } + + function CLOSE_GRACE_PERIOD() external view returns (uint64); + function VOUCHER_TYPEHASH() external view returns (bytes32); + + function open( + address payee, + address token, + uint96 deposit, + bytes32 salt, + address authorizedSigner, + uint32 expiresAt + ) + external + returns (bytes32 channelId); + + function settle( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + bytes calldata signature + ) + external; + + function topUp( + ChannelDescriptor calldata descriptor, + uint96 additionalDeposit, + uint32 newExpiresAt + ) + external; + + function close( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + uint96 captureAmount, + bytes calldata signature + ) + external; + + function requestClose(ChannelDescriptor calldata descriptor) external; + + function withdraw(ChannelDescriptor calldata descriptor) external; + + function getChannel(ChannelDescriptor calldata descriptor) + external + view + returns (Channel memory); + + function getChannelState(bytes32 channelId) external view returns (ChannelState memory); + + function getChannelStatesBatch(bytes32[] calldata channelIds) + external + view + returns (ChannelState[] memory); + + function computeChannelId( + address payer, + address payee, + address token, + bytes32 salt, + address authorizedSigner + ) + external + view + returns (bytes32); + + function getVoucherDigest( + bytes32 channelId, + uint96 cumulativeAmount + ) + external + view + returns (bytes32); + + function domainSeparator() external view returns (bytes32); + + event ChannelOpened( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + address token, + address authorizedSigner, + bytes32 salt, + uint96 deposit, + uint32 expiresAt + ); + + event Settled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 cumulativeAmount, + uint96 deltaPaid, + uint96 newSettled + ); + + event TopUp( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 additionalDeposit, + uint96 newDeposit, + uint32 newExpiresAt + ); + + event CloseRequested( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint256 closeGraceEnd + ); + + event ChannelClosed( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 settledToPayee, + uint96 refundedToPayer + ); + + event CloseRequestCancelled( + bytes32 indexed channelId, address indexed payer, address indexed payee + ); + + event ChannelExpired(bytes32 indexed channelId, address indexed payer, address indexed payee); + + error ChannelAlreadyExists(); + error ChannelNotFound(); + error ChannelFinalized(); + error NotPayer(); + error NotPayee(); + error InvalidPayee(); + error InvalidToken(); + error ZeroDeposit(); + error InvalidExpiry(); + error ChannelExpiredError(); + error InvalidSignature(); + error AmountExceedsDeposit(); + error AmountNotIncreasing(); + error CaptureAmountInvalid(); + error CloseNotReady(); + error DepositOverflow(); + error TransferFailed(); + +} From a72e0c0b9169e35dfd62eb10b41aa48defeeac7f Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 29 Apr 2026 16:22:36 +0530 Subject: [PATCH 19/33] docs(tip-1034): align reference escrow state layout Amp-Thread-ID: https://ampcode.com/threads/T-019dd8b6-f4df-709d-96be-9777eaafa232 --- tips/verify/src/TIP20ChannelEscrow.sol | 69 ++++++++++--------- .../src/interfaces/ITIP20ChannelEscrow.sol | 7 +- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/tips/verify/src/TIP20ChannelEscrow.sol b/tips/verify/src/TIP20ChannelEscrow.sol index d3ca981f45..376d4ace23 100644 --- a/tips/verify/src/TIP20ChannelEscrow.sol +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -20,9 +20,8 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { uint256 internal constant _DEPOSIT_OFFSET = 96; uint256 internal constant _EXPIRES_AT_OFFSET = 192; - uint256 internal constant _STATUS_OFFSET = 224; - uint32 internal constant _FINALIZED_STATUS = 1; - uint32 internal constant _CLOSE_REQUEST_OFFSET = 2; + uint256 internal constant _CLOSE_DATA_OFFSET = 224; + uint32 internal constant _FINALIZED_CLOSE_DATA = 1; bytes32 internal constant _EIP712_DOMAIN_TYPEHASH = keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" @@ -53,14 +52,15 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { channelStates[channelId] = _encodeChannelState( ChannelState({ - finalized: false, - closeRequestedAt: 0, - expiresAt: expiresAt, + settled: 0, deposit: deposit, - settled: 0 + expiresAt: expiresAt, + closeData: 0 }) ); + // The reference contract keeps ERC-20-style allowance flow for local verification. + // The enshrined precompile should use TIP-20 `systemTransferFrom` semantics instead. bool success = ITIP20(token).transferFrom(msg.sender, address(this), deposit); if (!success) revert TransferFailed(); @@ -80,7 +80,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payee) revert NotPayee(); - if (channel.finalized) revert ChannelFinalized(); + if (_isFinalized(channel.closeData)) revert ChannelFinalized(); if (_isExpired(channel.expiresAt)) revert ChannelExpiredError(); if (cumulativeAmount > channel.deposit) revert AmountExceedsDeposit(); if (cumulativeAmount <= channel.settled) revert AmountNotIncreasing(); @@ -110,7 +110,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payer) revert NotPayer(); - if (channel.finalized) revert ChannelFinalized(); + if (_isFinalized(channel.closeData)) revert ChannelFinalized(); if (additionalDeposit > type(uint96).max - channel.deposit) revert DepositOverflow(); if (newExpiresAt != 0) { @@ -121,6 +121,8 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (additionalDeposit > 0) { channel.deposit += additionalDeposit; + // The reference contract keeps ERC-20-style allowance flow for local verification. + // The enshrined precompile should use TIP-20 `systemTransferFrom` semantics instead. bool success = ITIP20(descriptor.token).transferFrom(msg.sender, address(this), additionalDeposit); if (!success) revert TransferFailed(); @@ -130,8 +132,8 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { channel.expiresAt = newExpiresAt; } - if (channel.closeRequestedAt != 0) { - channel.closeRequestedAt = 0; + if (_closeRequestedAt(channel.closeData) != 0) { + channel.closeData = 0; emit CloseRequestCancelled(channelId, descriptor.payer, descriptor.payee); } @@ -152,10 +154,10 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payer) revert NotPayer(); - if (channel.finalized) revert ChannelFinalized(); + if (_isFinalized(channel.closeData)) revert ChannelFinalized(); - if (channel.closeRequestedAt == 0) { - channel.closeRequestedAt = uint32(block.timestamp); + if (_closeRequestedAt(channel.closeData) == 0) { + channel.closeData = uint32(block.timestamp); channelStates[channelId] = _encodeChannelState(channel); emit CloseRequested( channelId, @@ -178,7 +180,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payee) revert NotPayee(); - if (channel.finalized) revert ChannelFinalized(); + if (_isFinalized(channel.closeData)) revert ChannelFinalized(); uint96 previousSettled = channel.settled; if (captureAmount < previousSettled || captureAmount > cumulativeAmount) { @@ -195,7 +197,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { uint96 refund = channel.deposit - captureAmount; channel.settled = captureAmount; - channel.finalized = true; + channel.closeData = _FINALIZED_CLOSE_DATA; channelStates[channelId] = _encodeChannelState(channel); if (delta > 0) { @@ -217,15 +219,16 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payer) revert NotPayer(); - if (channel.finalized) revert ChannelFinalized(); + if (_isFinalized(channel.closeData)) revert ChannelFinalized(); - bool closeGracePassed = channel.closeRequestedAt != 0 - && block.timestamp >= uint256(channel.closeRequestedAt) + CLOSE_GRACE_PERIOD; + uint32 closeRequestedAt = _closeRequestedAt(channel.closeData); + bool closeGracePassed = closeRequestedAt != 0 + && block.timestamp >= uint256(closeRequestedAt) + CLOSE_GRACE_PERIOD; if (!closeGracePassed && !_isExpired(channel.expiresAt)) revert CloseNotReady(); uint96 refund = channel.deposit - channel.settled; - channel.finalized = true; + channel.closeData = _FINALIZED_CLOSE_DATA; channelStates[channelId] = _encodeChannelState(channel); if (refund > 0) { @@ -328,13 +331,10 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { return state; } - uint32 status = uint32(packedState >> _STATUS_OFFSET); - state.finalized = status == _FINALIZED_STATUS; - state.closeRequestedAt = - status >= _CLOSE_REQUEST_OFFSET ? status - _CLOSE_REQUEST_OFFSET : 0; - state.expiresAt = uint32(packedState >> _EXPIRES_AT_OFFSET); - state.deposit = uint96(packedState >> _DEPOSIT_OFFSET); state.settled = uint96(packedState); + state.deposit = uint96(packedState >> _DEPOSIT_OFFSET); + state.expiresAt = uint32(packedState >> _EXPIRES_AT_OFFSET); + state.closeData = uint32(packedState >> _CLOSE_DATA_OFFSET); } function _encodeChannelState(ChannelState memory state) @@ -342,17 +342,18 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { pure returns (uint256 packedState) { - uint32 status; - if (state.finalized) { - status = _FINALIZED_STATUS; - } else if (state.closeRequestedAt != 0) { - status = state.closeRequestedAt + _CLOSE_REQUEST_OFFSET; - } - packedState = uint256(state.settled); packedState |= uint256(state.deposit) << _DEPOSIT_OFFSET; packedState |= uint256(state.expiresAt) << _EXPIRES_AT_OFFSET; - packedState |= uint256(status) << _STATUS_OFFSET; + packedState |= uint256(state.closeData) << _CLOSE_DATA_OFFSET; + } + + function _isFinalized(uint32 closeData) internal pure returns (bool) { + return closeData == _FINALIZED_CLOSE_DATA; + } + + function _closeRequestedAt(uint32 closeData) internal pure returns (uint32) { + return closeData >= 2 ? closeData : 0; } function _isExpired(uint32 expiresAt) internal view returns (bool) { diff --git a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol index 26dc21f7e6..b4dff617f5 100644 --- a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol +++ b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol @@ -14,11 +14,10 @@ interface ITIP20ChannelEscrow { } struct ChannelState { - bool finalized; - uint32 closeRequestedAt; - uint32 expiresAt; - uint96 deposit; uint96 settled; + uint96 deposit; + uint32 expiresAt; + uint32 closeData; } struct Channel { From 42733118b23dba092be447138a35feaf7e221ecc Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 23 Apr 2026 16:19:20 +0530 Subject: [PATCH 20/33] feat(tip-1034): implement channel escrow precompile Adds the native TIP-20 channel escrow precompile, wires T4 activation and payment-lane classification, and introduces the packed U96 storage primitive needed for the single-slot channel state. Amp-Thread-ID: https://ampcode.com/threads/T-019db9e6-a988-765a-ad31-301b7b72fc95 --- crates/contracts/src/precompiles/mod.rs | 2 + .../src/precompiles/tip20_channel_escrow.rs | 290 ++++++ crates/evm/src/block.rs | 7 +- crates/precompiles/src/error.rs | 9 +- crates/precompiles/src/lib.rs | 56 +- crates/precompiles/src/storage/types/mod.rs | 1 + .../src/storage/types/primitives.rs | 142 ++- .../src/tip20_channel_escrow/dispatch.rs | 66 ++ .../src/tip20_channel_escrow/mod.rs | 859 ++++++++++++++++++ crates/primitives/src/transaction/envelope.rs | 217 ++++- 10 files changed, 1615 insertions(+), 34 deletions(-) create mode 100644 crates/contracts/src/precompiles/tip20_channel_escrow.rs create mode 100644 crates/precompiles/src/tip20_channel_escrow/dispatch.rs create mode 100644 crates/precompiles/src/tip20_channel_escrow/mod.rs diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index 2b7df4eca3..f7a3d49adb 100644 --- a/crates/contracts/src/precompiles/mod.rs +++ b/crates/contracts/src/precompiles/mod.rs @@ -5,6 +5,7 @@ pub mod nonce; pub mod signature_verifier; pub mod stablecoin_dex; pub mod tip20; +pub mod tip20_channel_escrow; pub mod tip20_factory; pub mod tip403_registry; pub mod tip_fee_manager; @@ -20,6 +21,7 @@ pub use signature_verifier::*; pub use stablecoin_dex::*; pub use tip_fee_manager::*; pub use tip20::*; +pub use tip20_channel_escrow::*; pub use tip20_factory::*; pub use tip403_registry::*; pub use validator_config::*; diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs new file mode 100644 index 0000000000..e855179e48 --- /dev/null +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -0,0 +1,290 @@ +pub use ITIP20ChannelEscrow::{ + ITIP20ChannelEscrowErrors as TIP20ChannelEscrowError, + ITIP20ChannelEscrowEvents as TIP20ChannelEscrowEvent, +}; +use alloy_primitives::{Address, address}; +use alloy_sol_types::{SolCall, SolInterface, SolType}; + +pub const TIP20_CHANNEL_ESCROW_ADDRESS: Address = + address!("0x4D50500000000000000000000000000000000000"); + +const SECP256K1_SIGNATURE_LENGTH: usize = 65; +const P256_SIGNATURE_TYPE: u8 = 0x01; +const P256_SIGNATURE_LENGTH: usize = 130; +const WEBAUTHN_SIGNATURE_TYPE: u8 = 0x02; +const MIN_WEBAUTHN_SIGNATURE_LENGTH: usize = 129; +const MAX_WEBAUTHN_SIGNATURE_LENGTH: usize = 2049; + +crate::sol! { + #[derive(Debug, PartialEq, Eq)] + #[sol(abi)] + #[allow(clippy::too_many_arguments)] + interface ITIP20ChannelEscrow { + struct ChannelDescriptor { + address payer; + address payee; + address token; + bytes32 salt; + address authorizedSigner; + } + + struct ChannelState { + uint96 settled; + uint96 deposit; + uint32 expiresAt; + uint32 closeData; + } + + struct Channel { + ChannelDescriptor descriptor; + ChannelState state; + } + + function CLOSE_GRACE_PERIOD() external view returns (uint64); + function VOUCHER_TYPEHASH() external view returns (bytes32); + + function open( + address payee, + address token, + uint96 deposit, + bytes32 salt, + address authorizedSigner, + uint32 expiresAt + ) + external + returns (bytes32 channelId); + + function settle( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + bytes calldata signature + ) + external; + + function topUp( + ChannelDescriptor calldata descriptor, + uint96 additionalDeposit, + uint32 newExpiresAt + ) + external; + + function close( + ChannelDescriptor calldata descriptor, + uint96 cumulativeAmount, + uint96 captureAmount, + bytes calldata signature + ) + external; + + function requestClose(ChannelDescriptor calldata descriptor) external; + + function withdraw(ChannelDescriptor calldata descriptor) external; + + function getChannel(ChannelDescriptor calldata descriptor) + external + view + returns (Channel memory); + + function getChannelState(bytes32 channelId) external view returns (ChannelState memory); + + function getChannelStatesBatch(bytes32[] calldata channelIds) + external + view + returns (ChannelState[] memory); + + function computeChannelId( + address payer, + address payee, + address token, + bytes32 salt, + address authorizedSigner + ) + external + view + returns (bytes32); + + function getVoucherDigest(bytes32 channelId, uint96 cumulativeAmount) + external + view + returns (bytes32); + + function domainSeparator() external view returns (bytes32); + + event ChannelOpened( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + address token, + address authorizedSigner, + bytes32 salt, + uint96 deposit, + uint32 expiresAt + ); + + event Settled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 cumulativeAmount, + uint96 deltaPaid, + uint96 newSettled + ); + + event TopUp( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 additionalDeposit, + uint96 newDeposit, + uint32 newExpiresAt + ); + + event CloseRequested( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint256 closeGraceEnd + ); + + event ChannelClosed( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint96 settledToPayee, + uint96 refundedToPayer + ); + + event CloseRequestCancelled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee + ); + + event ChannelExpired(bytes32 indexed channelId, address indexed payer, address indexed payee); + + error ChannelAlreadyExists(); + error ChannelNotFound(); + error ChannelFinalized(); + error NotPayer(); + error NotPayee(); + error InvalidPayee(); + error InvalidToken(); + error ZeroDeposit(); + error InvalidExpiry(); + error ChannelExpiredError(); + error InvalidSignature(); + error AmountExceedsDeposit(); + error AmountNotIncreasing(); + error CaptureAmountInvalid(); + error CloseNotReady(); + error DepositOverflow(); + error TransferFailed(); + } +} + +impl ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls { + /// Returns `true` if `input` matches a channel escrow payment-lane selector and its calldata + /// is well-formed. `settle` and `close` also require a valid primitive signature encoding. + pub fn is_payment(input: &[u8]) -> bool { + fn is_static_call(input: &[u8]) -> bool { + input.first_chunk::<4>() == Some(&C::SELECTOR) + && input.len() + == 4 + as SolType>::ENCODED_SIZE.unwrap_or_default() + } + + if is_static_call::(input) + || is_static_call::(input) + || is_static_call::(input) + || is_static_call::(input) + { + return true; + } + + match Self::abi_decode(input) { + Ok(Self::settle(call)) => is_valid_primitive_signature_encoding(&call.signature), + Ok(Self::close(call)) => is_valid_primitive_signature_encoding(&call.signature), + _ => false, + } + } +} + +fn is_valid_primitive_signature_encoding(signature: &[u8]) -> bool { + match signature.len() { + SECP256K1_SIGNATURE_LENGTH => true, + P256_SIGNATURE_LENGTH => signature.first() == Some(&P256_SIGNATURE_TYPE), + MIN_WEBAUTHN_SIGNATURE_LENGTH..=MAX_WEBAUTHN_SIGNATURE_LENGTH => { + signature.first() == Some(&WEBAUTHN_SIGNATURE_TYPE) + } + _ => false, + } +} + +impl TIP20ChannelEscrowError { + pub const fn channel_already_exists() -> Self { + Self::ChannelAlreadyExists(ITIP20ChannelEscrow::ChannelAlreadyExists {}) + } + + pub const fn channel_not_found() -> Self { + Self::ChannelNotFound(ITIP20ChannelEscrow::ChannelNotFound {}) + } + + pub const fn channel_finalized() -> Self { + Self::ChannelFinalized(ITIP20ChannelEscrow::ChannelFinalized {}) + } + + pub const fn not_payer() -> Self { + Self::NotPayer(ITIP20ChannelEscrow::NotPayer {}) + } + + pub const fn not_payee() -> Self { + Self::NotPayee(ITIP20ChannelEscrow::NotPayee {}) + } + + pub const fn invalid_payee() -> Self { + Self::InvalidPayee(ITIP20ChannelEscrow::InvalidPayee {}) + } + + pub const fn invalid_token() -> Self { + Self::InvalidToken(ITIP20ChannelEscrow::InvalidToken {}) + } + + pub const fn zero_deposit() -> Self { + Self::ZeroDeposit(ITIP20ChannelEscrow::ZeroDeposit {}) + } + + pub const fn invalid_expiry() -> Self { + Self::InvalidExpiry(ITIP20ChannelEscrow::InvalidExpiry {}) + } + + pub const fn channel_expired() -> Self { + Self::ChannelExpiredError(ITIP20ChannelEscrow::ChannelExpiredError {}) + } + + pub const fn invalid_signature() -> Self { + Self::InvalidSignature(ITIP20ChannelEscrow::InvalidSignature {}) + } + + pub const fn amount_exceeds_deposit() -> Self { + Self::AmountExceedsDeposit(ITIP20ChannelEscrow::AmountExceedsDeposit {}) + } + + pub const fn amount_not_increasing() -> Self { + Self::AmountNotIncreasing(ITIP20ChannelEscrow::AmountNotIncreasing {}) + } + + pub const fn capture_amount_invalid() -> Self { + Self::CaptureAmountInvalid(ITIP20ChannelEscrow::CaptureAmountInvalid {}) + } + + pub const fn close_not_ready() -> Self { + Self::CloseNotReady(ITIP20ChannelEscrow::CloseNotReady {}) + } + + pub const fn deposit_overflow() -> Self { + Self::DepositOverflow(ITIP20ChannelEscrow::DepositOverflow {}) + } + + pub const fn transfer_failed() -> Self { + Self::TransferFailed(ITIP20ChannelEscrow::TransferFailed {}) + } +} diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index bac6540daa..2e4a2cf8a4 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -28,7 +28,8 @@ use reth_revm::{ use std::collections::{HashMap, HashSet}; use tempo_chainspec::{TempoChainSpec, hardfork::TempoHardforks}; use tempo_contracts::precompiles::{ - ADDRESS_REGISTRY_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, + ADDRESS_REGISTRY_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, TIP20_CHANNEL_ESCROW_ADDRESS, + VALIDATOR_CONFIG_V2_ADDRESS, }; use tempo_primitives::{ SubBlock, SubBlockMetadata, TempoReceipt, TempoTxEnvelope, TempoTxType, @@ -444,6 +445,9 @@ where self.deploy_precompile_at_boundary(SIGNATURE_VERIFIER_ADDRESS)?; self.deploy_precompile_at_boundary(ADDRESS_REGISTRY_ADDRESS)?; } + if self.inner.spec.is_t4_active_at_timestamp(timestamp) { + self.deploy_precompile_at_boundary(TIP20_CHANNEL_ESCROW_ADDRESS)?; + } Ok(()) } @@ -1593,7 +1597,6 @@ mod tests { ); } - #[test] fn test_deploy_precompile_at_boundary_dispatches_state_hook() { use std::sync::{Arc, Mutex}; diff --git a/crates/precompiles/src/error.rs b/crates/precompiles/src/error.rs index 094e1e43e1..0eaa472d63 100644 --- a/crates/precompiles/src/error.rs +++ b/crates/precompiles/src/error.rs @@ -21,7 +21,7 @@ use revm::{ }; use tempo_contracts::precompiles::{ AccountKeychainError, AddrRegistryError, FeeManagerError, NonceError, RolesAuthError, - SignatureVerifierError, StablecoinDEXError, TIP20FactoryError, TIP403RegistryError, + SignatureVerifierError, StablecoinDEXError, TIP20ChannelEscrowError, TIP20FactoryError, TIP403RegistryError, TIPFeeAMMError, UnknownFunctionSelector, ValidatorConfigError, ValidatorConfigV2Error, }; @@ -42,6 +42,10 @@ pub enum TempoPrecompileError { #[error("TIP20 factory error: {0:?}")] TIP20Factory(TIP20FactoryError), + /// Error from TIP-20 channel escrow + #[error("TIP20 channel escrow error: {0:?}")] + TIP20ChannelEscrowError(TIP20ChannelEscrowError), + /// Error from roles auth #[error("Roles auth error: {0:?}")] RolesAuthError(RolesAuthError), @@ -137,6 +141,7 @@ impl TempoPrecompileError { Self::OutOfGas | Self::Fatal(_) | Self::Panic(_) => true, Self::StablecoinDEX(_) | Self::TIP20(_) + | Self::TIP20ChannelEscrowError(_) | Self::NonceError(_) | Self::TIP20Factory(_) | Self::RolesAuthError(_) @@ -177,6 +182,7 @@ impl TempoPrecompileError { Self::StablecoinDEX(e) => e.abi_encode().into(), Self::TIP20(e) => e.abi_encode().into(), Self::TIP20Factory(e) => e.abi_encode().into(), + Self::TIP20ChannelEscrowError(e) => e.abi_encode().into(), Self::RolesAuthError(e) => e.abi_encode().into(), Self::AddrRegistryError(e) => e.abi_encode().into(), Self::TIP403RegistryError(e) => e.abi_encode().into(), @@ -251,6 +257,7 @@ pub fn error_decoder_registry() -> TempoPrecompileErrorRegistry { add_errors_to_registry(&mut registry, TempoPrecompileError::StablecoinDEX); add_errors_to_registry(&mut registry, TempoPrecompileError::TIP20); add_errors_to_registry(&mut registry, TempoPrecompileError::TIP20Factory); + add_errors_to_registry(&mut registry, TempoPrecompileError::TIP20ChannelEscrowError); add_errors_to_registry(&mut registry, TempoPrecompileError::RolesAuthError); add_errors_to_registry(&mut registry, TempoPrecompileError::AddrRegistryError); add_errors_to_registry(&mut registry, TempoPrecompileError::TIP403RegistryError); diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 5a3302e2fe..c9287a1b95 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -15,6 +15,7 @@ pub mod nonce; pub mod signature_verifier; pub mod stablecoin_dex; pub mod tip20; +pub mod tip20_channel_escrow; pub mod tip20_factory; pub mod tip403_registry; pub mod tip_fee_manager; @@ -25,10 +26,18 @@ pub mod validator_config_v2; pub mod test_util; use crate::{ - account_keychain::AccountKeychain, address_registry::AddressRegistry, nonce::NonceManager, - signature_verifier::SignatureVerifier, stablecoin_dex::StablecoinDEX, storage::StorageCtx, - tip_fee_manager::TipFeeManager, tip20::TIP20Token, tip20_factory::TIP20Factory, - tip403_registry::TIP403Registry, validator_config::ValidatorConfig, + account_keychain::AccountKeychain, + address_registry::AddressRegistry, + nonce::NonceManager, + signature_verifier::SignatureVerifier, + stablecoin_dex::StablecoinDEX, + storage::StorageCtx, + tip_fee_manager::TipFeeManager, + tip20::{TIP20Token, is_tip20_prefix}, + tip20_channel_escrow::TIP20ChannelEscrow, + tip20_factory::TIP20Factory, + tip403_registry::TIP403Registry, + validator_config::ValidatorConfig, validator_config_v2::ValidatorConfigV2, }; use tempo_chainspec::hardfork::TempoHardfork; @@ -52,7 +61,8 @@ use revm::{ pub use tempo_contracts::precompiles::{ ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, DEFAULT_FEE_TOKEN, NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, STABLECOIN_DEX_ADDRESS, - TIP_FEE_MANAGER_ADDRESS, TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, + TIP_FEE_MANAGER_ADDRESS, TIP20_CHANNEL_ESCROW_ADDRESS, TIP20_FACTORY_ADDRESS, + TIP403_REGISTRY_ADDRESS, VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, }; @@ -120,6 +130,8 @@ pub fn extend_tempo_precompiles(precompiles: &mut PrecompilesMap, cfg: &CfgEnv) -> DynPrecompile { + tempo_precompile!("TIP20ChannelEscrow", cfg, |input| { Self::new() }) + } +} + /// Dispatches a parameterless view call, encoding the return via `T`. #[inline] fn metadata(f: impl FnOnce() -> Result) -> PrecompileResult { @@ -1130,6 +1149,13 @@ mod tests { "SignatureVerifier should be registered at T3" ); + // Channel escrow should be registered at T4 + let channel_escrow_precompile = precompiles.get(&TIP20_CHANNEL_ESCROW_ADDRESS); + assert!( + channel_escrow_precompile.is_none(), + "TIP20 channel escrow should not be registered before T4" + ); + // TIP20 tokens with prefix should be registered let tip20_precompile = precompiles.get(&PATH_USD_ADDRESS); assert!( @@ -1157,6 +1183,26 @@ mod tests { ); } + #[test] + fn test_channel_escrow_registered_at_t4_only() { + let pre_t4 = CfgEnv::::default(); + assert!( + tempo_precompiles(&pre_t4) + .get(&TIP20_CHANNEL_ESCROW_ADDRESS) + .is_none(), + "TIP20 channel escrow should NOT be registered before T4" + ); + + let mut t4 = CfgEnv::::default(); + t4.set_spec(TempoHardfork::T4); + assert!( + tempo_precompiles(&t4) + .get(&TIP20_CHANNEL_ESCROW_ADDRESS) + .is_some(), + "TIP20 channel escrow should be registered at T4" + ); + } + #[test] fn test_p256verify_availability_across_t1c_boundary() { let has_p256 = |spec: TempoHardfork| -> bool { diff --git a/crates/precompiles/src/storage/types/mod.rs b/crates/precompiles/src/storage/types/mod.rs index 2994489d0d..9c36f706c0 100644 --- a/crates/precompiles/src/storage/types/mod.rs +++ b/crates/precompiles/src/storage/types/mod.rs @@ -17,6 +17,7 @@ pub use set::{Set, SetHandler}; pub mod bytes_like; mod primitives; +pub use primitives::U96; use crate::{ error::Result, diff --git a/crates/precompiles/src/storage/types/primitives.rs b/crates/precompiles/src/storage/types/primitives.rs index bd29fedcd1..cd7a8a7973 100644 --- a/crates/precompiles/src/storage/types/primitives.rs +++ b/crates/precompiles/src/storage/types/primitives.rs @@ -1,13 +1,84 @@ //! `StorableType`, `FromWord`, and `StorageKey` implementations for single-word primitives. //! -//! Covers Rust integers, Alloy integers, Alloy fixed bytes, `bool`, and `Address`. +//! Covers Rust integers, Alloy integers, Alloy fixed bytes, `bool`, `Address`, and `U96`. -use alloy::primitives::{Address, U256}; +use alloy::primitives::{Address, Uint, U256}; use revm::interpreter::instructions::utility::{IntoAddress, IntoU256}; use tempo_precompiles_macros; use crate::storage::types::*; +/// A 96-bit unsigned integer used for packed TIP-1034 channel accounting. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct U96(u128); + +impl U96 { + pub const MAX_VALUE: u128 = (1u128 << 96) - 1; + pub const ZERO: Self = Self(0); + pub const MAX: Self = Self(Self::MAX_VALUE); + + #[inline] + pub const fn new(value: u128) -> Option { + if value <= Self::MAX_VALUE { + Some(Self(value)) + } else { + None + } + } + + #[inline] + pub const fn as_u128(self) -> u128 { + self.0 + } + + #[inline] + pub const fn is_zero(self) -> bool { + self.0 == 0 + } + + #[inline] + pub fn checked_add(self, rhs: Self) -> Option { + self.0.checked_add(rhs.0).and_then(Self::new) + } + + #[inline] + pub fn checked_sub(self, rhs: Self) -> Option { + self.0.checked_sub(rhs.0).map(Self) + } +} + +impl TryFrom for U96 { + type Error = crate::error::TempoPrecompileError; + + fn try_from(value: u128) -> std::result::Result { + Self::new(value).ok_or_else(crate::error::TempoPrecompileError::under_overflow) + } +} + +impl From> for U96 { + fn from(value: Uint<96, 2>) -> Self { + Self(value.to::()) + } +} + +impl From for Uint<96, 2> { + fn from(value: U96) -> Self { + Self::from(value.as_u128()) + } +} + +impl From for u128 { + fn from(value: U96) -> Self { + value.0 + } +} + +impl From for U256 { + fn from(value: U96) -> Self { + U256::from(value.0) + } +} + // rust integers: (u)int8, (u)int16, (u)int32, (u)int64, (u)int128 tempo_precompiles_macros::storable_rust_ints!(); // alloy integers: U8, I8, U16, I16, U32, I32, U64, I64, U128, I128, U256, I256 @@ -17,6 +88,41 @@ tempo_precompiles_macros::storable_alloy_bytes!(); // -- MANUAL STORAGE TRAIT IMPLEMENTATIONS ------------------------------------- +impl StorableType for U96 { + const LAYOUT: Layout = Layout::Bytes(12); + + type Handler = Slot; + + fn handle(slot: U256, ctx: LayoutCtx, address: Address) -> Self::Handler { + Slot::new_with_ctx(slot, ctx, address) + } +} + +impl super::sealed::OnlyPrimitives for U96 {} +impl Packable for U96 {} +impl FromWord for U96 { + #[inline] + fn to_word(&self) -> U256 { + U256::from(self.0) + } + + #[inline] + fn from_word(word: U256) -> crate::error::Result { + if word > U256::from(U96::MAX_VALUE) { + return Err(crate::error::TempoPrecompileError::under_overflow()); + } + + Ok(Self(word.to::())) + } +} + +impl StorageKey for U96 { + #[inline] + fn as_storage_bytes(&self) -> impl AsRef<[u8]> { + self.0.to_be_bytes() + } +} + impl StorableType for bool { const LAYOUT: Layout = Layout::Bytes(1); @@ -100,6 +206,10 @@ mod tests { any::<[u8; 20]>().prop_map(Address::from) } + fn arb_u96() -> impl Strategy { + (0..=U96::MAX_VALUE).prop_map(|value| U96::new(value).unwrap()) + } + // -- STORAGE TESTS -------------------------------------------------------- // Generate property tests for all storage types: @@ -155,12 +265,40 @@ mod tests { assert_eq!(b, recovered, "Bool EVM word roundtrip failed"); }); } + + #[test] + fn test_u96_values(value in arb_u96(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, || { + let mut slot = U96::handle(base_slot, LayoutCtx::FULL, address); + + slot.write(value).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!(value, loaded, "U96 roundtrip failed"); + + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert_eq!(after_delete, U96::ZERO, "U96 not zero after delete"); + + let word = value.to_word(); + let recovered = ::from_word(word).unwrap(); + assert_eq!(value, recovered, "U96 EVM word roundtrip failed"); + }); + } } // -- WORD REPRESENTATION TESTS ------------------------------------------------ #[test] fn test_unsigned_word_byte_representation() { + assert_eq!(U96::ZERO.to_word(), gen_word_from(&["0x000000000000000000000000"])); + assert_eq!( + U96::new(0x1234567890ABCDEF12345678).unwrap().to_word(), + gen_word_from(&["0x1234567890ABCDEF12345678"]) + ); + assert_eq!(U96::MAX.to_word(), gen_word_from(&["0xFFFFFFFFFFFFFFFFFFFFFFFF"])); + assert!(U96::from_word(gen_word_from(&["0x01000000000000000000000000"])).is_err()); + // u8: single byte, right-aligned assert_eq!(0u8.to_word(), gen_word_from(&["0x00"])); assert_eq!(1u8.to_word(), gen_word_from(&["0x01"])); diff --git a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs new file mode 100644 index 0000000000..3d29c987f9 --- /dev/null +++ b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs @@ -0,0 +1,66 @@ +//! ABI dispatch for the [`TIP20ChannelEscrow`] precompile. + +use super::{CLOSE_GRACE_PERIOD, TIP20ChannelEscrow, VOUCHER_TYPEHASH}; +use crate::{Precompile, dispatch_call, input_cost, metadata, mutate, mutate_void, view}; +use alloy::{primitives::Address, sol_types::SolInterface}; +use revm::precompile::{PrecompileError, PrecompileResult}; +use tempo_contracts::precompiles::{ + ITIP20ChannelEscrow, ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls, +}; + +impl Precompile for TIP20ChannelEscrow { + fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { + self.storage + .deduct_gas(input_cost(calldata.len())) + .map_err(|_| PrecompileError::OutOfGas)?; + + dispatch_call( + calldata, + ITIP20ChannelEscrowCalls::abi_decode, + |call| match call { + ITIP20ChannelEscrowCalls::CLOSE_GRACE_PERIOD(_) => { + metadata::(|| { + Ok(CLOSE_GRACE_PERIOD) + }) + } + ITIP20ChannelEscrowCalls::VOUCHER_TYPEHASH(_) => { + metadata::(|| Ok(*VOUCHER_TYPEHASH)) + } + ITIP20ChannelEscrowCalls::open(call) => { + mutate(call, msg_sender, |sender, c| self.open(sender, c)) + } + ITIP20ChannelEscrowCalls::settle(call) => { + mutate_void(call, msg_sender, |sender, c| self.settle(sender, c)) + } + ITIP20ChannelEscrowCalls::topUp(call) => { + mutate_void(call, msg_sender, |sender, c| self.top_up(sender, c)) + } + ITIP20ChannelEscrowCalls::close(call) => { + mutate_void(call, msg_sender, |sender, c| self.close(sender, c)) + } + ITIP20ChannelEscrowCalls::requestClose(call) => { + mutate_void(call, msg_sender, |sender, c| self.request_close(sender, c)) + } + ITIP20ChannelEscrowCalls::withdraw(call) => { + mutate_void(call, msg_sender, |sender, c| self.withdraw(sender, c)) + } + ITIP20ChannelEscrowCalls::getChannel(call) => view(call, |c| self.get_channel(c)), + ITIP20ChannelEscrowCalls::getChannelState(call) => { + view(call, |c| self.get_channel_state(c)) + } + ITIP20ChannelEscrowCalls::getChannelStatesBatch(call) => { + view(call, |c| self.get_channel_states_batch(c)) + } + ITIP20ChannelEscrowCalls::computeChannelId(call) => { + view(call, |c| self.compute_channel_id(c)) + } + ITIP20ChannelEscrowCalls::getVoucherDigest(call) => { + view(call, |c| self.get_voucher_digest(c)) + } + ITIP20ChannelEscrowCalls::domainSeparator(call) => { + view(call, |_| self.domain_separator()) + } + }, + ) + } +} diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs new file mode 100644 index 0000000000..ac549ac043 --- /dev/null +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -0,0 +1,859 @@ +//! TIP-1034 TIP-20 channel escrow precompile. + +pub mod dispatch; + +use crate::{ + error::Result, + signature_verifier::SignatureVerifier, + storage::{Handler, Mapping, U96 as PackedU96}, + tip20::{TIP20Token, is_tip20_prefix}, +}; +use alloy::{ + primitives::{Address, B256, U256, Uint, keccak256}, + sol_types::SolValue, +}; +use std::sync::LazyLock; +pub use tempo_contracts::precompiles::{ + ITIP20ChannelEscrow, TIP20_CHANNEL_ESCROW_ADDRESS, TIP20ChannelEscrowError, + TIP20ChannelEscrowEvent, +}; +use tempo_precompiles_macros::{Storable, contract}; + +const FINALIZED_CLOSE_DATA: u32 = 1; + +/// 15 minute grace period between `requestClose` and `withdraw`. +pub const CLOSE_GRACE_PERIOD: u64 = 15 * 60; + +static VOUCHER_TYPEHASH: LazyLock = + LazyLock::new(|| keccak256(b"Voucher(bytes32 channelId,uint96 cumulativeAmount)")); +static EIP712_DOMAIN_TYPEHASH: LazyLock = LazyLock::new(|| { + keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") +}); +static NAME_HASH: LazyLock = LazyLock::new(|| keccak256(b"TIP20 Channel Escrow")); +static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); + +type AbiU96 = Uint<96, 2>; + +#[derive(Debug, Clone, Copy, Default, Storable)] +struct PackedChannelState { + settled: PackedU96, + deposit: PackedU96, + expires_at: u32, + close_data: u32, +} + +impl PackedChannelState { + fn exists(self) -> bool { + !self.deposit.is_zero() + } + + fn is_finalized(self) -> bool { + self.close_data == FINALIZED_CLOSE_DATA + } + + fn close_requested_at(self) -> Option { + (self.close_data >= 2).then_some(self.close_data) + } + + fn to_sol(self) -> ITIP20ChannelEscrow::ChannelState { + ITIP20ChannelEscrow::ChannelState { + settled: self.settled.into(), + deposit: self.deposit.into(), + expiresAt: self.expires_at, + closeData: self.close_data, + } + } +} + +#[contract(addr = TIP20_CHANNEL_ESCROW_ADDRESS)] +pub struct TIP20ChannelEscrow { + channel_states: Mapping, +} + +impl TIP20ChannelEscrow { + pub fn initialize(&mut self) -> Result<()> { + self.__initialize() + } + + pub fn open( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::openCall, + ) -> Result { + if call.payee == Address::ZERO { + return Err(TIP20ChannelEscrowError::invalid_payee().into()); + } + if !is_tip20_prefix(call.token) { + return Err(TIP20ChannelEscrowError::invalid_token().into()); + } + + let deposit = PackedU96::from(call.deposit); + if deposit.is_zero() { + return Err(TIP20ChannelEscrowError::zero_deposit().into()); + } + if call.expiresAt as u64 <= self.now() { + return Err(TIP20ChannelEscrowError::invalid_expiry().into()); + } + + let channel_id = self.compute_channel_id_inner( + msg_sender, + call.payee, + call.token, + call.salt, + call.authorizedSigner, + )?; + if self.channel_states[channel_id].read()?.exists() { + return Err(TIP20ChannelEscrowError::channel_already_exists().into()); + } + + let batch = self.storage.checkpoint(); + self.channel_states[channel_id].write(PackedChannelState { + settled: PackedU96::ZERO, + deposit, + expires_at: call.expiresAt, + close_data: 0, + })?; + TIP20Token::from_address(call.token)?.system_transfer_from( + msg_sender, + self.address, + U256::from(call.deposit), + )?; + self.emit_event(TIP20ChannelEscrowEvent::ChannelOpened( + ITIP20ChannelEscrow::ChannelOpened { + channelId: channel_id, + payer: msg_sender, + payee: call.payee, + token: call.token, + authorizedSigner: call.authorizedSigner, + salt: call.salt, + deposit: call.deposit, + expiresAt: call.expiresAt, + }, + ))?; + batch.commit(); + + Ok(channel_id) + } + + pub fn settle( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::settleCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let mut state = self.load_existing_state(channel_id)?; + + if msg_sender != call.descriptor.payee { + return Err(TIP20ChannelEscrowError::not_payee().into()); + } + if state.is_finalized() { + return Err(TIP20ChannelEscrowError::channel_finalized().into()); + } + if self.is_expired(state.expires_at) { + return Err(TIP20ChannelEscrowError::channel_expired().into()); + } + + let cumulative = PackedU96::from(call.cumulativeAmount); + if cumulative > state.deposit { + return Err(TIP20ChannelEscrowError::amount_exceeds_deposit().into()); + } + if cumulative <= state.settled { + return Err(TIP20ChannelEscrowError::amount_not_increasing().into()); + } + + self.validate_voucher( + &call.descriptor, + channel_id, + call.cumulativeAmount, + &call.signature, + )?; + + let delta = cumulative + .checked_sub(state.settled) + .expect("cumulative amount already checked to be increasing"); + + let batch = self.storage.checkpoint(); + state.settled = cumulative; + self.channel_states[channel_id].write(state)?; + TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( + self.address, + call.descriptor.payee, + U256::from(delta.as_u128()), + )?; + self.emit_event(TIP20ChannelEscrowEvent::Settled( + ITIP20ChannelEscrow::Settled { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + cumulativeAmount: call.cumulativeAmount, + deltaPaid: delta.into(), + newSettled: cumulative.into(), + }, + ))?; + batch.commit(); + + Ok(()) + } + + pub fn top_up( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::topUpCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let mut state = self.load_existing_state(channel_id)?; + + if msg_sender != call.descriptor.payer { + return Err(TIP20ChannelEscrowError::not_payer().into()); + } + if state.is_finalized() { + return Err(TIP20ChannelEscrowError::channel_finalized().into()); + } + + let additional = PackedU96::from(call.additionalDeposit); + let next_deposit = state + .deposit + .checked_add(additional) + .ok_or_else(TIP20ChannelEscrowError::deposit_overflow)?; + + if call.newExpiresAt != 0 { + if call.newExpiresAt as u64 <= self.now() || call.newExpiresAt <= state.expires_at { + return Err(TIP20ChannelEscrowError::invalid_expiry().into()); + } + } + + let had_close_request = state.close_requested_at().is_some(); + let batch = self.storage.checkpoint(); + + if !additional.is_zero() { + state.deposit = next_deposit; + TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( + msg_sender, + self.address, + U256::from(call.additionalDeposit), + )?; + } + if call.newExpiresAt != 0 { + state.expires_at = call.newExpiresAt; + } + if had_close_request { + state.close_data = 0; + } + + self.channel_states[channel_id].write(state)?; + if had_close_request { + self.emit_event(TIP20ChannelEscrowEvent::CloseRequestCancelled( + ITIP20ChannelEscrow::CloseRequestCancelled { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + }, + ))?; + } + self.emit_event(TIP20ChannelEscrowEvent::TopUp(ITIP20ChannelEscrow::TopUp { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + additionalDeposit: call.additionalDeposit, + newDeposit: state.deposit.into(), + newExpiresAt: state.expires_at, + }))?; + batch.commit(); + + Ok(()) + } + + pub fn request_close( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::requestCloseCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let mut state = self.load_existing_state(channel_id)?; + + if msg_sender != call.descriptor.payer { + return Err(TIP20ChannelEscrowError::not_payer().into()); + } + if state.is_finalized() { + return Err(TIP20ChannelEscrowError::channel_finalized().into()); + } + if state.close_requested_at().is_some() { + return Ok(()); + } + + let close_requested_at = self.now_u32(); + let batch = self.storage.checkpoint(); + state.close_data = close_requested_at; + self.channel_states[channel_id].write(state)?; + self.emit_event(TIP20ChannelEscrowEvent::CloseRequested( + ITIP20ChannelEscrow::CloseRequested { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + closeGraceEnd: U256::from(self.now() + CLOSE_GRACE_PERIOD), + }, + ))?; + batch.commit(); + + Ok(()) + } + + pub fn close( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::closeCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let mut state = self.load_existing_state(channel_id)?; + + if msg_sender != call.descriptor.payee { + return Err(TIP20ChannelEscrowError::not_payee().into()); + } + if state.is_finalized() { + return Err(TIP20ChannelEscrowError::channel_finalized().into()); + } + + let cumulative = PackedU96::from(call.cumulativeAmount); + let capture = PackedU96::from(call.captureAmount); + let previous_settled = state.settled; + if capture < previous_settled || capture > cumulative { + return Err(TIP20ChannelEscrowError::capture_amount_invalid().into()); + } + if capture > state.deposit { + return Err(TIP20ChannelEscrowError::amount_exceeds_deposit().into()); + } + + if capture > previous_settled { + if self.is_expired(state.expires_at) { + return Err(TIP20ChannelEscrowError::channel_expired().into()); + } + self.validate_voucher( + &call.descriptor, + channel_id, + call.cumulativeAmount, + &call.signature, + )?; + } + + let delta = capture + .checked_sub(previous_settled) + .expect("capture amount already checked against previous settled amount"); + let refund = state + .deposit + .checked_sub(capture) + .expect("capture amount already checked against deposit"); + + let batch = self.storage.checkpoint(); + state.settled = capture; + state.close_data = FINALIZED_CLOSE_DATA; + self.channel_states[channel_id].write(state)?; + + let mut token = TIP20Token::from_address(call.descriptor.token)?; + if !delta.is_zero() { + token.system_transfer_from( + self.address, + call.descriptor.payee, + U256::from(delta.as_u128()), + )?; + } + if !refund.is_zero() { + token.system_transfer_from( + self.address, + call.descriptor.payer, + U256::from(refund.as_u128()), + )?; + } + + self.emit_event(TIP20ChannelEscrowEvent::ChannelClosed( + ITIP20ChannelEscrow::ChannelClosed { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + settledToPayee: capture.into(), + refundedToPayer: refund.into(), + }, + ))?; + batch.commit(); + + Ok(()) + } + + pub fn withdraw( + &mut self, + msg_sender: Address, + call: ITIP20ChannelEscrow::withdrawCall, + ) -> Result<()> { + let channel_id = self.channel_id(&call.descriptor)?; + let mut state = self.load_existing_state(channel_id)?; + + if msg_sender != call.descriptor.payer { + return Err(TIP20ChannelEscrowError::not_payer().into()); + } + if state.is_finalized() { + return Err(TIP20ChannelEscrowError::channel_finalized().into()); + } + + let close_ready = state + .close_requested_at() + .is_some_and(|requested_at| self.now() >= requested_at as u64 + CLOSE_GRACE_PERIOD); + if !close_ready && !self.is_expired(state.expires_at) { + return Err(TIP20ChannelEscrowError::close_not_ready().into()); + } + + let refund = state + .deposit + .checked_sub(state.settled) + .expect("settled is always <= deposit"); + + let batch = self.storage.checkpoint(); + state.close_data = FINALIZED_CLOSE_DATA; + self.channel_states[channel_id].write(state)?; + if !refund.is_zero() { + TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( + self.address, + call.descriptor.payer, + U256::from(refund.as_u128()), + )?; + } + self.emit_event(TIP20ChannelEscrowEvent::ChannelExpired( + ITIP20ChannelEscrow::ChannelExpired { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + }, + ))?; + self.emit_event(TIP20ChannelEscrowEvent::ChannelClosed( + ITIP20ChannelEscrow::ChannelClosed { + channelId: channel_id, + payer: call.descriptor.payer, + payee: call.descriptor.payee, + settledToPayee: state.settled.into(), + refundedToPayer: refund.into(), + }, + ))?; + batch.commit(); + + Ok(()) + } + + pub fn get_channel( + &self, + call: ITIP20ChannelEscrow::getChannelCall, + ) -> Result { + let channel_id = self.channel_id(&call.descriptor)?; + Ok(ITIP20ChannelEscrow::Channel { + descriptor: call.descriptor, + state: self.channel_states[channel_id].read()?.to_sol(), + }) + } + + pub fn get_channel_state( + &self, + call: ITIP20ChannelEscrow::getChannelStateCall, + ) -> Result { + Ok(self.channel_states[call.channelId].read()?.to_sol()) + } + + pub fn get_channel_states_batch( + &self, + call: ITIP20ChannelEscrow::getChannelStatesBatchCall, + ) -> Result> { + call.channelIds + .into_iter() + .map(|channel_id| { + self.channel_states[channel_id] + .read() + .map(PackedChannelState::to_sol) + }) + .collect() + } + + pub fn compute_channel_id( + &self, + call: ITIP20ChannelEscrow::computeChannelIdCall, + ) -> Result { + self.compute_channel_id_inner( + call.payer, + call.payee, + call.token, + call.salt, + call.authorizedSigner, + ) + } + + pub fn get_voucher_digest( + &self, + call: ITIP20ChannelEscrow::getVoucherDigestCall, + ) -> Result { + self.get_voucher_digest_inner(call.channelId, call.cumulativeAmount) + } + + pub fn domain_separator(&self) -> Result { + self.domain_separator_inner() + } + + fn now(&self) -> u64 { + self.storage.timestamp().saturating_to::() + } + + fn now_u32(&self) -> u32 { + self.storage.timestamp().saturating_to::() + } + + fn is_expired(&self, expires_at: u32) -> bool { + self.now() >= expires_at as u64 + } + + fn channel_id(&self, descriptor: &ITIP20ChannelEscrow::ChannelDescriptor) -> Result { + self.compute_channel_id_inner( + descriptor.payer, + descriptor.payee, + descriptor.token, + descriptor.salt, + descriptor.authorizedSigner, + ) + } + + fn compute_channel_id_inner( + &self, + payer: Address, + payee: Address, + token: Address, + salt: B256, + authorized_signer: Address, + ) -> Result { + self.storage.keccak256( + &( + payer, + payee, + token, + salt, + authorized_signer, + self.address, + U256::from(self.storage.chain_id()), + ) + .abi_encode(), + ) + } + + fn load_existing_state(&self, channel_id: B256) -> Result { + let state = self.channel_states[channel_id].read()?; + if !state.exists() { + return Err(TIP20ChannelEscrowError::channel_not_found().into()); + } + Ok(state) + } + + fn expected_signer(&self, descriptor: &ITIP20ChannelEscrow::ChannelDescriptor) -> Address { + if descriptor.authorizedSigner.is_zero() { + descriptor.payer + } else { + descriptor.authorizedSigner + } + } + + fn validate_voucher( + &self, + descriptor: &ITIP20ChannelEscrow::ChannelDescriptor, + channel_id: B256, + cumulative_amount: AbiU96, + signature: &alloy::primitives::Bytes, + ) -> Result<()> { + let digest = self.get_voucher_digest_inner(channel_id, cumulative_amount)?; + let signer = SignatureVerifier::new() + .recover(digest, signature.clone()) + .map_err(|_| TIP20ChannelEscrowError::invalid_signature())?; + if signer != self.expected_signer(descriptor) { + return Err(TIP20ChannelEscrowError::invalid_signature().into()); + } + Ok(()) + } + + fn get_voucher_digest_inner( + &self, + channel_id: B256, + cumulative_amount: AbiU96, + ) -> Result { + let struct_hash = self + .storage + .keccak256(&(*VOUCHER_TYPEHASH, channel_id, cumulative_amount).abi_encode())?; + let domain_separator = self.domain_separator_inner()?; + + let mut digest_input = [0u8; 66]; + digest_input[0] = 0x19; + digest_input[1] = 0x01; + digest_input[2..34].copy_from_slice(domain_separator.as_slice()); + digest_input[34..66].copy_from_slice(struct_hash.as_slice()); + self.storage.keccak256(&digest_input) + } + + fn domain_separator_inner(&self) -> Result { + self.storage.keccak256( + &( + *EIP712_DOMAIN_TYPEHASH, + *NAME_HASH, + *VERSION_HASH, + U256::from(self.storage.chain_id()), + self.address, + ) + .abi_encode(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + Precompile, + storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider}, + test_util::{TIP20Setup, assert_full_coverage, check_selector_coverage}, + }; + use alloy::{ + primitives::{Bytes, Signature}, + sol_types::SolCall, + }; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_chainspec::hardfork::TempoHardfork; + use tempo_contracts::precompiles::ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls; + + fn abi_u96(value: u128) -> AbiU96 { + AbiU96::from(value) + } + + fn descriptor( + payer: Address, + payee: Address, + token: Address, + salt: B256, + authorized_signer: Address, + ) -> ITIP20ChannelEscrow::ChannelDescriptor { + ITIP20ChannelEscrow::ChannelDescriptor { + payer, + payee, + token, + salt, + authorizedSigner: authorized_signer, + } + } + + #[test] + fn test_selector_coverage() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + StorageCtx::enter(&mut storage, || { + let mut escrow = TIP20ChannelEscrow::new(); + let unsupported = check_selector_coverage( + &mut escrow, + ITIP20ChannelEscrowCalls::SELECTORS, + "ITIP20ChannelEscrow", + ITIP20ChannelEscrowCalls::name_by_selector, + ); + assert_full_coverage([unsupported]); + Ok(()) + }) + } + + #[test] + fn test_open_settle_close_flow_and_tombstone() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let payee = Address::random(); + let salt = B256::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(1_000u128)) + .apply()?; + + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + let now = StorageCtx::default().timestamp().to::(); + + let channel_id = escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + token: token.address(), + deposit: abi_u96(300), + salt, + authorizedSigner: Address::ZERO, + expiresAt: now + 1_000, + }, + )?; + + let digest = escrow.get_voucher_digest(ITIP20ChannelEscrow::getVoucherDigestCall { + channelId: channel_id, + cumulativeAmount: abi_u96(120), + })?; + let signature = + Bytes::copy_from_slice(&payer_signer.sign_hash_sync(&digest)?.as_bytes()); + + let channel_descriptor = descriptor(payer, payee, token.address(), salt, Address::ZERO); + escrow.settle( + payee, + ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor.clone(), + cumulativeAmount: abi_u96(120), + signature: signature.clone(), + }, + )?; + escrow.close( + payee, + ITIP20ChannelEscrow::closeCall { + descriptor: channel_descriptor.clone(), + cumulativeAmount: abi_u96(120), + captureAmount: abi_u96(120), + signature, + }, + )?; + + let state = escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { + channelId: channel_id, + })?; + assert_eq!(state.closeData, FINALIZED_CLOSE_DATA); + assert_eq!(state.deposit, 300); + assert_eq!(state.settled, 120); + + let reopen_result = escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + token: token.address(), + deposit: abi_u96(1), + salt, + authorizedSigner: Address::ZERO, + expiresAt: now + 2_000, + }, + ); + assert_eq!( + reopen_result.unwrap_err(), + TIP20ChannelEscrowError::channel_already_exists().into() + ); + + Ok(()) + }) + } + + #[test] + fn test_top_up_cancels_close_request() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let payer = Address::random(); + let payee = Address::random(); + let salt = B256::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(1_000u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + + let expires_at = StorageCtx::default().timestamp().to::() + 1_000; + let descriptor = descriptor(payer, payee, token.address(), salt, Address::ZERO); + escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + token: token.address(), + deposit: abi_u96(100), + salt, + authorizedSigner: Address::ZERO, + expiresAt: expires_at, + }, + )?; + + escrow.request_close( + payer, + ITIP20ChannelEscrow::requestCloseCall { + descriptor: descriptor.clone(), + }, + )?; + escrow.top_up( + payer, + ITIP20ChannelEscrow::topUpCall { + descriptor: descriptor.clone(), + additionalDeposit: abi_u96(25), + newExpiresAt: expires_at + 500, + }, + )?; + + let channel = escrow.get_channel(ITIP20ChannelEscrow::getChannelCall { descriptor })?; + assert_eq!(channel.state.closeData, 0); + assert_eq!(channel.state.deposit, 125); + assert_eq!(channel.state.expiresAt, expires_at + 500); + + Ok(()) + }) + } + + #[test] + fn test_dispatch_rejects_static_mutation() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + StorageCtx::enter(&mut storage, || { + let mut escrow = TIP20ChannelEscrow::new(); + let result = escrow.call( + &ITIP20ChannelEscrow::openCall { + payee: Address::random(), + token: TIP20_CHANNEL_ESCROW_ADDRESS, + deposit: abi_u96(1), + salt: B256::ZERO, + authorizedSigner: Address::ZERO, + expiresAt: 2, + } + .abi_encode(), + Address::ZERO, + ); + assert!(result.is_ok()); + Ok(()) + }) + } + + #[test] + fn test_settle_rejects_invalid_signature() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let payer = Address::random(); + let payee = Address::random(); + let salt = B256::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(100u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + let now = StorageCtx::default().timestamp().to::(); + escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + token: token.address(), + deposit: abi_u96(100), + salt, + authorizedSigner: Address::ZERO, + expiresAt: now + 1_000, + }, + )?; + + let result = escrow.settle( + payee, + ITIP20ChannelEscrow::settleCall { + descriptor: descriptor(payer, payee, token.address(), salt, Address::ZERO), + cumulativeAmount: abi_u96(10), + signature: Bytes::copy_from_slice( + &Signature::test_signature().as_bytes()[..64], + ), + }, + ); + assert_eq!( + result.unwrap_err(), + TIP20ChannelEscrowError::invalid_signature().into() + ); + Ok(()) + }) + } +} diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 356ad574ad..de4b3bad8a 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -9,7 +9,7 @@ use alloy_consensus::{ }; use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256, hex}; use core::fmt; -use tempo_contracts::precompiles::ITIP20; +use tempo_contracts::precompiles::{ITIP20, ITIP20ChannelEscrow, TIP20_CHANNEL_ESCROW_ADDRESS}; /// TIP20 payment address prefix (12 bytes for payment classification) /// Same as TIP20_TOKEN_PREFIX @@ -163,21 +163,27 @@ impl TempoTxEnvelope { /// [TIP-20 payment] classification: `to` address has the `0x20c0` prefix. /// - /// A transaction is considered a payment if its `to` address carries the TIP-20 prefix. - /// For AA transactions, every call must target a TIP-20 address. + /// A transaction is considered a payment if every call is either a TIP-20 payment target or a + /// valid TIP-1034 channel escrow operation, and the transaction carries no authorization side + /// effects. /// /// # NOTE /// Consensus-level classifier, used during block validation, against `general_gas_limit`. /// See [`is_payment_v2`](Self::is_payment_v2) for the stricter builder-level variant. /// /// [TIP-20 payment]: - pub fn is_payment_v1(&self) -> bool { + pub fn is_payment_v1(&self, t5_active: bool) -> bool { match self { - Self::Legacy(tx) => is_tip20_call(tx.tx().to.to()), - Self::Eip2930(tx) => is_tip20_call(tx.tx().to.to()), - Self::Eip1559(tx) => is_tip20_call(tx.tx().to.to()), - Self::Eip7702(tx) => is_tip20_call(Some(&tx.tx().to)), - Self::AA(tx) => tx.tx().calls.iter().all(|call| is_tip20_call(call.to.to())), + Self::Legacy(tx) => is_payment_call_v1(tx.tx().to.to(), &tx.tx().input, t5_active), + Self::Eip2930(tx) => is_payment_call_v1(tx.tx().to.to(), &tx.tx().input, t5_active), + Self::Eip1559(tx) => is_payment_call_v1(tx.tx().to.to(), &tx.tx().input, t5_active), + Self::Eip7702(tx) => { + tx.tx().authorization_list.is_empty() + && is_payment_call_v1(Some(&tx.tx().to), &tx.tx().input, t5_active) + } + Self::AA(tx) => aa_is_payment(tx.tx(), |to, input| { + is_payment_call_v1(to, input, t5_active) + }), } } @@ -194,33 +200,29 @@ impl TempoTxEnvelope { /// stricter classification at the protocol level. /// /// [TIP-20 payment]: - pub fn is_payment_v2(&self) -> bool { + pub fn is_payment_v2(&self, t5_active: bool) -> bool { match self { - Self::Legacy(tx) => is_tip20_payment(tx.tx().to.to(), &tx.tx().input), + Self::Legacy(tx) => is_payment_call_v2(tx.tx().to.to(), &tx.tx().input, t5_active), Self::Eip2930(tx) => { let tx = tx.tx(); - tx.access_list.is_empty() && is_tip20_payment(tx.to.to(), &tx.input) + tx.access_list.is_empty() && is_payment_call_v2(tx.to.to(), &tx.input, t5_active) } Self::Eip1559(tx) => { let tx = tx.tx(); - tx.access_list.is_empty() && is_tip20_payment(tx.to.to(), &tx.input) + tx.access_list.is_empty() && is_payment_call_v2(tx.to.to(), &tx.input, t5_active) } Self::Eip7702(tx) => { let tx = tx.tx(); tx.access_list.is_empty() && tx.authorization_list.is_empty() - && is_tip20_payment(Some(&tx.to), &tx.input) + && is_payment_call_v2(Some(&tx.to), &tx.input, t5_active) } Self::AA(tx) => { let tx = tx.tx(); - !tx.calls.is_empty() - && tx.key_authorization.is_none() - && tx.access_list.is_empty() - && tx.tempo_authorization_list.is_empty() - && tx - .calls - .iter() - .all(|call| is_tip20_payment(call.to.to(), &call.input)) + tx.access_list.is_empty() + && aa_is_payment(tx, |to, input| { + is_payment_call_v2(to, input, t5_active) + }) } } } @@ -485,6 +487,35 @@ fn is_tip20_payment(to: Option<&Address>, input: &[u8]) -> bool { is_tip20_call(to) && ITIP20::ITIP20Calls::is_payment(input) } +/// Returns `true` if `to` is the TIP-1034 channel escrow precompile and `input` is a recognized +/// payment-lane escrow call. +fn is_channel_escrow_payment(to: Option<&Address>, input: &[u8], t5_active: bool) -> bool { + t5_active + && to == Some(&TIP20_CHANNEL_ESCROW_ADDRESS) + && ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment(input) +} + +fn is_payment_call_v1(to: Option<&Address>, input: &[u8], t5_active: bool) -> bool { + is_tip20_call(to) || is_channel_escrow_payment(to, input, t5_active) +} + +fn is_payment_call_v2(to: Option<&Address>, input: &[u8], t5_active: bool) -> bool { + is_tip20_payment(to, input) || is_channel_escrow_payment(to, input, t5_active) +} + +fn aa_is_payment( + tx: &TempoTransaction, + is_payment_call: impl Fn(Option<&Address>, &[u8]) -> bool, +) -> bool { + tx.key_authorization.is_none() + && tx.tempo_authorization_list.is_empty() + && !tx.calls.is_empty() + && tx + .calls + .iter() + .all(|call| is_payment_call(call.to.to(), &call.input)) +} + #[cfg(feature = "rpc")] impl reth_rpc_convert::SignableTxRequest for alloy_rpc_types_eth::TransactionRequest @@ -517,19 +548,24 @@ impl reth_rpc_convert::TryIntoSimTx for alloy_rpc_types_eth::Tr mod tests { use super::*; use crate::transaction::{ - Call, TempoSignedAuthorization, TempoTransaction, + Call, TempoSignature, TempoSignedAuthorization, TempoTransaction, key_authorization::{KeyAuthorization, SignedKeyAuthorization}, tt_signature::PrimitiveSignature, }; use alloy_consensus::{TxEip1559, TxEip2930, TxEip7702}; use alloy_eips::{ eip2930::{AccessList, AccessListItem}, - eip7702::SignedAuthorization, + eip7702::{Authorization, SignedAuthorization}, }; use alloy_primitives::{Bytes, Signature, TxKind, U256, address}; use alloy_sol_types::SolCall; const PAYMENT_TKN: Address = address!("20c0000000000000000000000000000000000001"); + const CHANNEL_ESCROW: Address = TIP20_CHANNEL_ESCROW_ADDRESS; + + fn abi_u96(value: u128) -> alloy_primitives::Uint<96, 2> { + alloy_primitives::Uint::from(value) + } #[rustfmt::skip] /// Returns valid ABI-encoded calldata for every recognized TIP-20 payment selector. @@ -588,6 +624,89 @@ mod tests { ] } + fn channel_descriptor() -> ITIP20ChannelEscrow::ChannelDescriptor { + ITIP20ChannelEscrow::ChannelDescriptor { + payer: Address::random(), + payee: Address::random(), + token: PAYMENT_TKN, + salt: B256::random(), + authorizedSigner: Address::ZERO, + } + } + + fn primitive_signature_bytes() -> Bytes { + Bytes::copy_from_slice(&Signature::test_signature().as_bytes()) + } + + #[rustfmt::skip] + fn channel_escrow_calldatas() -> [Bytes; 6] { + let descriptor = channel_descriptor(); + let signature = primitive_signature_bytes(); + [ + ITIP20ChannelEscrow::openCall { + payee: descriptor.payee, + token: descriptor.token, + deposit: abi_u96(100), + salt: descriptor.salt, + authorizedSigner: descriptor.authorizedSigner, + expiresAt: 1000, + }.abi_encode().into(), + ITIP20ChannelEscrow::settleCall { + descriptor: descriptor.clone(), + cumulativeAmount: abi_u96(10), + signature: signature.clone(), + }.abi_encode().into(), + ITIP20ChannelEscrow::topUpCall { + descriptor: descriptor.clone(), + additionalDeposit: abi_u96(25), + newExpiresAt: 2000, + }.abi_encode().into(), + ITIP20ChannelEscrow::closeCall { + descriptor: descriptor.clone(), + cumulativeAmount: abi_u96(10), + captureAmount: abi_u96(10), + signature, + }.abi_encode().into(), + ITIP20ChannelEscrow::requestCloseCall { + descriptor: descriptor.clone(), + }.abi_encode().into(), + ITIP20ChannelEscrow::withdrawCall { descriptor }.abi_encode().into(), + ] + } + + fn invalid_channel_signature_calldata() -> [Bytes; 2] { + let descriptor = channel_descriptor(); + let invalid_signature = Bytes::from(vec![0x03; 21]); + [ + ITIP20ChannelEscrow::settleCall { + descriptor: descriptor.clone(), + cumulativeAmount: abi_u96(10), + signature: invalid_signature.clone(), + } + .abi_encode() + .into(), + ITIP20ChannelEscrow::closeCall { + descriptor, + cumulativeAmount: abi_u96(10), + captureAmount: abi_u96(10), + signature: invalid_signature, + } + .abi_encode() + .into(), + ] + } + + fn dummy_tempo_authorization() -> TempoSignedAuthorization { + TempoSignedAuthorization::new_unchecked( + Authorization { + chain_id: U256::from(1u128), + address: Address::random(), + nonce: 0, + }, + TempoSignature::default(), + ) + } + #[test] fn test_non_fee_token_access() { let legacy_tx = TxLegacy::default(); @@ -666,6 +785,38 @@ mod tests { assert!(!envelope.is_payment_v1()); } + #[test] + fn test_channel_escrow_payment_classification() { + for calldata in channel_escrow_calldatas() { + let tx = TxLegacy { + to: TxKind::Call(CHANNEL_ESCROW), + gas_limit: 21_000, + input: calldata, + ..Default::default() + }; + let envelope = + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); + assert!(envelope.is_payment_v1()); + assert!(envelope.is_payment_v2()); + } + } + + #[test] + fn test_channel_escrow_rejects_invalid_signature_encoding() { + for calldata in invalid_channel_signature_calldata() { + let tx = TxLegacy { + to: TxKind::Call(CHANNEL_ESCROW), + gas_limit: 21_000, + input: calldata, + ..Default::default() + }; + let envelope = + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); + assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v2()); + } + } + #[test] fn test_payment_classification_aa_no_to_address() { let call = Call { @@ -810,6 +961,7 @@ mod tests { ..Default::default() }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); + assert!(!envelope.is_payment_v1()); assert!( !envelope.is_payment_v2(), "AA with empty calls should not be V2 payment" @@ -942,6 +1094,23 @@ mod tests { } } + #[test] + fn test_payment_classification_rejects_aa_authorization_side_effects() { + let tx = TempoTransaction { + fee_token: Some(PAYMENT_TKN), + calls: vec![Call { + to: TxKind::Call(PAYMENT_TKN), + value: U256::ZERO, + input: payment_calldatas()[0].clone(), + }], + tempo_authorization_list: vec![dummy_tempo_authorization()], + ..Default::default() + }; + let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); + assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v2()); + } + #[test] fn test_system_tx_validation_and_recovery() { use alloy_consensus::transaction::SignerRecoverable; From d76eb73762a0f283b6095d1c45c35ec3addd45aa Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 23 Apr 2026 20:04:12 +0530 Subject: [PATCH 21/33] fix(tip-1034): gate escrow activation behind t5 Adds the T5 hardfork, moves channel escrow deployment and precompile registration behind that fork, and makes payment-lane classification explicitly hardfork-aware across consensus, pool, and payload-building paths. Amp-Thread-ID: https://ampcode.com/threads/T-019db9e6-a988-765a-ad31-301b7b72fc95 --- crates/evm/src/block.rs | 15 +++- crates/node/tests/it/block_building.rs | 9 +- crates/payload/builder/src/lib.rs | 5 +- crates/precompiles/src/lib.rs | 25 +++--- .../src/tip20_channel_escrow/mod.rs | 10 +-- crates/primitives/src/transaction/envelope.rs | 86 ++++++++++--------- crates/transaction-pool/src/transaction.rs | 64 +++++++++++--- 7 files changed, 138 insertions(+), 76 deletions(-) diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 2e4a2cf8a4..4b5dfefcc8 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -395,8 +395,12 @@ where } else { match self.section { BlockSection::StartOfBlock | BlockSection::NonShared => { + let t5_active = self + .inner + .spec + .is_t5_active_at_timestamp(self.evm().block().timestamp.to::()); if gas_used > self.non_shared_gas_left - || (!tx.is_payment_v1() && gas_used > self.non_payment_gas_left) + || (!tx.is_payment_v1(t5_active) && gas_used > self.non_payment_gas_left) { // Assume that this transaction wants to make use of gas incentive section // @@ -445,7 +449,7 @@ where self.deploy_precompile_at_boundary(SIGNATURE_VERIFIER_ADDRESS)?; self.deploy_precompile_at_boundary(ADDRESS_REGISTRY_ADDRESS)?; } - if self.inner.spec.is_t4_active_at_timestamp(timestamp) { + if self.inner.spec.is_t5_active_at_timestamp(timestamp) { self.deploy_precompile_at_boundary(TIP20_CHANNEL_ESCROW_ADDRESS)?; } @@ -499,10 +503,14 @@ where }; self.validate_tx(recovered.tx(), gas_used)? }; + let t5_active = self + .inner + .spec + .is_t5_active_at_timestamp(self.evm().block().timestamp.to::()); Ok(TempoTxResult { inner, next_section, - is_payment: recovered.tx().is_payment_v1(), + is_payment: recovered.tx().is_payment_v1(t5_active), tx: matches!(next_section, BlockSection::SubBlock { .. }) .then(|| recovered.tx().clone()), }) @@ -1597,6 +1605,7 @@ mod tests { ); } + #[test] fn test_deploy_precompile_at_boundary_dispatches_state_hook() { use std::sync::{Arc, Mutex}; diff --git a/crates/node/tests/it/block_building.rs b/crates/node/tests/it/block_building.rs index b474ca01d1..5f1a6b57a2 100644 --- a/crates/node/tests/it/block_building.rs +++ b/crates/node/tests/it/block_building.rs @@ -204,7 +204,10 @@ async fn sign_and_inject( /// Helper to count payment and non-payment transactions fn count_transaction_types(transactions: &[TempoTxEnvelope]) -> (usize, usize) { - let payment_count = transactions.iter().filter(|tx| tx.is_payment_v2()).count(); + let payment_count = transactions + .iter() + .filter(|tx| tx.is_payment_v2(false)) + .count(); (payment_count, transactions.len() - payment_count) } @@ -353,7 +356,7 @@ async fn test_block_building_only_payment_txs() -> eyre::Result<()> { for tx in &user_txs { assert!( - tx.is_payment_v2(), + tx.is_payment_v2(false), "All transactions should be payment transactions" ); } @@ -423,7 +426,7 @@ async fn test_block_building_only_non_payment_txs() -> eyre::Result<()> { for tx in &user_txs { assert!( - !tx.is_payment_v2(), + !tx.is_payment_v2(false), "All transactions should be non-payment transactions" ); } diff --git a/crates/payload/builder/src/lib.rs b/crates/payload/builder/src/lib.rs index a3f425dd91..aed4e979c2 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -309,6 +309,7 @@ where block_gas_limit, shared_gas_limit, ); + let t5_active = chain_spec.is_t5_active_at_timestamp(attributes.timestamp); let mut cumulative_gas_used = 0; let mut cumulative_state_gas_used = 0u64; @@ -478,7 +479,7 @@ where // If the tx is not a payment and will exceed the general gas limit // mark the tx as invalid and continue - if !pool_tx.transaction.is_payment() + if !pool_tx.transaction.is_payment(t5_active) && non_payment_gas_used + max_regular_gas_used > general_gas_limit { best_txs.mark_invalid( @@ -498,7 +499,7 @@ where } check_cancel!(); - let is_payment = pool_tx.transaction.is_payment(); + let is_payment = pool_tx.transaction.is_payment(t5_active); if is_payment { payment_transactions += 1; } diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index c9287a1b95..a69204be52 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -62,8 +62,7 @@ pub use tempo_contracts::precompiles::{ ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, DEFAULT_FEE_TOKEN, NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, TIP20_CHANNEL_ESCROW_ADDRESS, TIP20_FACTORY_ADDRESS, - TIP403_REGISTRY_ADDRESS, - VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, + TIP403_REGISTRY_ADDRESS, VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, }; // Re-export storage layout helpers for read-only contexts (e.g., pool validation) @@ -130,7 +129,7 @@ pub fn extend_tempo_precompiles(precompiles: &mut PrecompilesMap, cfg: &CfgEnv::default(); + fn test_channel_escrow_registered_at_t5_only() { + let pre_t5 = CfgEnv::::default(); assert!( - tempo_precompiles(&pre_t4) + tempo_precompiles(&pre_t5) .get(&TIP20_CHANNEL_ESCROW_ADDRESS) .is_none(), - "TIP20 channel escrow should NOT be registered before T4" + "TIP20 channel escrow should NOT be registered before T5" ); - let mut t4 = CfgEnv::::default(); - t4.set_spec(TempoHardfork::T4); + let mut t5 = CfgEnv::::default(); + t5.set_spec(TempoHardfork::T5); assert!( - tempo_precompiles(&t4) + tempo_precompiles(&t5) .get(&TIP20_CHANNEL_ESCROW_ADDRESS) .is_some(), - "TIP20 channel escrow should be registered at T4" + "TIP20 channel escrow should be registered at T5" ); } diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index ac549ac043..5ab84b77d9 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -640,7 +640,7 @@ mod tests { #[test] fn test_selector_coverage() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); StorageCtx::enter(&mut storage, || { let mut escrow = TIP20ChannelEscrow::new(); let unsupported = check_selector_coverage( @@ -656,7 +656,7 @@ mod tests { #[test] fn test_open_settle_close_flow_and_tombstone() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); let payer_signer = PrivateKeySigner::random(); let payer = payer_signer.address(); let payee = Address::random(); @@ -739,7 +739,7 @@ mod tests { #[test] fn test_top_up_cancels_close_request() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); let payer = Address::random(); let payee = Address::random(); let salt = B256::random(); @@ -792,7 +792,7 @@ mod tests { #[test] fn test_dispatch_rejects_static_mutation() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); StorageCtx::enter(&mut storage, || { let mut escrow = TIP20ChannelEscrow::new(); let result = escrow.call( @@ -814,7 +814,7 @@ mod tests { #[test] fn test_settle_rejects_invalid_signature() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); let payer = Address::random(); let payee = Address::random(); let salt = B256::random(); diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index de4b3bad8a..17c8ac8b82 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -562,6 +562,8 @@ mod tests { const PAYMENT_TKN: Address = address!("20c0000000000000000000000000000000000001"); const CHANNEL_ESCROW: Address = TIP20_CHANNEL_ESCROW_ADDRESS; + const PRE_T5: bool = false; + const T5_ACTIVE: bool = true; fn abi_u96(value: u128) -> alloy_primitives::Uint<96, 2> { alloy_primitives::Uint::from(value) @@ -735,7 +737,7 @@ mod tests { let signed = Signed::new_unhashed(tx, Signature::test_signature()); let envelope = TempoTxEnvelope::Legacy(signed); - assert!(envelope.is_payment_v1()); + assert!(envelope.is_payment_v1(PRE_T5)); } #[test] @@ -749,7 +751,7 @@ mod tests { let signed = Signed::new_unhashed(tx, Signature::test_signature()); let envelope = TempoTxEnvelope::Legacy(signed); - assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v1(PRE_T5)); } fn create_aa_envelope(call: Call) -> TempoTxEnvelope { @@ -770,7 +772,7 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(envelope.is_payment_v1()); + assert!(envelope.is_payment_v1(PRE_T5)); } #[test] @@ -782,7 +784,7 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v1(PRE_T5)); } #[test] @@ -796,8 +798,10 @@ mod tests { }; let envelope = TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(envelope.is_payment_v1()); - assert!(envelope.is_payment_v2()); + assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v2(PRE_T5)); + assert!(envelope.is_payment_v1(T5_ACTIVE)); + assert!(envelope.is_payment_v2(T5_ACTIVE)); } } @@ -812,8 +816,10 @@ mod tests { }; let envelope = TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1()); - assert!(!envelope.is_payment_v2()); + assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v1(T5_ACTIVE)); + assert!(!envelope.is_payment_v2(PRE_T5)); + assert!(!envelope.is_payment_v2(T5_ACTIVE)); } } @@ -825,7 +831,7 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v1(PRE_T5)); } #[test] @@ -838,7 +844,7 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(envelope.is_payment_v1()); + assert!(envelope.is_payment_v1(PRE_T5)); } #[test] @@ -851,7 +857,7 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v1(PRE_T5)); } #[test] @@ -863,7 +869,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip2930(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(envelope.is_payment_v1()); + assert!(envelope.is_payment_v1(PRE_T5)); // Eip2930 non-payment let tx = TxEip2930 { @@ -872,7 +878,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip2930(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v1(PRE_T5)); // Eip1559 payment let tx = TxEip1559 { @@ -881,7 +887,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip1559(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(envelope.is_payment_v1()); + assert!(envelope.is_payment_v1(PRE_T5)); // Eip1559 non-payment let tx = TxEip1559 { @@ -890,7 +896,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip1559(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v1(PRE_T5)); // Eip7702 payment (note: Eip7702 has direct `to` address, not TxKind) let tx = TxEip7702 { @@ -899,7 +905,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip7702(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(envelope.is_payment_v1()); + assert!(envelope.is_payment_v1(PRE_T5)); // Eip7702 non-payment let tx = TxEip7702 { @@ -908,15 +914,15 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip7702(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v1(PRE_T5)); } #[test] fn test_payment_v2_accepts_valid_calldata() { for calldata in payment_calldatas() { for envelope in payment_envelopes(calldata) { - assert!(envelope.is_payment_v1(), "V1 must accept valid calldata"); - assert!(envelope.is_payment_v2(), "V2 must accept valid calldata"); + assert!(envelope.is_payment_v1(PRE_T5), "V1 must accept valid calldata"); + assert!(envelope.is_payment_v2(PRE_T5), "V2 must accept valid calldata"); } } } @@ -924,8 +930,8 @@ mod tests { #[test] fn test_payment_v2_rejects_empty_calldata() { for envelope in payment_envelopes(Bytes::new()) { - assert!(envelope.is_payment_v1(), "V1 must accept (prefix-only)"); - assert!(!envelope.is_payment_v2(), "V2 must reject empty calldata"); + assert!(envelope.is_payment_v1(PRE_T5), "V1 must accept (prefix-only)"); + assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject empty calldata"); } } @@ -935,8 +941,8 @@ mod tests { let mut data = calldata.to_vec(); data.extend_from_slice(&[0u8; 32]); for envelope in payment_envelopes(Bytes::from(data)) { - assert!(envelope.is_payment_v1(), "V1 must accept (prefix-only)"); - assert!(!envelope.is_payment_v2(), "V2 must reject excess calldata"); + assert!(envelope.is_payment_v1(PRE_T5), "V1 must accept (prefix-only)"); + assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject excess calldata"); } } } @@ -947,8 +953,8 @@ mod tests { let mut data = calldata.to_vec(); data[..4].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); for envelope in payment_envelopes(Bytes::from(data)) { - assert!(envelope.is_payment_v1(), "V1 must accept (prefix-only)"); - assert!(!envelope.is_payment_v2(), "V2 must reject unknown selector"); + assert!(envelope.is_payment_v1(PRE_T5), "V1 must accept (prefix-only)"); + assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject unknown selector"); } } } @@ -961,10 +967,10 @@ mod tests { ..Default::default() }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); - assert!(!envelope.is_payment_v1()); + assert!(!envelope.is_payment_v1(PRE_T5)); assert!( - !envelope.is_payment_v2(), - "AA with empty calls should not be V2 payment" + !envelope.is_payment_v2(PRE_T5), + "AA with empty calls should not be strict payment" ); } @@ -993,11 +999,11 @@ mod tests { let envelope = TempoTxEnvelope::Eip7702(Signed::new_unhashed(tx, Signature::test_signature())); assert!( - envelope.is_payment_v1(), + envelope.is_payment_v1(PRE_T5), "V1 ignores authorization_list (backwards compat)" ); assert!( - !envelope.is_payment_v2(), + !envelope.is_payment_v2(PRE_T5), "V2 must reject EIP-7702 tx with non-empty authorization_list" ); } @@ -1031,11 +1037,11 @@ mod tests { }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); assert!( - envelope.is_payment_v1(), + envelope.is_payment_v1(PRE_T5), "V1 ignores side-effect fields (backwards compat)" ); assert!( - !envelope.is_payment_v2(), + !envelope.is_payment_v2(PRE_T5), "V2 must reject AA tx with key_authorization" ); } @@ -1066,11 +1072,11 @@ mod tests { }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); assert!( - envelope.is_payment_v1(), + envelope.is_payment_v1(PRE_T5), "V1 ignores side-effect fields (backwards compat)" ); assert!( - !envelope.is_payment_v2(), + !envelope.is_payment_v2(PRE_T5), "V2 must reject AA tx with tempo_authorization_list" ); } @@ -1089,8 +1095,8 @@ mod tests { }]); for envelope in payment_envelopes_with_access_list(calldata, access_list) { - assert!(envelope.is_payment_v1(), "V1 must ignore access_list"); - assert!(!envelope.is_payment_v2(), "V2 must reject access_list"); + assert!(envelope.is_payment_v1(PRE_T5), "V1 must ignore access_list"); + assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject access_list"); } } @@ -1107,8 +1113,8 @@ mod tests { ..Default::default() }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); - assert!(!envelope.is_payment_v1()); - assert!(!envelope.is_payment_v2()); + assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v2(PRE_T5)); } #[test] @@ -1268,7 +1274,7 @@ mod tests { ..Default::default() }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); - assert!(envelope.is_payment_v1(), "V1 must accept AA without calls"); - assert!(!envelope.is_payment_v2(), "V2 must reject AA without calls"); + assert!(!envelope.is_payment_v1(PRE_T5), "V1 must reject AA without calls"); + assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject AA without calls"); } } diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 7ca22324e0..98edaa2294 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -31,8 +31,10 @@ use thiserror::Error; #[derive(Debug, Clone)] pub struct TempoPooledTransaction { inner: EthPooledTransaction, - /// Cached payment classification for efficient block building - is_payment: bool, + /// Cached strict payment classification before T5 activates. + is_payment_pre_t5: bool, + /// Cached strict payment classification once T5 is active. + is_payment_t5: bool, /// Cached expiring nonce classification is_expiring_nonce: bool, /// Cached slot of the 2D nonce, if any. @@ -56,7 +58,8 @@ pub struct TempoPooledTransaction { impl TempoPooledTransaction { /// Create new instance of [Self] from the given consensus transactions and the encoded size. pub fn new(transaction: Recovered) -> Self { - let is_payment = transaction.is_payment_v2(); + let is_payment_pre_t5 = transaction.is_payment_v2(false); + let is_payment_t5 = transaction.is_payment_v2(true); let is_expiring_nonce = transaction .as_aa() .map(|tx| tx.tx().is_expiring_nonce_tx()) @@ -72,7 +75,8 @@ impl TempoPooledTransaction { blob_sidecar: EthBlobTransactionSidecar::None, transaction, }, - is_payment, + is_payment_pre_t5, + is_payment_t5, is_expiring_nonce, nonce_key_slot: OnceLock::new(), expiring_nonce_slot: OnceLock::new(), @@ -115,8 +119,12 @@ impl TempoPooledTransaction { /// Returns whether this is a payment transaction. /// /// Uses strict classification: TIP-20 prefix AND recognized calldata. - pub fn is_payment(&self) -> bool { - self.is_payment + pub fn is_payment(&self, t5_active: bool) -> bool { + if t5_active { + self.is_payment_t5 + } else { + self.is_payment_pre_t5 + } } /// Returns true if this transaction belongs into the 2D nonce pool: @@ -642,7 +650,7 @@ mod tests { use alloy_consensus::TxEip1559; use alloy_primitives::{Address, Signature, TxKind, address}; use alloy_sol_types::SolCall; - use tempo_contracts::precompiles::ITIP20; + use tempo_contracts::precompiles::{ITIP20, ITIP20ChannelEscrow, TIP20_CHANNEL_ESCROW_ADDRESS}; use tempo_precompiles::{PATH_USD_ADDRESS, nonce::NonceManager}; use tempo_primitives::transaction::{ TempoTransaction, @@ -679,7 +687,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(pooled_tx.is_payment()); + assert!(pooled_tx.is_payment(false)); } #[test] @@ -704,7 +712,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(!pooled_tx.is_payment()); + assert!(!pooled_tx.is_payment(false)); } #[test] @@ -714,7 +722,43 @@ mod tests { let pooled_tx = TxBuilder::eip1559(non_payment_addr) .gas_limit(21000) .build_eip1559(); - assert!(!pooled_tx.is_payment()); + assert!(!pooled_tx.is_payment(false)); + } + + #[test] + fn test_channel_escrow_payment_classification_activates_at_t5() { + let calldata = ITIP20ChannelEscrow::requestCloseCall { + descriptor: ITIP20ChannelEscrow::ChannelDescriptor { + payer: Address::random(), + payee: Address::random(), + token: PATH_USD_ADDRESS, + salt: B256::random(), + authorizedSigner: Address::ZERO, + }, + } + .abi_encode(); + + let tx = TxEip1559 { + to: TxKind::Call(TIP20_CHANNEL_ESCROW_ADDRESS), + gas_limit: 21_000, + input: Bytes::from(calldata), + ..Default::default() + }; + + let envelope = TempoTxEnvelope::Eip1559(alloy_consensus::Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )); + + let recovered = Recovered::new_unchecked( + envelope, + address!("0000000000000000000000000000000000000001"), + ); + + let pooled_tx = TempoPooledTransaction::new(recovered); + assert!(!pooled_tx.is_payment(false)); + assert!(pooled_tx.is_payment(true)); } #[test] From f3a62b1ec15b803110f51c63b65f97319344c05a Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Fri, 24 Apr 2026 19:07:21 +0530 Subject: [PATCH 22/33] fix(tip20): align channel escrow payment classification Amp-Thread-ID: https://ampcode.com/threads/T-019dbf99-f696-765a-ad5e-9fe90d1b140c --- .../src/precompiles/tip20_channel_escrow.rs | 28 ++++++- .../src/tip20_channel_escrow/mod.rs | 56 +++++++++++++- crates/primitives/src/transaction/envelope.rs | 77 ++++++++++++++++++- 3 files changed, 150 insertions(+), 11 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index e855179e48..80b78da806 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -14,6 +14,9 @@ const P256_SIGNATURE_LENGTH: usize = 130; const WEBAUTHN_SIGNATURE_TYPE: u8 = 0x02; const MIN_WEBAUTHN_SIGNATURE_LENGTH: usize = 129; const MAX_WEBAUTHN_SIGNATURE_LENGTH: usize = 2049; +const KEYCHAIN_SIGNATURE_TYPE_V1: u8 = 0x03; +const KEYCHAIN_SIGNATURE_TYPE_V2: u8 = 0x04; +const KEYCHAIN_SIGNER_LENGTH: usize = 20; crate::sol! { #[derive(Debug, PartialEq, Eq)] @@ -184,7 +187,7 @@ crate::sol! { impl ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls { /// Returns `true` if `input` matches a channel escrow payment-lane selector and its calldata - /// is well-formed. `settle` and `close` also require a valid primitive signature encoding. + /// is well-formed. `settle` and `close` also require a valid Tempo signature encoding. pub fn is_payment(input: &[u8]) -> bool { fn is_static_call(input: &[u8]) -> bool { input.first_chunk::<4>() == Some(&C::SELECTOR) @@ -201,13 +204,32 @@ impl ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls { } match Self::abi_decode(input) { - Ok(Self::settle(call)) => is_valid_primitive_signature_encoding(&call.signature), - Ok(Self::close(call)) => is_valid_primitive_signature_encoding(&call.signature), + Ok(Self::settle(call)) => is_valid_tempo_signature_encoding(&call.signature), + Ok(Self::close(call)) => is_valid_tempo_signature_encoding(&call.signature), _ => false, } } } +fn is_valid_tempo_signature_encoding(signature: &[u8]) -> bool { + if is_valid_primitive_signature_encoding(signature) { + return true; + } + + let Some((&signature_type, rest)) = signature.split_first() else { + return false; + }; + if !matches!( + signature_type, + KEYCHAIN_SIGNATURE_TYPE_V1 | KEYCHAIN_SIGNATURE_TYPE_V2 + ) || rest.len() <= KEYCHAIN_SIGNER_LENGTH + { + return false; + } + + is_valid_primitive_signature_encoding(&rest[KEYCHAIN_SIGNER_LENGTH..]) +} + fn is_valid_primitive_signature_encoding(signature: &[u8]) -> bool { match signature.len() { SECP256K1_SIGNATURE_LENGTH => true, diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index 5ab84b77d9..3ebb659845 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -178,7 +178,7 @@ impl TIP20ChannelEscrow { TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( self.address, call.descriptor.payee, - U256::from(delta.as_u128()), + U256::from(u128::from(delta)), )?; self.emit_event(TIP20ChannelEscrowEvent::Settled( ITIP20ChannelEscrow::Settled { @@ -353,14 +353,14 @@ impl TIP20ChannelEscrow { token.system_transfer_from( self.address, call.descriptor.payee, - U256::from(delta.as_u128()), + U256::from(u128::from(delta)), )?; } if !refund.is_zero() { token.system_transfer_from( self.address, call.descriptor.payer, - U256::from(refund.as_u128()), + U256::from(u128::from(refund)), )?; } @@ -412,7 +412,7 @@ impl TIP20ChannelEscrow { TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( self.address, call.descriptor.payer, - U256::from(refund.as_u128()), + U256::from(u128::from(refund)), )?; } self.emit_event(TIP20ChannelEscrowEvent::ChannelExpired( @@ -856,4 +856,52 @@ mod tests { Ok(()) }) } + + #[test] + fn test_settle_rejects_keychain_signature_wrapper() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + let salt = B256::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(100u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + let now = StorageCtx::default().timestamp().to::(); + escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + token: token.address(), + deposit: abi_u96(100), + salt, + authorizedSigner: Address::ZERO, + expiresAt: now + 1_000, + }, + )?; + + let mut keychain_signature = Vec::with_capacity(1 + 20 + 65); + keychain_signature.push(0x03); + keychain_signature.extend_from_slice(Address::random().as_slice()); + keychain_signature.extend_from_slice(Signature::test_signature().as_bytes().as_slice()); + + let result = escrow.settle( + payee, + ITIP20ChannelEscrow::settleCall { + descriptor: descriptor(payer, payee, token.address(), salt, Address::ZERO), + cumulativeAmount: abi_u96(10), + signature: keychain_signature.into(), + }, + ); + assert_eq!( + result.unwrap_err(), + TIP20ChannelEscrowError::invalid_signature().into() + ); + Ok(()) + }) + } } diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 17c8ac8b82..dadc060e28 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -182,7 +182,7 @@ impl TempoTxEnvelope { && is_payment_call_v1(Some(&tx.tx().to), &tx.tx().input, t5_active) } Self::AA(tx) => aa_is_payment(tx.tx(), |to, input| { - is_payment_call_v1(to, input, t5_active) + is_payment_call_v2(to, input, t5_active) }), } } @@ -640,6 +640,14 @@ mod tests { Bytes::copy_from_slice(&Signature::test_signature().as_bytes()) } + fn keychain_signature_bytes() -> Bytes { + let mut bytes = Vec::with_capacity(1 + 20 + 65); + bytes.push(0x03); + bytes.extend_from_slice(Address::random().as_slice()); + bytes.extend_from_slice(Signature::test_signature().as_bytes().as_slice()); + bytes.into() + } + #[rustfmt::skip] fn channel_escrow_calldatas() -> [Bytes; 6] { let descriptor = channel_descriptor(); @@ -698,6 +706,28 @@ mod tests { ] } + fn keychain_channel_signature_calldata() -> [Bytes; 2] { + let descriptor = channel_descriptor(); + let keychain_signature = keychain_signature_bytes(); + [ + ITIP20ChannelEscrow::settleCall { + descriptor: descriptor.clone(), + cumulativeAmount: abi_u96(10), + signature: keychain_signature.clone(), + } + .abi_encode() + .into(), + ITIP20ChannelEscrow::closeCall { + descriptor, + cumulativeAmount: abi_u96(10), + captureAmount: abi_u96(10), + signature: keychain_signature, + } + .abi_encode() + .into(), + ] + } + fn dummy_tempo_authorization() -> TempoSignedAuthorization { TempoSignedAuthorization::new_unchecked( Authorization { @@ -769,10 +799,29 @@ mod tests { let call = Call { to: TxKind::Call(payment_addr), value: U256::ZERO, - input: Bytes::new(), + input: ITIP20::transferCall { + to: Address::random(), + amount: U256::from(1u64), + } + .abi_encode() + .into(), }; let envelope = create_aa_envelope(call); assert!(envelope.is_payment_v1(PRE_T5)); + assert!(envelope.is_payment_v2(PRE_T5)); + } + + #[test] + fn test_payment_classification_aa_requires_strict_tip20_calldata() { + let payment_addr = address!("20c0000000000000000000000000000000000001"); + let call = Call { + to: TxKind::Call(payment_addr), + value: U256::ZERO, + input: Bytes::new(), + }; + let envelope = create_aa_envelope(call); + assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v2(PRE_T5)); } #[test] @@ -823,6 +872,24 @@ mod tests { } } + #[test] + fn test_channel_escrow_accepts_keychain_signature_encoding_for_classification() { + for calldata in keychain_channel_signature_calldata() { + let tx = TxLegacy { + to: TxKind::Call(CHANNEL_ESCROW), + gas_limit: 21_000, + input: calldata, + ..Default::default() + }; + let envelope = + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); + assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v2(PRE_T5)); + assert!(envelope.is_payment_v1(T5_ACTIVE)); + assert!(envelope.is_payment_v2(T5_ACTIVE)); + } + } + #[test] fn test_payment_classification_aa_no_to_address() { let call = Call { @@ -836,7 +903,8 @@ mod tests { #[test] fn test_payment_classification_aa_partial_match() { - // First 12 bytes match TIP20_PAYMENT_PREFIX, remaining 8 bytes differ + // AA classification still treats TIP-20 vanity addresses as payment targets, but only when + // the calldata is a recognized TIP-20 payment ABI. let payment_addr = address!("20c0000000000000000000001111111111111111"); let call = Call { to: TxKind::Call(payment_addr), @@ -844,7 +912,8 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v2(PRE_T5)); } #[test] From 25678e04b46215e56ff189731463f026a080d2c3 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 17:53:12 +0530 Subject: [PATCH 23/33] fix(tip-1034): align rebase follow-up with main Amp-Thread-ID: https://ampcode.com/threads/T-019dcee2-6079-716d-aecd-628012303ecf --- crates/precompiles/src/lib.rs | 18 +--- .../src/tip20_channel_escrow/dispatch.rs | 11 +- crates/primitives/src/transaction/envelope.rs | 100 +++++++++++++----- 3 files changed, 85 insertions(+), 44 deletions(-) diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index a69204be52..3e0a1b3cb7 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -26,19 +26,11 @@ pub mod validator_config_v2; pub mod test_util; use crate::{ - account_keychain::AccountKeychain, - address_registry::AddressRegistry, - nonce::NonceManager, - signature_verifier::SignatureVerifier, - stablecoin_dex::StablecoinDEX, - storage::StorageCtx, - tip_fee_manager::TipFeeManager, - tip20::{TIP20Token, is_tip20_prefix}, - tip20_channel_escrow::TIP20ChannelEscrow, - tip20_factory::TIP20Factory, - tip403_registry::TIP403Registry, - validator_config::ValidatorConfig, - validator_config_v2::ValidatorConfigV2, + account_keychain::AccountKeychain, address_registry::AddressRegistry, nonce::NonceManager, + signature_verifier::SignatureVerifier, stablecoin_dex::StablecoinDEX, storage::StorageCtx, + tip_fee_manager::TipFeeManager, tip20::TIP20Token, tip20_channel_escrow::TIP20ChannelEscrow, + tip20_factory::TIP20Factory, tip403_registry::TIP403Registry, + validator_config::ValidatorConfig, validator_config_v2::ValidatorConfigV2, }; use tempo_chainspec::hardfork::TempoHardfork; use tempo_primitives::TempoAddressExt; diff --git a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs index 3d29c987f9..4465c565c6 100644 --- a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs +++ b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs @@ -1,21 +1,22 @@ //! ABI dispatch for the [`TIP20ChannelEscrow`] precompile. use super::{CLOSE_GRACE_PERIOD, TIP20ChannelEscrow, VOUCHER_TYPEHASH}; -use crate::{Precompile, dispatch_call, input_cost, metadata, mutate, mutate_void, view}; +use crate::{Precompile, charge_input_cost, dispatch_call, metadata, mutate, mutate_void, view}; use alloy::{primitives::Address, sol_types::SolInterface}; -use revm::precompile::{PrecompileError, PrecompileResult}; +use revm::precompile::PrecompileResult; use tempo_contracts::precompiles::{ ITIP20ChannelEscrow, ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls, }; impl Precompile for TIP20ChannelEscrow { fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { - self.storage - .deduct_gas(input_cost(calldata.len())) - .map_err(|_| PrecompileError::OutOfGas)?; + if let Some(err) = charge_input_cost(&mut self.storage, calldata) { + return err; + } dispatch_call( calldata, + &[], ITIP20ChannelEscrowCalls::abi_decode, |call| match call { ITIP20ChannelEscrowCalls::CLOSE_GRACE_PERIOD(_) => { diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index dadc060e28..5c2ed68e79 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -220,9 +220,7 @@ impl TempoTxEnvelope { Self::AA(tx) => { let tx = tx.tx(); tx.access_list.is_empty() - && aa_is_payment(tx, |to, input| { - is_payment_call_v2(to, input, t5_active) - }) + && aa_is_payment(tx, |to, input| is_payment_call_v2(to, input, t5_active)) } } } @@ -990,18 +988,35 @@ mod tests { fn test_payment_v2_accepts_valid_calldata() { for calldata in payment_calldatas() { for envelope in payment_envelopes(calldata) { - assert!(envelope.is_payment_v1(PRE_T5), "V1 must accept valid calldata"); - assert!(envelope.is_payment_v2(PRE_T5), "V2 must accept valid calldata"); + assert!( + envelope.is_payment_v1(PRE_T5), + "V1 must accept valid calldata" + ); + assert!( + envelope.is_payment_v2(PRE_T5), + "V2 must accept valid calldata" + ); } } } #[test] fn test_payment_v2_rejects_empty_calldata() { - for envelope in payment_envelopes(Bytes::new()) { - assert!(envelope.is_payment_v1(PRE_T5), "V1 must accept (prefix-only)"); - assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject empty calldata"); - } + let tx = TxLegacy { + to: TxKind::Call(PAYMENT_TKN), + gas_limit: 21_000, + ..Default::default() + }; + let envelope = + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); + assert!( + envelope.is_payment_v1(PRE_T5), + "V1 must accept (prefix-only)" + ); + assert!( + !envelope.is_payment_v2(PRE_T5), + "V2 must reject empty calldata" + ); } #[test] @@ -1009,10 +1024,22 @@ mod tests { for calldata in payment_calldatas() { let mut data = calldata.to_vec(); data.extend_from_slice(&[0u8; 32]); - for envelope in payment_envelopes(Bytes::from(data)) { - assert!(envelope.is_payment_v1(PRE_T5), "V1 must accept (prefix-only)"); - assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject excess calldata"); - } + let tx = TxLegacy { + to: TxKind::Call(PAYMENT_TKN), + gas_limit: 21_000, + input: Bytes::from(data), + ..Default::default() + }; + let envelope = + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); + assert!( + envelope.is_payment_v1(PRE_T5), + "V1 must accept (prefix-only)" + ); + assert!( + !envelope.is_payment_v2(PRE_T5), + "V2 must reject excess calldata" + ); } } @@ -1021,10 +1048,22 @@ mod tests { for calldata in payment_calldatas() { let mut data = calldata.to_vec(); data[..4].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); - for envelope in payment_envelopes(Bytes::from(data)) { - assert!(envelope.is_payment_v1(PRE_T5), "V1 must accept (prefix-only)"); - assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject unknown selector"); - } + let tx = TxLegacy { + to: TxKind::Call(PAYMENT_TKN), + gas_limit: 21_000, + input: Bytes::from(data), + ..Default::default() + }; + let envelope = + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); + assert!( + envelope.is_payment_v1(PRE_T5), + "V1 must accept (prefix-only)" + ); + assert!( + !envelope.is_payment_v2(PRE_T5), + "V2 must reject unknown selector" + ); } } @@ -1068,8 +1107,8 @@ mod tests { let envelope = TempoTxEnvelope::Eip7702(Signed::new_unhashed(tx, Signature::test_signature())); assert!( - envelope.is_payment_v1(PRE_T5), - "V1 ignores authorization_list (backwards compat)" + !envelope.is_payment_v1(PRE_T5), + "V1 must reject authorization-bearing EIP-7702 transactions" ); assert!( !envelope.is_payment_v2(PRE_T5), @@ -1106,8 +1145,8 @@ mod tests { }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); assert!( - envelope.is_payment_v1(PRE_T5), - "V1 ignores side-effect fields (backwards compat)" + !envelope.is_payment_v1(PRE_T5), + "V1 must reject AA tx with key_authorization" ); assert!( !envelope.is_payment_v2(PRE_T5), @@ -1141,8 +1180,8 @@ mod tests { }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); assert!( - envelope.is_payment_v1(PRE_T5), - "V1 ignores side-effect fields (backwards compat)" + !envelope.is_payment_v1(PRE_T5), + "V1 must reject AA tx with tempo_authorization_list" ); assert!( !envelope.is_payment_v2(PRE_T5), @@ -1165,7 +1204,10 @@ mod tests { for envelope in payment_envelopes_with_access_list(calldata, access_list) { assert!(envelope.is_payment_v1(PRE_T5), "V1 must ignore access_list"); - assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject access_list"); + assert!( + !envelope.is_payment_v2(PRE_T5), + "V2 must reject access_list" + ); } } @@ -1343,7 +1385,13 @@ mod tests { ..Default::default() }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); - assert!(!envelope.is_payment_v1(PRE_T5), "V1 must reject AA without calls"); - assert!(!envelope.is_payment_v2(PRE_T5), "V2 must reject AA without calls"); + assert!( + !envelope.is_payment_v1(PRE_T5), + "V1 must reject AA without calls" + ); + assert!( + !envelope.is_payment_v2(PRE_T5), + "V2 must reject AA without calls" + ); } } From 16539d684d639a52d2d00d2c92cba393cdbe7f10 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 18:37:20 +0530 Subject: [PATCH 24/33] refactor(tip-1034): use shared U96 type Amp-Thread-ID: https://ampcode.com/threads/T-019dcee2-6079-716d-aecd-628012303ecf --- .../src/tip20_channel_escrow/mod.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index 3ebb659845..6a76cdb113 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -5,7 +5,7 @@ pub mod dispatch; use crate::{ error::Result, signature_verifier::SignatureVerifier, - storage::{Handler, Mapping, U96 as PackedU96}, + storage::{Handler, Mapping, U96}, tip20::{TIP20Token, is_tip20_prefix}, }; use alloy::{ @@ -36,8 +36,8 @@ type AbiU96 = Uint<96, 2>; #[derive(Debug, Clone, Copy, Default, Storable)] struct PackedChannelState { - settled: PackedU96, - deposit: PackedU96, + settled: U96, + deposit: U96, expires_at: u32, close_data: u32, } @@ -87,7 +87,7 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::invalid_token().into()); } - let deposit = PackedU96::from(call.deposit); + let deposit = U96::from(call.deposit); if deposit.is_zero() { return Err(TIP20ChannelEscrowError::zero_deposit().into()); } @@ -108,7 +108,7 @@ impl TIP20ChannelEscrow { let batch = self.storage.checkpoint(); self.channel_states[channel_id].write(PackedChannelState { - settled: PackedU96::ZERO, + settled: U96::ZERO, deposit, expires_at: call.expiresAt, close_data: 0, @@ -153,7 +153,7 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::channel_expired().into()); } - let cumulative = PackedU96::from(call.cumulativeAmount); + let cumulative = U96::from(call.cumulativeAmount); if cumulative > state.deposit { return Err(TIP20ChannelEscrowError::amount_exceeds_deposit().into()); } @@ -210,7 +210,7 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::channel_finalized().into()); } - let additional = PackedU96::from(call.additionalDeposit); + let additional = U96::from(call.additionalDeposit); let next_deposit = state .deposit .checked_add(additional) @@ -313,8 +313,8 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::channel_finalized().into()); } - let cumulative = PackedU96::from(call.cumulativeAmount); - let capture = PackedU96::from(call.captureAmount); + let cumulative = U96::from(call.cumulativeAmount); + let capture = U96::from(call.captureAmount); let previous_settled = state.settled; if capture < previous_settled || capture > cumulative { return Err(TIP20ChannelEscrowError::capture_amount_invalid().into()); From 6097a1e4b34dc730072dbd9579786dd4e1d14b7a Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 18:49:36 +0530 Subject: [PATCH 25/33] refactor(tip-1034): inline escrow u96 storage Amp-Thread-ID: https://ampcode.com/threads/T-019dcf16-6842-7198-a455-39038a230549 --- crates/precompiles/src/storage/types/mod.rs | 1 - .../src/storage/types/primitives.rs | 142 +----------------- .../src/tip20_channel_escrow/dispatch.rs | 4 +- .../src/tip20_channel_escrow/mod.rs | 131 ++++++++++++---- 4 files changed, 106 insertions(+), 172 deletions(-) diff --git a/crates/precompiles/src/storage/types/mod.rs b/crates/precompiles/src/storage/types/mod.rs index 9c36f706c0..2994489d0d 100644 --- a/crates/precompiles/src/storage/types/mod.rs +++ b/crates/precompiles/src/storage/types/mod.rs @@ -17,7 +17,6 @@ pub use set::{Set, SetHandler}; pub mod bytes_like; mod primitives; -pub use primitives::U96; use crate::{ error::Result, diff --git a/crates/precompiles/src/storage/types/primitives.rs b/crates/precompiles/src/storage/types/primitives.rs index cd7a8a7973..bd29fedcd1 100644 --- a/crates/precompiles/src/storage/types/primitives.rs +++ b/crates/precompiles/src/storage/types/primitives.rs @@ -1,84 +1,13 @@ //! `StorableType`, `FromWord`, and `StorageKey` implementations for single-word primitives. //! -//! Covers Rust integers, Alloy integers, Alloy fixed bytes, `bool`, `Address`, and `U96`. +//! Covers Rust integers, Alloy integers, Alloy fixed bytes, `bool`, and `Address`. -use alloy::primitives::{Address, Uint, U256}; +use alloy::primitives::{Address, U256}; use revm::interpreter::instructions::utility::{IntoAddress, IntoU256}; use tempo_precompiles_macros; use crate::storage::types::*; -/// A 96-bit unsigned integer used for packed TIP-1034 channel accounting. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct U96(u128); - -impl U96 { - pub const MAX_VALUE: u128 = (1u128 << 96) - 1; - pub const ZERO: Self = Self(0); - pub const MAX: Self = Self(Self::MAX_VALUE); - - #[inline] - pub const fn new(value: u128) -> Option { - if value <= Self::MAX_VALUE { - Some(Self(value)) - } else { - None - } - } - - #[inline] - pub const fn as_u128(self) -> u128 { - self.0 - } - - #[inline] - pub const fn is_zero(self) -> bool { - self.0 == 0 - } - - #[inline] - pub fn checked_add(self, rhs: Self) -> Option { - self.0.checked_add(rhs.0).and_then(Self::new) - } - - #[inline] - pub fn checked_sub(self, rhs: Self) -> Option { - self.0.checked_sub(rhs.0).map(Self) - } -} - -impl TryFrom for U96 { - type Error = crate::error::TempoPrecompileError; - - fn try_from(value: u128) -> std::result::Result { - Self::new(value).ok_or_else(crate::error::TempoPrecompileError::under_overflow) - } -} - -impl From> for U96 { - fn from(value: Uint<96, 2>) -> Self { - Self(value.to::()) - } -} - -impl From for Uint<96, 2> { - fn from(value: U96) -> Self { - Self::from(value.as_u128()) - } -} - -impl From for u128 { - fn from(value: U96) -> Self { - value.0 - } -} - -impl From for U256 { - fn from(value: U96) -> Self { - U256::from(value.0) - } -} - // rust integers: (u)int8, (u)int16, (u)int32, (u)int64, (u)int128 tempo_precompiles_macros::storable_rust_ints!(); // alloy integers: U8, I8, U16, I16, U32, I32, U64, I64, U128, I128, U256, I256 @@ -88,41 +17,6 @@ tempo_precompiles_macros::storable_alloy_bytes!(); // -- MANUAL STORAGE TRAIT IMPLEMENTATIONS ------------------------------------- -impl StorableType for U96 { - const LAYOUT: Layout = Layout::Bytes(12); - - type Handler = Slot; - - fn handle(slot: U256, ctx: LayoutCtx, address: Address) -> Self::Handler { - Slot::new_with_ctx(slot, ctx, address) - } -} - -impl super::sealed::OnlyPrimitives for U96 {} -impl Packable for U96 {} -impl FromWord for U96 { - #[inline] - fn to_word(&self) -> U256 { - U256::from(self.0) - } - - #[inline] - fn from_word(word: U256) -> crate::error::Result { - if word > U256::from(U96::MAX_VALUE) { - return Err(crate::error::TempoPrecompileError::under_overflow()); - } - - Ok(Self(word.to::())) - } -} - -impl StorageKey for U96 { - #[inline] - fn as_storage_bytes(&self) -> impl AsRef<[u8]> { - self.0.to_be_bytes() - } -} - impl StorableType for bool { const LAYOUT: Layout = Layout::Bytes(1); @@ -206,10 +100,6 @@ mod tests { any::<[u8; 20]>().prop_map(Address::from) } - fn arb_u96() -> impl Strategy { - (0..=U96::MAX_VALUE).prop_map(|value| U96::new(value).unwrap()) - } - // -- STORAGE TESTS -------------------------------------------------------- // Generate property tests for all storage types: @@ -265,40 +155,12 @@ mod tests { assert_eq!(b, recovered, "Bool EVM word roundtrip failed"); }); } - - #[test] - fn test_u96_values(value in arb_u96(), base_slot in arb_safe_slot()) { - let (mut storage, address) = setup_storage(); - StorageCtx::enter(&mut storage, || { - let mut slot = U96::handle(base_slot, LayoutCtx::FULL, address); - - slot.write(value).unwrap(); - let loaded = slot.read().unwrap(); - assert_eq!(value, loaded, "U96 roundtrip failed"); - - slot.delete().unwrap(); - let after_delete = slot.read().unwrap(); - assert_eq!(after_delete, U96::ZERO, "U96 not zero after delete"); - - let word = value.to_word(); - let recovered = ::from_word(word).unwrap(); - assert_eq!(value, recovered, "U96 EVM word roundtrip failed"); - }); - } } // -- WORD REPRESENTATION TESTS ------------------------------------------------ #[test] fn test_unsigned_word_byte_representation() { - assert_eq!(U96::ZERO.to_word(), gen_word_from(&["0x000000000000000000000000"])); - assert_eq!( - U96::new(0x1234567890ABCDEF12345678).unwrap().to_word(), - gen_word_from(&["0x1234567890ABCDEF12345678"]) - ); - assert_eq!(U96::MAX.to_word(), gen_word_from(&["0xFFFFFFFFFFFFFFFFFFFFFFFF"])); - assert!(U96::from_word(gen_word_from(&["0x01000000000000000000000000"])).is_err()); - // u8: single byte, right-aligned assert_eq!(0u8.to_word(), gen_word_from(&["0x00"])); assert_eq!(1u8.to_word(), gen_word_from(&["0x01"])); diff --git a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs index 4465c565c6..bf2bcbaa09 100644 --- a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs +++ b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs @@ -1,7 +1,7 @@ //! ABI dispatch for the [`TIP20ChannelEscrow`] precompile. -use super::{CLOSE_GRACE_PERIOD, TIP20ChannelEscrow, VOUCHER_TYPEHASH}; -use crate::{Precompile, charge_input_cost, dispatch_call, metadata, mutate, mutate_void, view}; +use super::{TIP20ChannelEscrow, CLOSE_GRACE_PERIOD, VOUCHER_TYPEHASH}; +use crate::{charge_input_cost, dispatch_call, metadata, mutate, mutate_void, view, Precompile}; use alloy::{primitives::Address, sol_types::SolInterface}; use revm::precompile::PrecompileResult; use tempo_contracts::precompiles::{ diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index 6a76cdb113..0a427d854c 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -5,19 +5,19 @@ pub mod dispatch; use crate::{ error::Result, signature_verifier::SignatureVerifier, - storage::{Handler, Mapping, U96}, - tip20::{TIP20Token, is_tip20_prefix}, + storage::{Handler, Mapping}, + tip20::{is_tip20_prefix, TIP20Token}, }; use alloy::{ - primitives::{Address, B256, U256, Uint, keccak256}, + primitives::{keccak256, Address, FixedBytes, Uint, B256, U256}, sol_types::SolValue, }; use std::sync::LazyLock; pub use tempo_contracts::precompiles::{ - ITIP20ChannelEscrow, TIP20_CHANNEL_ESCROW_ADDRESS, TIP20ChannelEscrowError, - TIP20ChannelEscrowEvent, + ITIP20ChannelEscrow, TIP20ChannelEscrowError, TIP20ChannelEscrowEvent, + TIP20_CHANNEL_ESCROW_ADDRESS, }; -use tempo_precompiles_macros::{Storable, contract}; +use tempo_precompiles_macros::{contract, Storable}; const FINALIZED_CLOSE_DATA: u32 = 1; @@ -33,18 +33,91 @@ static NAME_HASH: LazyLock = LazyLock::new(|| keccak256(b"TIP20 Channel Es static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); type AbiU96 = Uint<96, 2>; +type StorageU96 = FixedBytes<12>; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct U96(u128); + +impl U96 { + const MAX_VALUE: u128 = (1u128 << 96) - 1; + const ZERO: Self = Self(0); + + fn is_zero(self) -> bool { + self.0 == 0 + } + + fn checked_add(self, rhs: Self) -> Option { + self.0 + .checked_add(rhs.0) + .filter(|value| *value <= Self::MAX_VALUE) + .map(Self) + } + + fn checked_sub(self, rhs: Self) -> Option { + self.0.checked_sub(rhs.0).map(Self) + } +} + +impl From for U96 { + fn from(value: AbiU96) -> Self { + Self(value.to::()) + } +} + +impl From for U96 { + fn from(value: StorageU96) -> Self { + let mut bytes = [0u8; 16]; + bytes[4..].copy_from_slice(value.as_slice()); + Self(u128::from_be_bytes(bytes)) + } +} + +impl From for AbiU96 { + fn from(value: U96) -> Self { + Self::from(value.0) + } +} + +impl From for StorageU96 { + fn from(value: U96) -> Self { + let bytes = value.0.to_be_bytes(); + FixedBytes::from_slice(&bytes[4..]) + } +} + +impl From for u128 { + fn from(value: U96) -> Self { + value.0 + } +} #[derive(Debug, Clone, Copy, Default, Storable)] struct PackedChannelState { - settled: U96, - deposit: U96, + settled_raw: StorageU96, + deposit_raw: StorageU96, expires_at: u32, close_data: u32, } impl PackedChannelState { + fn settled(self) -> U96 { + self.settled_raw.into() + } + + fn deposit(self) -> U96 { + self.deposit_raw.into() + } + + fn set_settled(&mut self, value: U96) { + self.settled_raw = value.into(); + } + + fn set_deposit(&mut self, value: U96) { + self.deposit_raw = value.into(); + } + fn exists(self) -> bool { - !self.deposit.is_zero() + !self.deposit().is_zero() } fn is_finalized(self) -> bool { @@ -57,8 +130,8 @@ impl PackedChannelState { fn to_sol(self) -> ITIP20ChannelEscrow::ChannelState { ITIP20ChannelEscrow::ChannelState { - settled: self.settled.into(), - deposit: self.deposit.into(), + settled: self.settled().into(), + deposit: self.deposit().into(), expiresAt: self.expires_at, closeData: self.close_data, } @@ -108,8 +181,8 @@ impl TIP20ChannelEscrow { let batch = self.storage.checkpoint(); self.channel_states[channel_id].write(PackedChannelState { - settled: U96::ZERO, - deposit, + settled_raw: U96::ZERO.into(), + deposit_raw: deposit.into(), expires_at: call.expiresAt, close_data: 0, })?; @@ -154,10 +227,10 @@ impl TIP20ChannelEscrow { } let cumulative = U96::from(call.cumulativeAmount); - if cumulative > state.deposit { + if cumulative > state.deposit() { return Err(TIP20ChannelEscrowError::amount_exceeds_deposit().into()); } - if cumulative <= state.settled { + if cumulative <= state.settled() { return Err(TIP20ChannelEscrowError::amount_not_increasing().into()); } @@ -169,11 +242,11 @@ impl TIP20ChannelEscrow { )?; let delta = cumulative - .checked_sub(state.settled) + .checked_sub(state.settled()) .expect("cumulative amount already checked to be increasing"); let batch = self.storage.checkpoint(); - state.settled = cumulative; + state.set_settled(cumulative); self.channel_states[channel_id].write(state)?; TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( self.address, @@ -212,7 +285,7 @@ impl TIP20ChannelEscrow { let additional = U96::from(call.additionalDeposit); let next_deposit = state - .deposit + .deposit() .checked_add(additional) .ok_or_else(TIP20ChannelEscrowError::deposit_overflow)?; @@ -226,7 +299,7 @@ impl TIP20ChannelEscrow { let batch = self.storage.checkpoint(); if !additional.is_zero() { - state.deposit = next_deposit; + state.set_deposit(next_deposit); TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( msg_sender, self.address, @@ -255,7 +328,7 @@ impl TIP20ChannelEscrow { payer: call.descriptor.payer, payee: call.descriptor.payee, additionalDeposit: call.additionalDeposit, - newDeposit: state.deposit.into(), + newDeposit: state.deposit().into(), newExpiresAt: state.expires_at, }))?; batch.commit(); @@ -315,11 +388,11 @@ impl TIP20ChannelEscrow { let cumulative = U96::from(call.cumulativeAmount); let capture = U96::from(call.captureAmount); - let previous_settled = state.settled; + let previous_settled = state.settled(); if capture < previous_settled || capture > cumulative { return Err(TIP20ChannelEscrowError::capture_amount_invalid().into()); } - if capture > state.deposit { + if capture > state.deposit() { return Err(TIP20ChannelEscrowError::amount_exceeds_deposit().into()); } @@ -339,12 +412,12 @@ impl TIP20ChannelEscrow { .checked_sub(previous_settled) .expect("capture amount already checked against previous settled amount"); let refund = state - .deposit + .deposit() .checked_sub(capture) .expect("capture amount already checked against deposit"); let batch = self.storage.checkpoint(); - state.settled = capture; + state.set_settled(capture); state.close_data = FINALIZED_CLOSE_DATA; self.channel_states[channel_id].write(state)?; @@ -401,8 +474,8 @@ impl TIP20ChannelEscrow { } let refund = state - .deposit - .checked_sub(state.settled) + .deposit() + .checked_sub(state.settled()) .expect("settled is always <= deposit"); let batch = self.storage.checkpoint(); @@ -427,7 +500,7 @@ impl TIP20ChannelEscrow { channelId: channel_id, payer: call.descriptor.payer, payee: call.descriptor.payee, - settledToPayee: state.settled.into(), + settledToPayee: state.settled().into(), refundedToPayer: refund.into(), }, ))?; @@ -605,9 +678,9 @@ impl TIP20ChannelEscrow { mod tests { use super::*; use crate::{ + storage::{hashmap::HashMapStorageProvider, ContractStorage, StorageCtx}, + test_util::{assert_full_coverage, check_selector_coverage, TIP20Setup}, Precompile, - storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider}, - test_util::{TIP20Setup, assert_full_coverage, check_selector_coverage}, }; use alloy::{ primitives::{Bytes, Signature}, From 1170869e41936151073208bee2e109a8f565a5c4 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 19:22:35 +0530 Subject: [PATCH 26/33] refactor(primitives): keep escrow payment classification in envelope Amp-Thread-ID: https://ampcode.com/threads/T-019dcf0f-b633-761e-9c31-6c65eb7a3859 --- .../src/precompiles/tip20_channel_escrow.rs | 67 ------------------- crates/primitives/src/transaction/envelope.rs | 32 ++++++++- 2 files changed, 30 insertions(+), 69 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index 80b78da806..6ad8834e8c 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -3,21 +3,10 @@ pub use ITIP20ChannelEscrow::{ ITIP20ChannelEscrowEvents as TIP20ChannelEscrowEvent, }; use alloy_primitives::{Address, address}; -use alloy_sol_types::{SolCall, SolInterface, SolType}; pub const TIP20_CHANNEL_ESCROW_ADDRESS: Address = address!("0x4D50500000000000000000000000000000000000"); -const SECP256K1_SIGNATURE_LENGTH: usize = 65; -const P256_SIGNATURE_TYPE: u8 = 0x01; -const P256_SIGNATURE_LENGTH: usize = 130; -const WEBAUTHN_SIGNATURE_TYPE: u8 = 0x02; -const MIN_WEBAUTHN_SIGNATURE_LENGTH: usize = 129; -const MAX_WEBAUTHN_SIGNATURE_LENGTH: usize = 2049; -const KEYCHAIN_SIGNATURE_TYPE_V1: u8 = 0x03; -const KEYCHAIN_SIGNATURE_TYPE_V2: u8 = 0x04; -const KEYCHAIN_SIGNER_LENGTH: usize = 20; - crate::sol! { #[derive(Debug, PartialEq, Eq)] #[sol(abi)] @@ -185,62 +174,6 @@ crate::sol! { } } -impl ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls { - /// Returns `true` if `input` matches a channel escrow payment-lane selector and its calldata - /// is well-formed. `settle` and `close` also require a valid Tempo signature encoding. - pub fn is_payment(input: &[u8]) -> bool { - fn is_static_call(input: &[u8]) -> bool { - input.first_chunk::<4>() == Some(&C::SELECTOR) - && input.len() - == 4 + as SolType>::ENCODED_SIZE.unwrap_or_default() - } - - if is_static_call::(input) - || is_static_call::(input) - || is_static_call::(input) - || is_static_call::(input) - { - return true; - } - - match Self::abi_decode(input) { - Ok(Self::settle(call)) => is_valid_tempo_signature_encoding(&call.signature), - Ok(Self::close(call)) => is_valid_tempo_signature_encoding(&call.signature), - _ => false, - } - } -} - -fn is_valid_tempo_signature_encoding(signature: &[u8]) -> bool { - if is_valid_primitive_signature_encoding(signature) { - return true; - } - - let Some((&signature_type, rest)) = signature.split_first() else { - return false; - }; - if !matches!( - signature_type, - KEYCHAIN_SIGNATURE_TYPE_V1 | KEYCHAIN_SIGNATURE_TYPE_V2 - ) || rest.len() <= KEYCHAIN_SIGNER_LENGTH - { - return false; - } - - is_valid_primitive_signature_encoding(&rest[KEYCHAIN_SIGNER_LENGTH..]) -} - -fn is_valid_primitive_signature_encoding(signature: &[u8]) -> bool { - match signature.len() { - SECP256K1_SIGNATURE_LENGTH => true, - P256_SIGNATURE_LENGTH => signature.first() == Some(&P256_SIGNATURE_TYPE), - MIN_WEBAUTHN_SIGNATURE_LENGTH..=MAX_WEBAUTHN_SIGNATURE_LENGTH => { - signature.first() == Some(&WEBAUTHN_SIGNATURE_TYPE) - } - _ => false, - } -} - impl TIP20ChannelEscrowError { pub const fn channel_already_exists() -> Self { Self::ChannelAlreadyExists(ITIP20ChannelEscrow::ChannelAlreadyExists {}) diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 5c2ed68e79..97952e7845 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -1,4 +1,4 @@ -use super::tt_signed::AASigned; +use super::{TempoSignature, tt_signed::AASigned}; use crate::{TempoTransaction, subblock::PartialValidatorKey}; use alloy_consensus::{ EthereumTxEnvelope, SignableTransaction, Signed, Transaction, TxEip1559, TxEip2930, TxEip7702, @@ -8,6 +8,7 @@ use alloy_consensus::{ transaction::Either, }; use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256, hex}; +use alloy_sol_types::{SolCall, SolInterface, SolType}; use core::fmt; use tempo_contracts::precompiles::{ITIP20, ITIP20ChannelEscrow, TIP20_CHANNEL_ESCROW_ADDRESS}; @@ -490,7 +491,34 @@ fn is_tip20_payment(to: Option<&Address>, input: &[u8]) -> bool { fn is_channel_escrow_payment(to: Option<&Address>, input: &[u8], t5_active: bool) -> bool { t5_active && to == Some(&TIP20_CHANNEL_ESCROW_ADDRESS) - && ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment(input) + && is_channel_escrow_payment_calldata(input) +} + +/// Returns `true` if `input` matches a channel escrow payment-lane selector and its calldata is +/// well-formed. `settle` and `close` also require a valid Tempo signature encoding. +fn is_channel_escrow_payment_calldata(input: &[u8]) -> bool { + fn is_static_call(input: &[u8]) -> bool { + input.first_chunk::<4>() == Some(&C::SELECTOR) + && input.len() == 4 + as SolType>::ENCODED_SIZE.unwrap_or_default() + } + + if is_static_call::(input) + || is_static_call::(input) + || is_static_call::(input) + || is_static_call::(input) + { + return true; + } + + match ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::abi_decode(input) { + Ok(ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::settle(call)) => { + TempoSignature::from_bytes(&call.signature).is_ok() + } + Ok(ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::close(call)) => { + TempoSignature::from_bytes(&call.signature).is_ok() + } + _ => false, + } } fn is_payment_call_v1(to: Option<&Address>, input: &[u8], t5_active: bool) -> bool { From 056650fd944399a9d5912f7f6de1170dce1030b5 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 19:42:58 +0530 Subject: [PATCH 27/33] refactor(tip-1034): use alloy u96 in escrow Amp-Thread-ID: https://ampcode.com/threads/T-019dcf16-6842-7198-a455-39038a230549 --- .../src/tip20_channel_escrow/mod.rs | 145 +++++------------- crates/primitives/Cargo.toml | 1 + 2 files changed, 36 insertions(+), 110 deletions(-) diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index 0a427d854c..43d7dc3757 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -9,7 +9,7 @@ use crate::{ tip20::{is_tip20_prefix, TIP20Token}, }; use alloy::{ - primitives::{keccak256, Address, FixedBytes, Uint, B256, U256}, + primitives::{aliases::U96, keccak256, Address, B256, U256}, sol_types::SolValue, }; use std::sync::LazyLock; @@ -32,92 +32,17 @@ static EIP712_DOMAIN_TYPEHASH: LazyLock = LazyLock::new(|| { static NAME_HASH: LazyLock = LazyLock::new(|| keccak256(b"TIP20 Channel Escrow")); static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); -type AbiU96 = Uint<96, 2>; -type StorageU96 = FixedBytes<12>; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -struct U96(u128); - -impl U96 { - const MAX_VALUE: u128 = (1u128 << 96) - 1; - const ZERO: Self = Self(0); - - fn is_zero(self) -> bool { - self.0 == 0 - } - - fn checked_add(self, rhs: Self) -> Option { - self.0 - .checked_add(rhs.0) - .filter(|value| *value <= Self::MAX_VALUE) - .map(Self) - } - - fn checked_sub(self, rhs: Self) -> Option { - self.0.checked_sub(rhs.0).map(Self) - } -} - -impl From for U96 { - fn from(value: AbiU96) -> Self { - Self(value.to::()) - } -} - -impl From for U96 { - fn from(value: StorageU96) -> Self { - let mut bytes = [0u8; 16]; - bytes[4..].copy_from_slice(value.as_slice()); - Self(u128::from_be_bytes(bytes)) - } -} - -impl From for AbiU96 { - fn from(value: U96) -> Self { - Self::from(value.0) - } -} - -impl From for StorageU96 { - fn from(value: U96) -> Self { - let bytes = value.0.to_be_bytes(); - FixedBytes::from_slice(&bytes[4..]) - } -} - -impl From for u128 { - fn from(value: U96) -> Self { - value.0 - } -} - #[derive(Debug, Clone, Copy, Default, Storable)] struct PackedChannelState { - settled_raw: StorageU96, - deposit_raw: StorageU96, + settled: U96, + deposit: U96, expires_at: u32, close_data: u32, } impl PackedChannelState { - fn settled(self) -> U96 { - self.settled_raw.into() - } - - fn deposit(self) -> U96 { - self.deposit_raw.into() - } - - fn set_settled(&mut self, value: U96) { - self.settled_raw = value.into(); - } - - fn set_deposit(&mut self, value: U96) { - self.deposit_raw = value.into(); - } - fn exists(self) -> bool { - !self.deposit().is_zero() + !self.deposit.is_zero() } fn is_finalized(self) -> bool { @@ -130,8 +55,8 @@ impl PackedChannelState { fn to_sol(self) -> ITIP20ChannelEscrow::ChannelState { ITIP20ChannelEscrow::ChannelState { - settled: self.settled().into(), - deposit: self.deposit().into(), + settled: self.settled, + deposit: self.deposit, expiresAt: self.expires_at, closeData: self.close_data, } @@ -160,7 +85,7 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::invalid_token().into()); } - let deposit = U96::from(call.deposit); + let deposit = call.deposit; if deposit.is_zero() { return Err(TIP20ChannelEscrowError::zero_deposit().into()); } @@ -181,8 +106,8 @@ impl TIP20ChannelEscrow { let batch = self.storage.checkpoint(); self.channel_states[channel_id].write(PackedChannelState { - settled_raw: U96::ZERO.into(), - deposit_raw: deposit.into(), + settled: U96::ZERO, + deposit, expires_at: call.expiresAt, close_data: 0, })?; @@ -226,11 +151,11 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::channel_expired().into()); } - let cumulative = U96::from(call.cumulativeAmount); - if cumulative > state.deposit() { + let cumulative = call.cumulativeAmount; + if cumulative > state.deposit { return Err(TIP20ChannelEscrowError::amount_exceeds_deposit().into()); } - if cumulative <= state.settled() { + if cumulative <= state.settled { return Err(TIP20ChannelEscrowError::amount_not_increasing().into()); } @@ -242,16 +167,16 @@ impl TIP20ChannelEscrow { )?; let delta = cumulative - .checked_sub(state.settled()) + .checked_sub(state.settled) .expect("cumulative amount already checked to be increasing"); let batch = self.storage.checkpoint(); - state.set_settled(cumulative); + state.settled = cumulative; self.channel_states[channel_id].write(state)?; TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( self.address, call.descriptor.payee, - U256::from(u128::from(delta)), + U256::from(delta), )?; self.emit_event(TIP20ChannelEscrowEvent::Settled( ITIP20ChannelEscrow::Settled { @@ -283,9 +208,9 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::channel_finalized().into()); } - let additional = U96::from(call.additionalDeposit); + let additional = call.additionalDeposit; let next_deposit = state - .deposit() + .deposit .checked_add(additional) .ok_or_else(TIP20ChannelEscrowError::deposit_overflow)?; @@ -299,7 +224,7 @@ impl TIP20ChannelEscrow { let batch = self.storage.checkpoint(); if !additional.is_zero() { - state.set_deposit(next_deposit); + state.deposit = next_deposit; TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( msg_sender, self.address, @@ -328,7 +253,7 @@ impl TIP20ChannelEscrow { payer: call.descriptor.payer, payee: call.descriptor.payee, additionalDeposit: call.additionalDeposit, - newDeposit: state.deposit().into(), + newDeposit: state.deposit, newExpiresAt: state.expires_at, }))?; batch.commit(); @@ -386,13 +311,13 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::channel_finalized().into()); } - let cumulative = U96::from(call.cumulativeAmount); - let capture = U96::from(call.captureAmount); - let previous_settled = state.settled(); + let cumulative = call.cumulativeAmount; + let capture = call.captureAmount; + let previous_settled = state.settled; if capture < previous_settled || capture > cumulative { return Err(TIP20ChannelEscrowError::capture_amount_invalid().into()); } - if capture > state.deposit() { + if capture > state.deposit { return Err(TIP20ChannelEscrowError::amount_exceeds_deposit().into()); } @@ -412,12 +337,12 @@ impl TIP20ChannelEscrow { .checked_sub(previous_settled) .expect("capture amount already checked against previous settled amount"); let refund = state - .deposit() + .deposit .checked_sub(capture) .expect("capture amount already checked against deposit"); let batch = self.storage.checkpoint(); - state.set_settled(capture); + state.settled = capture; state.close_data = FINALIZED_CLOSE_DATA; self.channel_states[channel_id].write(state)?; @@ -426,14 +351,14 @@ impl TIP20ChannelEscrow { token.system_transfer_from( self.address, call.descriptor.payee, - U256::from(u128::from(delta)), + U256::from(delta), )?; } if !refund.is_zero() { token.system_transfer_from( self.address, call.descriptor.payer, - U256::from(u128::from(refund)), + U256::from(refund), )?; } @@ -474,8 +399,8 @@ impl TIP20ChannelEscrow { } let refund = state - .deposit() - .checked_sub(state.settled()) + .deposit + .checked_sub(state.settled) .expect("settled is always <= deposit"); let batch = self.storage.checkpoint(); @@ -485,7 +410,7 @@ impl TIP20ChannelEscrow { TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( self.address, call.descriptor.payer, - U256::from(u128::from(refund)), + U256::from(refund), )?; } self.emit_event(TIP20ChannelEscrowEvent::ChannelExpired( @@ -500,7 +425,7 @@ impl TIP20ChannelEscrow { channelId: channel_id, payer: call.descriptor.payer, payee: call.descriptor.payee, - settledToPayee: state.settled().into(), + settledToPayee: state.settled, refundedToPayer: refund.into(), }, ))?; @@ -629,7 +554,7 @@ impl TIP20ChannelEscrow { &self, descriptor: &ITIP20ChannelEscrow::ChannelDescriptor, channel_id: B256, - cumulative_amount: AbiU96, + cumulative_amount: U96, signature: &alloy::primitives::Bytes, ) -> Result<()> { let digest = self.get_voucher_digest_inner(channel_id, cumulative_amount)?; @@ -645,7 +570,7 @@ impl TIP20ChannelEscrow { fn get_voucher_digest_inner( &self, channel_id: B256, - cumulative_amount: AbiU96, + cumulative_amount: U96, ) -> Result { let struct_hash = self .storage @@ -691,8 +616,8 @@ mod tests { use tempo_chainspec::hardfork::TempoHardfork; use tempo_contracts::precompiles::ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls; - fn abi_u96(value: u128) -> AbiU96 { - AbiU96::from(value) + fn abi_u96(value: u128) -> U96 { + U96::from(value) } fn descriptor( diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 5126724a92..72661e933e 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -29,6 +29,7 @@ alloy-consensus = { workspace = true, features = ["k256"] } alloy-eips.workspace = true alloy-primitives.workspace = true alloy-rlp.workspace = true +alloy-sol-types.workspace = true alloy-serde = { workspace = true, optional = true } alloy-rpc-types-eth = { workspace = true, optional = true } alloy-network = { workspace = true, optional = true } From 9aca641d10c41f1162a44228e2cc92466fd598c5 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 29 Apr 2026 15:18:43 +0530 Subject: [PATCH 28/33] refactor(tip-1034): split payment lane follow-up Amp-Thread-ID: https://ampcode.com/threads/T-019dd89a-6be1-71e3-b698-fceed6549978 --- crates/evm/src/block.rs | 12 +- crates/node/tests/it/block_building.rs | 9 +- crates/payload/builder/src/lib.rs | 5 +- crates/primitives/src/transaction/envelope.rs | 456 +++--------------- crates/transaction-pool/src/transaction.rs | 64 +-- 5 files changed, 85 insertions(+), 461 deletions(-) diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 4b5dfefcc8..70c81834e3 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -395,12 +395,8 @@ where } else { match self.section { BlockSection::StartOfBlock | BlockSection::NonShared => { - let t5_active = self - .inner - .spec - .is_t5_active_at_timestamp(self.evm().block().timestamp.to::()); if gas_used > self.non_shared_gas_left - || (!tx.is_payment_v1(t5_active) && gas_used > self.non_payment_gas_left) + || (!tx.is_payment_v1() && gas_used > self.non_payment_gas_left) { // Assume that this transaction wants to make use of gas incentive section // @@ -503,14 +499,10 @@ where }; self.validate_tx(recovered.tx(), gas_used)? }; - let t5_active = self - .inner - .spec - .is_t5_active_at_timestamp(self.evm().block().timestamp.to::()); Ok(TempoTxResult { inner, next_section, - is_payment: recovered.tx().is_payment_v1(t5_active), + is_payment: recovered.tx().is_payment_v1(), tx: matches!(next_section, BlockSection::SubBlock { .. }) .then(|| recovered.tx().clone()), }) diff --git a/crates/node/tests/it/block_building.rs b/crates/node/tests/it/block_building.rs index 5f1a6b57a2..b474ca01d1 100644 --- a/crates/node/tests/it/block_building.rs +++ b/crates/node/tests/it/block_building.rs @@ -204,10 +204,7 @@ async fn sign_and_inject( /// Helper to count payment and non-payment transactions fn count_transaction_types(transactions: &[TempoTxEnvelope]) -> (usize, usize) { - let payment_count = transactions - .iter() - .filter(|tx| tx.is_payment_v2(false)) - .count(); + let payment_count = transactions.iter().filter(|tx| tx.is_payment_v2()).count(); (payment_count, transactions.len() - payment_count) } @@ -356,7 +353,7 @@ async fn test_block_building_only_payment_txs() -> eyre::Result<()> { for tx in &user_txs { assert!( - tx.is_payment_v2(false), + tx.is_payment_v2(), "All transactions should be payment transactions" ); } @@ -426,7 +423,7 @@ async fn test_block_building_only_non_payment_txs() -> eyre::Result<()> { for tx in &user_txs { assert!( - !tx.is_payment_v2(false), + !tx.is_payment_v2(), "All transactions should be non-payment transactions" ); } diff --git a/crates/payload/builder/src/lib.rs b/crates/payload/builder/src/lib.rs index aed4e979c2..a3f425dd91 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -309,7 +309,6 @@ where block_gas_limit, shared_gas_limit, ); - let t5_active = chain_spec.is_t5_active_at_timestamp(attributes.timestamp); let mut cumulative_gas_used = 0; let mut cumulative_state_gas_used = 0u64; @@ -479,7 +478,7 @@ where // If the tx is not a payment and will exceed the general gas limit // mark the tx as invalid and continue - if !pool_tx.transaction.is_payment(t5_active) + if !pool_tx.transaction.is_payment() && non_payment_gas_used + max_regular_gas_used > general_gas_limit { best_txs.mark_invalid( @@ -499,7 +498,7 @@ where } check_cancel!(); - let is_payment = pool_tx.transaction.is_payment(t5_active); + let is_payment = pool_tx.transaction.is_payment(); if is_payment { payment_transactions += 1; } diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 97952e7845..356ad574ad 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -1,4 +1,4 @@ -use super::{TempoSignature, tt_signed::AASigned}; +use super::tt_signed::AASigned; use crate::{TempoTransaction, subblock::PartialValidatorKey}; use alloy_consensus::{ EthereumTxEnvelope, SignableTransaction, Signed, Transaction, TxEip1559, TxEip2930, TxEip7702, @@ -8,9 +8,8 @@ use alloy_consensus::{ transaction::Either, }; use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256, hex}; -use alloy_sol_types::{SolCall, SolInterface, SolType}; use core::fmt; -use tempo_contracts::precompiles::{ITIP20, ITIP20ChannelEscrow, TIP20_CHANNEL_ESCROW_ADDRESS}; +use tempo_contracts::precompiles::ITIP20; /// TIP20 payment address prefix (12 bytes for payment classification) /// Same as TIP20_TOKEN_PREFIX @@ -164,27 +163,21 @@ impl TempoTxEnvelope { /// [TIP-20 payment] classification: `to` address has the `0x20c0` prefix. /// - /// A transaction is considered a payment if every call is either a TIP-20 payment target or a - /// valid TIP-1034 channel escrow operation, and the transaction carries no authorization side - /// effects. + /// A transaction is considered a payment if its `to` address carries the TIP-20 prefix. + /// For AA transactions, every call must target a TIP-20 address. /// /// # NOTE /// Consensus-level classifier, used during block validation, against `general_gas_limit`. /// See [`is_payment_v2`](Self::is_payment_v2) for the stricter builder-level variant. /// /// [TIP-20 payment]: - pub fn is_payment_v1(&self, t5_active: bool) -> bool { + pub fn is_payment_v1(&self) -> bool { match self { - Self::Legacy(tx) => is_payment_call_v1(tx.tx().to.to(), &tx.tx().input, t5_active), - Self::Eip2930(tx) => is_payment_call_v1(tx.tx().to.to(), &tx.tx().input, t5_active), - Self::Eip1559(tx) => is_payment_call_v1(tx.tx().to.to(), &tx.tx().input, t5_active), - Self::Eip7702(tx) => { - tx.tx().authorization_list.is_empty() - && is_payment_call_v1(Some(&tx.tx().to), &tx.tx().input, t5_active) - } - Self::AA(tx) => aa_is_payment(tx.tx(), |to, input| { - is_payment_call_v2(to, input, t5_active) - }), + Self::Legacy(tx) => is_tip20_call(tx.tx().to.to()), + Self::Eip2930(tx) => is_tip20_call(tx.tx().to.to()), + Self::Eip1559(tx) => is_tip20_call(tx.tx().to.to()), + Self::Eip7702(tx) => is_tip20_call(Some(&tx.tx().to)), + Self::AA(tx) => tx.tx().calls.iter().all(|call| is_tip20_call(call.to.to())), } } @@ -201,27 +194,33 @@ impl TempoTxEnvelope { /// stricter classification at the protocol level. /// /// [TIP-20 payment]: - pub fn is_payment_v2(&self, t5_active: bool) -> bool { + pub fn is_payment_v2(&self) -> bool { match self { - Self::Legacy(tx) => is_payment_call_v2(tx.tx().to.to(), &tx.tx().input, t5_active), + Self::Legacy(tx) => is_tip20_payment(tx.tx().to.to(), &tx.tx().input), Self::Eip2930(tx) => { let tx = tx.tx(); - tx.access_list.is_empty() && is_payment_call_v2(tx.to.to(), &tx.input, t5_active) + tx.access_list.is_empty() && is_tip20_payment(tx.to.to(), &tx.input) } Self::Eip1559(tx) => { let tx = tx.tx(); - tx.access_list.is_empty() && is_payment_call_v2(tx.to.to(), &tx.input, t5_active) + tx.access_list.is_empty() && is_tip20_payment(tx.to.to(), &tx.input) } Self::Eip7702(tx) => { let tx = tx.tx(); tx.access_list.is_empty() && tx.authorization_list.is_empty() - && is_payment_call_v2(Some(&tx.to), &tx.input, t5_active) + && is_tip20_payment(Some(&tx.to), &tx.input) } Self::AA(tx) => { let tx = tx.tx(); - tx.access_list.is_empty() - && aa_is_payment(tx, |to, input| is_payment_call_v2(to, input, t5_active)) + !tx.calls.is_empty() + && tx.key_authorization.is_none() + && tx.access_list.is_empty() + && tx.tempo_authorization_list.is_empty() + && tx + .calls + .iter() + .all(|call| is_tip20_payment(call.to.to(), &call.input)) } } } @@ -486,62 +485,6 @@ fn is_tip20_payment(to: Option<&Address>, input: &[u8]) -> bool { is_tip20_call(to) && ITIP20::ITIP20Calls::is_payment(input) } -/// Returns `true` if `to` is the TIP-1034 channel escrow precompile and `input` is a recognized -/// payment-lane escrow call. -fn is_channel_escrow_payment(to: Option<&Address>, input: &[u8], t5_active: bool) -> bool { - t5_active - && to == Some(&TIP20_CHANNEL_ESCROW_ADDRESS) - && is_channel_escrow_payment_calldata(input) -} - -/// Returns `true` if `input` matches a channel escrow payment-lane selector and its calldata is -/// well-formed. `settle` and `close` also require a valid Tempo signature encoding. -fn is_channel_escrow_payment_calldata(input: &[u8]) -> bool { - fn is_static_call(input: &[u8]) -> bool { - input.first_chunk::<4>() == Some(&C::SELECTOR) - && input.len() == 4 + as SolType>::ENCODED_SIZE.unwrap_or_default() - } - - if is_static_call::(input) - || is_static_call::(input) - || is_static_call::(input) - || is_static_call::(input) - { - return true; - } - - match ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::abi_decode(input) { - Ok(ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::settle(call)) => { - TempoSignature::from_bytes(&call.signature).is_ok() - } - Ok(ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::close(call)) => { - TempoSignature::from_bytes(&call.signature).is_ok() - } - _ => false, - } -} - -fn is_payment_call_v1(to: Option<&Address>, input: &[u8], t5_active: bool) -> bool { - is_tip20_call(to) || is_channel_escrow_payment(to, input, t5_active) -} - -fn is_payment_call_v2(to: Option<&Address>, input: &[u8], t5_active: bool) -> bool { - is_tip20_payment(to, input) || is_channel_escrow_payment(to, input, t5_active) -} - -fn aa_is_payment( - tx: &TempoTransaction, - is_payment_call: impl Fn(Option<&Address>, &[u8]) -> bool, -) -> bool { - tx.key_authorization.is_none() - && tx.tempo_authorization_list.is_empty() - && !tx.calls.is_empty() - && tx - .calls - .iter() - .all(|call| is_payment_call(call.to.to(), &call.input)) -} - #[cfg(feature = "rpc")] impl reth_rpc_convert::SignableTxRequest for alloy_rpc_types_eth::TransactionRequest @@ -574,26 +517,19 @@ impl reth_rpc_convert::TryIntoSimTx for alloy_rpc_types_eth::Tr mod tests { use super::*; use crate::transaction::{ - Call, TempoSignature, TempoSignedAuthorization, TempoTransaction, + Call, TempoSignedAuthorization, TempoTransaction, key_authorization::{KeyAuthorization, SignedKeyAuthorization}, tt_signature::PrimitiveSignature, }; use alloy_consensus::{TxEip1559, TxEip2930, TxEip7702}; use alloy_eips::{ eip2930::{AccessList, AccessListItem}, - eip7702::{Authorization, SignedAuthorization}, + eip7702::SignedAuthorization, }; use alloy_primitives::{Bytes, Signature, TxKind, U256, address}; use alloy_sol_types::SolCall; const PAYMENT_TKN: Address = address!("20c0000000000000000000000000000000000001"); - const CHANNEL_ESCROW: Address = TIP20_CHANNEL_ESCROW_ADDRESS; - const PRE_T5: bool = false; - const T5_ACTIVE: bool = true; - - fn abi_u96(value: u128) -> alloy_primitives::Uint<96, 2> { - alloy_primitives::Uint::from(value) - } #[rustfmt::skip] /// Returns valid ABI-encoded calldata for every recognized TIP-20 payment selector. @@ -652,119 +588,6 @@ mod tests { ] } - fn channel_descriptor() -> ITIP20ChannelEscrow::ChannelDescriptor { - ITIP20ChannelEscrow::ChannelDescriptor { - payer: Address::random(), - payee: Address::random(), - token: PAYMENT_TKN, - salt: B256::random(), - authorizedSigner: Address::ZERO, - } - } - - fn primitive_signature_bytes() -> Bytes { - Bytes::copy_from_slice(&Signature::test_signature().as_bytes()) - } - - fn keychain_signature_bytes() -> Bytes { - let mut bytes = Vec::with_capacity(1 + 20 + 65); - bytes.push(0x03); - bytes.extend_from_slice(Address::random().as_slice()); - bytes.extend_from_slice(Signature::test_signature().as_bytes().as_slice()); - bytes.into() - } - - #[rustfmt::skip] - fn channel_escrow_calldatas() -> [Bytes; 6] { - let descriptor = channel_descriptor(); - let signature = primitive_signature_bytes(); - [ - ITIP20ChannelEscrow::openCall { - payee: descriptor.payee, - token: descriptor.token, - deposit: abi_u96(100), - salt: descriptor.salt, - authorizedSigner: descriptor.authorizedSigner, - expiresAt: 1000, - }.abi_encode().into(), - ITIP20ChannelEscrow::settleCall { - descriptor: descriptor.clone(), - cumulativeAmount: abi_u96(10), - signature: signature.clone(), - }.abi_encode().into(), - ITIP20ChannelEscrow::topUpCall { - descriptor: descriptor.clone(), - additionalDeposit: abi_u96(25), - newExpiresAt: 2000, - }.abi_encode().into(), - ITIP20ChannelEscrow::closeCall { - descriptor: descriptor.clone(), - cumulativeAmount: abi_u96(10), - captureAmount: abi_u96(10), - signature, - }.abi_encode().into(), - ITIP20ChannelEscrow::requestCloseCall { - descriptor: descriptor.clone(), - }.abi_encode().into(), - ITIP20ChannelEscrow::withdrawCall { descriptor }.abi_encode().into(), - ] - } - - fn invalid_channel_signature_calldata() -> [Bytes; 2] { - let descriptor = channel_descriptor(); - let invalid_signature = Bytes::from(vec![0x03; 21]); - [ - ITIP20ChannelEscrow::settleCall { - descriptor: descriptor.clone(), - cumulativeAmount: abi_u96(10), - signature: invalid_signature.clone(), - } - .abi_encode() - .into(), - ITIP20ChannelEscrow::closeCall { - descriptor, - cumulativeAmount: abi_u96(10), - captureAmount: abi_u96(10), - signature: invalid_signature, - } - .abi_encode() - .into(), - ] - } - - fn keychain_channel_signature_calldata() -> [Bytes; 2] { - let descriptor = channel_descriptor(); - let keychain_signature = keychain_signature_bytes(); - [ - ITIP20ChannelEscrow::settleCall { - descriptor: descriptor.clone(), - cumulativeAmount: abi_u96(10), - signature: keychain_signature.clone(), - } - .abi_encode() - .into(), - ITIP20ChannelEscrow::closeCall { - descriptor, - cumulativeAmount: abi_u96(10), - captureAmount: abi_u96(10), - signature: keychain_signature, - } - .abi_encode() - .into(), - ] - } - - fn dummy_tempo_authorization() -> TempoSignedAuthorization { - TempoSignedAuthorization::new_unchecked( - Authorization { - chain_id: U256::from(1u128), - address: Address::random(), - nonce: 0, - }, - TempoSignature::default(), - ) - } - #[test] fn test_non_fee_token_access() { let legacy_tx = TxLegacy::default(); @@ -793,7 +616,7 @@ mod tests { let signed = Signed::new_unhashed(tx, Signature::test_signature()); let envelope = TempoTxEnvelope::Legacy(signed); - assert!(envelope.is_payment_v1(PRE_T5)); + assert!(envelope.is_payment_v1()); } #[test] @@ -807,7 +630,7 @@ mod tests { let signed = Signed::new_unhashed(tx, Signature::test_signature()); let envelope = TempoTxEnvelope::Legacy(signed); - assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v1()); } fn create_aa_envelope(call: Call) -> TempoTxEnvelope { @@ -821,24 +644,6 @@ mod tests { #[test] fn test_payment_classification_aa_with_tip20_prefix() { - let payment_addr = address!("20c0000000000000000000000000000000000001"); - let call = Call { - to: TxKind::Call(payment_addr), - value: U256::ZERO, - input: ITIP20::transferCall { - to: Address::random(), - amount: U256::from(1u64), - } - .abi_encode() - .into(), - }; - let envelope = create_aa_envelope(call); - assert!(envelope.is_payment_v1(PRE_T5)); - assert!(envelope.is_payment_v2(PRE_T5)); - } - - #[test] - fn test_payment_classification_aa_requires_strict_tip20_calldata() { let payment_addr = address!("20c0000000000000000000000000000000000001"); let call = Call { to: TxKind::Call(payment_addr), @@ -846,8 +651,7 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(!envelope.is_payment_v1(PRE_T5)); - assert!(!envelope.is_payment_v2(PRE_T5)); + assert!(envelope.is_payment_v1()); } #[test] @@ -859,61 +663,7 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(!envelope.is_payment_v1(PRE_T5)); - } - - #[test] - fn test_channel_escrow_payment_classification() { - for calldata in channel_escrow_calldatas() { - let tx = TxLegacy { - to: TxKind::Call(CHANNEL_ESCROW), - gas_limit: 21_000, - input: calldata, - ..Default::default() - }; - let envelope = - TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1(PRE_T5)); - assert!(!envelope.is_payment_v2(PRE_T5)); - assert!(envelope.is_payment_v1(T5_ACTIVE)); - assert!(envelope.is_payment_v2(T5_ACTIVE)); - } - } - - #[test] - fn test_channel_escrow_rejects_invalid_signature_encoding() { - for calldata in invalid_channel_signature_calldata() { - let tx = TxLegacy { - to: TxKind::Call(CHANNEL_ESCROW), - gas_limit: 21_000, - input: calldata, - ..Default::default() - }; - let envelope = - TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1(PRE_T5)); - assert!(!envelope.is_payment_v1(T5_ACTIVE)); - assert!(!envelope.is_payment_v2(PRE_T5)); - assert!(!envelope.is_payment_v2(T5_ACTIVE)); - } - } - - #[test] - fn test_channel_escrow_accepts_keychain_signature_encoding_for_classification() { - for calldata in keychain_channel_signature_calldata() { - let tx = TxLegacy { - to: TxKind::Call(CHANNEL_ESCROW), - gas_limit: 21_000, - input: calldata, - ..Default::default() - }; - let envelope = - TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1(PRE_T5)); - assert!(!envelope.is_payment_v2(PRE_T5)); - assert!(envelope.is_payment_v1(T5_ACTIVE)); - assert!(envelope.is_payment_v2(T5_ACTIVE)); - } + assert!(!envelope.is_payment_v1()); } #[test] @@ -924,13 +674,12 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v1()); } #[test] fn test_payment_classification_aa_partial_match() { - // AA classification still treats TIP-20 vanity addresses as payment targets, but only when - // the calldata is a recognized TIP-20 payment ABI. + // First 12 bytes match TIP20_PAYMENT_PREFIX, remaining 8 bytes differ let payment_addr = address!("20c0000000000000000000001111111111111111"); let call = Call { to: TxKind::Call(payment_addr), @@ -938,8 +687,7 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(!envelope.is_payment_v1(PRE_T5)); - assert!(!envelope.is_payment_v2(PRE_T5)); + assert!(envelope.is_payment_v1()); } #[test] @@ -952,7 +700,7 @@ mod tests { input: Bytes::new(), }; let envelope = create_aa_envelope(call); - assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v1()); } #[test] @@ -964,7 +712,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip2930(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(envelope.is_payment_v1(PRE_T5)); + assert!(envelope.is_payment_v1()); // Eip2930 non-payment let tx = TxEip2930 { @@ -973,7 +721,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip2930(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v1()); // Eip1559 payment let tx = TxEip1559 { @@ -982,7 +730,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip1559(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(envelope.is_payment_v1(PRE_T5)); + assert!(envelope.is_payment_v1()); // Eip1559 non-payment let tx = TxEip1559 { @@ -991,7 +739,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip1559(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v1()); // Eip7702 payment (note: Eip7702 has direct `to` address, not TxKind) let tx = TxEip7702 { @@ -1000,7 +748,7 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip7702(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(envelope.is_payment_v1(PRE_T5)); + assert!(envelope.is_payment_v1()); // Eip7702 non-payment let tx = TxEip7702 { @@ -1009,42 +757,25 @@ mod tests { }; let envelope = TempoTxEnvelope::Eip7702(Signed::new_unhashed(tx, Signature::test_signature())); - assert!(!envelope.is_payment_v1(PRE_T5)); + assert!(!envelope.is_payment_v1()); } #[test] fn test_payment_v2_accepts_valid_calldata() { for calldata in payment_calldatas() { for envelope in payment_envelopes(calldata) { - assert!( - envelope.is_payment_v1(PRE_T5), - "V1 must accept valid calldata" - ); - assert!( - envelope.is_payment_v2(PRE_T5), - "V2 must accept valid calldata" - ); + assert!(envelope.is_payment_v1(), "V1 must accept valid calldata"); + assert!(envelope.is_payment_v2(), "V2 must accept valid calldata"); } } } #[test] fn test_payment_v2_rejects_empty_calldata() { - let tx = TxLegacy { - to: TxKind::Call(PAYMENT_TKN), - gas_limit: 21_000, - ..Default::default() - }; - let envelope = - TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); - assert!( - envelope.is_payment_v1(PRE_T5), - "V1 must accept (prefix-only)" - ); - assert!( - !envelope.is_payment_v2(PRE_T5), - "V2 must reject empty calldata" - ); + for envelope in payment_envelopes(Bytes::new()) { + assert!(envelope.is_payment_v1(), "V1 must accept (prefix-only)"); + assert!(!envelope.is_payment_v2(), "V2 must reject empty calldata"); + } } #[test] @@ -1052,22 +783,10 @@ mod tests { for calldata in payment_calldatas() { let mut data = calldata.to_vec(); data.extend_from_slice(&[0u8; 32]); - let tx = TxLegacy { - to: TxKind::Call(PAYMENT_TKN), - gas_limit: 21_000, - input: Bytes::from(data), - ..Default::default() - }; - let envelope = - TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); - assert!( - envelope.is_payment_v1(PRE_T5), - "V1 must accept (prefix-only)" - ); - assert!( - !envelope.is_payment_v2(PRE_T5), - "V2 must reject excess calldata" - ); + for envelope in payment_envelopes(Bytes::from(data)) { + assert!(envelope.is_payment_v1(), "V1 must accept (prefix-only)"); + assert!(!envelope.is_payment_v2(), "V2 must reject excess calldata"); + } } } @@ -1076,22 +795,10 @@ mod tests { for calldata in payment_calldatas() { let mut data = calldata.to_vec(); data[..4].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); - let tx = TxLegacy { - to: TxKind::Call(PAYMENT_TKN), - gas_limit: 21_000, - input: Bytes::from(data), - ..Default::default() - }; - let envelope = - TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); - assert!( - envelope.is_payment_v1(PRE_T5), - "V1 must accept (prefix-only)" - ); - assert!( - !envelope.is_payment_v2(PRE_T5), - "V2 must reject unknown selector" - ); + for envelope in payment_envelopes(Bytes::from(data)) { + assert!(envelope.is_payment_v1(), "V1 must accept (prefix-only)"); + assert!(!envelope.is_payment_v2(), "V2 must reject unknown selector"); + } } } @@ -1103,10 +810,9 @@ mod tests { ..Default::default() }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); - assert!(!envelope.is_payment_v1(PRE_T5)); assert!( - !envelope.is_payment_v2(PRE_T5), - "AA with empty calls should not be strict payment" + !envelope.is_payment_v2(), + "AA with empty calls should not be V2 payment" ); } @@ -1135,11 +841,11 @@ mod tests { let envelope = TempoTxEnvelope::Eip7702(Signed::new_unhashed(tx, Signature::test_signature())); assert!( - !envelope.is_payment_v1(PRE_T5), - "V1 must reject authorization-bearing EIP-7702 transactions" + envelope.is_payment_v1(), + "V1 ignores authorization_list (backwards compat)" ); assert!( - !envelope.is_payment_v2(PRE_T5), + !envelope.is_payment_v2(), "V2 must reject EIP-7702 tx with non-empty authorization_list" ); } @@ -1173,11 +879,11 @@ mod tests { }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); assert!( - !envelope.is_payment_v1(PRE_T5), - "V1 must reject AA tx with key_authorization" + envelope.is_payment_v1(), + "V1 ignores side-effect fields (backwards compat)" ); assert!( - !envelope.is_payment_v2(PRE_T5), + !envelope.is_payment_v2(), "V2 must reject AA tx with key_authorization" ); } @@ -1208,11 +914,11 @@ mod tests { }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); assert!( - !envelope.is_payment_v1(PRE_T5), - "V1 must reject AA tx with tempo_authorization_list" + envelope.is_payment_v1(), + "V1 ignores side-effect fields (backwards compat)" ); assert!( - !envelope.is_payment_v2(PRE_T5), + !envelope.is_payment_v2(), "V2 must reject AA tx with tempo_authorization_list" ); } @@ -1231,31 +937,11 @@ mod tests { }]); for envelope in payment_envelopes_with_access_list(calldata, access_list) { - assert!(envelope.is_payment_v1(PRE_T5), "V1 must ignore access_list"); - assert!( - !envelope.is_payment_v2(PRE_T5), - "V2 must reject access_list" - ); + assert!(envelope.is_payment_v1(), "V1 must ignore access_list"); + assert!(!envelope.is_payment_v2(), "V2 must reject access_list"); } } - #[test] - fn test_payment_classification_rejects_aa_authorization_side_effects() { - let tx = TempoTransaction { - fee_token: Some(PAYMENT_TKN), - calls: vec![Call { - to: TxKind::Call(PAYMENT_TKN), - value: U256::ZERO, - input: payment_calldatas()[0].clone(), - }], - tempo_authorization_list: vec![dummy_tempo_authorization()], - ..Default::default() - }; - let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); - assert!(!envelope.is_payment_v1(PRE_T5)); - assert!(!envelope.is_payment_v2(PRE_T5)); - } - #[test] fn test_system_tx_validation_and_recovery() { use alloy_consensus::transaction::SignerRecoverable; @@ -1413,13 +1099,7 @@ mod tests { ..Default::default() }; let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); - assert!( - !envelope.is_payment_v1(PRE_T5), - "V1 must reject AA without calls" - ); - assert!( - !envelope.is_payment_v2(PRE_T5), - "V2 must reject AA without calls" - ); + assert!(envelope.is_payment_v1(), "V1 must accept AA without calls"); + assert!(!envelope.is_payment_v2(), "V2 must reject AA without calls"); } } diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 98edaa2294..7ca22324e0 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -31,10 +31,8 @@ use thiserror::Error; #[derive(Debug, Clone)] pub struct TempoPooledTransaction { inner: EthPooledTransaction, - /// Cached strict payment classification before T5 activates. - is_payment_pre_t5: bool, - /// Cached strict payment classification once T5 is active. - is_payment_t5: bool, + /// Cached payment classification for efficient block building + is_payment: bool, /// Cached expiring nonce classification is_expiring_nonce: bool, /// Cached slot of the 2D nonce, if any. @@ -58,8 +56,7 @@ pub struct TempoPooledTransaction { impl TempoPooledTransaction { /// Create new instance of [Self] from the given consensus transactions and the encoded size. pub fn new(transaction: Recovered) -> Self { - let is_payment_pre_t5 = transaction.is_payment_v2(false); - let is_payment_t5 = transaction.is_payment_v2(true); + let is_payment = transaction.is_payment_v2(); let is_expiring_nonce = transaction .as_aa() .map(|tx| tx.tx().is_expiring_nonce_tx()) @@ -75,8 +72,7 @@ impl TempoPooledTransaction { blob_sidecar: EthBlobTransactionSidecar::None, transaction, }, - is_payment_pre_t5, - is_payment_t5, + is_payment, is_expiring_nonce, nonce_key_slot: OnceLock::new(), expiring_nonce_slot: OnceLock::new(), @@ -119,12 +115,8 @@ impl TempoPooledTransaction { /// Returns whether this is a payment transaction. /// /// Uses strict classification: TIP-20 prefix AND recognized calldata. - pub fn is_payment(&self, t5_active: bool) -> bool { - if t5_active { - self.is_payment_t5 - } else { - self.is_payment_pre_t5 - } + pub fn is_payment(&self) -> bool { + self.is_payment } /// Returns true if this transaction belongs into the 2D nonce pool: @@ -650,7 +642,7 @@ mod tests { use alloy_consensus::TxEip1559; use alloy_primitives::{Address, Signature, TxKind, address}; use alloy_sol_types::SolCall; - use tempo_contracts::precompiles::{ITIP20, ITIP20ChannelEscrow, TIP20_CHANNEL_ESCROW_ADDRESS}; + use tempo_contracts::precompiles::ITIP20; use tempo_precompiles::{PATH_USD_ADDRESS, nonce::NonceManager}; use tempo_primitives::transaction::{ TempoTransaction, @@ -687,7 +679,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(pooled_tx.is_payment(false)); + assert!(pooled_tx.is_payment()); } #[test] @@ -712,7 +704,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(!pooled_tx.is_payment(false)); + assert!(!pooled_tx.is_payment()); } #[test] @@ -722,43 +714,7 @@ mod tests { let pooled_tx = TxBuilder::eip1559(non_payment_addr) .gas_limit(21000) .build_eip1559(); - assert!(!pooled_tx.is_payment(false)); - } - - #[test] - fn test_channel_escrow_payment_classification_activates_at_t5() { - let calldata = ITIP20ChannelEscrow::requestCloseCall { - descriptor: ITIP20ChannelEscrow::ChannelDescriptor { - payer: Address::random(), - payee: Address::random(), - token: PATH_USD_ADDRESS, - salt: B256::random(), - authorizedSigner: Address::ZERO, - }, - } - .abi_encode(); - - let tx = TxEip1559 { - to: TxKind::Call(TIP20_CHANNEL_ESCROW_ADDRESS), - gas_limit: 21_000, - input: Bytes::from(calldata), - ..Default::default() - }; - - let envelope = TempoTxEnvelope::Eip1559(alloy_consensus::Signed::new_unchecked( - tx, - Signature::test_signature(), - B256::ZERO, - )); - - let recovered = Recovered::new_unchecked( - envelope, - address!("0000000000000000000000000000000000000001"), - ); - - let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(!pooled_tx.is_payment(false)); - assert!(pooled_tx.is_payment(true)); + assert!(!pooled_tx.is_payment()); } #[test] From 00f91cc7bde7e7d88309081d7d0bd2582eab4eff Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 29 Apr 2026 16:18:02 +0530 Subject: [PATCH 29/33] chore(tip-1034): document sentinel timestamp caveat Amp-Thread-ID: https://ampcode.com/threads/T-019dd8b6-f4df-709d-96be-9777eaafa232 --- crates/precompiles/src/tip20_channel_escrow/mod.rs | 4 ++++ crates/primitives/Cargo.toml | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index 43d7dc3757..5bfd492379 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -279,6 +279,10 @@ impl TIP20ChannelEscrow { return Ok(()); } + // `close_data` reserves 0 and 1 as sentinels, so tests and local fixtures that run + // with synthetic block timestamps of 0 or 1 can encode inconsistent channel state. + // Mainnet/testnet timestamps are guaranteed to be > 1, so this only matters outside + // real network execution. let close_requested_at = self.now_u32(); let batch = self.storage.checkpoint(); state.close_data = close_requested_at; diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 72661e933e..5126724a92 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -29,7 +29,6 @@ alloy-consensus = { workspace = true, features = ["k256"] } alloy-eips.workspace = true alloy-primitives.workspace = true alloy-rlp.workspace = true -alloy-sol-types.workspace = true alloy-serde = { workspace = true, optional = true } alloy-rpc-types-eth = { workspace = true, optional = true } alloy-network = { workspace = true, optional = true } From fc5f98a2b3ab98cb03555ae0fdd936d50b8ad10c Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 30 Apr 2026 22:05:45 +0530 Subject: [PATCH 30/33] refactor(precompiles): use From for channel state conversion Amp-Thread-ID: https://ampcode.com/threads/T-019ddf3b-e80f-75d0-be3d-3cb4cba5873d --- .../src/tip20_channel_escrow/mod.rs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index 5bfd492379..f12f4a7588 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -40,6 +40,17 @@ struct PackedChannelState { close_data: u32, } +impl From for ITIP20ChannelEscrow::ChannelState { + fn from(state: PackedChannelState) -> Self { + Self { + settled: state.settled, + deposit: state.deposit, + expiresAt: state.expires_at, + closeData: state.close_data, + } + } +} + impl PackedChannelState { fn exists(self) -> bool { !self.deposit.is_zero() @@ -52,15 +63,6 @@ impl PackedChannelState { fn close_requested_at(self) -> Option { (self.close_data >= 2).then_some(self.close_data) } - - fn to_sol(self) -> ITIP20ChannelEscrow::ChannelState { - ITIP20ChannelEscrow::ChannelState { - settled: self.settled, - deposit: self.deposit, - expiresAt: self.expires_at, - closeData: self.close_data, - } - } } #[contract(addr = TIP20_CHANNEL_ESCROW_ADDRESS)] @@ -445,7 +447,7 @@ impl TIP20ChannelEscrow { let channel_id = self.channel_id(&call.descriptor)?; Ok(ITIP20ChannelEscrow::Channel { descriptor: call.descriptor, - state: self.channel_states[channel_id].read()?.to_sol(), + state: self.channel_states[channel_id].read()?.into(), }) } @@ -453,7 +455,7 @@ impl TIP20ChannelEscrow { &self, call: ITIP20ChannelEscrow::getChannelStateCall, ) -> Result { - Ok(self.channel_states[call.channelId].read()?.to_sol()) + Ok(self.channel_states[call.channelId].read()?.into()) } pub fn get_channel_states_batch( @@ -465,7 +467,7 @@ impl TIP20ChannelEscrow { .map(|channel_id| { self.channel_states[channel_id] .read() - .map(PackedChannelState::to_sol) + .map(Into::into) }) .collect() } From a462b9d1b27ae171cbc91329e746672cb82eae04 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 30 Apr 2026 22:27:02 +0530 Subject: [PATCH 31/33] feat(tip-1034): derive channel ids from open tx hash Makes TIP-20 channel escrow stateless on close by including the opening transaction hash in channelId derivation and deleting terminal state instead of keeping a tombstone. Threads the top-level tx hash through the revm/precompile execution path, updates the TIP-1034 spec and reference Solidity artifacts, and adds regression coverage for reopen and tx-hash scoping behavior. Amp-Thread-ID: https://ampcode.com/threads/T-019ddf4c-e3f2-722f-b6a9-5b822be31d91 --- .../src/precompiles/tip20_channel_escrow.rs | 5 +- crates/precompiles/src/error.rs | 5 +- crates/precompiles/src/storage/mod.rs | 2 +- .../precompiles/src/storage/thread_local.rs | 68 +++++- .../src/tip20_channel_escrow/dispatch.rs | 4 +- .../src/tip20_channel_escrow/mod.rs | 180 ++++++++-------- crates/revm/src/exec.rs | 54 +++-- crates/revm/src/tx.rs | 41 +++- tips/tip-1034.md | 38 ++-- tips/verify/src/TIP20ChannelEscrow.sol | 202 ++++++------------ .../src/interfaces/ITIP20ChannelEscrow.sol | 62 ++---- tips/verify/test/TIP20ChannelEscrow.t.sol | 144 ++++++------- 12 files changed, 401 insertions(+), 404 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index 6ad8834e8c..e4e930ad6b 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -18,6 +18,7 @@ crate::sol! { address token; bytes32 salt; address authorizedSigner; + bytes32 openTxHash; } struct ChannelState { @@ -89,7 +90,8 @@ crate::sol! { address payee, address token, bytes32 salt, - address authorizedSigner + address authorizedSigner, + bytes32 openTxHash ) external view @@ -109,6 +111,7 @@ crate::sol! { address token, address authorizedSigner, bytes32 salt, + bytes32 openTxHash, uint96 deposit, uint32 expiresAt ); diff --git a/crates/precompiles/src/error.rs b/crates/precompiles/src/error.rs index 0eaa472d63..809db79438 100644 --- a/crates/precompiles/src/error.rs +++ b/crates/precompiles/src/error.rs @@ -21,8 +21,9 @@ use revm::{ }; use tempo_contracts::precompiles::{ AccountKeychainError, AddrRegistryError, FeeManagerError, NonceError, RolesAuthError, - SignatureVerifierError, StablecoinDEXError, TIP20ChannelEscrowError, TIP20FactoryError, TIP403RegistryError, - TIPFeeAMMError, UnknownFunctionSelector, ValidatorConfigError, ValidatorConfigV2Error, + SignatureVerifierError, StablecoinDEXError, TIP20ChannelEscrowError, TIP20FactoryError, + TIP403RegistryError, TIPFeeAMMError, UnknownFunctionSelector, ValidatorConfigError, + ValidatorConfigV2Error, }; /// Top-level error type for all Tempo precompile operations diff --git a/crates/precompiles/src/storage/mod.rs b/crates/precompiles/src/storage/mod.rs index 16177c10a2..bd921d9fe3 100644 --- a/crates/precompiles/src/storage/mod.rs +++ b/crates/precompiles/src/storage/mod.rs @@ -8,7 +8,7 @@ pub mod hashmap; pub mod thread_local; use alloy::primitives::keccak256; -pub use thread_local::{CheckpointGuard, StorageCtx}; +pub use thread_local::{CheckpointGuard, CurrentTxHash, StorageCtx}; mod types; pub use types::*; diff --git a/crates/precompiles/src/storage/thread_local.rs b/crates/precompiles/src/storage/thread_local.rs index 2e51068ea8..289ca24dea 100644 --- a/crates/precompiles/src/storage/thread_local.rs +++ b/crates/precompiles/src/storage/thread_local.rs @@ -21,6 +21,12 @@ use crate::{ }; scoped_thread_local!(static STORAGE: RefCell<&mut dyn PrecompileStorageProvider>); +scoped_thread_local!(static CURRENT_TX_HASH: RefCell>); + +/// Provides access to the enclosing transaction hash when the caller can expose it. +pub trait CurrentTxHash { + fn current_tx_hash(&self) -> Option; +} /// Thread-local storage accessor that implements `PrecompileStorageProvider` without the trait bound. /// @@ -40,6 +46,12 @@ scoped_thread_local!(static STORAGE: RefCell<&mut dyn PrecompileStorageProvider> pub struct StorageCtx; impl StorageCtx { + /// Executes a closure with a scoped top-level transaction hash. + pub fn with_current_tx_hash(tx_hash: Option, f: impl FnOnce() -> R) -> R { + let tx_hash = RefCell::new(tx_hash); + CURRENT_TX_HASH.set(&tx_hash, f) + } + /// Enter storage context. All storage operations must happen within the closure. /// /// # IMPORTANT @@ -60,6 +72,18 @@ impl StorageCtx { STORAGE.set(&cell, f) } + /// Like [`Self::enter`], but also scopes the top-level transaction hash. + pub fn enter_with_tx_hash( + storage: &mut S, + tx_hash: Option, + f: impl FnOnce() -> R, + ) -> R + where + S: PrecompileStorageProvider, + { + Self::with_current_tx_hash(tx_hash, || Self::enter(storage, f)) + } + /// Execute an infallible function with access to the current thread-local storage provider. /// /// # Panics @@ -137,6 +161,14 @@ impl StorageCtx { Self::with_storage(|s| s.block_number()) } + /// Returns the current top-level transaction hash when available. + pub fn tx_hash(&self) -> Option { + if !CURRENT_TX_HASH.is_set() { + return None; + } + CURRENT_TX_HASH.with(|tx_hash| *tx_hash.borrow()) + } + /// Sets the bytecode at the given address. pub fn set_code(&mut self, address: Address, code: Bytecode) -> Result<()> { Self::try_with_storage(|s| s.set_code(address, code)) @@ -328,7 +360,7 @@ impl<'evm> StorageCtx { journal: &'evm mut J, block_env: &'evm dyn Block, cfg: &CfgEnv, - tx_env: &'evm impl Transaction, + tx_env: &'evm (impl Transaction + CurrentTxHash), f: impl FnOnce() -> R, ) -> R where @@ -338,7 +370,7 @@ impl<'evm> StorageCtx { let mut provider = EvmPrecompileStorageProvider::new_max_gas(internals, cfg); // The core logic of setting up thread-local storage is here. - Self::enter(&mut provider, f) + Self::enter_with_tx_hash(&mut provider, tx_env.current_tx_hash(), f) } /// Like [`enter_evm`](Self::enter_evm), but takes a `&mut impl ContextTr` @@ -346,6 +378,7 @@ impl<'evm> StorageCtx { pub fn enter_ctx(ctx: &mut C, f: impl FnOnce() -> R) -> R where C: ContextTr, Journal: Debug, Db: Database>, + C::Tx: CurrentTxHash, { let (tx, block, cfg, journal) = ctx.tx_block_cfg_journal_mut(); Self::enter_evm(journal, block, cfg, tx, f) @@ -361,12 +394,13 @@ impl<'evm> StorageCtx { ) -> (R, u64) where C: ContextTr, Journal: Debug, Db: Database>, + C::Tx: CurrentTxHash, { let (tx, block, cfg, journal) = ctx.tx_block_cfg_journal_mut(); let internals = EvmInternals::new(journal, block, cfg, tx); let mut provider = EvmPrecompileStorageProvider::new_with_gas_limit(internals, cfg, gas_limit, reservoir); - let result = Self::enter(&mut provider, f); + let result = Self::enter_with_tx_hash(&mut provider, tx.current_tx_hash(), f); let gas_used = provider.gas_used(); (result, gas_used) } @@ -376,7 +410,7 @@ impl<'evm> StorageCtx { journal: &'evm mut J, block_env: &'evm dyn Block, cfg: &CfgEnv, - tx_env: &'evm impl Transaction, + tx_env: &'evm (impl Transaction + CurrentTxHash), f: impl FnOnce(P) -> R, ) -> R where @@ -511,6 +545,32 @@ mod tests { }); } + #[test] + fn test_tx_hash_scope_override_and_restore() { + let mut storage = t1c_storage(); + let outer_hash = B256::repeat_byte(0x11); + let inner_hash = B256::repeat_byte(0x22); + + assert_eq!(StorageCtx::default().tx_hash(), None); + + StorageCtx::enter_with_tx_hash(&mut storage, Some(outer_hash), || { + let ctx = StorageCtx; + assert_eq!(ctx.tx_hash(), Some(outer_hash)); + + StorageCtx::with_current_tx_hash(Some(inner_hash), || { + assert_eq!(ctx.tx_hash(), Some(inner_hash)); + }); + assert_eq!(ctx.tx_hash(), Some(outer_hash)); + + StorageCtx::with_current_tx_hash(None, || { + assert_eq!(ctx.tx_hash(), None); + }); + assert_eq!(ctx.tx_hash(), Some(outer_hash)); + }); + + assert_eq!(StorageCtx::default().tx_hash(), None); + } + #[test] fn test_checkpoint_commit_and_revert() { let mut storage = t1c_storage(); diff --git a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs index bf2bcbaa09..4465c565c6 100644 --- a/crates/precompiles/src/tip20_channel_escrow/dispatch.rs +++ b/crates/precompiles/src/tip20_channel_escrow/dispatch.rs @@ -1,7 +1,7 @@ //! ABI dispatch for the [`TIP20ChannelEscrow`] precompile. -use super::{TIP20ChannelEscrow, CLOSE_GRACE_PERIOD, VOUCHER_TYPEHASH}; -use crate::{charge_input_cost, dispatch_call, metadata, mutate, mutate_void, view, Precompile}; +use super::{CLOSE_GRACE_PERIOD, TIP20ChannelEscrow, VOUCHER_TYPEHASH}; +use crate::{Precompile, charge_input_cost, dispatch_call, metadata, mutate, mutate_void, view}; use alloy::{primitives::Address, sol_types::SolInterface}; use revm::precompile::PrecompileResult; use tempo_contracts::precompiles::{ diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index f12f4a7588..d8e51c7dcf 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -6,20 +6,18 @@ use crate::{ error::Result, signature_verifier::SignatureVerifier, storage::{Handler, Mapping}, - tip20::{is_tip20_prefix, TIP20Token}, + tip20::{TIP20Token, is_tip20_prefix}, }; use alloy::{ - primitives::{aliases::U96, keccak256, Address, B256, U256}, + primitives::{Address, B256, U256, aliases::U96, keccak256}, sol_types::SolValue, }; use std::sync::LazyLock; pub use tempo_contracts::precompiles::{ - ITIP20ChannelEscrow, TIP20ChannelEscrowError, TIP20ChannelEscrowEvent, - TIP20_CHANNEL_ESCROW_ADDRESS, + ITIP20ChannelEscrow, TIP20_CHANNEL_ESCROW_ADDRESS, TIP20ChannelEscrowError, + TIP20ChannelEscrowEvent, }; -use tempo_precompiles_macros::{contract, Storable}; - -const FINALIZED_CLOSE_DATA: u32 = 1; +use tempo_precompiles_macros::{Storable, contract}; /// 15 minute grace period between `requestClose` and `withdraw`. pub const CLOSE_GRACE_PERIOD: u64 = 15 * 60; @@ -56,12 +54,8 @@ impl PackedChannelState { !self.deposit.is_zero() } - fn is_finalized(self) -> bool { - self.close_data == FINALIZED_CLOSE_DATA - } - fn close_requested_at(self) -> Option { - (self.close_data >= 2).then_some(self.close_data) + (self.close_data != 0).then_some(self.close_data) } } @@ -94,6 +88,9 @@ impl TIP20ChannelEscrow { if call.expiresAt as u64 <= self.now() { return Err(TIP20ChannelEscrowError::invalid_expiry().into()); } + let open_tx_hash = self.storage.tx_hash().ok_or_else(|| { + crate::error::TempoPrecompileError::Fatal("current tx hash unavailable".into()) + })?; let channel_id = self.compute_channel_id_inner( msg_sender, @@ -101,6 +98,7 @@ impl TIP20ChannelEscrow { call.token, call.salt, call.authorizedSigner, + open_tx_hash, )?; if self.channel_states[channel_id].read()?.exists() { return Err(TIP20ChannelEscrowError::channel_already_exists().into()); @@ -126,6 +124,7 @@ impl TIP20ChannelEscrow { token: call.token, authorizedSigner: call.authorizedSigner, salt: call.salt, + openTxHash: open_tx_hash, deposit: call.deposit, expiresAt: call.expiresAt, }, @@ -146,9 +145,6 @@ impl TIP20ChannelEscrow { if msg_sender != call.descriptor.payee { return Err(TIP20ChannelEscrowError::not_payee().into()); } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); - } if self.is_expired(state.expires_at) { return Err(TIP20ChannelEscrowError::channel_expired().into()); } @@ -206,9 +202,6 @@ impl TIP20ChannelEscrow { if msg_sender != call.descriptor.payer { return Err(TIP20ChannelEscrowError::not_payer().into()); } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); - } let additional = call.additionalDeposit; let next_deposit = state @@ -274,17 +267,12 @@ impl TIP20ChannelEscrow { if msg_sender != call.descriptor.payer { return Err(TIP20ChannelEscrowError::not_payer().into()); } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); - } if state.close_requested_at().is_some() { return Ok(()); } - // `close_data` reserves 0 and 1 as sentinels, so tests and local fixtures that run - // with synthetic block timestamps of 0 or 1 can encode inconsistent channel state. - // Mainnet/testnet timestamps are guaranteed to be > 1, so this only matters outside - // real network execution. + // `close_data = 0` is reserved for "no close request", so synthetic timestamp 0 in + // tests cannot be represented exactly. Real network timestamps are always non-zero. let close_requested_at = self.now_u32(); let batch = self.storage.checkpoint(); state.close_data = close_requested_at; @@ -308,14 +296,11 @@ impl TIP20ChannelEscrow { call: ITIP20ChannelEscrow::closeCall, ) -> Result<()> { let channel_id = self.channel_id(&call.descriptor)?; - let mut state = self.load_existing_state(channel_id)?; + let state = self.load_existing_state(channel_id)?; if msg_sender != call.descriptor.payee { return Err(TIP20ChannelEscrowError::not_payee().into()); } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); - } let cumulative = call.cumulativeAmount; let capture = call.captureAmount; @@ -348,24 +333,14 @@ impl TIP20ChannelEscrow { .expect("capture amount already checked against deposit"); let batch = self.storage.checkpoint(); - state.settled = capture; - state.close_data = FINALIZED_CLOSE_DATA; - self.channel_states[channel_id].write(state)?; + self.channel_states[channel_id].write(PackedChannelState::default())?; let mut token = TIP20Token::from_address(call.descriptor.token)?; if !delta.is_zero() { - token.system_transfer_from( - self.address, - call.descriptor.payee, - U256::from(delta), - )?; + token.system_transfer_from(self.address, call.descriptor.payee, U256::from(delta))?; } if !refund.is_zero() { - token.system_transfer_from( - self.address, - call.descriptor.payer, - U256::from(refund), - )?; + token.system_transfer_from(self.address, call.descriptor.payer, U256::from(refund))?; } self.emit_event(TIP20ChannelEscrowEvent::ChannelClosed( @@ -388,14 +363,11 @@ impl TIP20ChannelEscrow { call: ITIP20ChannelEscrow::withdrawCall, ) -> Result<()> { let channel_id = self.channel_id(&call.descriptor)?; - let mut state = self.load_existing_state(channel_id)?; + let state = self.load_existing_state(channel_id)?; if msg_sender != call.descriptor.payer { return Err(TIP20ChannelEscrowError::not_payer().into()); } - if state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().into()); - } let close_ready = state .close_requested_at() @@ -410,8 +382,7 @@ impl TIP20ChannelEscrow { .expect("settled is always <= deposit"); let batch = self.storage.checkpoint(); - state.close_data = FINALIZED_CLOSE_DATA; - self.channel_states[channel_id].write(state)?; + self.channel_states[channel_id].write(PackedChannelState::default())?; if !refund.is_zero() { TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( self.address, @@ -464,11 +435,7 @@ impl TIP20ChannelEscrow { ) -> Result> { call.channelIds .into_iter() - .map(|channel_id| { - self.channel_states[channel_id] - .read() - .map(Into::into) - }) + .map(|channel_id| self.channel_states[channel_id].read().map(Into::into)) .collect() } @@ -482,6 +449,7 @@ impl TIP20ChannelEscrow { call.token, call.salt, call.authorizedSigner, + call.openTxHash, ) } @@ -515,6 +483,7 @@ impl TIP20ChannelEscrow { descriptor.token, descriptor.salt, descriptor.authorizedSigner, + descriptor.openTxHash, ) } @@ -525,6 +494,7 @@ impl TIP20ChannelEscrow { token: Address, salt: B256, authorized_signer: Address, + open_tx_hash: B256, ) -> Result { self.storage.keccak256( &( @@ -533,6 +503,7 @@ impl TIP20ChannelEscrow { token, salt, authorized_signer, + open_tx_hash, self.address, U256::from(self.storage.chain_id()), ) @@ -573,11 +544,7 @@ impl TIP20ChannelEscrow { Ok(()) } - fn get_voucher_digest_inner( - &self, - channel_id: B256, - cumulative_amount: U96, - ) -> Result { + fn get_voucher_digest_inner(&self, channel_id: B256, cumulative_amount: U96) -> Result { let struct_hash = self .storage .keccak256(&(*VOUCHER_TYPEHASH, channel_id, cumulative_amount).abi_encode())?; @@ -609,9 +576,9 @@ impl TIP20ChannelEscrow { mod tests { use super::*; use crate::{ - storage::{hashmap::HashMapStorageProvider, ContractStorage, StorageCtx}, - test_util::{assert_full_coverage, check_selector_coverage, TIP20Setup}, Precompile, + storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider}, + test_util::{TIP20Setup, assert_full_coverage, check_selector_coverage}, }; use alloy::{ primitives::{Bytes, Signature}, @@ -632,6 +599,7 @@ mod tests { token: Address, salt: B256, authorized_signer: Address, + open_tx_hash: B256, ) -> ITIP20ChannelEscrow::ChannelDescriptor { ITIP20ChannelEscrow::ChannelDescriptor { payer, @@ -639,6 +607,7 @@ mod tests { token, salt, authorizedSigner: authorized_signer, + openTxHash: open_tx_hash, } } @@ -659,14 +628,16 @@ mod tests { } #[test] - fn test_open_settle_close_flow_and_tombstone() -> eyre::Result<()> { + fn test_open_settle_close_flow_and_reopen_with_new_tx_hash() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); let payer_signer = PrivateKeySigner::random(); let payer = payer_signer.address(); let payee = Address::random(); let salt = B256::random(); + let open_tx_hash = B256::repeat_byte(0x11); + let reopen_tx_hash = B256::repeat_byte(0x22); - StorageCtx::enter(&mut storage, || { + StorageCtx::enter_with_tx_hash(&mut storage, Some(open_tx_hash), || { let token = TIP20Setup::path_usd(payer) .with_issuer(payer) .with_mint(payer, U256::from(1_000u128)) @@ -695,7 +666,14 @@ mod tests { let signature = Bytes::copy_from_slice(&payer_signer.sign_hash_sync(&digest)?.as_bytes()); - let channel_descriptor = descriptor(payer, payee, token.address(), salt, Address::ZERO); + let channel_descriptor = descriptor( + payer, + payee, + token.address(), + salt, + Address::ZERO, + open_tx_hash, + ); escrow.settle( payee, ITIP20ChannelEscrow::settleCall { @@ -717,25 +695,25 @@ mod tests { let state = escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { channelId: channel_id, })?; - assert_eq!(state.closeData, FINALIZED_CLOSE_DATA); - assert_eq!(state.deposit, 300); - assert_eq!(state.settled, 120); - - let reopen_result = escrow.open( - payer, - ITIP20ChannelEscrow::openCall { - payee, - token: token.address(), - deposit: abi_u96(1), - salt, - authorizedSigner: Address::ZERO, - expiresAt: now + 2_000, - }, - ); - assert_eq!( - reopen_result.unwrap_err(), - TIP20ChannelEscrowError::channel_already_exists().into() - ); + assert_eq!(state.closeData, 0); + assert_eq!(state.deposit, 0); + assert_eq!(state.settled, 0); + + let reopened_channel_id = + StorageCtx::with_current_tx_hash(Some(reopen_tx_hash), || { + escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + token: token.address(), + deposit: abi_u96(1), + salt, + authorizedSigner: Address::ZERO, + expiresAt: now + 2_000, + }, + ) + })?; + assert_ne!(reopened_channel_id, channel_id); Ok(()) }) @@ -748,7 +726,8 @@ mod tests { let payee = Address::random(); let salt = B256::random(); - StorageCtx::enter(&mut storage, || { + let open_tx_hash = B256::repeat_byte(0x33); + StorageCtx::enter_with_tx_hash(&mut storage, Some(open_tx_hash), || { let token = TIP20Setup::path_usd(payer) .with_issuer(payer) .with_mint(payer, U256::from(1_000u128)) @@ -757,7 +736,14 @@ mod tests { escrow.initialize()?; let expires_at = StorageCtx::default().timestamp().to::() + 1_000; - let descriptor = descriptor(payer, payee, token.address(), salt, Address::ZERO); + let descriptor = descriptor( + payer, + payee, + token.address(), + salt, + Address::ZERO, + open_tx_hash, + ); escrow.open( payer, ITIP20ChannelEscrow::openCall { @@ -797,7 +783,7 @@ mod tests { #[test] fn test_dispatch_rejects_static_mutation() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); - StorageCtx::enter(&mut storage, || { + StorageCtx::enter_with_tx_hash(&mut storage, Some(B256::repeat_byte(0x44)), || { let mut escrow = TIP20ChannelEscrow::new(); let result = escrow.call( &ITIP20ChannelEscrow::openCall { @@ -823,7 +809,8 @@ mod tests { let payee = Address::random(); let salt = B256::random(); - StorageCtx::enter(&mut storage, || { + let open_tx_hash = B256::repeat_byte(0x55); + StorageCtx::enter_with_tx_hash(&mut storage, Some(open_tx_hash), || { let token = TIP20Setup::path_usd(payer) .with_issuer(payer) .with_mint(payer, U256::from(100u128)) @@ -846,7 +833,14 @@ mod tests { let result = escrow.settle( payee, ITIP20ChannelEscrow::settleCall { - descriptor: descriptor(payer, payee, token.address(), salt, Address::ZERO), + descriptor: descriptor( + payer, + payee, + token.address(), + salt, + Address::ZERO, + open_tx_hash, + ), cumulativeAmount: abi_u96(10), signature: Bytes::copy_from_slice( &Signature::test_signature().as_bytes()[..64], @@ -868,7 +862,8 @@ mod tests { let payee = Address::random(); let salt = B256::random(); - StorageCtx::enter(&mut storage, || { + let open_tx_hash = B256::repeat_byte(0x66); + StorageCtx::enter_with_tx_hash(&mut storage, Some(open_tx_hash), || { let token = TIP20Setup::path_usd(payer) .with_issuer(payer) .with_mint(payer, U256::from(100u128)) @@ -896,7 +891,14 @@ mod tests { let result = escrow.settle( payee, ITIP20ChannelEscrow::settleCall { - descriptor: descriptor(payer, payee, token.address(), salt, Address::ZERO), + descriptor: descriptor( + payer, + payee, + token.address(), + salt, + Address::ZERO, + open_tx_hash, + ), cumulativeAmount: abi_u96(10), signature: keychain_signature.into(), }, diff --git a/crates/revm/src/exec.rs b/crates/revm/src/exec.rs index 3bec5ea80b..6972072f7a 100644 --- a/crates/revm/src/exec.rs +++ b/crates/revm/src/exec.rs @@ -17,6 +17,7 @@ use revm::{ primitives::{Address, Bytes}, state::EvmState, }; +use tempo_precompiles::storage::{CurrentTxHash, StorageCtx}; /// Total gas system transactions are allowed to use. const SYSTEM_CALL_GAS_LIMIT: u64 = 250_000_000; @@ -36,9 +37,12 @@ where } fn transact_one(&mut self, tx: Self::Tx) -> Result { - self.inner.ctx.set_tx(tx); - let mut h = TempoEvmHandler::new(); - h.run(self) + let tx_hash = tx.current_tx_hash(); + StorageCtx::with_current_tx_hash(tx_hash, || { + self.inner.ctx.set_tx(tx); + let mut h = TempoEvmHandler::new(); + h.run(self) + }) } fn finalize(&mut self) -> Self::State { @@ -48,10 +52,13 @@ where fn replay( &mut self, ) -> Result, Self::Error> { - let mut h = TempoEvmHandler::new(); - h.run(self).map(|result| { - let state = self.finalize(); - ExecResultAndState::new(result, state) + let tx_hash = self.inner.ctx.tx.current_tx_hash(); + StorageCtx::with_current_tx_hash(tx_hash, || { + let mut h = TempoEvmHandler::new(); + h.run(self).map(|result| { + let state = self.finalize(); + ExecResultAndState::new(result, state) + }) }) } } @@ -77,9 +84,12 @@ where } fn inspect_one_tx(&mut self, tx: Self::Tx) -> Result { - self.inner.ctx.set_tx(tx); - let mut h = TempoEvmHandler::new(); - h.inspect_run(self) + let tx_hash = tx.current_tx_hash(); + StorageCtx::with_current_tx_hash(tx_hash, || { + self.inner.ctx.set_tx(tx); + let mut h = TempoEvmHandler::new(); + h.inspect_run(self) + }) } } @@ -100,11 +110,13 @@ where system_contract_address: Address, data: Bytes, ) -> Result { - let mut tx = TxEnv::new_system_tx_with_caller(caller, system_contract_address, data); - tx.set_gas_limit(SYSTEM_CALL_GAS_LIMIT); - self.inner.ctx.set_tx(tx.into()); - let mut h = TempoEvmHandler::new(); - h.run_system_call(self) + StorageCtx::with_current_tx_hash(None, || { + let mut tx = TxEnv::new_system_tx_with_caller(caller, system_contract_address, data); + tx.set_gas_limit(SYSTEM_CALL_GAS_LIMIT); + self.inner.ctx.set_tx(tx.into()); + let mut h = TempoEvmHandler::new(); + h.run_system_call(self) + }) } } @@ -119,11 +131,13 @@ where system_contract_address: Address, data: Bytes, ) -> Result { - let mut tx = TxEnv::new_system_tx_with_caller(caller, system_contract_address, data); - tx.set_gas_limit(SYSTEM_CALL_GAS_LIMIT); - self.inner.ctx.set_tx(tx.into()); - let mut h = TempoEvmHandler::new(); - h.inspect_run_system_call(self) + StorageCtx::with_current_tx_hash(None, || { + let mut tx = TxEnv::new_system_tx_with_caller(caller, system_contract_address, data); + tx.set_gas_limit(SYSTEM_CALL_GAS_LIMIT); + self.inner.ctx.set_tx(tx.into()); + let mut h = TempoEvmHandler::new(); + h.inspect_run_system_call(self) + }) } } diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index 17cbd8e4fd..613cc78818 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -1,5 +1,7 @@ use crate::TempoInvalidTransaction; -use alloy_consensus::{EthereumTxEnvelope, TxEip4844, Typed2718, crypto::secp256k1}; +use alloy_consensus::{ + EthereumTxEnvelope, TxEip4844, Typed2718, crypto::secp256k1, transaction::TxHashRef, +}; use alloy_evm::{FromRecoveredTx, FromTxWithEncoded, IntoTxEnv, TransactionEnvMut}; use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; use core::num::NonZeroU64; @@ -11,6 +13,7 @@ use revm::context::{ AccessList, AccessListItem, RecoveredAuthority, RecoveredAuthorization, SignedAuthorization, }, }; +use tempo_precompiles::storage::CurrentTxHash; use tempo_primitives::{ AASigned, TempoSignature, TempoTransaction, TempoTxEnvelope, transaction::{ @@ -93,6 +96,9 @@ pub struct TempoTxEnv { /// AA-specific transaction environment (boxed to keep TempoTxEnv lean for non-AA tx) pub tempo_tx_env: Option>, + + /// Transaction hash for the current top-level transaction. + pub tx_hash: Option, } impl TempoTxEnv { @@ -146,6 +152,11 @@ impl TempoTxEnv { ))) } } + + /// Returns the top-level transaction hash when available. + pub fn tx_hash(&self) -> Option { + self.tx_hash + } } impl From for TempoTxEnv { @@ -262,9 +273,18 @@ impl IntoTxEnv for TempoTxEnv { } } +impl CurrentTxHash for TempoTxEnv { + fn current_tx_hash(&self) -> Option { + self.tx_hash() + } +} + impl FromRecoveredTx> for TempoTxEnv { fn from_recovered_tx(tx: &EthereumTxEnvelope, sender: Address) -> Self { - TxEnv::from_recovered_tx(tx, sender).into() + Self { + tx_hash: Some(*tx.tx_hash()), + ..TxEnv::from_recovered_tx(tx, sender).into() + } } } @@ -365,6 +385,7 @@ impl FromRecoveredTx for TempoTxEnv { // can only be derived when given an entire block expiring_nonce_idx: None, })), + tx_hash: Some(*aa_signed.hash()), } } } @@ -378,10 +399,20 @@ impl FromRecoveredTx for TempoTxEnv { is_system_tx: tx.is_system_tx(), fee_payer: None, tempo_tx_env: None, // Non-AA transaction + tx_hash: Some(*tx.tx_hash()), + }, + TempoTxEnvelope::Eip2930(tx) => Self { + tx_hash: Some(*tx.tx_hash()), + ..TxEnv::from_recovered_tx(tx.tx(), sender).into() + }, + TempoTxEnvelope::Eip1559(tx) => Self { + tx_hash: Some(*tx.tx_hash()), + ..TxEnv::from_recovered_tx(tx.tx(), sender).into() + }, + TempoTxEnvelope::Eip7702(tx) => Self { + tx_hash: Some(*tx.tx_hash()), + ..TxEnv::from_recovered_tx(tx.tx(), sender).into() }, - TempoTxEnvelope::Eip2930(tx) => TxEnv::from_recovered_tx(tx.tx(), sender).into(), - TempoTxEnvelope::Eip1559(tx) => TxEnv::from_recovered_tx(tx.tx(), sender).into(), - TempoTxEnvelope::Eip7702(tx) => TxEnv::from_recovered_tx(tx.tx(), sender).into(), TempoTxEnvelope::AA(tx) => Self::from_recovered_tx(tx, sender), } } diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 40e99745fd..9886b2b082 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -60,6 +60,7 @@ struct ChannelDescriptor { address token; bytes32 salt; address authorizedSigner; + bytes32 openTxHash; } struct ChannelState { @@ -100,11 +101,10 @@ decades. `closeData` MUST be encoded as: 1. `0` for an active channel with no close request. -2. `1` for a finalized channel tombstone. -3. Any value `>= 2` for an active channel with a close request, where the stored value is the exact `closeRequestedAt` timestamp. +2. Any non-zero value for an active channel with a close request, where the stored value is the + exact `closeRequestedAt` timestamp. -This means timestamps `0` and `1` are reserved sentinel values and are not representable as -close-request times. +Closed channels MUST not retain any packed `ChannelState` slot at all. `channelId` MUST use the following deterministic domain-separated construction: @@ -116,12 +116,17 @@ channelId = keccak256( token, salt, authorizedSigner, + openTxHash, TIP20_CHANNEL_ESCROW, block.chainid ) ); ``` +For `open`, `openTxHash` MUST be the hash of the enclosing channel-open transaction. For all +post-open operations, `openTxHash` MUST be supplied via `ChannelDescriptor` so the implementation +can recompute the same `channelId` without storing immutable descriptor fields on-chain. + ### Interface The canonical interface for this TIP is [`tips/verify/src/interfaces/ITIP20ChannelEscrow.sol`](verify/src/interfaces/ITIP20ChannelEscrow.sol). @@ -156,10 +161,10 @@ strict predicate `timestamp > block.timestamp`, and expiry checks MUST use the s Execution semantics are: 1. `open` MUST reject zero deposit, invalid token address, invalid payee address, and any `expiresAt` value where `expiresAt <= block.timestamp`. -2. `open` MUST persist only the packed `ChannelState` slot and MUST emit the full immutable descriptor in `ChannelOpened`. +2. `open` MUST derive `openTxHash` from the enclosing transaction hash, persist only the packed `ChannelState` slot, and MUST emit the full immutable descriptor in `ChannelOpened`. 3. Post-open methods (`settle`, `topUp`, `close`, `requestClose`, `withdraw`, and descriptor-based views) MUST recompute `channelId` from the supplied descriptor and use that derived id for storage lookup. 4. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST satisfy both `newExpiresAt > block.timestamp` and `newExpiresAt > current expiresAt`. -5. If `closeData >= 2`, a successful `topUp` MUST clear it back to `0` and emit `CloseRequestCancelled`. +5. If `closeData != 0`, a successful `topUp` MUST clear it back to `0` and emit `CloseRequestCancelled`. 6. `requestClose` MUST set `closeData = uint32(block.timestamp)` on the first successful call and leave it unchanged on later successful calls. 7. `settle` MUST reject when `block.timestamp >= expiresAt`. 8. `close` MUST reject when `block.timestamp >= expiresAt` and `captureAmount > previousSettled`. @@ -170,7 +175,7 @@ Execution semantics are: 13. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. 14. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. 15. `withdraw` MUST be allowed when either the close grace period has elapsed from `closeData` or `block.timestamp >= expiresAt`. -16. Terminal `close` and `withdraw` MUST set `closeData = 1` and MUST NOT delete the slot, so the same descriptor and `channelId` cannot be reopened and replay old vouchers. +16. Terminal `close` and `withdraw` MUST delete the stored slot entirely. Reopening the same logical channel in a later transaction MUST produce a different `channelId` because `openTxHash` changes. ## Native Escrow Movement @@ -230,16 +235,15 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 5. Only payee can `settle` and `close`. 6. A channel MUST consume exactly one storage slot of mutable on-chain state. 7. `closeData == 0` MUST mean active with no close request. -8. `closeData == 1` MUST mean finalized. -9. `closeData >= 2` MUST mean active with a close request timestamp equal to `closeData`. -10. Once finalized, all state-changing methods MUST revert for that channel. -11. Finalized channels MUST retain a non-zero tombstone state so the same descriptor cannot be reopened. -12. Fund conservation MUST hold at all terminal states. -13. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. -14. `open` and `topUp` MUST not require a prior user `approve` transaction. -15. No capture-increasing operation may succeed past `expiresAt`. -16. `topUp` MUST NOT reduce or preserve the channel expiry when `newExpiresAt` is provided. -17. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. +8. `closeData != 0` MUST mean active with a close request timestamp equal to `closeData`. +9. Closed channels MUST have no remaining mutable on-chain state. +10. Reopening the same logical channel in a later transaction MUST yield a different `channelId` because `openTxHash` is different. +11. Fund conservation MUST hold at all terminal states. +12. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. +13. `open` and `topUp` MUST not require a prior user `approve` transaction. +14. No capture-increasing operation may succeed past `expiresAt`. +15. `topUp` MUST NOT reduce or preserve the channel expiry when `newExpiresAt` is provided. +16. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. ## References diff --git a/tips/verify/src/TIP20ChannelEscrow.sol b/tips/verify/src/TIP20ChannelEscrow.sol index 376d4ace23..b1a387b853 100644 --- a/tips/verify/src/TIP20ChannelEscrow.sol +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -1,35 +1,38 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import { ISignatureVerifier } from "./interfaces/ISignatureVerifier.sol"; -import { ITIP20 } from "./interfaces/ITIP20.sol"; -import { ITIP20ChannelEscrow } from "./interfaces/ITIP20ChannelEscrow.sol"; +import {ISignatureVerifier} from "./interfaces/ISignatureVerifier.sol"; +import {ITIP20} from "./interfaces/ITIP20.sol"; +import {ITIP20ChannelEscrow} from "./interfaces/ITIP20ChannelEscrow.sol"; /// @title TIP20ChannelEscrow /// @notice Reference contract for the TIP-1034 channel model. contract TIP20ChannelEscrow is ITIP20ChannelEscrow { - address public constant TIP20_CHANNEL_ESCROW = 0x4d50500000000000000000000000000000000000; - address public constant SIGNATURE_VERIFIER_PRECOMPILE = - 0x5165300000000000000000000000000000000000; + address public constant SIGNATURE_VERIFIER_PRECOMPILE = 0x5165300000000000000000000000000000000000; - bytes32 public constant VOUCHER_TYPEHASH = - keccak256("Voucher(bytes32 channelId,uint96 cumulativeAmount)"); + bytes32 public constant VOUCHER_TYPEHASH = keccak256("Voucher(bytes32 channelId,uint96 cumulativeAmount)"); uint64 public constant CLOSE_GRACE_PERIOD = 15 minutes; uint256 internal constant _DEPOSIT_OFFSET = 96; uint256 internal constant _EXPIRES_AT_OFFSET = 192; uint256 internal constant _CLOSE_DATA_OFFSET = 224; - uint32 internal constant _FINALIZED_CLOSE_DATA = 1; - bytes32 internal constant _EIP712_DOMAIN_TYPEHASH = keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" - ); + bytes32 internal constant _EIP712_DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); bytes32 internal constant _NAME_HASH = keccak256("TIP20 Channel Escrow"); bytes32 internal constant _VERSION_HASH = keccak256("1"); mapping(bytes32 => uint256) internal channelStates; + bytes32 internal _openTxHashContext; + + error OpenTxHashNotSet(); + + /// @dev Reference-contract-only hook. The precompile derives this from the enclosing tx hash. + function setOpenTxHashForTest(bytes32 openTxHash) external { + _openTxHashContext = openTxHash; + } function open( address payee, @@ -38,49 +41,32 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { bytes32 salt, address authorizedSigner, uint32 expiresAt - ) - external - returns (bytes32 channelId) - { + ) external returns (bytes32 channelId) { if (payee == address(0)) revert InvalidPayee(); if (token == address(0)) revert InvalidToken(); if (deposit == 0) revert ZeroDeposit(); if (expiresAt <= block.timestamp) revert InvalidExpiry(); - channelId = computeChannelId(msg.sender, payee, token, salt, authorizedSigner); + bytes32 openTxHash = _consumeOpenTxHash(); + channelId = computeChannelId(msg.sender, payee, token, salt, authorizedSigner, openTxHash); if (channelStates[channelId] != 0) revert ChannelAlreadyExists(); - channelStates[channelId] = _encodeChannelState( - ChannelState({ - settled: 0, - deposit: deposit, - expiresAt: expiresAt, - closeData: 0 - }) - ); + channelStates[channelId] = + _encodeChannelState(ChannelState({settled: 0, deposit: deposit, expiresAt: expiresAt, closeData: 0})); // The reference contract keeps ERC-20-style allowance flow for local verification. // The enshrined precompile should use TIP-20 `systemTransferFrom` semantics instead. bool success = ITIP20(token).transferFrom(msg.sender, address(this), deposit); if (!success) revert TransferFailed(); - emit ChannelOpened( - channelId, msg.sender, payee, token, authorizedSigner, salt, deposit, expiresAt - ); + emit ChannelOpened(channelId, msg.sender, payee, token, authorizedSigner, salt, openTxHash, deposit, expiresAt); } - function settle( - ChannelDescriptor calldata descriptor, - uint96 cumulativeAmount, - bytes calldata signature - ) - external - { + function settle(ChannelDescriptor calldata descriptor, uint96 cumulativeAmount, bytes calldata signature) external { bytes32 channelId = _channelId(descriptor); ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payee) revert NotPayee(); - if (_isFinalized(channel.closeData)) revert ChannelFinalized(); if (_isExpired(channel.expiresAt)) revert ChannelExpiredError(); if (cumulativeAmount > channel.deposit) revert AmountExceedsDeposit(); if (cumulativeAmount <= channel.settled) revert AmountNotIncreasing(); @@ -94,23 +80,14 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { bool success = ITIP20(descriptor.token).transfer(descriptor.payee, delta); if (!success) revert TransferFailed(); - emit Settled( - channelId, descriptor.payer, descriptor.payee, cumulativeAmount, delta, channel.settled - ); + emit Settled(channelId, descriptor.payer, descriptor.payee, cumulativeAmount, delta, channel.settled); } - function topUp( - ChannelDescriptor calldata descriptor, - uint96 additionalDeposit, - uint32 newExpiresAt - ) - external - { + function topUp(ChannelDescriptor calldata descriptor, uint96 additionalDeposit, uint32 newExpiresAt) external { bytes32 channelId = _channelId(descriptor); ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payer) revert NotPayer(); - if (_isFinalized(channel.closeData)) revert ChannelFinalized(); if (additionalDeposit > type(uint96).max - channel.deposit) revert DepositOverflow(); if (newExpiresAt != 0) { @@ -123,8 +100,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { // The reference contract keeps ERC-20-style allowance flow for local verification. // The enshrined precompile should use TIP-20 `systemTransferFrom` semantics instead. - bool success = - ITIP20(descriptor.token).transferFrom(msg.sender, address(this), additionalDeposit); + bool success = ITIP20(descriptor.token).transferFrom(msg.sender, address(this), additionalDeposit); if (!success) revert TransferFailed(); } @@ -132,21 +108,14 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { channel.expiresAt = newExpiresAt; } - if (_closeRequestedAt(channel.closeData) != 0) { + if (channel.closeData != 0) { channel.closeData = 0; emit CloseRequestCancelled(channelId, descriptor.payer, descriptor.payee); } channelStates[channelId] = _encodeChannelState(channel); - emit TopUp( - channelId, - descriptor.payer, - descriptor.payee, - additionalDeposit, - channel.deposit, - channel.expiresAt - ); + emit TopUp(channelId, descriptor.payer, descriptor.payee, additionalDeposit, channel.deposit, channel.expiresAt); } function requestClose(ChannelDescriptor calldata descriptor) external { @@ -154,16 +123,12 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payer) revert NotPayer(); - if (_isFinalized(channel.closeData)) revert ChannelFinalized(); - if (_closeRequestedAt(channel.closeData) == 0) { + if (channel.closeData == 0) { channel.closeData = uint32(block.timestamp); channelStates[channelId] = _encodeChannelState(channel); emit CloseRequested( - channelId, - descriptor.payer, - descriptor.payee, - uint256(block.timestamp) + CLOSE_GRACE_PERIOD + channelId, descriptor.payer, descriptor.payee, uint256(block.timestamp) + CLOSE_GRACE_PERIOD ); } } @@ -173,14 +138,11 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { uint96 cumulativeAmount, uint96 captureAmount, bytes calldata signature - ) - external - { + ) external { bytes32 channelId = _channelId(descriptor); ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payee) revert NotPayee(); - if (_isFinalized(channel.closeData)) revert ChannelFinalized(); uint96 previousSettled = channel.settled; if (captureAmount < previousSettled || captureAmount > cumulativeAmount) { @@ -196,9 +158,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { uint96 delta = captureAmount - previousSettled; uint96 refund = channel.deposit - captureAmount; - channel.settled = captureAmount; - channel.closeData = _FINALIZED_CLOSE_DATA; - channelStates[channelId] = _encodeChannelState(channel); + delete channelStates[channelId]; if (delta > 0) { bool payeeTransferSucceeded = ITIP20(descriptor.token).transfer(descriptor.payee, delta); @@ -206,8 +166,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { } if (refund > 0) { - bool payerTransferSucceeded = - ITIP20(descriptor.token).transfer(descriptor.payer, refund); + bool payerTransferSucceeded = ITIP20(descriptor.token).transfer(descriptor.payer, refund); if (!payerTransferSucceeded) revert TransferFailed(); } @@ -219,17 +178,15 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payer) revert NotPayer(); - if (_isFinalized(channel.closeData)) revert ChannelFinalized(); uint32 closeRequestedAt = _closeRequestedAt(channel.closeData); - bool closeGracePassed = closeRequestedAt != 0 - && block.timestamp >= uint256(closeRequestedAt) + CLOSE_GRACE_PERIOD; + bool closeGracePassed = + closeRequestedAt != 0 && block.timestamp >= uint256(closeRequestedAt) + CLOSE_GRACE_PERIOD; if (!closeGracePassed && !_isExpired(channel.expiresAt)) revert CloseNotReady(); uint96 refund = channel.deposit - channel.settled; - channel.closeData = _FINALIZED_CLOSE_DATA; - channelStates[channelId] = _encodeChannelState(channel); + delete channelStates[channelId]; if (refund > 0) { bool success = ITIP20(descriptor.token).transfer(descriptor.payer, refund); @@ -240,17 +197,14 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { emit ChannelClosed(channelId, descriptor.payer, descriptor.payee, channel.settled, refund); } - function getChannel(ChannelDescriptor calldata descriptor) - external - view - returns (Channel memory channel) - { + function getChannel(ChannelDescriptor calldata descriptor) external view returns (Channel memory channel) { channel.descriptor = ChannelDescriptor({ payer: descriptor.payer, payee: descriptor.payee, token: descriptor.token, salt: descriptor.salt, - authorizedSigner: descriptor.authorizedSigner + authorizedSigner: descriptor.authorizedSigner, + openTxHash: descriptor.openTxHash }); channel.state = _decodeChannelState(channelStates[_channelId(descriptor)]); } @@ -259,11 +213,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { return _decodeChannelState(channelStates[channelId]); } - function getChannelStatesBatch(bytes32[] calldata channelIds) - external - view - returns (ChannelState[] memory states) - { + function getChannelStatesBatch(bytes32[] calldata channelIds) external view returns (ChannelState[] memory states) { uint256 length = channelIds.length; states = new ChannelState[](length); @@ -277,27 +227,15 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { address payee, address token, bytes32 salt, - address authorizedSigner - ) - public - view - returns (bytes32) - { + address authorizedSigner, + bytes32 openTxHash + ) public view returns (bytes32) { return keccak256( - abi.encode( - payer, payee, token, salt, authorizedSigner, TIP20_CHANNEL_ESCROW, block.chainid - ) + abi.encode(payer, payee, token, salt, authorizedSigner, openTxHash, TIP20_CHANNEL_ESCROW, block.chainid) ); } - function getVoucherDigest( - bytes32 channelId, - uint96 cumulativeAmount - ) - external - view - returns (bytes32) - { + function getVoucherDigest(bytes32 channelId, uint96 cumulativeAmount) external view returns (bytes32) { bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); return _hashTypedData(structHash); } @@ -312,7 +250,8 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { descriptor.payee, descriptor.token, descriptor.salt, - descriptor.authorizedSigner + descriptor.authorizedSigner, + descriptor.openTxHash ); } @@ -322,11 +261,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { return _decodeChannelState(packedState); } - function _decodeChannelState(uint256 packedState) - internal - pure - returns (ChannelState memory state) - { + function _decodeChannelState(uint256 packedState) internal pure returns (ChannelState memory state) { if (packedState == 0) { return state; } @@ -337,23 +272,15 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { state.closeData = uint32(packedState >> _CLOSE_DATA_OFFSET); } - function _encodeChannelState(ChannelState memory state) - internal - pure - returns (uint256 packedState) - { + function _encodeChannelState(ChannelState memory state) internal pure returns (uint256 packedState) { packedState = uint256(state.settled); packedState |= uint256(state.deposit) << _DEPOSIT_OFFSET; packedState |= uint256(state.expiresAt) << _EXPIRES_AT_OFFSET; packedState |= uint256(state.closeData) << _CLOSE_DATA_OFFSET; } - function _isFinalized(uint32 closeData) internal pure returns (bool) { - return closeData == _FINALIZED_CLOSE_DATA; - } - function _closeRequestedAt(uint32 closeData) internal pure returns (uint32) { - return closeData >= 2 ? closeData : 0; + return closeData; } function _isExpired(uint32 expiresAt) internal view returns (bool) { @@ -365,19 +292,14 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { bytes32 channelId, uint96 cumulativeAmount, bytes calldata signature - ) - internal - view - { + ) internal view { bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); bytes32 digest = _hashTypedData(structHash); - address expectedSigner = descriptor.authorizedSigner != address(0) - ? descriptor.authorizedSigner - : descriptor.payer; + address expectedSigner = + descriptor.authorizedSigner != address(0) ? descriptor.authorizedSigner : descriptor.payer; bool isValid; - try ISignatureVerifier(SIGNATURE_VERIFIER_PRECOMPILE) - .verify(expectedSigner, digest, signature) returns ( + try ISignatureVerifier(SIGNATURE_VERIFIER_PRECOMPILE).verify(expectedSigner, digest, signature) returns ( bool valid ) { isValid = valid; @@ -389,19 +311,19 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { } function _domainSeparator() internal view returns (bytes32) { - return keccak256( - abi.encode( - _EIP712_DOMAIN_TYPEHASH, - _NAME_HASH, - _VERSION_HASH, - block.chainid, - TIP20_CHANNEL_ESCROW - ) - ); + return + keccak256( + abi.encode(_EIP712_DOMAIN_TYPEHASH, _NAME_HASH, _VERSION_HASH, block.chainid, TIP20_CHANNEL_ESCROW) + ); } function _hashTypedData(bytes32 structHash) internal view returns (bytes32) { return keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); } + function _consumeOpenTxHash() internal returns (bytes32 openTxHash) { + openTxHash = _openTxHashContext; + if (openTxHash == bytes32(0)) revert OpenTxHashNotSet(); + delete _openTxHashContext; + } } diff --git a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol index b4dff617f5..61ba32e135 100644 --- a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol +++ b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol @@ -4,13 +4,13 @@ pragma solidity >=0.8.20 <0.9.0; /// @title ITIP20ChannelEscrow /// @notice Reference interface for the TIP-1034 channel model. interface ITIP20ChannelEscrow { - struct ChannelDescriptor { address payer; address payee; address token; bytes32 salt; address authorizedSigner; + bytes32 openTxHash; } struct ChannelState { @@ -35,66 +35,39 @@ interface ITIP20ChannelEscrow { bytes32 salt, address authorizedSigner, uint32 expiresAt - ) - external - returns (bytes32 channelId); + ) external returns (bytes32 channelId); - function settle( - ChannelDescriptor calldata descriptor, - uint96 cumulativeAmount, - bytes calldata signature - ) - external; + function settle(ChannelDescriptor calldata descriptor, uint96 cumulativeAmount, bytes calldata signature) external; - function topUp( - ChannelDescriptor calldata descriptor, - uint96 additionalDeposit, - uint32 newExpiresAt - ) - external; + function topUp(ChannelDescriptor calldata descriptor, uint96 additionalDeposit, uint32 newExpiresAt) external; function close( ChannelDescriptor calldata descriptor, uint96 cumulativeAmount, uint96 captureAmount, bytes calldata signature - ) - external; + ) external; function requestClose(ChannelDescriptor calldata descriptor) external; function withdraw(ChannelDescriptor calldata descriptor) external; - function getChannel(ChannelDescriptor calldata descriptor) - external - view - returns (Channel memory); + function getChannel(ChannelDescriptor calldata descriptor) external view returns (Channel memory); function getChannelState(bytes32 channelId) external view returns (ChannelState memory); - function getChannelStatesBatch(bytes32[] calldata channelIds) - external - view - returns (ChannelState[] memory); + function getChannelStatesBatch(bytes32[] calldata channelIds) external view returns (ChannelState[] memory); function computeChannelId( address payer, address payee, address token, bytes32 salt, - address authorizedSigner - ) - external - view - returns (bytes32); - - function getVoucherDigest( - bytes32 channelId, - uint96 cumulativeAmount - ) - external - view - returns (bytes32); + address authorizedSigner, + bytes32 openTxHash + ) external view returns (bytes32); + + function getVoucherDigest(bytes32 channelId, uint96 cumulativeAmount) external view returns (bytes32); function domainSeparator() external view returns (bytes32); @@ -105,6 +78,7 @@ interface ITIP20ChannelEscrow { address token, address authorizedSigner, bytes32 salt, + bytes32 openTxHash, uint96 deposit, uint32 expiresAt ); @@ -128,10 +102,7 @@ interface ITIP20ChannelEscrow { ); event CloseRequested( - bytes32 indexed channelId, - address indexed payer, - address indexed payee, - uint256 closeGraceEnd + bytes32 indexed channelId, address indexed payer, address indexed payee, uint256 closeGraceEnd ); event ChannelClosed( @@ -142,9 +113,7 @@ interface ITIP20ChannelEscrow { uint96 refundedToPayer ); - event CloseRequestCancelled( - bytes32 indexed channelId, address indexed payer, address indexed payee - ); + event CloseRequestCancelled(bytes32 indexed channelId, address indexed payer, address indexed payee); event ChannelExpired(bytes32 indexed channelId, address indexed payer, address indexed payee); @@ -165,5 +134,4 @@ interface ITIP20ChannelEscrow { error CloseNotReady(); error DepositOverflow(); error TransferFailed(); - } diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol index df6534dc70..58eadee54c 100644 --- a/tips/verify/test/TIP20ChannelEscrow.t.sol +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -1,44 +1,24 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import { TIP20 } from "../src/TIP20.sol"; -import { TIP20ChannelEscrow } from "../src/TIP20ChannelEscrow.sol"; -import { ITIP20ChannelEscrow } from "../src/interfaces/ITIP20ChannelEscrow.sol"; -import { BaseTest } from "./BaseTest.t.sol"; +import {TIP20} from "../src/TIP20.sol"; +import {TIP20ChannelEscrow} from "../src/TIP20ChannelEscrow.sol"; +import {ITIP20ChannelEscrow} from "../src/interfaces/ITIP20ChannelEscrow.sol"; +import {BaseTest} from "./BaseTest.t.sol"; contract MockSignatureVerifier { - error InvalidFormat(); error InvalidSignature(); - function recover(bytes32 hash, bytes calldata signature) - external - pure - returns (address signer) - { + function recover(bytes32 hash, bytes calldata signature) external pure returns (address signer) { return _recover(hash, signature); } - function verify( - address signer, - bytes32 hash, - bytes calldata signature - ) - external - pure - returns (bool) - { + function verify(address signer, bytes32 hash, bytes calldata signature) external pure returns (bool) { return _recover(hash, signature) == signer; } - function _recover( - bytes32 hash, - bytes calldata signature - ) - internal - pure - returns (address signer) - { + function _recover(bytes32 hash, bytes calldata signature) internal pure returns (address signer) { if (signature.length != 65) revert InvalidSignature(); bytes32 r; @@ -57,17 +37,17 @@ contract MockSignatureVerifier { signer = ecrecover(hash, v, r, s); if (signer == address(0)) revert InvalidSignature(); } - } contract TIP20ChannelEscrowTest is BaseTest { - TIP20ChannelEscrow public channel; TIP20 public token; address public payer; uint256 public payerKey; address public payee; + bytes32 internal lastOpenTxHash; + uint256 internal openTxCounter; uint96 internal constant DEPOSIT = 1_000_000; bytes32 internal constant SALT = bytes32(uint256(1)); @@ -78,9 +58,7 @@ contract TIP20ChannelEscrowTest is BaseTest { channel = new TIP20ChannelEscrow(); MockSignatureVerifier verifier = new MockSignatureVerifier(); vm.etch(channel.SIGNATURE_VERIFIER_PRECOMPILE(), address(verifier).code); - token = TIP20( - factory.createToken("Stream Token", "STR", "USD", pathUSD, admin, bytes32("stream")) - ); + token = TIP20(factory.createToken("Stream Token", "STR", "USD", pathUSD, admin, bytes32("stream"))); (payer, payerKey) = makeAddrAndKey("payer"); payee = makeAddr("payee"); @@ -99,23 +77,30 @@ contract TIP20ChannelEscrowTest is BaseTest { } function _openChannel() internal returns (bytes32) { + _prepareNextOpenTxHash(); vm.prank(payer); return channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); } function _openChannelWithExpiry(uint32 expiresAt) internal returns (bytes32) { + _prepareNextOpenTxHash(); vm.prank(payer); return channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); } function _descriptor() internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { - return _descriptor(SALT, address(0)); + return _descriptor(SALT, address(0), lastOpenTxHash); } - function _descriptor( - bytes32 salt, - address authorizedSigner - ) + function _descriptor(bytes32 salt, address authorizedSigner) + internal + view + returns (ITIP20ChannelEscrow.ChannelDescriptor memory) + { + return _descriptor(salt, authorizedSigner, lastOpenTxHash); + } + + function _descriptor(bytes32 salt, address authorizedSigner, bytes32 openTxHash) internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) @@ -125,10 +110,17 @@ contract TIP20ChannelEscrowTest is BaseTest { payee: payee, token: address(token), salt: salt, - authorizedSigner: authorizedSigner + authorizedSigner: authorizedSigner, + openTxHash: openTxHash }); } + function _prepareNextOpenTxHash() internal returns (bytes32 openTxHash) { + openTxHash = keccak256(abi.encodePacked("open", ++openTxCounter)); + channel.setOpenTxHashForTest(openTxHash); + lastOpenTxHash = openTxHash; + } + function _channelStateSlot(bytes32 channelId) internal pure returns (bytes32) { return keccak256(abi.encode(channelId, uint256(0))); } @@ -137,15 +129,7 @@ contract TIP20ChannelEscrowTest is BaseTest { return _signVoucher(channelId, amount, payerKey); } - function _signVoucher( - bytes32 channelId, - uint96 amount, - uint256 signerKey - ) - internal - view - returns (bytes memory) - { + function _signVoucher(bytes32 channelId, uint96 amount, uint256 signerKey) internal view returns (bytes memory) { bytes32 digest = channel.getVoucherDigest(channelId, amount); (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, digest); return abi.encodePacked(r, s, v); @@ -153,16 +137,17 @@ contract TIP20ChannelEscrowTest is BaseTest { function test_open_success() public { uint32 expiresAt = _defaultExpiry(); + bytes32 openTxHash = _prepareNextOpenTxHash(); vm.prank(payer); - bytes32 channelId = - channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); + bytes32 channelId = channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); ITIP20ChannelEscrow.Channel memory ch = channel.getChannel(_descriptor()); assertEq(ch.descriptor.payer, payer); assertEq(ch.descriptor.payee, payee); assertEq(ch.descriptor.token, address(token)); assertEq(ch.descriptor.authorizedSigner, address(0)); + assertEq(ch.descriptor.openTxHash, openTxHash); assertEq(ch.state.settled, 0); assertEq(ch.state.deposit, DEPOSIT); assertEq(ch.state.expiresAt, expiresAt); @@ -171,35 +156,42 @@ contract TIP20ChannelEscrowTest is BaseTest { } function test_open_revert_zeroPayee() public { + _prepareNextOpenTxHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidPayee.selector); channel.open(address(0), address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); } function test_open_revert_zeroToken() public { + _prepareNextOpenTxHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidToken.selector); channel.open(payee, address(0), DEPOSIT, SALT, address(0), _defaultExpiry()); } function test_open_revert_zeroDeposit() public { + _prepareNextOpenTxHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ZeroDeposit.selector); channel.open(payee, address(token), 0, SALT, address(0), _defaultExpiry()); } function test_open_revert_invalidExpiry() public { + _prepareNextOpenTxHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidExpiry.selector); channel.open(payee, address(token), DEPOSIT, SALT, address(0), uint32(block.timestamp)); } - function test_open_revert_duplicate() public { - _openChannel(); + function test_open_same_descriptor_uses_distinct_open_tx_hashes() public { + bytes32 channelId1 = _openChannel(); + bytes32 openTxHash1 = lastOpenTxHash; - vm.prank(payer); - vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); - channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); + bytes32 channelId2 = _openChannel(); + bytes32 openTxHash2 = lastOpenTxHash; + + assertNotEq(openTxHash1, openTxHash2); + assertNotEq(channelId1, channelId2); } function test_settle_success() public { @@ -245,9 +237,9 @@ contract TIP20ChannelEscrowTest is BaseTest { function test_authorizedSigner_settleSuccess() public { (address delegateSigner, uint256 delegateKey) = makeAddrAndKey("delegate"); + _prepareNextOpenTxHash(); vm.prank(payer); - bytes32 channelId = - channel.open(payee, address(token), DEPOSIT, SALT, delegateSigner, _defaultExpiry()); + bytes32 channelId = channel.open(payee, address(token), DEPOSIT, SALT, delegateSigner, _defaultExpiry()); bytes memory sig = _signVoucher(channelId, 500_000, delegateKey); @@ -314,8 +306,8 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.close(_descriptor(), 900_000, 600_000, sig); ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); - assertEq(ch.settled, 600_000); - assertEq(ch.closeData, 1); + assertEq(ch.settled, 0); + assertEq(ch.closeData, 0); assertEq(token.balanceOf(payee), payeeBalanceBefore + 600_000); assertEq(token.balanceOf(payer), payerBalanceBefore + 400_000); } @@ -334,7 +326,7 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.close(_descriptor(), 800_000, 500_000, closeSig); assertEq(token.balanceOf(payee), payeeBalanceBefore + 200_000); - assertEq(channel.getChannelState(channelId).settled, 500_000); + assertEq(channel.getChannelState(channelId).settled, 0); } function test_close_allowsVoucherAmountAboveDepositWhenCaptureWithinDeposit() public { @@ -347,8 +339,8 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.close(_descriptor(), DEPOSIT + 250_000, DEPOSIT, sig); ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); - assertEq(ch.settled, DEPOSIT); - assertEq(ch.closeData, 1); + assertEq(ch.settled, 0); + assertEq(ch.closeData, 0); assertEq(token.balanceOf(payee), payeeBalanceBefore + DEPOSIT); } @@ -377,22 +369,21 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payee); channel.close(_descriptor(), 300_000, 300_000, ""); - assertEq(channel.getChannelState(channelId).closeData, 1); + assertEq(channel.getChannelState(channelId).closeData, 0); assertEq(token.balanceOf(payer), payerBalanceBefore + (DEPOSIT - 300_000)); } - function test_close_keepsTombstoneAndBlocksReopen() public { + function test_close_clears_state_and_allows_reopen_with_new_open_tx_hash() public { bytes32 channelId = _openChannel(); bytes memory sig = _signVoucher(channelId, 600_000); vm.prank(payee); channel.close(_descriptor(), 600_000, 600_000, sig); - assertEq(channel.getChannelState(channelId).closeData, 1); + assertEq(channel.getChannelState(channelId).closeData, 0); - vm.prank(payer); - vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); - channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); + bytes32 reopenedChannelId = _openChannel(); + assertNotEq(reopenedChannelId, channelId); } function test_withdraw_afterGracePeriod() public { @@ -407,7 +398,7 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payer); channel.withdraw(_descriptor()); - assertEq(channel.getChannelState(channelId).closeData, 1); + assertEq(channel.getChannelState(channelId).closeData, 0); assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); } @@ -420,21 +411,22 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payer); channel.withdraw(_descriptor()); - assertEq(channel.getChannelState(channelId).closeData, 1); + assertEq(channel.getChannelState(channelId).closeData, 0); assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); } function test_getChannelStatesBatch_success() public { bytes32 channelId1 = _openChannel(); + bytes32 channel1OpenTxHash = lastOpenTxHash; + _prepareNextOpenTxHash(); vm.prank(payer); - bytes32 channelId2 = channel.open( - payee, address(token), DEPOSIT, bytes32(uint256(2)), address(0), _defaultExpiry() - ); + bytes32 channelId2 = + channel.open(payee, address(token), DEPOSIT, bytes32(uint256(2)), address(0), _defaultExpiry()); bytes memory sig = _signVoucher(channelId1, 400_000); vm.prank(payee); - channel.settle(_descriptor(), 400_000, sig); + channel.settle(_descriptor(SALT, address(0), channel1OpenTxHash), 400_000, sig); bytes32[] memory ids = new bytes32[](2); ids[0] = channelId1; @@ -448,12 +440,12 @@ contract TIP20ChannelEscrowTest is BaseTest { function test_computeChannelId_usesFixedPrecompileAddress() public { TIP20ChannelEscrow other = new TIP20ChannelEscrow(); + bytes32 openTxHash = keccak256("openTxHash"); - bytes32 id1 = channel.computeChannelId(payer, payee, address(token), SALT, address(0)); - bytes32 id2 = other.computeChannelId(payer, payee, address(token), SALT, address(0)); + bytes32 id1 = channel.computeChannelId(payer, payee, address(token), SALT, address(0), openTxHash); + bytes32 id2 = other.computeChannelId(payer, payee, address(token), SALT, address(0), openTxHash); assertEq(id1, id2); assertEq(channel.domainSeparator(), other.domainSeparator()); } - } From a547250540b88af0912d74072bddbe57b927d05d Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Fri, 1 May 2026 02:30:19 +0530 Subject: [PATCH 32/33] refactor(tip-1034): seed channel tx hash like tx.origin Stores the current top-level tx hash in TIP-20 channel escrow transient storage and seeds it from the handler, mirroring the existing tx.origin pattern used by account keychain. Removes the generic StorageCtx tx-hash thread-local path and updates escrow tests to seed the precompile-local context explicitly. Amp-Thread-ID: https://ampcode.com/threads/T-019ddf4c-e3f2-722f-b6a9-5b822be31d91 --- crates/precompiles/src/storage/mod.rs | 2 +- .../precompiles/src/storage/thread_local.rs | 70 +----------- .../src/tip20_channel_escrow/mod.rs | 100 ++++++++++++++---- crates/revm/src/exec.rs | 54 ++++------ crates/revm/src/handler.rs | 22 ++++ crates/revm/src/tx.rs | 7 -- 6 files changed, 125 insertions(+), 130 deletions(-) diff --git a/crates/precompiles/src/storage/mod.rs b/crates/precompiles/src/storage/mod.rs index bd921d9fe3..16177c10a2 100644 --- a/crates/precompiles/src/storage/mod.rs +++ b/crates/precompiles/src/storage/mod.rs @@ -8,7 +8,7 @@ pub mod hashmap; pub mod thread_local; use alloy::primitives::keccak256; -pub use thread_local::{CheckpointGuard, CurrentTxHash, StorageCtx}; +pub use thread_local::{CheckpointGuard, StorageCtx}; mod types; pub use types::*; diff --git a/crates/precompiles/src/storage/thread_local.rs b/crates/precompiles/src/storage/thread_local.rs index 289ca24dea..8fcb3305f0 100644 --- a/crates/precompiles/src/storage/thread_local.rs +++ b/crates/precompiles/src/storage/thread_local.rs @@ -21,12 +21,6 @@ use crate::{ }; scoped_thread_local!(static STORAGE: RefCell<&mut dyn PrecompileStorageProvider>); -scoped_thread_local!(static CURRENT_TX_HASH: RefCell>); - -/// Provides access to the enclosing transaction hash when the caller can expose it. -pub trait CurrentTxHash { - fn current_tx_hash(&self) -> Option; -} /// Thread-local storage accessor that implements `PrecompileStorageProvider` without the trait bound. /// @@ -46,12 +40,6 @@ pub trait CurrentTxHash { pub struct StorageCtx; impl StorageCtx { - /// Executes a closure with a scoped top-level transaction hash. - pub fn with_current_tx_hash(tx_hash: Option, f: impl FnOnce() -> R) -> R { - let tx_hash = RefCell::new(tx_hash); - CURRENT_TX_HASH.set(&tx_hash, f) - } - /// Enter storage context. All storage operations must happen within the closure. /// /// # IMPORTANT @@ -72,18 +60,6 @@ impl StorageCtx { STORAGE.set(&cell, f) } - /// Like [`Self::enter`], but also scopes the top-level transaction hash. - pub fn enter_with_tx_hash( - storage: &mut S, - tx_hash: Option, - f: impl FnOnce() -> R, - ) -> R - where - S: PrecompileStorageProvider, - { - Self::with_current_tx_hash(tx_hash, || Self::enter(storage, f)) - } - /// Execute an infallible function with access to the current thread-local storage provider. /// /// # Panics @@ -161,14 +137,6 @@ impl StorageCtx { Self::with_storage(|s| s.block_number()) } - /// Returns the current top-level transaction hash when available. - pub fn tx_hash(&self) -> Option { - if !CURRENT_TX_HASH.is_set() { - return None; - } - CURRENT_TX_HASH.with(|tx_hash| *tx_hash.borrow()) - } - /// Sets the bytecode at the given address. pub fn set_code(&mut self, address: Address, code: Bytecode) -> Result<()> { Self::try_with_storage(|s| s.set_code(address, code)) @@ -360,7 +328,7 @@ impl<'evm> StorageCtx { journal: &'evm mut J, block_env: &'evm dyn Block, cfg: &CfgEnv, - tx_env: &'evm (impl Transaction + CurrentTxHash), + tx_env: &'evm impl Transaction, f: impl FnOnce() -> R, ) -> R where @@ -368,9 +336,7 @@ impl<'evm> StorageCtx { { let internals = EvmInternals::new(journal, block_env, cfg, tx_env); let mut provider = EvmPrecompileStorageProvider::new_max_gas(internals, cfg); - - // The core logic of setting up thread-local storage is here. - Self::enter_with_tx_hash(&mut provider, tx_env.current_tx_hash(), f) + Self::enter(&mut provider, f) } /// Like [`enter_evm`](Self::enter_evm), but takes a `&mut impl ContextTr` @@ -378,7 +344,6 @@ impl<'evm> StorageCtx { pub fn enter_ctx(ctx: &mut C, f: impl FnOnce() -> R) -> R where C: ContextTr, Journal: Debug, Db: Database>, - C::Tx: CurrentTxHash, { let (tx, block, cfg, journal) = ctx.tx_block_cfg_journal_mut(); Self::enter_evm(journal, block, cfg, tx, f) @@ -394,13 +359,12 @@ impl<'evm> StorageCtx { ) -> (R, u64) where C: ContextTr, Journal: Debug, Db: Database>, - C::Tx: CurrentTxHash, { let (tx, block, cfg, journal) = ctx.tx_block_cfg_journal_mut(); let internals = EvmInternals::new(journal, block, cfg, tx); let mut provider = EvmPrecompileStorageProvider::new_with_gas_limit(internals, cfg, gas_limit, reservoir); - let result = Self::enter_with_tx_hash(&mut provider, tx.current_tx_hash(), f); + let result = Self::enter(&mut provider, f); let gas_used = provider.gas_used(); (result, gas_used) } @@ -410,7 +374,7 @@ impl<'evm> StorageCtx { journal: &'evm mut J, block_env: &'evm dyn Block, cfg: &CfgEnv, - tx_env: &'evm (impl Transaction + CurrentTxHash), + tx_env: &'evm impl Transaction, f: impl FnOnce(P) -> R, ) -> R where @@ -545,32 +509,6 @@ mod tests { }); } - #[test] - fn test_tx_hash_scope_override_and_restore() { - let mut storage = t1c_storage(); - let outer_hash = B256::repeat_byte(0x11); - let inner_hash = B256::repeat_byte(0x22); - - assert_eq!(StorageCtx::default().tx_hash(), None); - - StorageCtx::enter_with_tx_hash(&mut storage, Some(outer_hash), || { - let ctx = StorageCtx; - assert_eq!(ctx.tx_hash(), Some(outer_hash)); - - StorageCtx::with_current_tx_hash(Some(inner_hash), || { - assert_eq!(ctx.tx_hash(), Some(inner_hash)); - }); - assert_eq!(ctx.tx_hash(), Some(outer_hash)); - - StorageCtx::with_current_tx_hash(None, || { - assert_eq!(ctx.tx_hash(), None); - }); - assert_eq!(ctx.tx_hash(), Some(outer_hash)); - }); - - assert_eq!(StorageCtx::default().tx_hash(), None); - } - #[test] fn test_checkpoint_commit_and_revert() { let mut storage = t1c_storage(); diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index d8e51c7dcf..341303deb7 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -62,6 +62,12 @@ impl PackedChannelState { #[contract(addr = TIP20_CHANNEL_ESCROW_ADDRESS)] pub struct TIP20ChannelEscrow { channel_states: Mapping, + + // WARNING(rusowsky): transient storage slots must always be placed at the very end until the + // `contract` macro is refactored and has 2 independent layouts (persistent and transient). + // If new (persistent) storage fields need to be added to the precompile, they must go above + // this one. + current_tx_hash: B256, } impl TIP20ChannelEscrow { @@ -69,6 +75,14 @@ impl TIP20ChannelEscrow { self.__initialize() } + /// Sets the current top-level transaction hash for the active execution. + /// + /// Called by the handler before transaction execution. Uses transient storage, so it is + /// automatically cleared after the transaction. + pub fn set_current_tx_hash(&mut self, tx_hash: B256) -> Result<()> { + self.current_tx_hash.t_write(tx_hash) + } + pub fn open( &mut self, msg_sender: Address, @@ -88,9 +102,12 @@ impl TIP20ChannelEscrow { if call.expiresAt as u64 <= self.now() { return Err(TIP20ChannelEscrowError::invalid_expiry().into()); } - let open_tx_hash = self.storage.tx_hash().ok_or_else(|| { - crate::error::TempoPrecompileError::Fatal("current tx hash unavailable".into()) - })?; + let open_tx_hash = self.current_tx_hash.t_read()?; + if open_tx_hash.is_zero() { + return Err( + crate::error::TempoPrecompileError::Fatal("current tx hash unavailable".into()) + ); + } let channel_id = self.compute_channel_id_inner( msg_sender, @@ -637,7 +654,7 @@ mod tests { let open_tx_hash = B256::repeat_byte(0x11); let reopen_tx_hash = B256::repeat_byte(0x22); - StorageCtx::enter_with_tx_hash(&mut storage, Some(open_tx_hash), || { + StorageCtx::enter(&mut storage, || { let token = TIP20Setup::path_usd(payer) .with_issuer(payer) .with_mint(payer, U256::from(1_000u128)) @@ -645,6 +662,7 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + escrow.set_current_tx_hash(open_tx_hash)?; let now = StorageCtx::default().timestamp().to::(); let channel_id = escrow.open( @@ -699,26 +717,60 @@ mod tests { assert_eq!(state.deposit, 0); assert_eq!(state.settled, 0); - let reopened_channel_id = - StorageCtx::with_current_tx_hash(Some(reopen_tx_hash), || { - escrow.open( - payer, - ITIP20ChannelEscrow::openCall { - payee, - token: token.address(), - deposit: abi_u96(1), - salt, - authorizedSigner: Address::ZERO, - expiresAt: now + 2_000, - }, - ) - })?; + escrow.set_current_tx_hash(reopen_tx_hash)?; + let reopened_channel_id = escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + token: token.address(), + deposit: abi_u96(1), + salt, + authorizedSigner: Address::ZERO, + expiresAt: now + 2_000, + }, + )?; assert_ne!(reopened_channel_id, channel_id); Ok(()) }) } + #[test] + fn test_open_rejects_missing_tx_hash_context() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(1_000u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + + let err = escrow + .open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + token: token.address(), + deposit: abi_u96(1), + salt: B256::random(), + authorizedSigner: Address::ZERO, + expiresAt: StorageCtx::default().timestamp().to::() + 1_000, + }, + ) + .unwrap_err(); + assert_eq!( + err, + crate::error::TempoPrecompileError::Fatal("current tx hash unavailable".into()) + ); + + Ok(()) + }) + } + #[test] fn test_top_up_cancels_close_request() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); @@ -727,13 +779,14 @@ mod tests { let salt = B256::random(); let open_tx_hash = B256::repeat_byte(0x33); - StorageCtx::enter_with_tx_hash(&mut storage, Some(open_tx_hash), || { + StorageCtx::enter(&mut storage, || { let token = TIP20Setup::path_usd(payer) .with_issuer(payer) .with_mint(payer, U256::from(1_000u128)) .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + escrow.set_current_tx_hash(open_tx_hash)?; let expires_at = StorageCtx::default().timestamp().to::() + 1_000; let descriptor = descriptor( @@ -783,8 +836,9 @@ mod tests { #[test] fn test_dispatch_rejects_static_mutation() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); - StorageCtx::enter_with_tx_hash(&mut storage, Some(B256::repeat_byte(0x44)), || { + StorageCtx::enter(&mut storage, || { let mut escrow = TIP20ChannelEscrow::new(); + escrow.set_current_tx_hash(B256::repeat_byte(0x44))?; let result = escrow.call( &ITIP20ChannelEscrow::openCall { payee: Address::random(), @@ -810,13 +864,14 @@ mod tests { let salt = B256::random(); let open_tx_hash = B256::repeat_byte(0x55); - StorageCtx::enter_with_tx_hash(&mut storage, Some(open_tx_hash), || { + StorageCtx::enter(&mut storage, || { let token = TIP20Setup::path_usd(payer) .with_issuer(payer) .with_mint(payer, U256::from(100u128)) .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + escrow.set_current_tx_hash(open_tx_hash)?; let now = StorageCtx::default().timestamp().to::(); escrow.open( payer, @@ -863,13 +918,14 @@ mod tests { let salt = B256::random(); let open_tx_hash = B256::repeat_byte(0x66); - StorageCtx::enter_with_tx_hash(&mut storage, Some(open_tx_hash), || { + StorageCtx::enter(&mut storage, || { let token = TIP20Setup::path_usd(payer) .with_issuer(payer) .with_mint(payer, U256::from(100u128)) .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + escrow.set_current_tx_hash(open_tx_hash)?; let now = StorageCtx::default().timestamp().to::(); escrow.open( payer, diff --git a/crates/revm/src/exec.rs b/crates/revm/src/exec.rs index 6972072f7a..3bec5ea80b 100644 --- a/crates/revm/src/exec.rs +++ b/crates/revm/src/exec.rs @@ -17,7 +17,6 @@ use revm::{ primitives::{Address, Bytes}, state::EvmState, }; -use tempo_precompiles::storage::{CurrentTxHash, StorageCtx}; /// Total gas system transactions are allowed to use. const SYSTEM_CALL_GAS_LIMIT: u64 = 250_000_000; @@ -37,12 +36,9 @@ where } fn transact_one(&mut self, tx: Self::Tx) -> Result { - let tx_hash = tx.current_tx_hash(); - StorageCtx::with_current_tx_hash(tx_hash, || { - self.inner.ctx.set_tx(tx); - let mut h = TempoEvmHandler::new(); - h.run(self) - }) + self.inner.ctx.set_tx(tx); + let mut h = TempoEvmHandler::new(); + h.run(self) } fn finalize(&mut self) -> Self::State { @@ -52,13 +48,10 @@ where fn replay( &mut self, ) -> Result, Self::Error> { - let tx_hash = self.inner.ctx.tx.current_tx_hash(); - StorageCtx::with_current_tx_hash(tx_hash, || { - let mut h = TempoEvmHandler::new(); - h.run(self).map(|result| { - let state = self.finalize(); - ExecResultAndState::new(result, state) - }) + let mut h = TempoEvmHandler::new(); + h.run(self).map(|result| { + let state = self.finalize(); + ExecResultAndState::new(result, state) }) } } @@ -84,12 +77,9 @@ where } fn inspect_one_tx(&mut self, tx: Self::Tx) -> Result { - let tx_hash = tx.current_tx_hash(); - StorageCtx::with_current_tx_hash(tx_hash, || { - self.inner.ctx.set_tx(tx); - let mut h = TempoEvmHandler::new(); - h.inspect_run(self) - }) + self.inner.ctx.set_tx(tx); + let mut h = TempoEvmHandler::new(); + h.inspect_run(self) } } @@ -110,13 +100,11 @@ where system_contract_address: Address, data: Bytes, ) -> Result { - StorageCtx::with_current_tx_hash(None, || { - let mut tx = TxEnv::new_system_tx_with_caller(caller, system_contract_address, data); - tx.set_gas_limit(SYSTEM_CALL_GAS_LIMIT); - self.inner.ctx.set_tx(tx.into()); - let mut h = TempoEvmHandler::new(); - h.run_system_call(self) - }) + let mut tx = TxEnv::new_system_tx_with_caller(caller, system_contract_address, data); + tx.set_gas_limit(SYSTEM_CALL_GAS_LIMIT); + self.inner.ctx.set_tx(tx.into()); + let mut h = TempoEvmHandler::new(); + h.run_system_call(self) } } @@ -131,13 +119,11 @@ where system_contract_address: Address, data: Bytes, ) -> Result { - StorageCtx::with_current_tx_hash(None, || { - let mut tx = TxEnv::new_system_tx_with_caller(caller, system_contract_address, data); - tx.set_gas_limit(SYSTEM_CALL_GAS_LIMIT); - self.inner.ctx.set_tx(tx.into()); - let mut h = TempoEvmHandler::new(); - h.inspect_run_system_call(self) - }) + let mut tx = TxEnv::new_system_tx_with_caller(caller, system_contract_address, data); + tx.set_gas_limit(SYSTEM_CALL_GAS_LIMIT); + self.inner.ctx.set_tx(tx.into()); + let mut h = TempoEvmHandler::new(); + h.inspect_run_system_call(self) } } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index e86a1d677e..8d09dfa598 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -50,6 +50,7 @@ use tempo_precompiles::{ storage::{ Handler as _, PrecompileStorageProvider, StorageCtx, evm::EvmPrecompileStorageProvider, }, + tip20_channel_escrow::TIP20ChannelEscrow, tip_fee_manager::TipFeeManager, tip20::{ITIP20::InsufficientBalance, TIP20Error, TIP20Token}, }; @@ -416,6 +417,26 @@ impl TempoEvmHandler { ) .map_err(|e| EVMError::Custom(e.to_string())) } + + fn seed_channel_escrow_tx_hash( + &self, + evm: &mut TempoEvm, + ) -> Result<(), EVMError> { + let ctx = evm.ctx_mut(); + let tx_hash = ctx.tx.tx_hash().unwrap_or_default(); + + StorageCtx::enter_evm( + &mut ctx.journaled_state, + &ctx.block, + &ctx.cfg, + &ctx.tx, + || { + let mut escrow = TIP20ChannelEscrow::new(); + escrow.set_current_tx_hash(tx_hash) + }, + ) + .map_err(|e| EVMError::Custom(e.to_string())) + } } impl TempoEvmHandler @@ -869,6 +890,7 @@ where init_gas: &mut InitialAndFloorGas, ) -> Result<(), Self::Error> { self.seed_tx_origin(evm)?; + self.seed_channel_escrow_tx_hash(evm)?; let block = &evm.inner.ctx.block; let tx = &evm.inner.ctx.tx; diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index 613cc78818..1e30029591 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -13,7 +13,6 @@ use revm::context::{ AccessList, AccessListItem, RecoveredAuthority, RecoveredAuthorization, SignedAuthorization, }, }; -use tempo_precompiles::storage::CurrentTxHash; use tempo_primitives::{ AASigned, TempoSignature, TempoTransaction, TempoTxEnvelope, transaction::{ @@ -273,12 +272,6 @@ impl IntoTxEnv for TempoTxEnv { } } -impl CurrentTxHash for TempoTxEnv { - fn current_tx_hash(&self) -> Option { - self.tx_hash() - } -} - impl FromRecoveredTx> for TempoTxEnv { fn from_recovered_tx(tx: &EthereumTxEnvelope, sender: Address) -> Self { Self { From 7803d44c9d461a2d172deb75cd6f31f4f9b76c85 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 5 May 2026 15:42:32 +0530 Subject: [PATCH 33/33] fix(tip-1034): guard tx-hash channel reopens Amp-Thread-ID: https://ampcode.com/threads/T-019df78c-3898-7678-b748-4e02744f128a --- .../src/precompiles/tip20_channel_escrow.rs | 39 ++--- .../src/tip20_channel_escrow/mod.rs | 163 ++++++++++++------ crates/revm/src/handler.rs | 2 +- tips/tip-1034.md | 103 +++++++---- tips/verify/src/TIP20ChannelEscrow.sol | 52 +++--- .../src/interfaces/ITIP20ChannelEscrow.sol | 22 +-- tips/verify/test/TIP20ChannelEscrow.t.sol | 103 +++-------- 7 files changed, 241 insertions(+), 243 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index e4e930ad6b..d001a2c26a 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -15,6 +15,7 @@ crate::sol! { struct ChannelDescriptor { address payer; address payee; + address operator; address token; bytes32 salt; address authorizedSigner; @@ -24,7 +25,6 @@ crate::sol! { struct ChannelState { uint96 settled; uint96 deposit; - uint32 expiresAt; uint32 closeData; } @@ -38,11 +38,11 @@ crate::sol! { function open( address payee, + address operator, address token, uint96 deposit, bytes32 salt, - address authorizedSigner, - uint32 expiresAt + address authorizedSigner ) external returns (bytes32 channelId); @@ -56,8 +56,7 @@ crate::sol! { function topUp( ChannelDescriptor calldata descriptor, - uint96 additionalDeposit, - uint32 newExpiresAt + uint96 additionalDeposit ) external; @@ -88,6 +87,7 @@ crate::sol! { function computeChannelId( address payer, address payee, + address operator, address token, bytes32 salt, address authorizedSigner, @@ -108,12 +108,12 @@ crate::sol! { bytes32 indexed channelId, address indexed payer, address indexed payee, + address operator, address token, address authorizedSigner, bytes32 salt, bytes32 openTxHash, - uint96 deposit, - uint32 expiresAt + uint96 deposit ); event Settled( @@ -130,8 +130,7 @@ crate::sol! { address indexed payer, address indexed payee, uint96 additionalDeposit, - uint96 newDeposit, - uint32 newExpiresAt + uint96 newDeposit ); event CloseRequested( @@ -155,18 +154,14 @@ crate::sol! { address indexed payee ); - event ChannelExpired(bytes32 indexed channelId, address indexed payer, address indexed payee); - error ChannelAlreadyExists(); error ChannelNotFound(); - error ChannelFinalized(); error NotPayer(); error NotPayee(); + error NotPayeeOrOperator(); error InvalidPayee(); error InvalidToken(); error ZeroDeposit(); - error InvalidExpiry(); - error ChannelExpiredError(); error InvalidSignature(); error AmountExceedsDeposit(); error AmountNotIncreasing(); @@ -186,10 +181,6 @@ impl TIP20ChannelEscrowError { Self::ChannelNotFound(ITIP20ChannelEscrow::ChannelNotFound {}) } - pub const fn channel_finalized() -> Self { - Self::ChannelFinalized(ITIP20ChannelEscrow::ChannelFinalized {}) - } - pub const fn not_payer() -> Self { Self::NotPayer(ITIP20ChannelEscrow::NotPayer {}) } @@ -198,6 +189,10 @@ impl TIP20ChannelEscrowError { Self::NotPayee(ITIP20ChannelEscrow::NotPayee {}) } + pub const fn not_payee_or_operator() -> Self { + Self::NotPayeeOrOperator(ITIP20ChannelEscrow::NotPayeeOrOperator {}) + } + pub const fn invalid_payee() -> Self { Self::InvalidPayee(ITIP20ChannelEscrow::InvalidPayee {}) } @@ -210,14 +205,6 @@ impl TIP20ChannelEscrowError { Self::ZeroDeposit(ITIP20ChannelEscrow::ZeroDeposit {}) } - pub const fn invalid_expiry() -> Self { - Self::InvalidExpiry(ITIP20ChannelEscrow::InvalidExpiry {}) - } - - pub const fn channel_expired() -> Self { - Self::ChannelExpiredError(ITIP20ChannelEscrow::ChannelExpiredError {}) - } - pub const fn invalid_signature() -> Self { Self::InvalidSignature(ITIP20ChannelEscrow::InvalidSignature {}) } diff --git a/crates/precompiles/src/tip20_channel_escrow/mod.rs b/crates/precompiles/src/tip20_channel_escrow/mod.rs index 341303deb7..8d336922a1 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -34,7 +34,6 @@ static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); struct PackedChannelState { settled: U96, deposit: U96, - expires_at: u32, close_data: u32, } @@ -43,7 +42,6 @@ impl From for ITIP20ChannelEscrow::ChannelState { Self { settled: state.settled, deposit: state.deposit, - expiresAt: state.expires_at, closeData: state.close_data, } } @@ -66,8 +64,9 @@ pub struct TIP20ChannelEscrow { // WARNING(rusowsky): transient storage slots must always be placed at the very end until the // `contract` macro is refactored and has 2 independent layouts (persistent and transient). // If new (persistent) storage fields need to be added to the precompile, they must go above - // this one. + // these ones. current_tx_hash: B256, + opened_this_tx: Mapping, } impl TIP20ChannelEscrow { @@ -99,19 +98,17 @@ impl TIP20ChannelEscrow { if deposit.is_zero() { return Err(TIP20ChannelEscrowError::zero_deposit().into()); } - if call.expiresAt as u64 <= self.now() { - return Err(TIP20ChannelEscrowError::invalid_expiry().into()); - } let open_tx_hash = self.current_tx_hash.t_read()?; if open_tx_hash.is_zero() { - return Err( - crate::error::TempoPrecompileError::Fatal("current tx hash unavailable".into()) - ); + return Err(crate::error::TempoPrecompileError::Fatal( + "current tx hash unavailable".into(), + )); } let channel_id = self.compute_channel_id_inner( msg_sender, call.payee, + call.operator, call.token, call.salt, call.authorizedSigner, @@ -120,12 +117,14 @@ impl TIP20ChannelEscrow { if self.channel_states[channel_id].read()?.exists() { return Err(TIP20ChannelEscrowError::channel_already_exists().into()); } + if self.opened_this_tx[channel_id].t_read()? { + return Err(TIP20ChannelEscrowError::channel_already_exists().into()); + } let batch = self.storage.checkpoint(); self.channel_states[channel_id].write(PackedChannelState { settled: U96::ZERO, deposit, - expires_at: call.expiresAt, close_data: 0, })?; TIP20Token::from_address(call.token)?.system_transfer_from( @@ -133,17 +132,18 @@ impl TIP20ChannelEscrow { self.address, U256::from(call.deposit), )?; + self.opened_this_tx[channel_id].t_write(true)?; self.emit_event(TIP20ChannelEscrowEvent::ChannelOpened( ITIP20ChannelEscrow::ChannelOpened { channelId: channel_id, payer: msg_sender, payee: call.payee, + operator: call.operator, token: call.token, authorizedSigner: call.authorizedSigner, salt: call.salt, openTxHash: open_tx_hash, deposit: call.deposit, - expiresAt: call.expiresAt, }, ))?; batch.commit(); @@ -159,11 +159,10 @@ impl TIP20ChannelEscrow { let channel_id = self.channel_id(&call.descriptor)?; let mut state = self.load_existing_state(channel_id)?; - if msg_sender != call.descriptor.payee { - return Err(TIP20ChannelEscrowError::not_payee().into()); - } - if self.is_expired(state.expires_at) { - return Err(TIP20ChannelEscrowError::channel_expired().into()); + if msg_sender != call.descriptor.payee + && (call.descriptor.operator.is_zero() || msg_sender != call.descriptor.operator) + { + return Err(TIP20ChannelEscrowError::not_payee_or_operator().into()); } let cumulative = call.cumulativeAmount; @@ -226,12 +225,6 @@ impl TIP20ChannelEscrow { .checked_add(additional) .ok_or_else(TIP20ChannelEscrowError::deposit_overflow)?; - if call.newExpiresAt != 0 { - if call.newExpiresAt as u64 <= self.now() || call.newExpiresAt <= state.expires_at { - return Err(TIP20ChannelEscrowError::invalid_expiry().into()); - } - } - let had_close_request = state.close_requested_at().is_some(); let batch = self.storage.checkpoint(); @@ -243,9 +236,6 @@ impl TIP20ChannelEscrow { U256::from(call.additionalDeposit), )?; } - if call.newExpiresAt != 0 { - state.expires_at = call.newExpiresAt; - } if had_close_request { state.close_data = 0; } @@ -266,7 +256,6 @@ impl TIP20ChannelEscrow { payee: call.descriptor.payee, additionalDeposit: call.additionalDeposit, newDeposit: state.deposit, - newExpiresAt: state.expires_at, }))?; batch.commit(); @@ -330,9 +319,6 @@ impl TIP20ChannelEscrow { } if capture > previous_settled { - if self.is_expired(state.expires_at) { - return Err(TIP20ChannelEscrowError::channel_expired().into()); - } self.validate_voucher( &call.descriptor, channel_id, @@ -389,7 +375,7 @@ impl TIP20ChannelEscrow { let close_ready = state .close_requested_at() .is_some_and(|requested_at| self.now() >= requested_at as u64 + CLOSE_GRACE_PERIOD); - if !close_ready && !self.is_expired(state.expires_at) { + if !close_ready { return Err(TIP20ChannelEscrowError::close_not_ready().into()); } @@ -407,13 +393,6 @@ impl TIP20ChannelEscrow { U256::from(refund), )?; } - self.emit_event(TIP20ChannelEscrowEvent::ChannelExpired( - ITIP20ChannelEscrow::ChannelExpired { - channelId: channel_id, - payer: call.descriptor.payer, - payee: call.descriptor.payee, - }, - ))?; self.emit_event(TIP20ChannelEscrowEvent::ChannelClosed( ITIP20ChannelEscrow::ChannelClosed { channelId: channel_id, @@ -463,6 +442,7 @@ impl TIP20ChannelEscrow { self.compute_channel_id_inner( call.payer, call.payee, + call.operator, call.token, call.salt, call.authorizedSigner, @@ -489,14 +469,11 @@ impl TIP20ChannelEscrow { self.storage.timestamp().saturating_to::() } - fn is_expired(&self, expires_at: u32) -> bool { - self.now() >= expires_at as u64 - } - fn channel_id(&self, descriptor: &ITIP20ChannelEscrow::ChannelDescriptor) -> Result { self.compute_channel_id_inner( descriptor.payer, descriptor.payee, + descriptor.operator, descriptor.token, descriptor.salt, descriptor.authorizedSigner, @@ -508,6 +485,7 @@ impl TIP20ChannelEscrow { &self, payer: Address, payee: Address, + operator: Address, token: Address, salt: B256, authorized_signer: Address, @@ -517,6 +495,7 @@ impl TIP20ChannelEscrow { &( payer, payee, + operator, token, salt, authorized_signer, @@ -621,6 +600,7 @@ mod tests { ITIP20ChannelEscrow::ChannelDescriptor { payer, payee, + operator: Address::ZERO, token, salt, authorizedSigner: authorized_signer, @@ -663,17 +643,16 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; escrow.set_current_tx_hash(open_tx_hash)?; - let now = StorageCtx::default().timestamp().to::(); let channel_id = escrow.open( payer, ITIP20ChannelEscrow::openCall { payee, + operator: Address::ZERO, token: token.address(), deposit: abi_u96(300), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 1_000, }, )?; @@ -717,16 +696,32 @@ mod tests { assert_eq!(state.deposit, 0); assert_eq!(state.settled, 0); + let same_tx_reopen = escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator: Address::ZERO, + token: token.address(), + deposit: abi_u96(1), + salt, + authorizedSigner: Address::ZERO, + }, + ); + assert_eq!( + same_tx_reopen.unwrap_err(), + TIP20ChannelEscrowError::channel_already_exists().into() + ); + escrow.set_current_tx_hash(reopen_tx_hash)?; let reopened_channel_id = escrow.open( payer, ITIP20ChannelEscrow::openCall { payee, + operator: Address::ZERO, token: token.address(), deposit: abi_u96(1), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 2_000, }, )?; assert_ne!(reopened_channel_id, channel_id); @@ -754,11 +749,11 @@ mod tests { payer, ITIP20ChannelEscrow::openCall { payee, + operator: Address::ZERO, token: token.address(), deposit: abi_u96(1), salt: B256::random(), authorizedSigner: Address::ZERO, - expiresAt: StorageCtx::default().timestamp().to::() + 1_000, }, ) .unwrap_err(); @@ -771,6 +766,71 @@ mod tests { }) } + #[test] + fn test_operator_can_settle_to_payee() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let payee = Address::random(); + let operator = Address::random(); + let salt = B256::random(); + let open_tx_hash = B256::repeat_byte(0x77); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(1_000u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + escrow.set_current_tx_hash(open_tx_hash)?; + + let channel_id = escrow.open( + payer, + ITIP20ChannelEscrow::openCall { + payee, + operator, + token: token.address(), + deposit: abi_u96(100), + salt, + authorizedSigner: Address::ZERO, + }, + )?; + let digest = escrow.get_voucher_digest(ITIP20ChannelEscrow::getVoucherDigestCall { + channelId: channel_id, + cumulativeAmount: abi_u96(40), + })?; + let signature = + Bytes::copy_from_slice(&payer_signer.sign_hash_sync(&digest)?.as_bytes()); + + let mut channel_descriptor = descriptor( + payer, + payee, + token.address(), + salt, + Address::ZERO, + open_tx_hash, + ); + channel_descriptor.operator = operator; + escrow.settle( + operator, + ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor, + cumulativeAmount: abi_u96(40), + signature, + }, + )?; + + assert_eq!( + token.balance_of(tempo_contracts::precompiles::ITIP20::balanceOfCall { + account: payee, + })?, + U256::from(40) + ); + Ok(()) + }) + } + #[test] fn test_top_up_cancels_close_request() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); @@ -788,7 +848,6 @@ mod tests { escrow.initialize()?; escrow.set_current_tx_hash(open_tx_hash)?; - let expires_at = StorageCtx::default().timestamp().to::() + 1_000; let descriptor = descriptor( payer, payee, @@ -801,11 +860,11 @@ mod tests { payer, ITIP20ChannelEscrow::openCall { payee, + operator: Address::ZERO, token: token.address(), deposit: abi_u96(100), salt, authorizedSigner: Address::ZERO, - expiresAt: expires_at, }, )?; @@ -820,14 +879,12 @@ mod tests { ITIP20ChannelEscrow::topUpCall { descriptor: descriptor.clone(), additionalDeposit: abi_u96(25), - newExpiresAt: expires_at + 500, }, )?; let channel = escrow.get_channel(ITIP20ChannelEscrow::getChannelCall { descriptor })?; assert_eq!(channel.state.closeData, 0); assert_eq!(channel.state.deposit, 125); - assert_eq!(channel.state.expiresAt, expires_at + 500); Ok(()) }) @@ -842,11 +899,11 @@ mod tests { let result = escrow.call( &ITIP20ChannelEscrow::openCall { payee: Address::random(), + operator: Address::ZERO, token: TIP20_CHANNEL_ESCROW_ADDRESS, deposit: abi_u96(1), salt: B256::ZERO, authorizedSigner: Address::ZERO, - expiresAt: 2, } .abi_encode(), Address::ZERO, @@ -872,16 +929,15 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; escrow.set_current_tx_hash(open_tx_hash)?; - let now = StorageCtx::default().timestamp().to::(); escrow.open( payer, ITIP20ChannelEscrow::openCall { payee, + operator: Address::ZERO, token: token.address(), deposit: abi_u96(100), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 1_000, }, )?; @@ -926,16 +982,15 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; escrow.set_current_tx_hash(open_tx_hash)?; - let now = StorageCtx::default().timestamp().to::(); escrow.open( payer, ITIP20ChannelEscrow::openCall { payee, + operator: Address::ZERO, token: token.address(), deposit: abi_u96(100), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 1_000, }, )?; diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 8d09dfa598..3fcbe4607a 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -50,9 +50,9 @@ use tempo_precompiles::{ storage::{ Handler as _, PrecompileStorageProvider, StorageCtx, evm::EvmPrecompileStorageProvider, }, - tip20_channel_escrow::TIP20ChannelEscrow, tip_fee_manager::TipFeeManager, tip20::{ITIP20::InsufficientBalance, TIP20Error, TIP20Token}, + tip20_channel_escrow::TIP20ChannelEscrow, }; use tempo_primitives::{ TempoAddressExt, diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 9886b2b082..9ef9b42cf2 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -2,7 +2,7 @@ id: TIP-1034 title: TIP-20 Channel Escrow Precompile description: Enshrines TIP-20 channel escrow as a Tempo precompile with payment-lane admission and native escrow transfer semantics. -authors: Tanishk Goyal +authors: Tanishk Goyal, Brendan Ryan status: Draft related: TIP-20, TIP-1000, TIP-1020, Tempo Session, Tempo Charge protocolVersion: TBD @@ -12,7 +12,7 @@ protocolVersion: TBD ## Abstract -This TIP enshrines TIP-20 channel escrow in Tempo as a native precompile. The implementation follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow, with expiry and explicit partial-capture updates defined in this spec. +This TIP enshrines TIP-20 channel escrow in Tempo as a native precompile. The implementation follows the existing reference channel model (`open`, `settle`, `topUp`, `requestClose`, `close`, `withdraw`) and keeps the same EIP-712 voucher flow, with explicit partial-capture updates defined in this spec. The precompile is introduced to reduce execution overhead, remove the separate `approve` UX via native escrow movement, and make channel operations first-class payment-lane traffic under congestion. @@ -57,6 +57,7 @@ Channels are unidirectional (`payer -> payee`) and token-specific. struct ChannelDescriptor { address payer; address payee; + address operator; address token; bytes32 salt; address authorizedSigner; @@ -66,7 +67,6 @@ struct ChannelDescriptor { struct ChannelState { uint96 settled; uint96 deposit; - uint32 expiresAt; uint32 closeData; } @@ -86,17 +86,17 @@ The packed slot layout is: ```text bits 0..95 settled uint96 bits 96..191 deposit uint96 -bits 192..223 expiresAt uint32 -bits 224..255 closeData uint32 +bits 192..223 closeData uint32 +bits 224..255 reserved zero ``` These widths are chosen to keep the entire mutable channel state in one storage slot without introducing any practical limit for production usage. `uint96` supports up to `2^96 - 1 = 79,228,162,514,264,337,593,543,950,335` base units, which is far above the supply or escrow size of any realistic TIP-20 token deployment. `uint32` stores second-resolution unix -timestamps through February 2106, which is sufficient for channel expiry and close-grace tracking -because channels are expected to live for minutes, hours, days, or months rather than many -decades. +timestamps through February 2106, which is sufficient for close-request tracking because channels +are expected to live for minutes, hours, days, or months rather than many decades. The reserved +high 32 bits MUST remain zero. `closeData` MUST be encoded as: @@ -113,6 +113,7 @@ channelId = keccak256( abi.encode( payer, payee, + operator, token, salt, authorizedSigner, @@ -154,28 +155,32 @@ This means: 3. TIP-1020 keychain wrapper signatures (`0x03` / `0x04`) MUST be rejected for direct voucher verification. 4. Delegated voucher signing MUST use `authorizedSigner`, rather than a keychain wrapper around `payer`. -Execution semantics use exact timestamp boundaries. Future-required timestamps MUST use the -strict predicate `timestamp > block.timestamp`, and expiry checks MUST use the strict predicate -`block.timestamp >= expiresAt`. Implementations MUST NOT substitute different operators. +Execution semantics use exact timestamp boundaries. Close-grace completion MUST use the strict +predicate `block.timestamp >= closeRequestedAt + CLOSE_GRACE_PERIOD`. Implementations MUST NOT +substitute a different comparison predicate. + +`operator` is an immutable settlement authority for the channel. `address(0)` means the payee is +the only settlement operator. A nonzero `operator` MAY submit `settle` on the payee's behalf, but +all settlement payouts still transfer to `payee`. Execution semantics are: -1. `open` MUST reject zero deposit, invalid token address, invalid payee address, and any `expiresAt` value where `expiresAt <= block.timestamp`. +1. `open` MUST reject zero deposit, invalid token address, and invalid payee address. 2. `open` MUST derive `openTxHash` from the enclosing transaction hash, persist only the packed `ChannelState` slot, and MUST emit the full immutable descriptor in `ChannelOpened`. 3. Post-open methods (`settle`, `topUp`, `close`, `requestClose`, `withdraw`, and descriptor-based views) MUST recompute `channelId` from the supplied descriptor and use that derived id for storage lookup. -4. `topUp` MAY extend expiry using `newExpiresAt`; when non-zero, it MUST satisfy both `newExpiresAt > block.timestamp` and `newExpiresAt > current expiresAt`. -5. If `closeData != 0`, a successful `topUp` MUST clear it back to `0` and emit `CloseRequestCancelled`. -6. `requestClose` MUST set `closeData = uint32(block.timestamp)` on the first successful call and leave it unchanged on later successful calls. -7. `settle` MUST reject when `block.timestamp >= expiresAt`. -8. `close` MUST reject when `block.timestamp >= expiresAt` and `captureAmount > previousSettled`. -9. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. -10. Signer MUST be `authorizedSigner` from the supplied descriptor when set, otherwise `payer` from the supplied descriptor. -11. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`. -12. `close` MUST reject when `captureAmount > deposit`, even if `cumulativeAmount > deposit`. -13. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. -14. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. -15. `withdraw` MUST be allowed when either the close grace period has elapsed from `closeData` or `block.timestamp >= expiresAt`. -16. Terminal `close` and `withdraw` MUST delete the stored slot entirely. Reopening the same logical channel in a later transaction MUST produce a different `channelId` because `openTxHash` changes. +4. If `closeData != 0`, a successful `topUp` MUST clear it back to `0` and emit `CloseRequestCancelled`. +5. `requestClose` MUST set `closeData = uint32(block.timestamp)` on the first successful call and leave it unchanged on later successful calls. +6. `settle` MUST be callable only by `payee`, or by `operator` when `operator != address(0)`. +7. `close` MUST be callable only by `payee`. +8. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. +9. Signer MUST be `authorizedSigner` from the supplied descriptor when set, otherwise `payer` from the supplied descriptor. +10. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`. +11. `close` MUST reject when `captureAmount > deposit`, even if `cumulativeAmount > deposit`. +12. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. +13. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. +14. `withdraw` MUST be allowed only when the close grace period has elapsed from `closeData`. +15. Terminal `close` and `withdraw` MUST delete the stored slot entirely. Reopening the same logical channel in a later transaction MUST produce a different `channelId` because `openTxHash` changes. +16. Within one top-level transaction, `open` MUST reject any `channelId` that was already opened earlier in that same transaction, even if the channel was terminally closed or withdrawn before the later `open` call. ## Native Escrow Movement @@ -187,6 +192,29 @@ Required behavior: 2. `topUp` escrows `additionalDeposit` the same way. 3. `settle`, `close`, and `withdraw` payout paths continue to transfer TIP-20 value using protocol-native token movement. +### No Separate Emergency Close + +This TIP does not define a separate `emergencyClose` entrypoint for ordinary recipient-specific +`close` failures. + +`close` is the only channel operation that atomically performs both outbound payout legs in one +call: `escrow -> payee` and `escrow -> payer`. If that combined path fails only because one +recipient leg cannot receive funds under the token's current transfer policy, the unaffected party +already has a unilateral single-recipient fallback: + +1. If the payer-side refund leg makes `close` unusable, the payee can continue using `settle` + subject to the normal `settle` bounds. +2. If the payee-side payout leg makes `close` unusable, the payer can recover the remaining escrow + via `requestClose` + `withdraw`. + +This means a recipient-specific `close` failure does not create a new bilateral hostage condition, +so a dedicated `emergencyClose` is not required for that case. + +This reasoning is limited to recipient-specific `close` failures. It does not change the general +requirement that `settle`, `close`, and `withdraw` all rely on protocol-native TIP-20 payout +transfers. If the token's pause state or transfer policy later prevents the escrow address from +sending funds at all, all outbound exit paths can fail. + ## Payment-Lane Integration (Mandatory) Channel escrow operations MUST be treated as payment-lane transactions in consensus classification, pool admission, and payload building. @@ -232,18 +260,19 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 2. `settled` is monotonic and can never decrease. 3. Any successful capture (`settle` or `close`) MUST be authorized by a valid voucher signature from the expected signer. 4. Only payer can `topUp`, `requestClose`, and `withdraw`. -5. Only payee can `settle` and `close`. -6. A channel MUST consume exactly one storage slot of mutable on-chain state. -7. `closeData == 0` MUST mean active with no close request. -8. `closeData != 0` MUST mean active with a close request timestamp equal to `closeData`. -9. Closed channels MUST have no remaining mutable on-chain state. -10. Reopening the same logical channel in a later transaction MUST yield a different `channelId` because `openTxHash` is different. -11. Fund conservation MUST hold at all terminal states. -12. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. -13. `open` and `topUp` MUST not require a prior user `approve` transaction. -14. No capture-increasing operation may succeed past `expiresAt`. -15. `topUp` MUST NOT reduce or preserve the channel expiry when `newExpiresAt` is provided. -16. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. +5. Only payee, or a nonzero operator, can `settle`. +6. Only payee can `close`. +7. A channel MUST consume exactly one storage slot of mutable on-chain state. +8. `closeData == 0` MUST mean active with no close request. +9. `closeData != 0` MUST mean active with a close request timestamp equal to `closeData`. +10. Closed channels MUST have no remaining mutable on-chain state. +11. Reopening the same logical channel in a later transaction MUST yield a different `channelId` because `openTxHash` is different. +12. Reopening the same `channelId` within one top-level transaction MUST be impossible. +13. Fund conservation MUST hold at all terminal states. +14. Channel escrow calls MUST be classified as payment transactions in both consensus and strict builder/pool classifiers, AA payment classification MUST require `calls.length > 0`, and transactions with authorization side effects MUST be classified as non-payment. +15. `open` and `topUp` MUST not require a prior user `approve` transaction. +16. `withdraw` MUST require an active close request whose grace period has elapsed. +17. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`, and `captureAmount <= deposit`. ## References diff --git a/tips/verify/src/TIP20ChannelEscrow.sol b/tips/verify/src/TIP20ChannelEscrow.sol index b1a387b853..46fed6068f 100644 --- a/tips/verify/src/TIP20ChannelEscrow.sol +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -16,8 +16,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { uint64 public constant CLOSE_GRACE_PERIOD = 15 minutes; uint256 internal constant _DEPOSIT_OFFSET = 96; - uint256 internal constant _EXPIRES_AT_OFFSET = 192; - uint256 internal constant _CLOSE_DATA_OFFSET = 224; + uint256 internal constant _CLOSE_DATA_OFFSET = 192; bytes32 internal constant _EIP712_DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); @@ -26,6 +25,10 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { mapping(bytes32 => uint256) internal channelStates; bytes32 internal _openTxHashContext; + // Reference-contract-only approximation of the precompile's transient per-transaction guard. + // Because `channelId` includes `openTxHash`, this does not block real cross-transaction + // reopens, which always use a different transaction hash. + mapping(bytes32 => bool) internal _openedChannelIdsForTest; error OpenTxHashNotSet(); @@ -36,38 +39,40 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { function open( address payee, + address operator, address token, uint96 deposit, bytes32 salt, - address authorizedSigner, - uint32 expiresAt + address authorizedSigner ) external returns (bytes32 channelId) { if (payee == address(0)) revert InvalidPayee(); if (token == address(0)) revert InvalidToken(); if (deposit == 0) revert ZeroDeposit(); - if (expiresAt <= block.timestamp) revert InvalidExpiry(); bytes32 openTxHash = _consumeOpenTxHash(); - channelId = computeChannelId(msg.sender, payee, token, salt, authorizedSigner, openTxHash); + channelId = computeChannelId(msg.sender, payee, operator, token, salt, authorizedSigner, openTxHash); if (channelStates[channelId] != 0) revert ChannelAlreadyExists(); + if (_openedChannelIdsForTest[channelId]) revert ChannelAlreadyExists(); channelStates[channelId] = - _encodeChannelState(ChannelState({settled: 0, deposit: deposit, expiresAt: expiresAt, closeData: 0})); + _encodeChannelState(ChannelState({settled: 0, deposit: deposit, closeData: 0})); // The reference contract keeps ERC-20-style allowance flow for local verification. // The enshrined precompile should use TIP-20 `systemTransferFrom` semantics instead. bool success = ITIP20(token).transferFrom(msg.sender, address(this), deposit); if (!success) revert TransferFailed(); + _openedChannelIdsForTest[channelId] = true; - emit ChannelOpened(channelId, msg.sender, payee, token, authorizedSigner, salt, openTxHash, deposit, expiresAt); + emit ChannelOpened(channelId, msg.sender, payee, operator, token, authorizedSigner, salt, openTxHash, deposit); } function settle(ChannelDescriptor calldata descriptor, uint96 cumulativeAmount, bytes calldata signature) external { bytes32 channelId = _channelId(descriptor); ChannelState memory channel = _loadChannelState(channelId); - if (msg.sender != descriptor.payee) revert NotPayee(); - if (_isExpired(channel.expiresAt)) revert ChannelExpiredError(); + if (msg.sender != descriptor.payee && (descriptor.operator == address(0) || msg.sender != descriptor.operator)) { + revert NotPayeeOrOperator(); + } if (cumulativeAmount > channel.deposit) revert AmountExceedsDeposit(); if (cumulativeAmount <= channel.settled) revert AmountNotIncreasing(); @@ -83,17 +88,13 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { emit Settled(channelId, descriptor.payer, descriptor.payee, cumulativeAmount, delta, channel.settled); } - function topUp(ChannelDescriptor calldata descriptor, uint96 additionalDeposit, uint32 newExpiresAt) external { + function topUp(ChannelDescriptor calldata descriptor, uint96 additionalDeposit) external { bytes32 channelId = _channelId(descriptor); ChannelState memory channel = _loadChannelState(channelId); if (msg.sender != descriptor.payer) revert NotPayer(); if (additionalDeposit > type(uint96).max - channel.deposit) revert DepositOverflow(); - if (newExpiresAt != 0) { - if (newExpiresAt <= block.timestamp) revert InvalidExpiry(); - if (newExpiresAt <= channel.expiresAt) revert InvalidExpiry(); - } if (additionalDeposit > 0) { channel.deposit += additionalDeposit; @@ -104,10 +105,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (!success) revert TransferFailed(); } - if (newExpiresAt != 0) { - channel.expiresAt = newExpiresAt; - } - if (channel.closeData != 0) { channel.closeData = 0; emit CloseRequestCancelled(channelId, descriptor.payer, descriptor.payee); @@ -115,7 +112,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { channelStates[channelId] = _encodeChannelState(channel); - emit TopUp(channelId, descriptor.payer, descriptor.payee, additionalDeposit, channel.deposit, channel.expiresAt); + emit TopUp(channelId, descriptor.payer, descriptor.payee, additionalDeposit, channel.deposit); } function requestClose(ChannelDescriptor calldata descriptor) external { @@ -151,7 +148,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (captureAmount > channel.deposit) revert AmountExceedsDeposit(); if (captureAmount > previousSettled) { - if (_isExpired(channel.expiresAt)) revert ChannelExpiredError(); _validateVoucher(descriptor, channelId, cumulativeAmount, signature); } @@ -183,7 +179,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { bool closeGracePassed = closeRequestedAt != 0 && block.timestamp >= uint256(closeRequestedAt) + CLOSE_GRACE_PERIOD; - if (!closeGracePassed && !_isExpired(channel.expiresAt)) revert CloseNotReady(); + if (!closeGracePassed) revert CloseNotReady(); uint96 refund = channel.deposit - channel.settled; delete channelStates[channelId]; @@ -193,7 +189,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (!success) revert TransferFailed(); } - emit ChannelExpired(channelId, descriptor.payer, descriptor.payee); emit ChannelClosed(channelId, descriptor.payer, descriptor.payee, channel.settled, refund); } @@ -201,6 +196,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { channel.descriptor = ChannelDescriptor({ payer: descriptor.payer, payee: descriptor.payee, + operator: descriptor.operator, token: descriptor.token, salt: descriptor.salt, authorizedSigner: descriptor.authorizedSigner, @@ -225,13 +221,14 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { function computeChannelId( address payer, address payee, + address operator, address token, bytes32 salt, address authorizedSigner, bytes32 openTxHash ) public view returns (bytes32) { return keccak256( - abi.encode(payer, payee, token, salt, authorizedSigner, openTxHash, TIP20_CHANNEL_ESCROW, block.chainid) + abi.encode(payer, payee, operator, token, salt, authorizedSigner, openTxHash, TIP20_CHANNEL_ESCROW, block.chainid) ); } @@ -248,6 +245,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { return computeChannelId( descriptor.payer, descriptor.payee, + descriptor.operator, descriptor.token, descriptor.salt, descriptor.authorizedSigner, @@ -268,14 +266,12 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { state.settled = uint96(packedState); state.deposit = uint96(packedState >> _DEPOSIT_OFFSET); - state.expiresAt = uint32(packedState >> _EXPIRES_AT_OFFSET); state.closeData = uint32(packedState >> _CLOSE_DATA_OFFSET); } function _encodeChannelState(ChannelState memory state) internal pure returns (uint256 packedState) { packedState = uint256(state.settled); packedState |= uint256(state.deposit) << _DEPOSIT_OFFSET; - packedState |= uint256(state.expiresAt) << _EXPIRES_AT_OFFSET; packedState |= uint256(state.closeData) << _CLOSE_DATA_OFFSET; } @@ -283,10 +279,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { return closeData; } - function _isExpired(uint32 expiresAt) internal view returns (bool) { - return block.timestamp >= expiresAt; - } - function _validateVoucher( ChannelDescriptor calldata descriptor, bytes32 channelId, diff --git a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol index 61ba32e135..45bbe64b5b 100644 --- a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol +++ b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol @@ -7,6 +7,7 @@ interface ITIP20ChannelEscrow { struct ChannelDescriptor { address payer; address payee; + address operator; address token; bytes32 salt; address authorizedSigner; @@ -16,7 +17,6 @@ interface ITIP20ChannelEscrow { struct ChannelState { uint96 settled; uint96 deposit; - uint32 expiresAt; uint32 closeData; } @@ -30,16 +30,16 @@ interface ITIP20ChannelEscrow { function open( address payee, + address operator, address token, uint96 deposit, bytes32 salt, - address authorizedSigner, - uint32 expiresAt + address authorizedSigner ) external returns (bytes32 channelId); function settle(ChannelDescriptor calldata descriptor, uint96 cumulativeAmount, bytes calldata signature) external; - function topUp(ChannelDescriptor calldata descriptor, uint96 additionalDeposit, uint32 newExpiresAt) external; + function topUp(ChannelDescriptor calldata descriptor, uint96 additionalDeposit) external; function close( ChannelDescriptor calldata descriptor, @@ -61,6 +61,7 @@ interface ITIP20ChannelEscrow { function computeChannelId( address payer, address payee, + address operator, address token, bytes32 salt, address authorizedSigner, @@ -75,12 +76,12 @@ interface ITIP20ChannelEscrow { bytes32 indexed channelId, address indexed payer, address indexed payee, + address operator, address token, address authorizedSigner, bytes32 salt, bytes32 openTxHash, - uint96 deposit, - uint32 expiresAt + uint96 deposit ); event Settled( @@ -97,8 +98,7 @@ interface ITIP20ChannelEscrow { address indexed payer, address indexed payee, uint96 additionalDeposit, - uint96 newDeposit, - uint32 newExpiresAt + uint96 newDeposit ); event CloseRequested( @@ -115,18 +115,14 @@ interface ITIP20ChannelEscrow { event CloseRequestCancelled(bytes32 indexed channelId, address indexed payer, address indexed payee); - event ChannelExpired(bytes32 indexed channelId, address indexed payer, address indexed payee); - error ChannelAlreadyExists(); error ChannelNotFound(); - error ChannelFinalized(); error NotPayer(); error NotPayee(); + error NotPayeeOrOperator(); error InvalidPayee(); error InvalidToken(); error ZeroDeposit(); - error InvalidExpiry(); - error ChannelExpiredError(); error InvalidSignature(); error AmountExceedsDeposit(); error AmountNotIncreasing(); diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol index 58eadee54c..265bc56536 100644 --- a/tips/verify/test/TIP20ChannelEscrow.t.sol +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -72,20 +72,10 @@ contract TIP20ChannelEscrowTest is BaseTest { token.approve(address(channel), type(uint256).max); } - function _defaultExpiry() internal view returns (uint32) { - return uint32(block.timestamp + 1 days); - } - function _openChannel() internal returns (bytes32) { _prepareNextOpenTxHash(); vm.prank(payer); - return channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); - } - - function _openChannelWithExpiry(uint32 expiresAt) internal returns (bytes32) { - _prepareNextOpenTxHash(); - vm.prank(payer); - return channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); + return channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); } function _descriptor() internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { @@ -108,6 +98,7 @@ contract TIP20ChannelEscrowTest is BaseTest { return ITIP20ChannelEscrow.ChannelDescriptor({ payer: payer, payee: payee, + operator: address(0), token: address(token), salt: salt, authorizedSigner: authorizedSigner, @@ -136,21 +127,20 @@ contract TIP20ChannelEscrowTest is BaseTest { } function test_open_success() public { - uint32 expiresAt = _defaultExpiry(); bytes32 openTxHash = _prepareNextOpenTxHash(); vm.prank(payer); - bytes32 channelId = channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); + bytes32 channelId = channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); ITIP20ChannelEscrow.Channel memory ch = channel.getChannel(_descriptor()); assertEq(ch.descriptor.payer, payer); assertEq(ch.descriptor.payee, payee); + assertEq(ch.descriptor.operator, address(0)); assertEq(ch.descriptor.token, address(token)); assertEq(ch.descriptor.authorizedSigner, address(0)); assertEq(ch.descriptor.openTxHash, openTxHash); assertEq(ch.state.settled, 0); assertEq(ch.state.deposit, DEPOSIT); - assertEq(ch.state.expiresAt, expiresAt); assertEq(ch.state.closeData, 0); assertEq(channel.getChannelState(channelId).deposit, DEPOSIT); } @@ -159,28 +149,21 @@ contract TIP20ChannelEscrowTest is BaseTest { _prepareNextOpenTxHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidPayee.selector); - channel.open(address(0), address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); + channel.open(address(0), address(0), address(token), DEPOSIT, SALT, address(0)); } function test_open_revert_zeroToken() public { _prepareNextOpenTxHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidToken.selector); - channel.open(payee, address(0), DEPOSIT, SALT, address(0), _defaultExpiry()); + channel.open(payee, address(0), address(0), DEPOSIT, SALT, address(0)); } function test_open_revert_zeroDeposit() public { _prepareNextOpenTxHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ZeroDeposit.selector); - channel.open(payee, address(token), 0, SALT, address(0), _defaultExpiry()); - } - - function test_open_revert_invalidExpiry() public { - _prepareNextOpenTxHash(); - vm.prank(payer); - vm.expectRevert(ITIP20ChannelEscrow.InvalidExpiry.selector); - channel.open(payee, address(token), DEPOSIT, SALT, address(0), uint32(block.timestamp)); + channel.open(payee, address(0), address(token), 0, SALT, address(0)); } function test_open_same_descriptor_uses_distinct_open_tx_hashes() public { @@ -205,16 +188,6 @@ contract TIP20ChannelEscrowTest is BaseTest { assertEq(token.balanceOf(payee), 500_000); } - function test_settle_revert_afterExpiry() public { - bytes32 channelId = _openChannelWithExpiry(uint32(block.timestamp + 10)); - bytes memory sig = _signVoucher(channelId, 500_000); - - vm.warp(block.timestamp + 10); - vm.prank(payee); - vm.expectRevert(ITIP20ChannelEscrow.ChannelExpiredError.selector); - channel.settle(_descriptor(), 500_000, sig); - } - function test_settle_revert_invalidSignature() public { bytes32 channelId = _openChannel(); (, uint256 wrongKey) = makeAddrAndKey("wrong"); @@ -239,7 +212,7 @@ contract TIP20ChannelEscrowTest is BaseTest { _prepareNextOpenTxHash(); vm.prank(payer); - bytes32 channelId = channel.open(payee, address(token), DEPOSIT, SALT, delegateSigner, _defaultExpiry()); + bytes32 channelId = channel.open(payee, address(0), address(token), DEPOSIT, SALT, delegateSigner); bytes memory sig = _signVoucher(channelId, 500_000, delegateKey); @@ -249,24 +222,14 @@ contract TIP20ChannelEscrowTest is BaseTest { assertEq(channel.getChannelState(channelId).settled, 500_000); } - function test_topUp_updatesDepositAndExpiry() public { + function test_topUp_updatesDeposit() public { bytes32 channelId = _openChannel(); - uint32 nextExpiry = uint32(block.timestamp + 2 days); vm.prank(payer); - channel.topUp(_descriptor(), 250_000, nextExpiry); + channel.topUp(_descriptor(), 250_000); ITIP20ChannelEscrow.ChannelState memory ch = channel.getChannelState(channelId); assertEq(ch.deposit, DEPOSIT + 250_000); - assertEq(ch.expiresAt, nextExpiry); - } - - function test_topUp_revert_nonIncreasingExpiry() public { - _openChannel(); - - vm.prank(payer); - vm.expectRevert(ITIP20ChannelEscrow.InvalidExpiry.selector); - channel.topUp(_descriptor(), 0, _defaultExpiry()); } function test_topUp_cancelsCloseRequest() public { @@ -276,7 +239,7 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.requestClose(_descriptor()); vm.prank(payer); - channel.topUp(_descriptor(), 100_000, 0); + channel.topUp(_descriptor(), 100_000); assertEq(channel.getChannelState(channelId).closeData, 0); } @@ -292,7 +255,7 @@ contract TIP20ChannelEscrowTest is BaseTest { assertEq(ch.closeData, closeRequestedAt); uint256 raw = uint256(vm.load(address(channel), _channelStateSlot(channelId))); - assertEq(uint32(raw >> 224), closeRequestedAt); + assertEq(uint32(raw >> 192), closeRequestedAt); } function test_close_partialCapture_success() public { @@ -356,32 +319,21 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.close(_descriptor(), 300_000, 200_000, ""); } - function test_close_afterExpiry_allowsNoAdditionalCapture() public { - bytes32 channelId = _openChannelWithExpiry(uint32(block.timestamp + 10)); - bytes memory sig = _signVoucher(channelId, 300_000); - - vm.prank(payee); - channel.settle(_descriptor(), 300_000, sig); - - vm.warp(block.timestamp + 10); - - uint256 payerBalanceBefore = token.balanceOf(payer); - vm.prank(payee); - channel.close(_descriptor(), 300_000, 300_000, ""); - - assertEq(channel.getChannelState(channelId).closeData, 0); - assertEq(token.balanceOf(payer), payerBalanceBefore + (DEPOSIT - 300_000)); - } - function test_close_clears_state_and_allows_reopen_with_new_open_tx_hash() public { bytes32 channelId = _openChannel(); bytes memory sig = _signVoucher(channelId, 600_000); + bytes32 originalOpenTxHash = lastOpenTxHash; vm.prank(payee); channel.close(_descriptor(), 600_000, 600_000, sig); assertEq(channel.getChannelState(channelId).closeData, 0); + channel.setOpenTxHashForTest(originalOpenTxHash); + vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); + channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); + bytes32 reopenedChannelId = _openChannel(); assertNotEq(reopenedChannelId, channelId); } @@ -402,19 +354,6 @@ contract TIP20ChannelEscrowTest is BaseTest { assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); } - function test_withdraw_afterExpiryWithoutCloseRequest() public { - bytes32 channelId = _openChannelWithExpiry(uint32(block.timestamp + 10)); - - vm.warp(block.timestamp + 10); - - uint256 payerBalanceBefore = token.balanceOf(payer); - vm.prank(payer); - channel.withdraw(_descriptor()); - - assertEq(channel.getChannelState(channelId).closeData, 0); - assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); - } - function test_getChannelStatesBatch_success() public { bytes32 channelId1 = _openChannel(); bytes32 channel1OpenTxHash = lastOpenTxHash; @@ -422,7 +361,7 @@ contract TIP20ChannelEscrowTest is BaseTest { _prepareNextOpenTxHash(); vm.prank(payer); bytes32 channelId2 = - channel.open(payee, address(token), DEPOSIT, bytes32(uint256(2)), address(0), _defaultExpiry()); + channel.open(payee, address(0), address(token), DEPOSIT, bytes32(uint256(2)), address(0)); bytes memory sig = _signVoucher(channelId1, 400_000); vm.prank(payee); @@ -442,8 +381,8 @@ contract TIP20ChannelEscrowTest is BaseTest { TIP20ChannelEscrow other = new TIP20ChannelEscrow(); bytes32 openTxHash = keccak256("openTxHash"); - bytes32 id1 = channel.computeChannelId(payer, payee, address(token), SALT, address(0), openTxHash); - bytes32 id2 = other.computeChannelId(payer, payee, address(token), SALT, address(0), openTxHash); + bytes32 id1 = channel.computeChannelId(payer, payee, address(0), address(token), SALT, address(0), openTxHash); + bytes32 id2 = other.computeChannelId(payer, payee, address(0), address(token), SALT, address(0), openTxHash); assertEq(id1, id2); assertEq(channel.domainSeparator(), other.domainSeparator());