From 0df58be14d2d1e5d35606792e46f99e1bcefcd37 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 8 Apr 2026 09:17:00 +0200 Subject: [PATCH 01/68] docs(tip-1045): payment lane classification --- tips/tip-1045.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tips/tip-1045.md diff --git a/tips/tip-1045.md b/tips/tip-1045.md new file mode 100644 index 0000000000..2228cbc822 --- /dev/null +++ b/tips/tip-1045.md @@ -0,0 +1,78 @@ +--- +id: TIP-1045 +title: Payment Transaction Classification +description: Defines consensus rules for classifying transactions as payment lane eligible, including address prefix and calldata selector checks. +authors: @0xrusowsky, Dan Robinson @danrobinson, Dankrad Feist @dankrad +status: Draft +related: TIP-1010, TIP-1000, Payment Lane Specification +protocolVersion: T5 +--- + +# TIP-1045: Payment Transaction Classification + +## Abstract + +This TIP formalizes the consensus rules for classifying a transaction as payment lane eligible under TIP-1010. Starting with the T2 hardfork, it introduces a mandatory calldata selector allowlist and requires that transactions carry no side-effect fields (authorization lists, key authorizations) to qualify as payments. + +## Motivation + +TIP-1010 defines the payment lane gas budget but leaves the classification criteria underspecified — only requiring that the transaction targets a TIP-20 address. This TIP tightens the classification at the consensus level in T2 by additionally requiring recognized payment calldata and the absence of any side-effect fields. + +--- + +# Specification + +A transaction qualifies for the payment lane when **every** call it contains targets a TIP-20 token address AND carries recognized payment calldata, AND the transaction carries no side-effect fields that mutate non-payment state. + +## Address Check + +The `to` address must start with the 12-byte TIP-20 token prefix `0x20C000000000000000000000`. Contract-creation transactions (`to = null`) never qualify. + +## Calldata Check (T5+) + +Starting with the **T5 hardfork**, the transaction's calldata must match one of the recognized TIP-20 selectors with the exact expected ABI-encoded length (selector + parameters, no trailing bytes): + +| Selector | Function | Calldata length (bytes) | +|----------|----------|------------------------| +| `transfer(address,uint256)` | Simple transfer | 68 | +| `transferWithMemo(address,uint256,bytes32)` | Transfer with memo | 100 | +| `transferFrom(address,address,uint256)` | Delegated transfer | 100 | +| `transferFromWithMemo(address,address,uint256,bytes32)` | Delegated transfer with memo | 132 | +| `approve(address,uint256)` | Spending approval | 68 | +| `mint(address,uint256)` | Token mint | 68 | +| `mintWithMemo(address,uint256,bytes32)` | Mint with memo | 100 | +| `burn(uint256)` | Token burn | 36 | +| `burnWithMemo(uint256,bytes32)` | Burn with memo | 68 | + +Transactions with empty calldata, unrecognized selectors, or extra trailing bytes are classified as general transactions. + +## AA (Type 0x76) Transactions + +An account-abstraction transaction is in the payments lane if **all** of the following hold: + +1. Every call in the transaction individually satisfies both the address and calldata checks. +2. The `key_authorization` field is `None`. +3. The `tempo_authorization_list` is empty. + +These side-effect fields (`key_authorization`, `tempo_authorization_list`) enable non-payment state mutations — key provisioning and EIP-7702-style delegations respectively. A transaction carrying any of these fields MUST be classified as general, even if all its calls target TIP-20 addresses with valid selectors. + +Any transaction with non-empty `key_authorization` or `tempo_authorization_list` MUST be automatically classified as general, regardless of its call targets or calldata. + +## EIP-7702 (Type 0x04) Transactions + +An EIP-7702 transaction is in the payments lane only if its `authorization_list` is empty. The `authorization_list` establishes code delegations — a non-payment state mutation — and any transaction carrying one MUST be classified as general. + +Any EIP-7702 transaction with a non-empty `authorization_list` MUST be automatically classified as general, regardless of targets or calldata. + +## Pre-T2 (Backward Compatibility) + +Before T2, only the address prefix check is enforced at the consensus level. + +# Invariants + +1. **Classification completeness**: Every transaction MUST be classified as exactly one of payment or general — never both, never neither. +2. **Selector strictness**: After T2, a transaction targeting a TIP-20 address with calldata that does not match an allowed selector MUST be classified as general and subject to `general_gas_limit`. +3. **AA atomicity**: An AA transaction is a payment only if **all** of its calls independently qualify AND the transaction carries no side-effect fields (`key_authorization`, `tempo_authorization_list`). A single non-qualifying call or any present side-effect field MUST cause the entire transaction to be classified as general. +4. **No trailing bytes**: Calldata longer than the exact expected ABI-encoded length for a matched selector MUST cause general classification. +5. **No delegations**: An EIP-7702 transaction with a non-empty `authorization_list` MUST be classified as general, regardless of its target or calldata. +6. Transactions with a type not explicitly covered by this specification MUST be classified as general. From 4badec4af48568c90d1eadc3cb4b3c8ca27bf8df Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 9 Apr 2026 08:37:12 +0200 Subject: [PATCH 02/68] fix: disallow access lists --- tips/tip-1045.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index 2228cbc822..5ab76598ee 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -12,7 +12,7 @@ protocolVersion: T5 ## Abstract -This TIP formalizes the consensus rules for classifying a transaction as payment lane eligible under TIP-1010. Starting with the T2 hardfork, it introduces a mandatory calldata selector allowlist and requires that transactions carry no side-effect fields (authorization lists, key authorizations) to qualify as payments. +This TIP formalizes the consensus rules for classifying a transaction as payment lane eligible under TIP-1010. Starting with the T2 hardfork, it introduces a mandatory calldata selector allowlist and requires that transactions carry no disqualifying fields (authorization lists, key authorizations, access lists) to qualify as payments. ## Motivation @@ -22,15 +22,15 @@ TIP-1010 defines the payment lane gas budget but leaves the classification crite # Specification -A transaction qualifies for the payment lane when **every** call it contains targets a TIP-20 token address AND carries recognized payment calldata, AND the transaction carries no side-effect fields that mutate non-payment state. +A transaction qualifies for the payment lane when **every** call it contains targets a TIP-20 token address AND carries recognized payment calldata, AND the transaction carries none of the disqualifying fields defined below. ## Address Check The `to` address must start with the 12-byte TIP-20 token prefix `0x20C000000000000000000000`. Contract-creation transactions (`to = null`) never qualify. -## Calldata Check (T5+) +## Calldata Check -Starting with the **T5 hardfork**, the transaction's calldata must match one of the recognized TIP-20 selectors with the exact expected ABI-encoded length (selector + parameters, no trailing bytes): +The transaction's calldata must match one of the recognized TIP-20 selectors with the exact expected ABI-encoded length (selector + parameters, no trailing bytes): | Selector | Function | Calldata length (bytes) | |----------|----------|------------------------| @@ -46,6 +46,10 @@ Starting with the **T5 hardfork**, the transaction's calldata must match one of Transactions with empty calldata, unrecognized selectors, or extra trailing bytes are classified as general transactions. +## Access Lists + +Any transaction carrying a non-empty `access_list` MUST be classified as general, regardless of its type, targets, or calldata. Access lists can carry arbitrary addresses and storage keys, enabling arbitrary data injection at payment lane gas prices. + ## AA (Type 0x76) Transactions An account-abstraction transaction is in the payments lane if **all** of the following hold: @@ -53,6 +57,7 @@ An account-abstraction transaction is in the payments lane if **all** of the fol 1. Every call in the transaction individually satisfies both the address and calldata checks. 2. The `key_authorization` field is `None`. 3. The `tempo_authorization_list` is empty. +4. The `access_list` is empty. These side-effect fields (`key_authorization`, `tempo_authorization_list`) enable non-payment state mutations — key provisioning and EIP-7702-style delegations respectively. A transaction carrying any of these fields MUST be classified as general, even if all its calls target TIP-20 addresses with valid selectors. @@ -72,7 +77,7 @@ Before T2, only the address prefix check is enforced at the consensus level. 1. **Classification completeness**: Every transaction MUST be classified as exactly one of payment or general — never both, never neither. 2. **Selector strictness**: After T2, a transaction targeting a TIP-20 address with calldata that does not match an allowed selector MUST be classified as general and subject to `general_gas_limit`. -3. **AA atomicity**: An AA transaction is a payment only if **all** of its calls independently qualify AND the transaction carries no side-effect fields (`key_authorization`, `tempo_authorization_list`). A single non-qualifying call or any present side-effect field MUST cause the entire transaction to be classified as general. +3. **AA atomicity**: An AA transaction is a payment only if **all** of its calls independently qualify AND the transaction carries none of the disqualifying fields (`key_authorization`, `tempo_authorization_list`, `access_list`). A single non-qualifying call or any present disqualifying field MUST cause the entire transaction to be classified as general. 4. **No trailing bytes**: Calldata longer than the exact expected ABI-encoded length for a matched selector MUST cause general classification. 5. **No delegations**: An EIP-7702 transaction with a non-empty `authorization_list` MUST be classified as general, regardless of its target or calldata. -6. Transactions with a type not explicitly covered by this specification MUST be classified as general. +6. **No access lists**: Any transaction with a non-empty `access_list` MUST be classified as general, regardless of type, targets, or calldata. Non-empty access lists allow carrying arbitrary data at payment lane gas prices. From c97d6e7b8d1fbad127a265de6116feb480960318 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 29 Apr 2026 18:32:46 +0200 Subject: [PATCH 03/68] docs: simplify design --- tips/tip-1045.md | 80 +++++++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index 5ab76598ee..543e6eb082 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -1,8 +1,8 @@ --- id: TIP-1045 title: Payment Transaction Classification -description: Defines consensus rules for classifying transactions as payment lane eligible, including address prefix and calldata selector checks. -authors: @0xrusowsky, Dan Robinson @danrobinson, Dankrad Feist @dankrad +description: Defines consensus rules for classifying transactions as payment lane eligible using an allow-list of call targets/selectors and empty access lists. +authors: @0xrusowsky, Arsenii Kulikov @klkvr, Dan Robinson @danrobinson, Tanishk Goyal @legion2002 status: Draft related: TIP-1010, TIP-1000, Payment Lane Specification protocolVersion: T5 @@ -12,72 +12,68 @@ protocolVersion: T5 ## Abstract -This TIP formalizes the consensus rules for classifying a transaction as payment lane eligible under TIP-1010. Starting with the T2 hardfork, it introduces a mandatory calldata selector allowlist and requires that transactions carry no disqualifying fields (authorization lists, key authorizations, access lists) to qualify as payments. +This TIP formalizes the consensus rules for classifying a transaction as payment lane eligible under TIP-1010. Starting with the T5 hardfork, consensus requires every call in the transaction to match a payment call allow-list entry (target + selector + exact ABI-encoded calldata length) and requires `access_list` to be empty. ## Motivation -TIP-1010 defines the payment lane gas budget but leaves the classification criteria underspecified — only requiring that the transaction targets a TIP-20 address. This TIP tightens the classification at the consensus level in T2 by additionally requiring recognized payment calldata and the absence of any side-effect fields. +TIP-1010 defines the payment lane gas budget but leaves the classification criteria underspecified — only requiring that the transaction targets a TIP-20 address. As payment lane eligibility expands beyond TIP-20 calls, consensus needs a generic but stable predicate that can be maintained indefinitely for historical validation. + +This TIP therefore enshrines only the minimum long-term consensus rule: a transaction is payment lane eligible if every call it contains matches an allow-listed payment call shape and the transaction carries no access list. Builders MAY apply stricter local policy when selecting transactions for the payment lane, but such heuristics are intentionally left out of scope. --- # Specification -A transaction qualifies for the payment lane when **every** call it contains targets a TIP-20 token address AND carries recognized payment calldata, AND the transaction carries none of the disqualifying fields defined below. +A transaction qualifies for the payment lane when **every** call it contains is an allow-listed payment call and the transaction carries an empty `access_list`. + +## Payment Call Allow-List -## Address Check +A call qualifies as a payment call if, for some entry in the payment call allow-list table, all of the following hold: -The `to` address must start with the 12-byte TIP-20 token prefix `0x20C000000000000000000000`. Contract-creation transactions (`to = null`) never qualify. +1. It is not a contract-creation call. Calls with `to = null`, `CREATE`, or `CREATE2` never match. +2. Its target satisfies the entry's target-match predicate. +3. The first 4 bytes of its calldata equal the entry's selector. +4. Its total calldata length equals the entry's listed ABI-encoded length (selector + parameters, with no trailing bytes). -## Calldata Check +The initial payment call allow-list is: -The transaction's calldata must match one of the recognized TIP-20 selectors with the exact expected ABI-encoded length (selector + parameters, no trailing bytes): +| Target | Selector | Function | Calldata length (bytes) | +|--------|----------|----------|-------------------------| +| TIP-20 | `transfer(address,uint256)` | Simple transfer | 68 | +| TIP-20 | `transferWithMemo(address,uint256,bytes32)` | Transfer with memo | 100 | +| TIP-20 | `transferFrom(address,address,uint256)` | Delegated transfer | 100 | +| TIP-20 | `transferFromWithMemo(address,address,uint256,bytes32)` | Delegated transfer with memo | 132 | +| TIP-20 | `approve(address,uint256)` | Spending approval | 68 | +| TIP-20 | `mint(address,uint256)` | Token mint | 68 | +| TIP-20 | `mintWithMemo(address,uint256,bytes32)` | Mint with memo | 100 | +| TIP-20 | `burn(uint256)` | Token burn | 36 | +| TIP-20 | `burnWithMemo(uint256,bytes32)` | Burn with memo | 68 | -| Selector | Function | Calldata length (bytes) | -|----------|----------|------------------------| -| `transfer(address,uint256)` | Simple transfer | 68 | -| `transferWithMemo(address,uint256,bytes32)` | Transfer with memo | 100 | -| `transferFrom(address,address,uint256)` | Delegated transfer | 100 | -| `transferFromWithMemo(address,address,uint256,bytes32)` | Delegated transfer with memo | 132 | -| `approve(address,uint256)` | Spending approval | 68 | -| `mint(address,uint256)` | Token mint | 68 | -| `mintWithMemo(address,uint256,bytes32)` | Mint with memo | 100 | -| `burn(uint256)` | Token burn | 36 | -| `burnWithMemo(uint256,bytes32)` | Burn with memo | 68 | +The `Target` column may specify either an exact target precompile. For TIP-20 precompiles, the match is against the `0x20C000000000000000000000` prefix. -Transactions with empty calldata, unrecognized selectors, or extra trailing bytes are classified as general transactions. +Transactions with empty calldata, unrecognized selectors, target/selector combinations not present in the allow-list, or extra trailing bytes are classified as general transactions. ## Access Lists Any transaction carrying a non-empty `access_list` MUST be classified as general, regardless of its type, targets, or calldata. Access lists can carry arbitrary addresses and storage keys, enabling arbitrary data injection at payment lane gas prices. -## AA (Type 0x76) Transactions - -An account-abstraction transaction is in the payments lane if **all** of the following hold: - -1. Every call in the transaction individually satisfies both the address and calldata checks. -2. The `key_authorization` field is `None`. -3. The `tempo_authorization_list` is empty. -4. The `access_list` is empty. - -These side-effect fields (`key_authorization`, `tempo_authorization_list`) enable non-payment state mutations — key provisioning and EIP-7702-style delegations respectively. A transaction carrying any of these fields MUST be classified as general, even if all its calls target TIP-20 addresses with valid selectors. - -Any transaction with non-empty `key_authorization` or `tempo_authorization_list` MUST be automatically classified as general, regardless of its call targets or calldata. +## Transaction Types -## EIP-7702 (Type 0x04) Transactions +For legacy, EIP-2930, EIP-1559, and EIP-7702 transactions, the single top-level call must match the payment call allow-list and `access_list` must be empty. -An EIP-7702 transaction is in the payments lane only if its `authorization_list` is empty. The `authorization_list` establishes code delegations — a non-payment state mutation — and any transaction carrying one MUST be classified as general. +For account-abstraction (type `0x76`) transactions, `calls` must be non-empty, every call in the batch must individually match the payment call allow-list, and `access_list` must be empty. -Any EIP-7702 transaction with a non-empty `authorization_list` MUST be automatically classified as general, regardless of targets or calldata. +No other transaction fields participate in consensus payment classification. In particular, `authorization_list`, `tempo_authorization_list`, `key_authorization`, signature fields, fee sponsorship fields, and validity-window fields do not affect payment versus general classification. -## Pre-T2 (Backward Compatibility) +## Pre-T5 (Backward Compatibility) -Before T2, only the address prefix check is enforced at the consensus level. +Before T5, only the legacy TIP-20 address prefix check is enforced at the consensus level. # Invariants 1. **Classification completeness**: Every transaction MUST be classified as exactly one of payment or general — never both, never neither. -2. **Selector strictness**: After T2, a transaction targeting a TIP-20 address with calldata that does not match an allowed selector MUST be classified as general and subject to `general_gas_limit`. -3. **AA atomicity**: An AA transaction is a payment only if **all** of its calls independently qualify AND the transaction carries none of the disqualifying fields (`key_authorization`, `tempo_authorization_list`, `access_list`). A single non-qualifying call or any present disqualifying field MUST cause the entire transaction to be classified as general. -4. **No trailing bytes**: Calldata longer than the exact expected ABI-encoded length for a matched selector MUST cause general classification. -5. **No delegations**: An EIP-7702 transaction with a non-empty `authorization_list` MUST be classified as general, regardless of its target or calldata. +2. **Allow-list strictness**: After T5, a transaction containing any call that does not match an allow-listed `(target, selector, calldata length)` tuple MUST be classified as general and subject to `general_gas_limit`. +3. **No trailing bytes**: Calldata longer than the exact expected ABI-encoded length for a matched allow-list entry MUST cause general classification. +4. **No contract creation**: Any transaction containing `CREATE`, `CREATE2`, or `to = null` MUST be classified as general. +5. **AA atomicity**: An AA transaction is a payment only if `calls` is non-empty and **all** of its calls independently match the payment call allow-list. A single non-qualifying call MUST cause the entire transaction to be classified as general. 6. **No access lists**: Any transaction with a non-empty `access_list` MUST be classified as general, regardless of type, targets, or calldata. Non-empty access lists allow carrying arbitrary data at payment lane gas prices. From e44731a0083a9b2dae06dcc14a0965abc7cadfb5 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 30 Apr 2026 08:15:44 +0200 Subject: [PATCH 04/68] chore: support TIP20ChannelEscrow (TIP-1034) --- tips/tip-1045.md | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index 543e6eb082..a22c1ecac6 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -38,20 +38,24 @@ A call qualifies as a payment call if, for some entry in the payment call allow- The initial payment call allow-list is: | Target | Selector | Function | Calldata length (bytes) | -|--------|----------|----------|-------------------------| -| TIP-20 | `transfer(address,uint256)` | Simple transfer | 68 | -| TIP-20 | `transferWithMemo(address,uint256,bytes32)` | Transfer with memo | 100 | -| TIP-20 | `transferFrom(address,address,uint256)` | Delegated transfer | 100 | -| TIP-20 | `transferFromWithMemo(address,address,uint256,bytes32)` | Delegated transfer with memo | 132 | -| TIP-20 | `approve(address,uint256)` | Spending approval | 68 | -| TIP-20 | `mint(address,uint256)` | Token mint | 68 | -| TIP-20 | `mintWithMemo(address,uint256,bytes32)` | Mint with memo | 100 | -| TIP-20 | `burn(uint256)` | Token burn | 36 | -| TIP-20 | `burnWithMemo(uint256,bytes32)` | Burn with memo | 68 | - -The `Target` column may specify either an exact target precompile. For TIP-20 precompiles, the match is against the `0x20C000000000000000000000` prefix. - -Transactions with empty calldata, unrecognized selectors, target/selector combinations not present in the allow-list, or extra trailing bytes are classified as general transactions. +|--------|----------|----------|---------------------| +| TIP-20 | `transfer(address,uint256)` | Simple transfer | 68 bytes | +| TIP-20 | `transferWithMemo(address,uint256,bytes32)` | Transfer with memo | 100 bytes | +| TIP-20 | `transferFrom(address,address,uint256)` | Delegated transfer | 100 bytes | +| TIP-20 | `transferFromWithMemo(address,address,uint256,bytes32)` | Delegated transfer with memo | 132 bytes | +| TIP-20 | `approve(address,uint256)` | Spending approval | 68 bytes | +| TIP-20 | `mint(address,uint256)` | Token mint | 68 bytes | +| TIP-20 | `mintWithMemo(address,uint256,bytes32)` | Mint with memo | 100 bytes | +| TIP-20 | `burn(uint256)` | Token burn | 36 bytes | +| TIP-20 | `burnWithMemo(uint256,bytes32)` | Burn with memo | 68 bytes | +| TIP20ChannelEscrow | `open(address,address,uint96,bytes32,address,uint32)` | Open channel | 196 bytes | +| TIP20ChannelEscrow | `topUp((address,address,address,bytes32,address),uint96,uint32)` | Top up channel | 228 bytes | +| TIP20ChannelEscrow | `settle((address,address,address,bytes32,address),uint96,bytes)` | Settle channel | up to 2048 bytes | +| TIP20ChannelEscrow | `close((address,address,address,bytes32,address),uint96,uint96,bytes)` | Close channel | up to 2048 bytes | + +The `Target` column may specify either an exact target precompile address or an address class. For TIP-20 precompiles, the match is against the `0x20C000000000000000000000` prefix. + +Calls with empty calldata, unrecognized selectors, target/selector combinations not present in the allow-list, malformed ABI encoding, or calldata that violates the matched entry's calldata length constraint, do not match any allow-list entry. ## Access Lists @@ -72,8 +76,8 @@ Before T5, only the legacy TIP-20 address prefix check is enforced at the consen # Invariants 1. **Classification completeness**: Every transaction MUST be classified as exactly one of payment or general — never both, never neither. -2. **Allow-list strictness**: After T5, a transaction containing any call that does not match an allow-listed `(target, selector, calldata length)` tuple MUST be classified as general and subject to `general_gas_limit`. -3. **No trailing bytes**: Calldata longer than the exact expected ABI-encoded length for a matched allow-list entry MUST cause general classification. +2. **Allow-list strictness**: A transaction containing any call that does not match an allow-listed `(target, selector, calldata constraint)` entry MUST be classified as general and subject to `general_gas_limit`. +3. **No trailing bytes**: Calldata longer than the expected ABI-encoded length for a matched allow-list entry MUST be classified as general. 4. **No contract creation**: Any transaction containing `CREATE`, `CREATE2`, or `to = null` MUST be classified as general. 5. **AA atomicity**: An AA transaction is a payment only if `calls` is non-empty and **all** of its calls independently match the payment call allow-list. A single non-qualifying call MUST cause the entire transaction to be classified as general. 6. **No access lists**: Any transaction with a non-empty `access_list` MUST be classified as general, regardless of type, targets, or calldata. Non-empty access lists allow carrying arbitrary data at payment lane gas prices. From 9a508f4639d98de02cfd7e9c19941a6995973abb Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 17 Mar 2026 01:37:12 +0400 Subject: [PATCH 05/68] 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 06/68] 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 07/68] 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 08/68] 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 09/68] 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 10/68] 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 11/68] 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 12/68] 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 13/68] 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 14/68] 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 15/68] 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 16/68] 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 17/68] 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 18/68] 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 19/68] 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 20/68] 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 21/68] 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 22/68] 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 23/68] 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 e498bfaae7a33219abfe7c3dcb63cd2698dd5652 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Fri, 1 May 2026 02:55:12 +0530 Subject: [PATCH 24/68] docs(tip-1034): add Brendan Ryan as author Amp-Thread-ID: https://ampcode.com/threads/T-019de046-fa79-770c-93f8-84e06fd1acbb --- 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 40e99745fd..d4e88153e9 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 From f86234b144b0515f4ca40c6d0b59bd21c588c5d0 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Fri, 1 May 2026 02:55:35 +0530 Subject: [PATCH 25/68] docs(tip-1034): explain why close needs no emergency path Amp-Thread-ID: https://ampcode.com/threads/T-019ddf3b-e80f-75d0-be3d-3cb4cba5873d --- tips/tip-1034.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index d4e88153e9..cf0ed04db9 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -182,6 +182,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` + before `expiresAt`, 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`, or directly via `withdraw` once `block.timestamp >= expiresAt`. + +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. From 6e74784395b239df263762fef59ce7ddf11e4299 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Fri, 1 May 2026 18:03:43 +0530 Subject: [PATCH 26/68] refactor(tip-1034): remove channel expiry Amp-Thread-ID: https://ampcode.com/threads/T-019de37f-1c81-72dd-984c-8460e5729fd5 --- tips/tip-1034.md | 53 ++++++------ tips/verify/src/TIP20ChannelEscrow.sol | 37 ++------ .../src/interfaces/ITIP20ChannelEscrow.sol | 17 +--- tips/verify/test/TIP20ChannelEscrow.t.sol | 86 ++++--------------- 4 files changed, 53 insertions(+), 140 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index cf0ed04db9..1a48fb6b45 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -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. @@ -65,7 +65,6 @@ struct ChannelDescriptor { struct ChannelState { uint96 settled; uint96 deposit; - uint32 expiresAt; uint32 closeData; } @@ -85,17 +84,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: @@ -149,28 +148,25 @@ 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 operator. 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 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`. -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 set `closeData = 1` and MUST NOT delete the slot, so the same descriptor and `channelId` cannot be reopened and replay old vouchers. +4. If `closeData >= 2`, 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. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. +7. Signer MUST be `authorizedSigner` from the supplied descriptor when set, otherwise `payer` from the supplied descriptor. +8. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`. +9. `close` MUST reject when `captureAmount > deposit`, even if `cumulativeAmount > deposit`. +10. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. +11. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. +12. `withdraw` MUST be allowed only when the close grace period has elapsed from `closeData`. +13. 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 @@ -193,9 +189,9 @@ recipient leg cannot receive funds under the token's current transfer policy, th already has a unilateral single-recipient fallback: 1. If the payer-side refund leg makes `close` unusable, the payee can continue using `settle` - before `expiresAt`, subject to the normal `settle` bounds. + 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`, or directly via `withdraw` once `block.timestamp >= expiresAt`. + 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. @@ -260,9 +256,8 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 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`. +15. `withdraw` MUST require an active close request whose grace period has elapsed. +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..61df2b9f87 100644 --- a/tips/verify/src/TIP20ChannelEscrow.sol +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -19,8 +19,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; uint32 internal constant _FINALIZED_CLOSE_DATA = 1; bytes32 internal constant _EIP712_DOMAIN_TYPEHASH = keccak256( @@ -36,8 +35,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { address token, uint96 deposit, bytes32 salt, - address authorizedSigner, - uint32 expiresAt + address authorizedSigner ) external returns (bytes32 channelId) @@ -45,7 +43,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { 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(); @@ -54,7 +51,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { ChannelState({ settled: 0, deposit: deposit, - expiresAt: expiresAt, closeData: 0 }) ); @@ -64,9 +60,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { 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, deposit); } function settle( @@ -81,7 +75,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { 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(); @@ -101,8 +94,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { function topUp( ChannelDescriptor calldata descriptor, - uint96 additionalDeposit, - uint32 newExpiresAt + uint96 additionalDeposit ) external { @@ -113,10 +105,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (_isFinalized(channel.closeData)) 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; @@ -128,10 +116,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (!success) revert TransferFailed(); } - if (newExpiresAt != 0) { - channel.expiresAt = newExpiresAt; - } - if (_closeRequestedAt(channel.closeData) != 0) { channel.closeData = 0; emit CloseRequestCancelled(channelId, descriptor.payer, descriptor.payee); @@ -144,8 +128,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { descriptor.payer, descriptor.payee, additionalDeposit, - channel.deposit, - channel.expiresAt + channel.deposit ); } @@ -189,7 +172,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); } @@ -225,7 +207,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; channel.closeData = _FINALIZED_CLOSE_DATA; @@ -236,7 +218,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); } @@ -333,7 +314,6 @@ 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); } @@ -344,7 +324,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { { packedState = uint256(state.settled); packedState |= uint256(state.deposit) << _DEPOSIT_OFFSET; - packedState |= uint256(state.expiresAt) << _EXPIRES_AT_OFFSET; packedState |= uint256(state.closeData) << _CLOSE_DATA_OFFSET; } @@ -356,10 +335,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { return closeData >= 2 ? closeData : 0; } - 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 b4dff617f5..8cad7e4247 100644 --- a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol +++ b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol @@ -16,7 +16,6 @@ interface ITIP20ChannelEscrow { struct ChannelState { uint96 settled; uint96 deposit; - uint32 expiresAt; uint32 closeData; } @@ -33,8 +32,7 @@ interface ITIP20ChannelEscrow { address token, uint96 deposit, bytes32 salt, - address authorizedSigner, - uint32 expiresAt + address authorizedSigner ) external returns (bytes32 channelId); @@ -48,8 +46,7 @@ interface ITIP20ChannelEscrow { function topUp( ChannelDescriptor calldata descriptor, - uint96 additionalDeposit, - uint32 newExpiresAt + uint96 additionalDeposit ) external; @@ -105,8 +102,7 @@ interface ITIP20ChannelEscrow { address token, address authorizedSigner, bytes32 salt, - uint96 deposit, - uint32 expiresAt + uint96 deposit ); event Settled( @@ -123,8 +119,7 @@ interface ITIP20ChannelEscrow { address indexed payer, address indexed payee, uint96 additionalDeposit, - uint96 newDeposit, - uint32 newExpiresAt + uint96 newDeposit ); event CloseRequested( @@ -146,8 +141,6 @@ interface ITIP20ChannelEscrow { 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(); @@ -156,8 +149,6 @@ interface ITIP20ChannelEscrow { 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 df6534dc70..abcda2d814 100644 --- a/tips/verify/test/TIP20ChannelEscrow.t.sol +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -94,18 +94,9 @@ 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) { vm.prank(payer); - return channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); - } - - function _openChannelWithExpiry(uint32 expiresAt) internal returns (bytes32) { - vm.prank(payer); - return channel.open(payee, address(token), DEPOSIT, SALT, address(0), expiresAt); + return channel.open(payee, address(token), DEPOSIT, SALT, address(0)); } function _descriptor() internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { @@ -152,11 +143,8 @@ contract TIP20ChannelEscrowTest is BaseTest { } function test_open_success() public { - uint32 expiresAt = _defaultExpiry(); - 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)); ITIP20ChannelEscrow.Channel memory ch = channel.getChannel(_descriptor()); assertEq(ch.descriptor.payer, payer); @@ -165,7 +153,6 @@ contract TIP20ChannelEscrowTest is BaseTest { assertEq(ch.descriptor.authorizedSigner, address(0)); 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); } @@ -173,25 +160,19 @@ contract TIP20ChannelEscrowTest is BaseTest { 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()); + channel.open(address(0), address(token), DEPOSIT, SALT, address(0)); } function test_open_revert_zeroToken() public { vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidToken.selector); - channel.open(payee, address(0), DEPOSIT, SALT, address(0), _defaultExpiry()); + channel.open(payee, address(0), DEPOSIT, SALT, address(0)); } 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), uint32(block.timestamp)); + channel.open(payee, address(token), 0, SALT, address(0)); } function test_open_revert_duplicate() public { @@ -199,7 +180,7 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); - channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); + channel.open(payee, address(token), DEPOSIT, SALT, address(0)); } function test_settle_success() public { @@ -213,16 +194,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"); @@ -246,8 +217,7 @@ contract TIP20ChannelEscrowTest is BaseTest { (address delegateSigner, uint256 delegateKey) = makeAddrAndKey("delegate"); vm.prank(payer); - bytes32 channelId = - channel.open(payee, address(token), DEPOSIT, SALT, delegateSigner, _defaultExpiry()); + bytes32 channelId = channel.open(payee, address(token), DEPOSIT, SALT, delegateSigner); bytes memory sig = _signVoucher(channelId, 500_000, delegateKey); @@ -257,24 +227,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 { @@ -284,7 +244,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); } @@ -300,7 +260,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 { @@ -364,15 +324,13 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.close(_descriptor(), 300_000, 200_000, ""); } - function test_close_afterExpiry_allowsNoAdditionalCapture() public { - bytes32 channelId = _openChannelWithExpiry(uint32(block.timestamp + 10)); + function test_close_allowsNoAdditionalCaptureWithoutSignature() public { + bytes32 channelId = _openChannel(); 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, ""); @@ -392,7 +350,7 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); - channel.open(payee, address(token), DEPOSIT, SALT, address(0), _defaultExpiry()); + channel.open(payee, address(token), DEPOSIT, SALT, address(0)); } function test_withdraw_afterGracePeriod() public { @@ -411,26 +369,20 @@ 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); + function test_withdraw_revert_withoutCloseRequest() public { + _openChannel(); - uint256 payerBalanceBefore = token.balanceOf(payer); vm.prank(payer); + vm.expectRevert(ITIP20ChannelEscrow.CloseNotReady.selector); channel.withdraw(_descriptor()); - - assertEq(channel.getChannelState(channelId).closeData, 1); - assertEq(token.balanceOf(payer), payerBalanceBefore + DEPOSIT); } function test_getChannelStatesBatch_success() public { bytes32 channelId1 = _openChannel(); 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)); bytes memory sig = _signVoucher(channelId1, 400_000); vm.prank(payee); From 7f4459b71151eacceb63eaa50a893059c50dcfea Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 4 May 2026 15:11:22 +0200 Subject: [PATCH 27/68] chore: generalize calldata length rule --- tips/tip-1045.md | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index a22c1ecac6..3bb47ac528 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -35,23 +35,28 @@ A call qualifies as a payment call if, for some entry in the payment call allow- 3. The first 4 bytes of its calldata equal the entry's selector. 4. Its total calldata length equals the entry's listed ABI-encoded length (selector + parameters, with no trailing bytes). +Calldata length is derived from the entry's signature rather than listed per row: + +- For entries whose parameters are all static ABI types, the expected calldata length is exactly `4 + num_params * 32` bytes (tuples of static types are inlined, so each leaf static type contributes 32 bytes). +- For entries containing any dynamic ABI type (`bytes`, `string`, dynamic arrays), the encoded length depends on the payload. The constraint becomes `calldata_length <= 2048`, and the encoding must still be well-formed with no trailing bytes beyond the ABI-encoded payload. + The initial payment call allow-list is: -| Target | Selector | Function | Calldata length (bytes) | -|--------|----------|----------|---------------------| -| TIP-20 | `transfer(address,uint256)` | Simple transfer | 68 bytes | -| TIP-20 | `transferWithMemo(address,uint256,bytes32)` | Transfer with memo | 100 bytes | -| TIP-20 | `transferFrom(address,address,uint256)` | Delegated transfer | 100 bytes | -| TIP-20 | `transferFromWithMemo(address,address,uint256,bytes32)` | Delegated transfer with memo | 132 bytes | -| TIP-20 | `approve(address,uint256)` | Spending approval | 68 bytes | -| TIP-20 | `mint(address,uint256)` | Token mint | 68 bytes | -| TIP-20 | `mintWithMemo(address,uint256,bytes32)` | Mint with memo | 100 bytes | -| TIP-20 | `burn(uint256)` | Token burn | 36 bytes | -| TIP-20 | `burnWithMemo(uint256,bytes32)` | Burn with memo | 68 bytes | -| TIP20ChannelEscrow | `open(address,address,uint96,bytes32,address,uint32)` | Open channel | 196 bytes | -| TIP20ChannelEscrow | `topUp((address,address,address,bytes32,address),uint96,uint32)` | Top up channel | 228 bytes | -| TIP20ChannelEscrow | `settle((address,address,address,bytes32,address),uint96,bytes)` | Settle channel | up to 2048 bytes | -| TIP20ChannelEscrow | `close((address,address,address,bytes32,address),uint96,uint96,bytes)` | Close channel | up to 2048 bytes | +| Target | Selector | Function | +|--------|----------|----------| +| TIP-20 | `transfer(address,uint256)` | Simple transfer | +| TIP-20 | `transferWithMemo(address,uint256,bytes32)` | Transfer with memo | +| TIP-20 | `transferFrom(address,address,uint256)` | Delegated transfer | +| TIP-20 | `transferFromWithMemo(address,address,uint256,bytes32)` | Delegated transfer with memo | +| TIP-20 | `approve(address,uint256)` | Spending approval | +| TIP-20 | `mint(address,uint256)` | Token mint | +| TIP-20 | `mintWithMemo(address,uint256,bytes32)` | Mint with memo | +| TIP-20 | `burn(uint256)` | Token burn | +| TIP-20 | `burnWithMemo(uint256,bytes32)` | Burn with memo | +| TIP20ChannelEscrow | `open(address,address,uint96,bytes32,address,uint32)` | Open channel | +| TIP20ChannelEscrow | `topUp((address,address,address,bytes32,address),uint96,uint32)` | Top up channel | +| TIP20ChannelEscrow | `settle((address,address,address,bytes32,address),uint96,bytes)` | Settle channel | +| TIP20ChannelEscrow | `close((address,address,address,bytes32,address),uint96,uint96,bytes)` | Close channel | The `Target` column may specify either an exact target precompile address or an address class. For TIP-20 precompiles, the match is against the `0x20C000000000000000000000` prefix. From 60a03e5de41e96b3d1f8eee19399083f85793346 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 4 May 2026 15:11:42 +0200 Subject: [PATCH 28/68] fix: forbid auth lists in payment lane --- tips/tip-1045.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index 3bb47ac528..898d7d769f 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -66,13 +66,19 @@ Calls with empty calldata, unrecognized selectors, target/selector combinations Any transaction carrying a non-empty `access_list` MUST be classified as general, regardless of its type, targets, or calldata. Access lists can carry arbitrary addresses and storage keys, enabling arbitrary data injection at payment lane gas prices. +## Authorization Lists + +Any transaction carrying a non-empty `authorization_list` (EIP-7702) or non-empty `tempo_authorization_list` MUST be classified as general. Like access lists, both fields are unbounded in entry count and each entry carries attacker-chosen signed payloads, enabling arbitrary data injection at payment lane gas prices. + +`key_authorization` is exempt from this restriction and MAY be present in payment transactions. It is a single, size-bounded structure tied to onboarding the transaction's sender, and removing it from the payment lane would break first-transaction UX without offering a comparable data-injection surface. + ## Transaction Types -For legacy, EIP-2930, EIP-1559, and EIP-7702 transactions, the single top-level call must match the payment call allow-list and `access_list` must be empty. +For legacy, EIP-2930, EIP-1559, and EIP-7702 transactions, the single top-level call must match the payment call allow-list, `access_list` must be empty, and `authorization_list` must be empty. -For account-abstraction (type `0x76`) transactions, `calls` must be non-empty, every call in the batch must individually match the payment call allow-list, and `access_list` must be empty. +For account-abstraction (type `0x76`) transactions, `calls` must be non-empty, every call in the batch must individually match the payment call allow-list, `access_list` must be empty, and `tempo_authorization_list` must be empty. -No other transaction fields participate in consensus payment classification. In particular, `authorization_list`, `tempo_authorization_list`, `key_authorization`, signature fields, fee sponsorship fields, and validity-window fields do not affect payment versus general classification. +No other transaction fields participate in consensus payment classification. In particular, `key_authorization`, signature fields, fee sponsorship fields, and validity-window fields do not affect payment versus general classification. ## Pre-T5 (Backward Compatibility) @@ -86,3 +92,4 @@ Before T5, only the legacy TIP-20 address prefix check is enforced at the consen 4. **No contract creation**: Any transaction containing `CREATE`, `CREATE2`, or `to = null` MUST be classified as general. 5. **AA atomicity**: An AA transaction is a payment only if `calls` is non-empty and **all** of its calls independently match the payment call allow-list. A single non-qualifying call MUST cause the entire transaction to be classified as general. 6. **No access lists**: Any transaction with a non-empty `access_list` MUST be classified as general, regardless of type, targets, or calldata. Non-empty access lists allow carrying arbitrary data at payment lane gas prices. +7. **No authorization lists**: Any transaction with a non-empty `authorization_list` or non-empty `tempo_authorization_list` MUST be classified as general. `key_authorization` is exempt and does not affect classification. From 37b61b49ab330ea85abcbae2c486787dcebdf762 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <90208954+0xrusowsky@users.noreply.github.com> Date: Mon, 4 May 2026 18:12:25 +0200 Subject: [PATCH 29/68] docs: related TIPs --- tips/tip-1045.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index 898d7d769f..08a201c060 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -4,7 +4,7 @@ title: Payment Transaction Classification description: Defines consensus rules for classifying transactions as payment lane eligible using an allow-list of call targets/selectors and empty access lists. authors: @0xrusowsky, Arsenii Kulikov @klkvr, Dan Robinson @danrobinson, Tanishk Goyal @legion2002 status: Draft -related: TIP-1010, TIP-1000, Payment Lane Specification +related: TIP-1010, TIP-1000, Payment Lane Specification, TIP-20, TIP-1034 protocolVersion: T5 --- From 48a9fdf586b5764df9baa1539ac4ac7b87ba5cd6 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 4 May 2026 20:05:34 +0200 Subject: [PATCH 30/68] fix: clarify calldata length requirements --- tips/tip-1045.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index 898d7d769f..4f0e80ef32 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -33,12 +33,12 @@ A call qualifies as a payment call if, for some entry in the payment call allow- 1. It is not a contract-creation call. Calls with `to = null`, `CREATE`, or `CREATE2` never match. 2. Its target satisfies the entry's target-match predicate. 3. The first 4 bytes of its calldata equal the entry's selector. -4. Its total calldata length equals the entry's listed ABI-encoded length (selector + parameters, with no trailing bytes). +4. Its calldata satisfies the size and encoding constraints derived from the entry's signature, as defined below. -Calldata length is derived from the entry's signature rather than listed per row: +The constraints depend on whether the entry's parameters contain any dynamic ABI types (`bytes`, `string`, dynamic arrays, or tuples/arrays transitively containing them): -- For entries whose parameters are all static ABI types, the expected calldata length is exactly `4 + num_params * 32` bytes (tuples of static types are inlined, so each leaf static type contributes 32 bytes). -- For entries containing any dynamic ABI type (`bytes`, `string`, dynamic arrays), the encoded length depends on the payload. The constraint becomes `calldata_length <= 2048`, and the encoding must still be well-formed with no trailing bytes beyond the ABI-encoded payload. +- **Static-only entries**: calldata length MUST equal `4 + num_params * 32` bytes. +- **Entries with any dynamic type**: calldata length MUST be `<= 2048` bytes. The initial payment call allow-list is: From 2e7eb1ebcdf538251496b3e1579623e9b1091916 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 5 May 2026 15:14:51 +0530 Subject: [PATCH 31/68] docs(tip-1034): add channel operator field Amp-Thread-ID: https://ampcode.com/threads/T-019df774-16f7-72fe-b60f-c147ecaffb57 --- tips/tip-1034.md | 37 ++++++---- tips/verify/src/TIP20ChannelEscrow.sol | 25 +++++-- .../src/interfaces/ITIP20ChannelEscrow.sol | 5 ++ tips/verify/test/TIP20ChannelEscrow.t.sol | 69 +++++++++++++++---- 4 files changed, 106 insertions(+), 30 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 1a48fb6b45..1ef7a84b28 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -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; @@ -112,6 +113,7 @@ channelId = keccak256( abi.encode( payer, payee, + operator, token, salt, authorizedSigner, @@ -150,7 +152,11 @@ This means: 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 operator. +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: @@ -166,7 +172,9 @@ Execution semantics are: 10. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. 11. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. 12. `withdraw` MUST be allowed only when the close grace period has elapsed from `closeData`. -13. 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. +13. `settle` MUST be callable only by `payee`, or by `operator` when `operator != address(0)`. +14. `close` MUST be callable only by `payee`. +15. 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 @@ -246,18 +254,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 == 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. `withdraw` MUST require an active close request whose grace period has elapsed. -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 == 1` MUST mean finalized. +10. `closeData >= 2` MUST mean active with a close request timestamp equal to `closeData`. +11. Once finalized, all state-changing methods MUST revert for that channel. +12. Finalized channels MUST retain a non-zero tombstone state so the same descriptor cannot be reopened. +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 61df2b9f87..4dbafdac60 100644 --- a/tips/verify/src/TIP20ChannelEscrow.sol +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -32,6 +32,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { function open( address payee, + address operator, address token, uint96 deposit, bytes32 salt, @@ -44,7 +45,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (token == address(0)) revert InvalidToken(); if (deposit == 0) revert ZeroDeposit(); - channelId = computeChannelId(msg.sender, payee, token, salt, authorizedSigner); + channelId = computeChannelId(msg.sender, payee, operator, token, salt, authorizedSigner); if (channelStates[channelId] != 0) revert ChannelAlreadyExists(); channelStates[channelId] = _encodeChannelState( @@ -60,7 +61,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { bool success = ITIP20(token).transferFrom(msg.sender, address(this), deposit); if (!success) revert TransferFailed(); - emit ChannelOpened(channelId, msg.sender, payee, token, authorizedSigner, salt, deposit); + emit ChannelOpened(channelId, msg.sender, payee, operator, token, authorizedSigner, salt, deposit); } function settle( @@ -73,7 +74,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { bytes32 channelId = _channelId(descriptor); ChannelState memory channel = _loadChannelState(channelId); - if (msg.sender != descriptor.payee) revert NotPayee(); + _requirePayeeOrOperator(descriptor); if (_isFinalized(channel.closeData)) revert ChannelFinalized(); if (cumulativeAmount > channel.deposit) revert AmountExceedsDeposit(); if (cumulativeAmount <= channel.settled) revert AmountNotIncreasing(); @@ -229,6 +230,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 @@ -256,6 +258,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { function computeChannelId( address payer, address payee, + address operator, address token, bytes32 salt, address authorizedSigner @@ -266,7 +269,14 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { { return keccak256( abi.encode( - payer, payee, token, salt, authorizedSigner, TIP20_CHANNEL_ESCROW, block.chainid + payer, + payee, + operator, + token, + salt, + authorizedSigner, + TIP20_CHANNEL_ESCROW, + block.chainid ) ); } @@ -291,12 +301,19 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { return computeChannelId( descriptor.payer, descriptor.payee, + descriptor.operator, descriptor.token, descriptor.salt, descriptor.authorizedSigner ); } + function _requirePayeeOrOperator(ChannelDescriptor calldata descriptor) internal view { + if (msg.sender == descriptor.payee) return; + if (descriptor.operator != address(0) && msg.sender == descriptor.operator) return; + revert NotPayeeOrOperator(); + } + function _loadChannelState(bytes32 channelId) internal view returns (ChannelState memory) { uint256 packedState = channelStates[channelId]; if (packedState == 0) revert ChannelNotFound(); diff --git a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol index 8cad7e4247..4ed6219632 100644 --- a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol +++ b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol @@ -8,6 +8,7 @@ interface ITIP20ChannelEscrow { struct ChannelDescriptor { address payer; address payee; + address operator; address token; bytes32 salt; address authorizedSigner; @@ -29,6 +30,7 @@ interface ITIP20ChannelEscrow { function open( address payee, + address operator, address token, uint96 deposit, bytes32 salt, @@ -77,6 +79,7 @@ interface ITIP20ChannelEscrow { function computeChannelId( address payer, address payee, + address operator, address token, bytes32 salt, address authorizedSigner @@ -99,6 +102,7 @@ interface ITIP20ChannelEscrow { bytes32 indexed channelId, address indexed payer, address indexed payee, + address operator, address token, address authorizedSigner, bytes32 salt, @@ -146,6 +150,7 @@ interface ITIP20ChannelEscrow { error ChannelFinalized(); error NotPayer(); error NotPayee(); + error NotPayeeOrOperator(); error InvalidPayee(); error InvalidToken(); error ZeroDeposit(); diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol index abcda2d814..439440dbb5 100644 --- a/tips/verify/test/TIP20ChannelEscrow.t.sol +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -96,7 +96,7 @@ contract TIP20ChannelEscrowTest is BaseTest { function _openChannel() internal returns (bytes32) { vm.prank(payer); - return channel.open(payee, address(token), DEPOSIT, SALT, address(0)); + return channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); } function _descriptor() internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { @@ -110,10 +110,23 @@ contract TIP20ChannelEscrowTest is BaseTest { internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) + { + return _descriptor(salt, address(0), authorizedSigner); + } + + function _descriptor( + bytes32 salt, + address operator, + address authorizedSigner + ) + internal + view + returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { return ITIP20ChannelEscrow.ChannelDescriptor({ payer: payer, payee: payee, + operator: operator, token: address(token), salt: salt, authorizedSigner: authorizedSigner @@ -144,11 +157,13 @@ contract TIP20ChannelEscrowTest is BaseTest { function test_open_success() public { vm.prank(payer); - bytes32 channelId = channel.open(payee, address(token), DEPOSIT, SALT, address(0)); + 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.state.settled, 0); @@ -160,19 +175,19 @@ contract TIP20ChannelEscrowTest is BaseTest { function test_open_revert_zeroPayee() public { vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidPayee.selector); - channel.open(address(0), address(token), DEPOSIT, SALT, address(0)); + channel.open(address(0), address(0), address(token), DEPOSIT, SALT, address(0)); } function test_open_revert_zeroToken() public { vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidToken.selector); - channel.open(payee, address(0), DEPOSIT, SALT, address(0)); + channel.open(payee, address(0), address(0), DEPOSIT, SALT, address(0)); } function test_open_revert_zeroDeposit() public { vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ZeroDeposit.selector); - channel.open(payee, address(token), 0, SALT, address(0)); + channel.open(payee, address(0), address(token), 0, SALT, address(0)); } function test_open_revert_duplicate() public { @@ -180,7 +195,7 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); - channel.open(payee, address(token), DEPOSIT, SALT, address(0)); + channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); } function test_settle_success() public { @@ -217,7 +232,8 @@ contract TIP20ChannelEscrowTest is BaseTest { (address delegateSigner, uint256 delegateKey) = makeAddrAndKey("delegate"); vm.prank(payer); - bytes32 channelId = channel.open(payee, address(token), DEPOSIT, SALT, delegateSigner); + bytes32 channelId = + channel.open(payee, address(0), address(token), DEPOSIT, SALT, delegateSigner); bytes memory sig = _signVoucher(channelId, 500_000, delegateKey); @@ -227,6 +243,32 @@ contract TIP20ChannelEscrowTest is BaseTest { assertEq(channel.getChannelState(channelId).settled, 500_000); } + function test_operator_settleSuccess() public { + address operator = makeAddr("operator"); + + vm.prank(payer); + bytes32 channelId = + channel.open(payee, operator, address(token), DEPOSIT, SALT, address(0)); + + bytes memory sig = _signVoucher(channelId, 500_000); + + vm.prank(operator); + channel.settle(_descriptor(SALT, operator, address(0)), 500_000, sig); + + assertEq(channel.getChannelState(channelId).settled, 500_000); + assertEq(token.balanceOf(payee), 500_000); + } + + function test_operator_zeroDoesNotAuthorizeArbitrarySettler() public { + address operator = makeAddr("operator"); + bytes32 channelId = _openChannel(); + bytes memory sig = _signVoucher(channelId, 500_000); + + vm.prank(operator); + vm.expectRevert(ITIP20ChannelEscrow.NotPayeeOrOperator.selector); + channel.settle(_descriptor(), 500_000, sig); + } + function test_topUp_updatesDeposit() public { bytes32 channelId = _openChannel(); @@ -350,7 +392,7 @@ contract TIP20ChannelEscrowTest is BaseTest { vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); - channel.open(payee, address(token), DEPOSIT, SALT, address(0)); + channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); } function test_withdraw_afterGracePeriod() public { @@ -381,8 +423,9 @@ contract TIP20ChannelEscrowTest is BaseTest { bytes32 channelId1 = _openChannel(); vm.prank(payer); - bytes32 channelId2 = - channel.open(payee, address(token), DEPOSIT, bytes32(uint256(2)), address(0)); + bytes32 channelId2 = channel.open( + payee, address(0), address(token), DEPOSIT, bytes32(uint256(2)), address(0) + ); bytes memory sig = _signVoucher(channelId1, 400_000); vm.prank(payee); @@ -401,8 +444,10 @@ contract TIP20ChannelEscrowTest is BaseTest { 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)); + bytes32 id1 = + channel.computeChannelId(payer, payee, address(0), address(token), SALT, address(0)); + bytes32 id2 = + other.computeChannelId(payer, payee, address(0), address(token), SALT, address(0)); assertEq(id1, id2); assertEq(channel.domainSeparator(), other.domainSeparator()); From 66dfc052c758c9bd2884420b5528b2dfbf9ef1a8 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 5 May 2026 16:04:02 +0530 Subject: [PATCH 32/68] docs(tip-1034): derive channels from open tx hash Amp-Thread-ID: https://ampcode.com/threads/T-019df7af-4aff-72db-a1fc-75dd79aa3607 --- tips/tip-1034.md | 46 +++--- tips/verify/src/TIP20ChannelEscrow.sol | 105 +++++++------ .../src/interfaces/ITIP20ChannelEscrow.sol | 12 +- tips/verify/test/TIP20ChannelEscrow.t.sol | 139 ++++++++++-------- 4 files changed, 165 insertions(+), 137 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 1ef7a84b28..9ef9b42cf2 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -61,6 +61,7 @@ struct ChannelDescriptor { address token; bytes32 salt; address authorizedSigner; + bytes32 openTxHash; } struct ChannelState { @@ -100,11 +101,10 @@ high 32 bits MUST remain zero. `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: @@ -117,12 +117,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). @@ -161,20 +166,21 @@ all settlement payouts still transfer to `payee`. Execution semantics are: 1. `open` MUST reject zero deposit, invalid token address, and invalid payee address. -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. If `closeData >= 2`, a successful `topUp` MUST clear it back to `0` and emit `CloseRequestCancelled`. +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. `close` MUST validate the voucher signature via TIP-1020 for any capture-increasing close. -7. Signer MUST be `authorizedSigner` from the supplied descriptor when set, otherwise `payer` from the supplied descriptor. -8. `close` MUST enforce `previousSettled <= captureAmount <= cumulativeAmount`. -9. `close` MUST reject when `captureAmount > deposit`, even if `cumulativeAmount > deposit`. -10. A `close` voucher with `cumulativeAmount > deposit` remains valid for signature verification; `captureAmount` is the escrow-bounded amount that may actually be paid out. -11. `close` MUST settle `captureAmount - previousSettled` to payee and refund `deposit - captureAmount` to payer. -12. `withdraw` MUST be allowed only when the close grace period has elapsed from `closeData`. -13. `settle` MUST be callable only by `payee`, or by `operator` when `operator != address(0)`. -14. `close` MUST be callable only by `payee`. -15. 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. +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 @@ -258,10 +264,10 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 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 == 1` MUST mean finalized. -10. `closeData >= 2` MUST mean active with a close request timestamp equal to `closeData`. -11. Once finalized, all state-changing methods MUST revert for that channel. -12. Finalized channels MUST retain a non-zero tombstone state so the same descriptor cannot be reopened. +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. diff --git a/tips/verify/src/TIP20ChannelEscrow.sol b/tips/verify/src/TIP20ChannelEscrow.sol index 4dbafdac60..599e6c368b 100644 --- a/tips/verify/src/TIP20ChannelEscrow.sol +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -20,7 +20,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { uint256 internal constant _DEPOSIT_OFFSET = 96; uint256 internal constant _CLOSE_DATA_OFFSET = 192; - uint32 internal constant _FINALIZED_CLOSE_DATA = 1; bytes32 internal constant _EIP712_DOMAIN_TYPEHASH = keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" @@ -29,6 +28,18 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { bytes32 internal constant _VERSION_HASH = keccak256("1"); 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(); + + /// @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, @@ -45,23 +56,33 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (token == address(0)) revert InvalidToken(); if (deposit == 0) revert ZeroDeposit(); - channelId = computeChannelId(msg.sender, payee, operator, token, salt, authorizedSigner); + bytes32 openTxHash = _consumeOpenTxHash(); + 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, - closeData: 0 - }) - ); + channelStates[channelId] = + _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, operator, token, authorizedSigner, salt, deposit); + emit ChannelOpened( + channelId, + msg.sender, + payee, + operator, + token, + authorizedSigner, + salt, + openTxHash, + deposit + ); } function settle( @@ -74,8 +95,12 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { bytes32 channelId = _channelId(descriptor); ChannelState memory channel = _loadChannelState(channelId); - _requirePayeeOrOperator(descriptor); - if (_isFinalized(channel.closeData)) revert ChannelFinalized(); + 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(); @@ -93,17 +118,11 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { ); } - function topUp( - ChannelDescriptor calldata descriptor, - uint96 additionalDeposit - ) - 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 (_isFinalized(channel.closeData)) revert ChannelFinalized(); if (additionalDeposit > type(uint96).max - channel.deposit) revert DepositOverflow(); @@ -117,7 +136,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (!success) revert TransferFailed(); } - if (_closeRequestedAt(channel.closeData) != 0) { + if (channel.closeData != 0) { channel.closeData = 0; emit CloseRequestCancelled(channelId, descriptor.payer, descriptor.payee); } @@ -125,11 +144,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { channelStates[channelId] = _encodeChannelState(channel); emit TopUp( - channelId, - descriptor.payer, - descriptor.payee, - additionalDeposit, - channel.deposit + channelId, descriptor.payer, descriptor.payee, additionalDeposit, channel.deposit ); } @@ -138,9 +153,8 @@ 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( @@ -164,7 +178,6 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { 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) { @@ -179,9 +192,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); @@ -202,7 +213,6 @@ 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 @@ -211,8 +221,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (!closeGracePassed) 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); @@ -233,7 +242,8 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { operator: descriptor.operator, token: descriptor.token, salt: descriptor.salt, - authorizedSigner: descriptor.authorizedSigner + authorizedSigner: descriptor.authorizedSigner, + openTxHash: descriptor.openTxHash }); channel.state = _decodeChannelState(channelStates[_channelId(descriptor)]); } @@ -261,7 +271,8 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { address operator, address token, bytes32 salt, - address authorizedSigner + address authorizedSigner, + bytes32 openTxHash ) public view @@ -275,6 +286,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { token, salt, authorizedSigner, + openTxHash, TIP20_CHANNEL_ESCROW, block.chainid ) @@ -304,16 +316,11 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { descriptor.operator, descriptor.token, descriptor.salt, - descriptor.authorizedSigner + descriptor.authorizedSigner, + descriptor.openTxHash ); } - function _requirePayeeOrOperator(ChannelDescriptor calldata descriptor) internal view { - if (msg.sender == descriptor.payee) return; - if (descriptor.operator != address(0) && msg.sender == descriptor.operator) return; - revert NotPayeeOrOperator(); - } - function _loadChannelState(bytes32 channelId) internal view returns (ChannelState memory) { uint256 packedState = channelStates[channelId]; if (packedState == 0) revert ChannelNotFound(); @@ -344,12 +351,8 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { 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 _validateVoucher( @@ -396,4 +399,10 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { 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 4ed6219632..b40785c242 100644 --- a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol +++ b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol @@ -12,6 +12,7 @@ interface ITIP20ChannelEscrow { address token; bytes32 salt; address authorizedSigner; + bytes32 openTxHash; } struct ChannelState { @@ -46,11 +47,7 @@ interface ITIP20ChannelEscrow { ) external; - function topUp( - ChannelDescriptor calldata descriptor, - uint96 additionalDeposit - ) - external; + function topUp(ChannelDescriptor calldata descriptor, uint96 additionalDeposit) external; function close( ChannelDescriptor calldata descriptor, @@ -82,7 +79,8 @@ interface ITIP20ChannelEscrow { address operator, address token, bytes32 salt, - address authorizedSigner + address authorizedSigner, + bytes32 openTxHash ) external view @@ -106,6 +104,7 @@ interface ITIP20ChannelEscrow { address token, address authorizedSigner, bytes32 salt, + bytes32 openTxHash, uint96 deposit ); @@ -147,7 +146,6 @@ interface ITIP20ChannelEscrow { error ChannelAlreadyExists(); error ChannelNotFound(); - error ChannelFinalized(); error NotPayer(); error NotPayee(); error NotPayeeOrOperator(); diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol index 439440dbb5..48808ef30a 100644 --- a/tips/verify/test/TIP20ChannelEscrow.t.sol +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -1,10 +1,10 @@ // 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 { MockTIP20 } from "./mocks/MockTIP20.sol"; +import { Test } from "forge-std/Test.sol"; contract MockSignatureVerifier { @@ -60,47 +60,43 @@ contract MockSignatureVerifier { } -contract TIP20ChannelEscrowTest is BaseTest { +contract TIP20ChannelEscrowTest is Test { TIP20ChannelEscrow public channel; - TIP20 public token; + MockTIP20 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)); - function setUp() public override { - super.setUp(); - + function setUp() public { 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 = new MockTIP20("Stream Token", "STR", 20_000_000); (payer, payerKey) = makeAddrAndKey("payer"); payee = makeAddr("payee"); - vm.startPrank(admin); - token.grantRole(_ISSUER_ROLE, admin); - token.mint(payer, 20_000_000); - vm.stopPrank(); + token.transfer(payer, 20_000_000); vm.prank(payer); token.approve(address(channel), type(uint256).max); } function _openChannel() internal returns (bytes32) { + _prepareNextOpenTxHash(); vm.prank(payer); return channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); } function _descriptor() internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { - return _descriptor(SALT, address(0)); + return _descriptor(SALT, address(0), lastOpenTxHash); } function _descriptor( @@ -111,13 +107,26 @@ contract TIP20ChannelEscrowTest is BaseTest { view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { - return _descriptor(salt, address(0), authorizedSigner); + return _descriptor(salt, authorizedSigner, lastOpenTxHash); } function _descriptor( + bytes32 salt, + address authorizedSigner, + bytes32 openTxHash + ) + internal + view + returns (ITIP20ChannelEscrow.ChannelDescriptor memory) + { + return _descriptorWithOperator(salt, address(0), authorizedSigner, openTxHash); + } + + function _descriptorWithOperator( bytes32 salt, address operator, - address authorizedSigner + address authorizedSigner, + bytes32 openTxHash ) internal view @@ -129,10 +138,17 @@ contract TIP20ChannelEscrowTest is BaseTest { operator: operator, 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))); } @@ -156,6 +172,8 @@ contract TIP20ChannelEscrowTest is BaseTest { } function test_open_success() public { + bytes32 openTxHash = _prepareNextOpenTxHash(); + vm.prank(payer); bytes32 channelId = channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); @@ -166,6 +184,7 @@ contract TIP20ChannelEscrowTest is BaseTest { 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.closeData, 0); @@ -173,29 +192,35 @@ contract TIP20ChannelEscrowTest is BaseTest { } function test_open_revert_zeroPayee() public { + _prepareNextOpenTxHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidPayee.selector); 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), 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(0), address(token), 0, SALT, address(0)); } - 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(0), address(token), DEPOSIT, SALT, address(0)); + bytes32 channelId2 = _openChannel(); + bytes32 openTxHash2 = lastOpenTxHash; + + assertNotEq(openTxHash1, openTxHash2); + assertNotEq(channelId1, channelId2); } function test_settle_success() public { @@ -231,6 +256,7 @@ 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(0), address(token), DEPOSIT, SALT, delegateSigner); @@ -246,14 +272,16 @@ contract TIP20ChannelEscrowTest is BaseTest { function test_operator_settleSuccess() public { address operator = makeAddr("operator"); + _prepareNextOpenTxHash(); vm.prank(payer); - bytes32 channelId = - channel.open(payee, operator, address(token), DEPOSIT, SALT, address(0)); + bytes32 channelId = channel.open(payee, operator, address(token), DEPOSIT, SALT, address(0)); bytes memory sig = _signVoucher(channelId, 500_000); vm.prank(operator); - channel.settle(_descriptor(SALT, operator, address(0)), 500_000, sig); + channel.settle( + _descriptorWithOperator(SALT, operator, address(0), lastOpenTxHash), 500_000, sig + ); assertEq(channel.getChannelState(channelId).settled, 500_000); assertEq(token.balanceOf(payee), 500_000); @@ -316,8 +344,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); } @@ -336,7 +364,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 { @@ -349,8 +377,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); } @@ -366,33 +394,23 @@ contract TIP20ChannelEscrowTest is BaseTest { channel.close(_descriptor(), 300_000, 200_000, ""); } - function test_close_allowsNoAdditionalCaptureWithoutSignature() public { - bytes32 channelId = _openChannel(); - bytes memory sig = _signVoucher(channelId, 300_000); - - vm.prank(payee); - channel.settle(_descriptor(), 300_000, sig); - - uint256 payerBalanceBefore = token.balanceOf(payer); - vm.prank(payee); - channel.close(_descriptor(), 300_000, 300_000, ""); - - assertEq(channel.getChannelState(channelId).closeData, 1); - 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); + bytes32 originalOpenTxHash = lastOpenTxHash; vm.prank(payee); channel.close(_descriptor(), 600_000, 600_000, sig); - assertEq(channel.getChannelState(channelId).closeData, 1); + 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); } function test_withdraw_afterGracePeriod() public { @@ -407,21 +425,15 @@ 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_withdraw_revert_withoutCloseRequest() public { - _openChannel(); - - vm.prank(payer); - vm.expectRevert(ITIP20ChannelEscrow.CloseNotReady.selector); - channel.withdraw(_descriptor()); - } - function test_getChannelStatesBatch_success() public { bytes32 channelId1 = _openChannel(); + bytes32 channel1OpenTxHash = lastOpenTxHash; + _prepareNextOpenTxHash(); vm.prank(payer); bytes32 channelId2 = channel.open( payee, address(0), address(token), DEPOSIT, bytes32(uint256(2)), address(0) @@ -429,7 +441,7 @@ contract TIP20ChannelEscrowTest is BaseTest { 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; @@ -443,11 +455,14 @@ contract TIP20ChannelEscrowTest is BaseTest { function test_computeChannelId_usesFixedPrecompileAddress() public { TIP20ChannelEscrow other = new TIP20ChannelEscrow(); + bytes32 openTxHash = keccak256("openTxHash"); - bytes32 id1 = - channel.computeChannelId(payer, payee, address(0), address(token), SALT, address(0)); - bytes32 id2 = - other.computeChannelId(payer, payee, address(0), address(token), SALT, address(0)); + 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()); From 0cc095847af8384a338ae042ba64697de1e79f45 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 5 May 2026 16:11:36 +0530 Subject: [PATCH 33/68] docs(tip-1034): clarify settled invariant Amp-Thread-ID: https://ampcode.com/threads/T-019df7af-4aff-72db-a1fc-75dd79aa3607 --- 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 9ef9b42cf2..67bf9e568a 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -257,7 +257,7 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat # Invariants 1. `settled <= deposit` MUST hold in all reachable states. -2. `settled` is monotonic and can never decrease. +2. `settled` is monotonically non-decreasing. 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, or a nonzero operator, can `settle`. From 3f9b33080b40b69de129f13723956a31fa8168de Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 5 May 2026 16:27:11 +0530 Subject: [PATCH 34/68] docs(tip-1034): explain close voucher bounds Amp-Thread-ID: https://ampcode.com/threads/T-019df7af-4aff-72db-a1fc-75dd79aa3607 --- tips/tip-1034.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 67bf9e568a..b72d43dc07 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -182,6 +182,12 @@ Execution semantics are: 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. +The `cumulativeAmount > deposit` allowance is specific to `close`, because `close` has a separate +`captureAmount` parameter. The voucher proves that the payer authorized at least the captured +amount, while `captureAmount` chooses the final escrow-bounded payout. `settle` does not have a +separate capture parameter, so its `cumulativeAmount` is the exact new settled amount and MUST +remain bounded by the current deposit. + ## Native Escrow Movement In this precompile, escrow transfers MUST use system TIP-20 movement semantics equivalent to `systemTransferFrom`. From 16a8ee0e1066a3a8570487472e2a9e0c53f7080b Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 5 May 2026 16:38:12 +0530 Subject: [PATCH 35/68] docs(tip-1034): document same transaction opens Amp-Thread-ID: https://ampcode.com/threads/T-019df7af-4aff-72db-a1fc-75dd79aa3607 --- tips/tip-1034.md | 22 +++++++++++++++++++ tips/verify/src/TIP20ChannelEscrow.sol | 22 +++++++++++++++++-- tips/verify/test/TIP20ChannelEscrow.t.sol | 26 +++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index b72d43dc07..1c3f39852a 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -128,6 +128,28 @@ For `open`, `openTxHash` MUST be the hash of the enclosing channel-open transact post-open operations, `openTxHash` MUST be supplied via `ChannelDescriptor` so the implementation can recompute the same `channelId` without storing immutable descriptor fields on-chain. +### Same-Transaction Opens + +Multiple `open` calls MAY appear in the same top-level transaction, including a Tempo AA batch, as +long as each call derives a distinct `channelId`. Because all calls in the same top-level +transaction share the same `openTxHash`, distinct same-transaction opens MUST differ in at least one +other descriptor field, such as `payee`, `operator`, `token`, `salt`, or `authorizedSigner`. + +This is safe because vouchers are bound to the resulting `channelId`, and each distinct +`channelId` has independent escrow state. For example, an AA transaction MAY atomically open +several channels for different sessions or payees. + +An `open` call MUST NOT succeed if the derived `channelId` was already opened earlier in the same +top-level transaction. This requirement covers both ordinary duplicate opens and the otherwise +dangerous `open -> close` or `open -> withdraw` followed by a second `open` with the same +descriptor. Terminal `close` and `withdraw` delete persistent channel state, so implementations MUST +also maintain transient per-transaction opened-channel tracking to reject such same-transaction +reopens after deletion. + +Reopening the same logical descriptor in a later transaction is allowed after terminal closure, +because the later transaction has a different `openTxHash` and therefore derives a different +`channelId`. + ### Interface The canonical interface for this TIP is [`tips/verify/src/interfaces/ITIP20ChannelEscrow.sol`](verify/src/interfaces/ITIP20ChannelEscrow.sol). diff --git a/tips/verify/src/TIP20ChannelEscrow.sol b/tips/verify/src/TIP20ChannelEscrow.sol index 599e6c368b..8a2f0e2b2a 100644 --- a/tips/verify/src/TIP20ChannelEscrow.sol +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -29,9 +29,17 @@ 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. + // The enshrined precompile should track `openedThisTx[channelId]` in transient storage and + // clear it automatically at the end of the top-level transaction. That allows multiple `open` + // calls in one AA batch when they derive distinct channel IDs, while preventing the same channel + // ID from being reopened after a same-transaction terminal `close` or `withdraw` deletes the + // persistent state slot. + // + // This Solidity reference uses persistent storage because tests cannot model precompile + // transient storage directly. Since `channelId` includes `openTxHash`, a real cross-transaction + // reopen has a different ID and is not blocked by this persistent approximation. mapping(bytes32 => bool) internal _openedChannelIdsForTest; error OpenTxHashNotSet(); @@ -60,7 +68,14 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { channelId = computeChannelId( msg.sender, payee, operator, token, salt, authorizedSigner, openTxHash ); + + // Reject ordinary duplicate opens while the channel is still active. if (channelStates[channelId] != 0) revert ChannelAlreadyExists(); + + // Also reject same-top-level-transaction reopens of a channel ID that was opened earlier + // and then terminally closed or withdrawn. Without this guard, terminal deletion would make + // the persistent state slot look unused again even though the enclosing tx hash, and thus + // the derived channel ID, is unchanged for later calls in the same AA batch. if (_openedChannelIdsForTest[channelId]) revert ChannelAlreadyExists(); channelStates[channelId] = @@ -70,6 +85,9 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { // The enshrined precompile should use TIP-20 `systemTransferFrom` semantics instead. bool success = ITIP20(token).transferFrom(msg.sender, address(this), deposit); if (!success) revert TransferFailed(); + + // Mark after the escrow transfer succeeds so failed opens do not poison the guard. The real + // precompile marker is transient and only protects the current top-level transaction. _openedChannelIdsForTest[channelId] = true; emit ChannelOpened( diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol index 48808ef30a..67388b6028 100644 --- a/tips/verify/test/TIP20ChannelEscrow.t.sol +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -223,6 +223,27 @@ contract TIP20ChannelEscrowTest is Test { assertNotEq(channelId1, channelId2); } + function test_open_allows_distinct_channel_ids_with_same_open_tx_hash() public { + bytes32 sharedOpenTxHash = keccak256("same top-level tx"); + + channel.setOpenTxHashForTest(sharedOpenTxHash); + vm.prank(payer); + bytes32 channelId1 = + channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); + + channel.setOpenTxHashForTest(sharedOpenTxHash); + vm.prank(payer); + bytes32 channelId2 = channel.open( + payee, address(0), address(token), DEPOSIT, bytes32(uint256(2)), address(0) + ); + + // Same top-level transaction means the same openTxHash, but distinct descriptors still + // derive independent channel IDs and are safe to open atomically in one AA batch. + assertNotEq(channelId1, channelId2); + assertEq(channel.getChannelState(channelId1).deposit, DEPOSIT); + assertEq(channel.getChannelState(channelId2).deposit, DEPOSIT); + } + function test_settle_success() public { bytes32 channelId = _openChannel(); bytes memory sig = _signVoucher(channelId, 500_000); @@ -404,11 +425,16 @@ contract TIP20ChannelEscrowTest is Test { assertEq(channel.getChannelState(channelId).closeData, 0); + // Reusing the original openTxHash approximates a later call in the same top-level AA batch. + // The persistent channel slot has been deleted by close, so the per-transaction opened-ID + // guard is what prevents reopening the same channel ID before the transaction ends. channel.setOpenTxHashForTest(originalOpenTxHash); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); + // A later transaction has a fresh openTxHash, so the same logical descriptor derives a new + // channel ID and may be opened after the previous channel terminally closed. bytes32 reopenedChannelId = _openChannel(); assertNotEq(reopenedChannelId, channelId); } From a895cb3b621fefd4d50f728f0ed47adcd0a9a9b1 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 5 May 2026 15:46:11 +0200 Subject: [PATCH 36/68] fix: limit payment lane access key auth to 1KB --- tips/tip-1045.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index 5a37b5e8b7..2dbae5ec6a 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -72,6 +72,10 @@ Any transaction carrying a non-empty `authorization_list` (EIP-7702) or non-empt `key_authorization` is exempt from this restriction and MAY be present in payment transactions. It is a single, size-bounded structure tied to onboarding the transaction's sender, and removing it from the payment lane would break first-transaction UX without offering a comparable data-injection surface. +To bound the data-injection surface that `key_authorization` itself can carry, payment transactions MUST satisfy `len(rlp(key_authorization)) <= 1024` bytes. Any payment-eligible transaction whose `key_authorization` exceeds this bound MUST be classified as general. The 1 KB ceiling comfortably fits realistic provisioning payloads with limits and scopes. + +No consensus-level cap is placed on the overall transaction size: payment transactions are free to batch many TIP-20 calls in a single tx, and any tx-size shaping is left to the builder. + ## Transaction Types For legacy, EIP-2930, EIP-1559, and EIP-7702 transactions, the single top-level call must match the payment call allow-list, `access_list` must be empty, and `authorization_list` must be empty. @@ -91,5 +95,6 @@ Before T5, only the legacy TIP-20 address prefix check is enforced at the consen 3. **No trailing bytes**: Calldata longer than the expected ABI-encoded length for a matched allow-list entry MUST be classified as general. 4. **No contract creation**: Any transaction containing `CREATE`, `CREATE2`, or `to = null` MUST be classified as general. 5. **AA atomicity**: An AA transaction is a payment only if `calls` is non-empty and **all** of its calls independently match the payment call allow-list. A single non-qualifying call MUST cause the entire transaction to be classified as general. -6. **No access lists**: Any transaction with a non-empty `access_list` MUST be classified as general, regardless of type, targets, or calldata. Non-empty access lists allow carrying arbitrary data at payment lane gas prices. -7. **No authorization lists**: Any transaction with a non-empty `authorization_list` or non-empty `tempo_authorization_list` MUST be classified as general. `key_authorization` is exempt and does not affect classification. +6. **Bounded key authorization**: Any transaction whose `key_authorization` is present and whose RLP encoding exceeds 1024 bytes MUST be classified as general. No consensus cap is placed on the overall transaction size. +7. **No access lists**: Any transaction with a non-empty `access_list` MUST be classified as general, regardless of type, targets, or calldata. Non-empty access lists allow carrying arbitrary data at payment lane gas prices. +8. **No authorization lists**: Any transaction with a non-empty `authorization_list` or non-empty `tempo_authorization_list` MUST be classified as general. From 320b802d1c20ec42809ed9594715f70304de1970 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 6 May 2026 16:00:51 +0530 Subject: [PATCH 37/68] docs(tip-1034): use expiring nonce hash for channel IDs Amp-Thread-ID: https://ampcode.com/threads/T-019dfcd0-bc06-77ad-9d17-36b4f6f87ad2 --- tips/tip-1034.md | 38 +++++--- tips/verify/src/TIP20ChannelEscrow.sol | 41 ++++----- .../src/interfaces/ITIP20ChannelEscrow.sol | 6 +- tips/verify/test/TIP20ChannelEscrow.t.sol | 86 +++++++++---------- 4 files changed, 93 insertions(+), 78 deletions(-) diff --git a/tips/tip-1034.md b/tips/tip-1034.md index 1c3f39852a..9418a0b829 100644 --- a/tips/tip-1034.md +++ b/tips/tip-1034.md @@ -4,7 +4,7 @@ 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, Brendan Ryan status: Draft -related: TIP-20, TIP-1000, TIP-1020, Tempo Session, Tempo Charge +related: TIP-20, TIP-1000, TIP-1009, TIP-1020, Tempo Session, Tempo Charge protocolVersion: TBD --- @@ -61,7 +61,7 @@ struct ChannelDescriptor { address token; bytes32 salt; address authorizedSigner; - bytes32 openTxHash; + bytes32 expiringNonceHash; } struct ChannelState { @@ -117,23 +117,36 @@ channelId = keccak256( token, salt, authorizedSigner, - openTxHash, + expiringNonceHash, 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. +For `open`, `expiringNonceHash` MUST be the expiring nonce hash of the enclosing +channel-open transaction. This is the sender-bound transaction signing payload hash used for +expiring nonce replay protection, not the full transaction hash, so it is invariant to fee-payer +signature changes while still identifying a transaction that cannot be replayed. `open` MUST reject +if the enclosing transaction does not have an expiring nonce hash available. + +The full transaction hash is intentionally not used because it commits to fee-payer signature data. +Changing the fee payer would change the transaction hash even though the sender-authorized channel +open payload is the same, which would make the channel ID relayer-dependent. Using the expiring +nonce hash keeps the stateless, client-predictable properties of a transaction-derived seed while +binding the channel ID to the replay-protected sender payload. + +For all post-open operations, `expiringNonceHash` MUST be supplied via `ChannelDescriptor` so the +implementation can recompute the same `channelId` without storing immutable descriptor fields +on-chain. ### Same-Transaction Opens Multiple `open` calls MAY appear in the same top-level transaction, including a Tempo AA batch, as long as each call derives a distinct `channelId`. Because all calls in the same top-level -transaction share the same `openTxHash`, distinct same-transaction opens MUST differ in at least one -other descriptor field, such as `payee`, `operator`, `token`, `salt`, or `authorizedSigner`. +transaction share the same `expiringNonceHash`, distinct same-transaction opens MUST differ in at +least one other descriptor field, such as `payee`, `operator`, `token`, `salt`, or +`authorizedSigner`. This is safe because vouchers are bound to the resulting `channelId`, and each distinct `channelId` has independent escrow state. For example, an AA transaction MAY atomically open @@ -147,7 +160,7 @@ also maintain transient per-transaction opened-channel tracking to reject such s reopens after deletion. Reopening the same logical descriptor in a later transaction is allowed after terminal closure, -because the later transaction has a different `openTxHash` and therefore derives a different +because the later transaction has a different `expiringNonceHash` and therefore derives a different `channelId`. ### Interface @@ -188,7 +201,7 @@ all settlement payouts still transfer to `payee`. Execution semantics are: 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`. +2. `open` MUST derive `expiringNonceHash` from the enclosing transaction's expiring nonce 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. 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. @@ -201,7 +214,7 @@ Execution semantics are: 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. +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 `expiringNonceHash` 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. The `cumulativeAmount > deposit` allowance is specific to `close`, because `close` has a separate @@ -294,7 +307,7 @@ With this integration, channel lifecycle calls consume payment-lane capacity rat 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. +11. Reopening the same logical channel in a later transaction MUST yield a different `channelId` because `expiringNonceHash` 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. @@ -306,6 +319,7 @@ 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-1009](tip-1009.md) - [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) diff --git a/tips/verify/src/TIP20ChannelEscrow.sol b/tips/verify/src/TIP20ChannelEscrow.sol index 8a2f0e2b2a..5eb282f8c5 100644 --- a/tips/verify/src/TIP20ChannelEscrow.sol +++ b/tips/verify/src/TIP20ChannelEscrow.sol @@ -28,7 +28,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { bytes32 internal constant _VERSION_HASH = keccak256("1"); mapping(bytes32 => uint256) internal channelStates; - bytes32 internal _openTxHashContext; + bytes32 internal _expiringNonceHashContext; // Reference-contract-only approximation of the precompile's transient per-transaction guard. // The enshrined precompile should track `openedThisTx[channelId]` in transient storage and @@ -38,15 +38,16 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { // persistent state slot. // // This Solidity reference uses persistent storage because tests cannot model precompile - // transient storage directly. Since `channelId` includes `openTxHash`, a real cross-transaction - // reopen has a different ID and is not blocked by this persistent approximation. + // transient storage directly. Since `channelId` includes `expiringNonceHash`, a real + // cross-transaction reopen has a different ID and is not blocked by this persistent + // approximation. mapping(bytes32 => bool) internal _openedChannelIdsForTest; - error OpenTxHashNotSet(); + error ExpiringNonceHashNotSet(); - /// @dev Reference-contract-only hook. The precompile derives this from the enclosing tx hash. - function setOpenTxHashForTest(bytes32 openTxHash) external { - _openTxHashContext = openTxHash; + /// @dev Reference-contract-only hook. The precompile derives this from the enclosing tx's expiring nonce hash. + function setExpiringNonceHashForTest(bytes32 expiringNonceHash) external { + _expiringNonceHashContext = expiringNonceHash; } function open( @@ -64,9 +65,9 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { if (token == address(0)) revert InvalidToken(); if (deposit == 0) revert ZeroDeposit(); - bytes32 openTxHash = _consumeOpenTxHash(); + bytes32 expiringNonceHash = _consumeExpiringNonceHash(); channelId = computeChannelId( - msg.sender, payee, operator, token, salt, authorizedSigner, openTxHash + msg.sender, payee, operator, token, salt, authorizedSigner, expiringNonceHash ); // Reject ordinary duplicate opens while the channel is still active. @@ -74,8 +75,8 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { // Also reject same-top-level-transaction reopens of a channel ID that was opened earlier // and then terminally closed or withdrawn. Without this guard, terminal deletion would make - // the persistent state slot look unused again even though the enclosing tx hash, and thus - // the derived channel ID, is unchanged for later calls in the same AA batch. + // the persistent state slot look unused again even though the enclosing expiring nonce + // hash, and thus the derived channel ID, is unchanged for later calls in the same AA batch. if (_openedChannelIdsForTest[channelId]) revert ChannelAlreadyExists(); channelStates[channelId] = @@ -98,7 +99,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { token, authorizedSigner, salt, - openTxHash, + expiringNonceHash, deposit ); } @@ -261,7 +262,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { token: descriptor.token, salt: descriptor.salt, authorizedSigner: descriptor.authorizedSigner, - openTxHash: descriptor.openTxHash + expiringNonceHash: descriptor.expiringNonceHash }); channel.state = _decodeChannelState(channelStates[_channelId(descriptor)]); } @@ -290,7 +291,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { address token, bytes32 salt, address authorizedSigner, - bytes32 openTxHash + bytes32 expiringNonceHash ) public view @@ -304,7 +305,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { token, salt, authorizedSigner, - openTxHash, + expiringNonceHash, TIP20_CHANNEL_ESCROW, block.chainid ) @@ -335,7 +336,7 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { descriptor.token, descriptor.salt, descriptor.authorizedSigner, - descriptor.openTxHash + descriptor.expiringNonceHash ); } @@ -417,10 +418,10 @@ contract TIP20ChannelEscrow is ITIP20ChannelEscrow { return keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); } - function _consumeOpenTxHash() internal returns (bytes32 openTxHash) { - openTxHash = _openTxHashContext; - if (openTxHash == bytes32(0)) revert OpenTxHashNotSet(); - delete _openTxHashContext; + function _consumeExpiringNonceHash() internal returns (bytes32 expiringNonceHash) { + expiringNonceHash = _expiringNonceHashContext; + if (expiringNonceHash == bytes32(0)) revert ExpiringNonceHashNotSet(); + delete _expiringNonceHashContext; } } diff --git a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol index b40785c242..def8cb1fdb 100644 --- a/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol +++ b/tips/verify/src/interfaces/ITIP20ChannelEscrow.sol @@ -12,7 +12,7 @@ interface ITIP20ChannelEscrow { address token; bytes32 salt; address authorizedSigner; - bytes32 openTxHash; + bytes32 expiringNonceHash; } struct ChannelState { @@ -80,7 +80,7 @@ interface ITIP20ChannelEscrow { address token, bytes32 salt, address authorizedSigner, - bytes32 openTxHash + bytes32 expiringNonceHash ) external view @@ -104,7 +104,7 @@ interface ITIP20ChannelEscrow { address token, address authorizedSigner, bytes32 salt, - bytes32 openTxHash, + bytes32 expiringNonceHash, uint96 deposit ); diff --git a/tips/verify/test/TIP20ChannelEscrow.t.sol b/tips/verify/test/TIP20ChannelEscrow.t.sol index 67388b6028..11fe96ac57 100644 --- a/tips/verify/test/TIP20ChannelEscrow.t.sol +++ b/tips/verify/test/TIP20ChannelEscrow.t.sol @@ -68,8 +68,8 @@ contract TIP20ChannelEscrowTest is Test { address public payer; uint256 public payerKey; address public payee; - bytes32 internal lastOpenTxHash; - uint256 internal openTxCounter; + bytes32 internal lastExpiringNonceHash; + uint256 internal expiringNonceCounter; uint96 internal constant DEPOSIT = 1_000_000; bytes32 internal constant SALT = bytes32(uint256(1)); @@ -90,13 +90,13 @@ contract TIP20ChannelEscrowTest is Test { } function _openChannel() internal returns (bytes32) { - _prepareNextOpenTxHash(); + _prepareNextExpiringNonceHash(); vm.prank(payer); return channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); } function _descriptor() internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { - return _descriptor(SALT, address(0), lastOpenTxHash); + return _descriptor(SALT, address(0), lastExpiringNonceHash); } function _descriptor( @@ -107,26 +107,26 @@ contract TIP20ChannelEscrowTest is Test { view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { - return _descriptor(salt, authorizedSigner, lastOpenTxHash); + return _descriptor(salt, authorizedSigner, lastExpiringNonceHash); } function _descriptor( bytes32 salt, address authorizedSigner, - bytes32 openTxHash + bytes32 expiringNonceHash ) internal view returns (ITIP20ChannelEscrow.ChannelDescriptor memory) { - return _descriptorWithOperator(salt, address(0), authorizedSigner, openTxHash); + return _descriptorWithOperator(salt, address(0), authorizedSigner, expiringNonceHash); } function _descriptorWithOperator( bytes32 salt, address operator, address authorizedSigner, - bytes32 openTxHash + bytes32 expiringNonceHash ) internal view @@ -139,14 +139,14 @@ contract TIP20ChannelEscrowTest is Test { token: address(token), salt: salt, authorizedSigner: authorizedSigner, - openTxHash: openTxHash + expiringNonceHash: expiringNonceHash }); } - function _prepareNextOpenTxHash() internal returns (bytes32 openTxHash) { - openTxHash = keccak256(abi.encodePacked("open", ++openTxCounter)); - channel.setOpenTxHashForTest(openTxHash); - lastOpenTxHash = openTxHash; + function _prepareNextExpiringNonceHash() internal returns (bytes32 expiringNonceHash) { + expiringNonceHash = keccak256(abi.encodePacked("open", ++expiringNonceCounter)); + channel.setExpiringNonceHashForTest(expiringNonceHash); + lastExpiringNonceHash = expiringNonceHash; } function _channelStateSlot(bytes32 channelId) internal pure returns (bytes32) { @@ -172,7 +172,7 @@ contract TIP20ChannelEscrowTest is Test { } function test_open_success() public { - bytes32 openTxHash = _prepareNextOpenTxHash(); + bytes32 expiringNonceHash = _prepareNextExpiringNonceHash(); vm.prank(payer); bytes32 channelId = @@ -184,7 +184,7 @@ contract TIP20ChannelEscrowTest is Test { 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.descriptor.expiringNonceHash, expiringNonceHash); assertEq(ch.state.settled, 0); assertEq(ch.state.deposit, DEPOSIT); assertEq(ch.state.closeData, 0); @@ -192,53 +192,53 @@ contract TIP20ChannelEscrowTest is Test { } function test_open_revert_zeroPayee() public { - _prepareNextOpenTxHash(); + _prepareNextExpiringNonceHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidPayee.selector); channel.open(address(0), address(0), address(token), DEPOSIT, SALT, address(0)); } function test_open_revert_zeroToken() public { - _prepareNextOpenTxHash(); + _prepareNextExpiringNonceHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.InvalidToken.selector); channel.open(payee, address(0), address(0), DEPOSIT, SALT, address(0)); } function test_open_revert_zeroDeposit() public { - _prepareNextOpenTxHash(); + _prepareNextExpiringNonceHash(); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ZeroDeposit.selector); channel.open(payee, address(0), address(token), 0, SALT, address(0)); } - function test_open_same_descriptor_uses_distinct_open_tx_hashes() public { + function test_open_same_descriptor_uses_distinct_expiring_nonce_hashes() public { bytes32 channelId1 = _openChannel(); - bytes32 openTxHash1 = lastOpenTxHash; + bytes32 expiringNonceHash1 = lastExpiringNonceHash; bytes32 channelId2 = _openChannel(); - bytes32 openTxHash2 = lastOpenTxHash; + bytes32 expiringNonceHash2 = lastExpiringNonceHash; - assertNotEq(openTxHash1, openTxHash2); + assertNotEq(expiringNonceHash1, expiringNonceHash2); assertNotEq(channelId1, channelId2); } - function test_open_allows_distinct_channel_ids_with_same_open_tx_hash() public { - bytes32 sharedOpenTxHash = keccak256("same top-level tx"); + function test_open_allows_distinct_channel_ids_with_same_expiring_nonce_hash() public { + bytes32 sharedExpiringNonceHash = keccak256("same top-level tx"); - channel.setOpenTxHashForTest(sharedOpenTxHash); + channel.setExpiringNonceHashForTest(sharedExpiringNonceHash); vm.prank(payer); bytes32 channelId1 = channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); - channel.setOpenTxHashForTest(sharedOpenTxHash); + channel.setExpiringNonceHashForTest(sharedExpiringNonceHash); vm.prank(payer); bytes32 channelId2 = channel.open( payee, address(0), address(token), DEPOSIT, bytes32(uint256(2)), address(0) ); - // Same top-level transaction means the same openTxHash, but distinct descriptors still - // derive independent channel IDs and are safe to open atomically in one AA batch. + // Same top-level transaction means the same expiringNonceHash, but distinct descriptors + // still derive independent channel IDs and are safe to open atomically in one AA batch. assertNotEq(channelId1, channelId2); assertEq(channel.getChannelState(channelId1).deposit, DEPOSIT); assertEq(channel.getChannelState(channelId2).deposit, DEPOSIT); @@ -277,7 +277,7 @@ contract TIP20ChannelEscrowTest is Test { function test_authorizedSigner_settleSuccess() public { (address delegateSigner, uint256 delegateKey) = makeAddrAndKey("delegate"); - _prepareNextOpenTxHash(); + _prepareNextExpiringNonceHash(); vm.prank(payer); bytes32 channelId = channel.open(payee, address(0), address(token), DEPOSIT, SALT, delegateSigner); @@ -293,7 +293,7 @@ contract TIP20ChannelEscrowTest is Test { function test_operator_settleSuccess() public { address operator = makeAddr("operator"); - _prepareNextOpenTxHash(); + _prepareNextExpiringNonceHash(); vm.prank(payer); bytes32 channelId = channel.open(payee, operator, address(token), DEPOSIT, SALT, address(0)); @@ -301,7 +301,7 @@ contract TIP20ChannelEscrowTest is Test { vm.prank(operator); channel.settle( - _descriptorWithOperator(SALT, operator, address(0), lastOpenTxHash), 500_000, sig + _descriptorWithOperator(SALT, operator, address(0), lastExpiringNonceHash), 500_000, sig ); assertEq(channel.getChannelState(channelId).settled, 500_000); @@ -415,26 +415,26 @@ contract TIP20ChannelEscrowTest is Test { channel.close(_descriptor(), 300_000, 200_000, ""); } - function test_close_clears_state_and_allows_reopen_with_new_open_tx_hash() public { + function test_close_clears_state_and_allows_reopen_with_new_expiring_nonce_hash() public { bytes32 channelId = _openChannel(); bytes memory sig = _signVoucher(channelId, 600_000); - bytes32 originalOpenTxHash = lastOpenTxHash; + bytes32 originalExpiringNonceHash = lastExpiringNonceHash; vm.prank(payee); channel.close(_descriptor(), 600_000, 600_000, sig); assertEq(channel.getChannelState(channelId).closeData, 0); - // Reusing the original openTxHash approximates a later call in the same top-level AA batch. + // Reusing the original expiringNonceHash approximates a later call in the same top-level AA batch. // The persistent channel slot has been deleted by close, so the per-transaction opened-ID // guard is what prevents reopening the same channel ID before the transaction ends. - channel.setOpenTxHashForTest(originalOpenTxHash); + channel.setExpiringNonceHashForTest(originalExpiringNonceHash); vm.prank(payer); vm.expectRevert(ITIP20ChannelEscrow.ChannelAlreadyExists.selector); channel.open(payee, address(0), address(token), DEPOSIT, SALT, address(0)); - // A later transaction has a fresh openTxHash, so the same logical descriptor derives a new - // channel ID and may be opened after the previous channel terminally closed. + // A later transaction has a fresh expiringNonceHash, so the same logical descriptor derives + // a new channel ID and may be opened after the previous channel terminally closed. bytes32 reopenedChannelId = _openChannel(); assertNotEq(reopenedChannelId, channelId); } @@ -457,9 +457,9 @@ contract TIP20ChannelEscrowTest is Test { function test_getChannelStatesBatch_success() public { bytes32 channelId1 = _openChannel(); - bytes32 channel1OpenTxHash = lastOpenTxHash; + bytes32 channel1ExpiringNonceHash = lastExpiringNonceHash; - _prepareNextOpenTxHash(); + _prepareNextExpiringNonceHash(); vm.prank(payer); bytes32 channelId2 = channel.open( payee, address(0), address(token), DEPOSIT, bytes32(uint256(2)), address(0) @@ -467,7 +467,7 @@ contract TIP20ChannelEscrowTest is Test { bytes memory sig = _signVoucher(channelId1, 400_000); vm.prank(payee); - channel.settle(_descriptor(SALT, address(0), channel1OpenTxHash), 400_000, sig); + channel.settle(_descriptor(SALT, address(0), channel1ExpiringNonceHash), 400_000, sig); bytes32[] memory ids = new bytes32[](2); ids[0] = channelId1; @@ -481,13 +481,13 @@ contract TIP20ChannelEscrowTest is Test { function test_computeChannelId_usesFixedPrecompileAddress() public { TIP20ChannelEscrow other = new TIP20ChannelEscrow(); - bytes32 openTxHash = keccak256("openTxHash"); + bytes32 expiringNonceHash = keccak256("expiringNonceHash"); bytes32 id1 = channel.computeChannelId( - payer, payee, address(0), address(token), SALT, address(0), openTxHash + payer, payee, address(0), address(token), SALT, address(0), expiringNonceHash ); bytes32 id2 = other.computeChannelId( - payer, payee, address(0), address(token), SALT, address(0), openTxHash + payer, payee, address(0), address(token), SALT, address(0), expiringNonceHash ); assertEq(id1, id2); From 0d833062ce770c546053703a1d398d50bf98e2c2 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 23 Apr 2026 16:19:20 +0530 Subject: [PATCH 38/68] 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 1cab6639c1..54dbece121 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, @@ -458,6 +459,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(()) } @@ -1608,7 +1612,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 8c0792017d..3b5416d71c 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 { @@ -1138,6 +1157,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!( @@ -1165,6 +1191,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 1d1025cddbec0afa940910081f04a19a8edb5fab Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 23 Apr 2026 20:04:12 +0530 Subject: [PATCH 39/68] 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 54dbece121..488dbcce50 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -409,8 +409,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 // @@ -459,7 +463,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)?; } @@ -513,10 +517,14 @@ where } else { self.validate_tx(recovered.tx(), block_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()), block_gas_used, @@ -1612,6 +1620,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 779d4bec8b..3234baf838 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; @@ -479,7 +480,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( @@ -499,7 +500,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 3b5416d71c..2d4c10903b 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 60a160085f..a04a88b9db 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. @@ -61,7 +63,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()) @@ -77,7 +80,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(), @@ -121,8 +125,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: @@ -661,7 +669,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, @@ -698,7 +706,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(pooled_tx.is_payment()); + assert!(pooled_tx.is_payment(false)); } #[test] @@ -723,7 +731,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(!pooled_tx.is_payment()); + assert!(!pooled_tx.is_payment(false)); } #[test] @@ -733,7 +741,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 0262aa46e46b9e78c67922780f91006974b84917 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Fri, 24 Apr 2026 19:07:21 +0530 Subject: [PATCH 40/68] 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 fb4b777bb1b89487abbf25a96c83da4382978056 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 17:53:12 +0530 Subject: [PATCH 41/68] 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 2d4c10903b..993a057710 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 4886c81f386b3c6cd7c20c568f42d3b8caef30ef Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 18:37:20 +0530 Subject: [PATCH 42/68] 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 d989c2830156f0a48fc38b7f4ba2c72bb0beedc3 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 18:49:36 +0530 Subject: [PATCH 43/68] 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 3bf5bdedf4bea806535e16dc0c8c45d21ddd6ec6 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 19:22:35 +0530 Subject: [PATCH 44/68] 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 f970c68c94e21e424f698401d69bd636f2842a6c Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 19:42:58 +0530 Subject: [PATCH 45/68] 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 0c808a246a5d0ac150e47bfeadf91c90687aceaa Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 29 Apr 2026 15:18:43 +0530 Subject: [PATCH 46/68] 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 488dbcce50..5dd00f4603 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -409,12 +409,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 // @@ -517,14 +513,10 @@ where } else { self.validate_tx(recovered.tx(), block_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()), block_gas_used, 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 3234baf838..779d4bec8b 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; @@ -480,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(t5_active) + if !pool_tx.transaction.is_payment() && non_payment_gas_used + max_regular_gas_used > general_gas_limit { best_txs.mark_invalid( @@ -500,7 +499,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 a04a88b9db..60a160085f 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. @@ -63,8 +61,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()) @@ -80,8 +77,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(), @@ -125,12 +121,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: @@ -669,7 +661,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, @@ -706,7 +698,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(pooled_tx.is_payment(false)); + assert!(pooled_tx.is_payment()); } #[test] @@ -731,7 +723,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(!pooled_tx.is_payment(false)); + assert!(!pooled_tx.is_payment()); } #[test] @@ -741,43 +733,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 3245dc59bed8e640f2aaa87a94f50832da8fd968 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 29 Apr 2026 16:18:02 +0530 Subject: [PATCH 47/68] 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 1f4c83868a556f02a3574d2138c2c8b4d3a7c940 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Fri, 1 May 2026 18:30:21 +0530 Subject: [PATCH 48/68] refactor(tip-1034): remove escrow expiry Amp-Thread-ID: https://ampcode.com/threads/T-019de37f-1c81-72dd-984c-8460e5729fd5 --- .../src/precompiles/tip20_channel_escrow.rs | 25 +--- .../src/tip20_channel_escrow/mod.rs | 115 +++++++----------- 2 files changed, 50 insertions(+), 90 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index 6ad8834e8c..c92eafe7f0 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -23,7 +23,6 @@ crate::sol! { struct ChannelState { uint96 settled; uint96 deposit; - uint32 expiresAt; uint32 closeData; } @@ -40,8 +39,7 @@ crate::sol! { address token, uint96 deposit, bytes32 salt, - address authorizedSigner, - uint32 expiresAt + address authorizedSigner ) external returns (bytes32 channelId); @@ -55,8 +53,7 @@ crate::sol! { function topUp( ChannelDescriptor calldata descriptor, - uint96 additionalDeposit, - uint32 newExpiresAt + uint96 additionalDeposit ) external; @@ -109,8 +106,7 @@ crate::sol! { address token, address authorizedSigner, bytes32 salt, - uint96 deposit, - uint32 expiresAt + uint96 deposit ); event Settled( @@ -127,8 +123,7 @@ crate::sol! { address indexed payer, address indexed payee, uint96 additionalDeposit, - uint96 newDeposit, - uint32 newExpiresAt + uint96 newDeposit ); event CloseRequested( @@ -152,8 +147,6 @@ crate::sol! { address indexed payee ); - event ChannelExpired(bytes32 indexed channelId, address indexed payer, address indexed payee); - error ChannelAlreadyExists(); error ChannelNotFound(); error ChannelFinalized(); @@ -162,8 +155,6 @@ crate::sol! { error InvalidPayee(); error InvalidToken(); error ZeroDeposit(); - error InvalidExpiry(); - error ChannelExpiredError(); error InvalidSignature(); error AmountExceedsDeposit(); error AmountNotIncreasing(); @@ -207,14 +198,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 5bfd492379..f5ed1e846c 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -6,18 +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}; +use tempo_precompiles_macros::{Storable, contract}; const FINALIZED_CLOSE_DATA: u32 = 1; @@ -36,7 +36,6 @@ static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); struct PackedChannelState { settled: U96, deposit: U96, - expires_at: u32, close_data: u32, } @@ -57,7 +56,6 @@ impl PackedChannelState { ITIP20ChannelEscrow::ChannelState { settled: self.settled, deposit: self.deposit, - expiresAt: self.expires_at, closeData: self.close_data, } } @@ -89,9 +87,6 @@ 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 channel_id = self.compute_channel_id_inner( msg_sender, @@ -108,7 +103,6 @@ impl TIP20ChannelEscrow { 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( @@ -125,7 +119,6 @@ impl TIP20ChannelEscrow { authorizedSigner: call.authorizedSigner, salt: call.salt, deposit: call.deposit, - expiresAt: call.expiresAt, }, ))?; batch.commit(); @@ -147,9 +140,6 @@ impl TIP20ChannelEscrow { 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 = call.cumulativeAmount; if cumulative > state.deposit { @@ -214,12 +204,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(); @@ -231,9 +215,6 @@ impl TIP20ChannelEscrow { U256::from(call.additionalDeposit), )?; } - if call.newExpiresAt != 0 { - state.expires_at = call.newExpiresAt; - } if had_close_request { state.close_data = 0; } @@ -254,7 +235,6 @@ impl TIP20ChannelEscrow { payee: call.descriptor.payee, additionalDeposit: call.additionalDeposit, newDeposit: state.deposit, - newExpiresAt: state.expires_at, }))?; batch.commit(); @@ -326,9 +306,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, @@ -352,18 +329,10 @@ impl TIP20ChannelEscrow { 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( @@ -398,7 +367,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()); } @@ -417,13 +386,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, @@ -502,10 +464,6 @@ 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, @@ -571,11 +529,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())?; @@ -607,9 +561,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}, @@ -672,7 +626,6 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let now = StorageCtx::default().timestamp().to::(); let channel_id = escrow.open( payer, @@ -682,7 +635,6 @@ mod tests { deposit: abi_u96(300), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 1_000, }, )?; @@ -727,7 +679,6 @@ mod tests { deposit: abi_u96(1), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 2_000, }, ); assert_eq!( @@ -754,7 +705,6 @@ mod tests { 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, @@ -764,7 +714,6 @@ mod tests { deposit: abi_u96(100), salt, authorizedSigner: Address::ZERO, - expiresAt: expires_at, }, )?; @@ -779,14 +728,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(()) }) @@ -804,7 +751,6 @@ mod tests { deposit: abi_u96(1), salt: B256::ZERO, authorizedSigner: Address::ZERO, - expiresAt: 2, } .abi_encode(), Address::ZERO, @@ -828,7 +774,6 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let now = StorageCtx::default().timestamp().to::(); escrow.open( payer, ITIP20ChannelEscrow::openCall { @@ -837,7 +782,6 @@ mod tests { deposit: abi_u96(100), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 1_000, }, )?; @@ -873,7 +817,6 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let now = StorageCtx::default().timestamp().to::(); escrow.open( payer, ITIP20ChannelEscrow::openCall { @@ -882,7 +825,6 @@ mod tests { deposit: abi_u96(100), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 1_000, }, )?; @@ -906,4 +848,39 @@ mod tests { Ok(()) }) } + + #[test] + fn test_withdraw_requires_close_request() -> 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 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, + }, + )?; + + let result = escrow.withdraw(payer, ITIP20ChannelEscrow::withdrawCall { descriptor }); + assert_eq!( + result.unwrap_err(), + TIP20ChannelEscrowError::close_not_ready().into() + ); + Ok(()) + }) + } } From acaec75ea53031dde49a45ff366dd656e2fcf084 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 7 May 2026 00:08:08 +0200 Subject: [PATCH 49/68] fix: improve wording and narrative --- tips/tip-1045.md | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index 2dbae5ec6a..71a88d2884 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -1,7 +1,7 @@ --- id: TIP-1045 title: Payment Transaction Classification -description: Defines consensus rules for classifying transactions as payment lane eligible using an allow-list of call targets/selectors and empty access lists. +description: Defines consensus rules for classifying transactions as payment lane eligible using an allow-list of call targets/selectors and bounded auxiliary payloads. authors: @0xrusowsky, Arsenii Kulikov @klkvr, Dan Robinson @danrobinson, Tanishk Goyal @legion2002 status: Draft related: TIP-1010, TIP-1000, Payment Lane Specification, TIP-20, TIP-1034 @@ -12,33 +12,40 @@ protocolVersion: T5 ## Abstract -This TIP formalizes the consensus rules for classifying a transaction as payment lane eligible under TIP-1010. Starting with the T5 hardfork, consensus requires every call in the transaction to match a payment call allow-list entry (target + selector + exact ABI-encoded calldata length) and requires `access_list` to be empty. +This TIP formalizes the consensus rules for classifying a transaction as payment lane eligible under TIP-1010. Starting with the T5 hardfork, consensus requires every call in the transaction to match a payment call allow-list entry (target + selector + ABI encoding constraints) and requires auxiliary payload fields such as access lists and authorization lists to satisfy the restrictions below. ## Motivation TIP-1010 defines the payment lane gas budget but leaves the classification criteria underspecified — only requiring that the transaction targets a TIP-20 address. As payment lane eligibility expands beyond TIP-20 calls, consensus needs a generic but stable predicate that can be maintained indefinitely for historical validation. -This TIP therefore enshrines only the minimum long-term consensus rule: a transaction is payment lane eligible if every call it contains matches an allow-listed payment call shape and the transaction carries no access list. Builders MAY apply stricter local policy when selecting transactions for the payment lane, but such heuristics are intentionally left out of scope. +This TIP therefore enshrines only the minimum long-term consensus rule: a transaction is payment lane eligible if every call it contains matches an allow-listed payment call shape and the transaction satisfies the auxiliary-payload restrictions below. Builders MAY apply stricter local policy when selecting transactions for the payment lane, but such heuristics are intentionally left out of scope. --- # Specification -A transaction qualifies for the payment lane when **every** call it contains is an allow-listed payment call and the transaction carries an empty `access_list`. +A transaction qualifies for the payment lane when all of the following hold: + +1. EVERY call it contains is an allow-listed payment call. +2. `access_list` is empty. +3. `authorization_list` and `tempo_authorization_list` are empty. +4. If `key_authorization` is present, `len(rlp(key_authorization)) <= 1024` bytes. + +These auxiliary-payload restrictions prevent transactions from consuming payment-lane capacity with non-payment metadata. Unbounded sidecars are excluded from the payment lane, while `key_authorization` remains eligible only under a fixed size bound. ## Payment Call Allow-List A call qualifies as a payment call if, for some entry in the payment call allow-list table, all of the following hold: -1. It is not a contract-creation call. Calls with `to = null`, `CREATE`, or `CREATE2` never match. +1. It is NOT a contract-creation request. 2. Its target satisfies the entry's target-match predicate. 3. The first 4 bytes of its calldata equal the entry's selector. 4. Its calldata satisfies the size and encoding constraints derived from the entry's signature, as defined below. -The constraints depend on whether the entry's parameters contain any dynamic ABI types (`bytes`, `string`, dynamic arrays, or tuples/arrays transitively containing them): +Calldata MUST be the canonical ABI encoding for the entry's signature. Additional constraints depend on whether the entry's parameters contain any dynamic ABI types (`bytes`, `string`, dynamic arrays, or tuples/arrays transitively containing them): -- **Static-only entries**: calldata length MUST equal `4 + num_params * 32` bytes. -- **Entries with any dynamic type**: calldata length MUST be `<= 2048` bytes. +- **Static-only entries**: calldata MUST have no trailing bytes; equivalently, its length MUST be the 4-byte selector plus one 32-byte ABI word per parameter. +- **Entries with any dynamic type**: calldata's total length MUST be `<= 2048` bytes. The initial payment call allow-list is: @@ -60,15 +67,15 @@ The initial payment call allow-list is: The `Target` column may specify either an exact target precompile address or an address class. For TIP-20 precompiles, the match is against the `0x20C000000000000000000000` prefix. -Calls with empty calldata, unrecognized selectors, target/selector combinations not present in the allow-list, malformed ABI encoding, or calldata that violates the matched entry's calldata length constraint, do not match any allow-list entry. +Calls with empty calldata, unrecognized selectors, target/selector combinations not present in the allow-list, malformed ABI encoding, or calldata that violates the matched entry's size or encoding constraints, do not match any allow-list entry. ## Access Lists -Any transaction carrying a non-empty `access_list` MUST be classified as general, regardless of its type, targets, or calldata. Access lists can carry arbitrary addresses and storage keys, enabling arbitrary data injection at payment lane gas prices. +Any transaction carrying a non-empty `access_list` MUST be classified as general, regardless of its type, targets, or calldata. Access lists can carry arbitrary addresses and storage keys as transaction metadata. ## Authorization Lists -Any transaction carrying a non-empty `authorization_list` (EIP-7702) or non-empty `tempo_authorization_list` MUST be classified as general. Like access lists, both fields are unbounded in entry count and each entry carries attacker-chosen signed payloads, enabling arbitrary data injection at payment lane gas prices. +Any transaction carrying a non-empty `authorization_list` (EIP-7702) or non-empty `tempo_authorization_list` MUST be classified as general. Both fields are unbounded in entry count and each entry carries attacker-chosen signed payloads. `key_authorization` is exempt from this restriction and MAY be present in payment transactions. It is a single, size-bounded structure tied to onboarding the transaction's sender, and removing it from the payment lane would break first-transaction UX without offering a comparable data-injection surface. @@ -78,11 +85,11 @@ No consensus-level cap is placed on the overall transaction size: payment transa ## Transaction Types -For legacy, EIP-2930, EIP-1559, and EIP-7702 transactions, the single top-level call must match the payment call allow-list, `access_list` must be empty, and `authorization_list` must be empty. +For legacy, EIP-2930, EIP-1559, and EIP-7702 transactions, the single top-level call must match the payment call allow-list, `access_list` must be empty, `authorization_list` must be empty, and any `key_authorization` must satisfy the 1024-byte RLP bound. -For account-abstraction (type `0x76`) transactions, `calls` must be non-empty, every call in the batch must individually match the payment call allow-list, `access_list` must be empty, and `tempo_authorization_list` must be empty. +For account-abstraction (type `0x76`) transactions, `calls` must be non-empty, every call in the batch must individually match the payment call allow-list, `access_list` must be empty, `tempo_authorization_list` must be empty, and any `key_authorization` must satisfy the 1024-byte RLP bound. -No other transaction fields participate in consensus payment classification. In particular, `key_authorization`, signature fields, fee sponsorship fields, and validity-window fields do not affect payment versus general classification. +No other transaction fields participate in consensus payment classification. In particular, signature fields, fee sponsorship fields, and validity-window fields do not affect payment versus general classification. ## Pre-T5 (Backward Compatibility) @@ -92,8 +99,8 @@ Before T5, only the legacy TIP-20 address prefix check is enforced at the consen 1. **Classification completeness**: Every transaction MUST be classified as exactly one of payment or general — never both, never neither. 2. **Allow-list strictness**: A transaction containing any call that does not match an allow-listed `(target, selector, calldata constraint)` entry MUST be classified as general and subject to `general_gas_limit`. -3. **No trailing bytes**: Calldata longer than the expected ABI-encoded length for a matched allow-list entry MUST be classified as general. -4. **No contract creation**: Any transaction containing `CREATE`, `CREATE2`, or `to = null` MUST be classified as general. +3. **Canonical calldata**: Static-only entries MUST have exactly the static ABI-encoded length. Entries with dynamic types MUST use canonical ABI encoding and MUST be at most 2048 bytes. Any malformed encoding, trailing bytes, or size violation MUST be classified as general. +4. **No contract creation**: Any transaction or AA call that creates a new contract MUST be classified as general. 5. **AA atomicity**: An AA transaction is a payment only if `calls` is non-empty and **all** of its calls independently match the payment call allow-list. A single non-qualifying call MUST cause the entire transaction to be classified as general. 6. **Bounded key authorization**: Any transaction whose `key_authorization` is present and whose RLP encoding exceeds 1024 bytes MUST be classified as general. No consensus cap is placed on the overall transaction size. 7. **No access lists**: Any transaction with a non-empty `access_list` MUST be classified as general, regardless of type, targets, or calldata. Non-empty access lists allow carrying arbitrary data at payment lane gas prices. From 7c99e53b5fecfdf60d57d9162193d434dfdac3e3 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <90208954+0xrusowsky@users.noreply.github.com> Date: Thu, 7 May 2026 00:08:40 +0200 Subject: [PATCH 50/68] style: approve Co-authored-by: Jennifer --- tips/tip-1045.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1045.md b/tips/tip-1045.md index 71a88d2884..a340790664 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -3,7 +3,7 @@ id: TIP-1045 title: Payment Transaction Classification description: Defines consensus rules for classifying transactions as payment lane eligible using an allow-list of call targets/selectors and bounded auxiliary payloads. authors: @0xrusowsky, Arsenii Kulikov @klkvr, Dan Robinson @danrobinson, Tanishk Goyal @legion2002 -status: Draft +status: Approved related: TIP-1010, TIP-1000, Payment Lane Specification, TIP-20, TIP-1034 protocolVersion: T5 --- From 9d22e1282f507e658792dc24b8f28135c946bfac Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 7 May 2026 00:35:58 +0200 Subject: [PATCH 51/68] feat(primitives): is_payment_v2 --- crates/contracts/src/precompiles/tip20.rs | 1 + crates/evm/src/block.rs | 61 +++++++++++++- crates/primitives/src/address.rs | 2 +- crates/primitives/src/transaction/envelope.rs | 83 ++++++++++++++----- crates/primitives/src/transaction/mod.rs | 3 +- 5 files changed, 126 insertions(+), 24 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20.rs b/crates/contracts/src/precompiles/tip20.rs index bcd9a5ca5b..cd3ffaff08 100644 --- a/crates/contracts/src/precompiles/tip20.rs +++ b/crates/contracts/src/precompiles/tip20.rs @@ -177,6 +177,7 @@ impl ITIP20::ITIP20Calls { /// Returns `true` if `input` matches one of the recognized [TIP-20 payment] selectors: /// - `transfer` / `transferWithMemo` /// - `transferFrom` / `transferFromWithMemo` + /// - `approve` /// - `mint` / `mintWithMemo` /// - `burn` / `burnWithMemo` /// diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 1cab6639c1..b9d0b23860 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -26,7 +26,10 @@ use reth_revm::{ state::{Account, Bytecode, EvmState}, }; use std::collections::{HashMap, HashSet}; -use tempo_chainspec::{TempoChainSpec, hardfork::TempoHardforks}; +use tempo_chainspec::{ + TempoChainSpec, + hardfork::{TempoHardfork, TempoHardforks}, +}; use tempo_contracts::precompiles::{ ADDRESS_REGISTRY_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, }; @@ -133,6 +136,7 @@ pub struct TempoBlockExecutor<'a, DB: Database, I> { pub(crate) inner: EthBlockExecutor<'a, TempoEvm, &'a TempoChainSpec, TempoReceiptBuilder>, + hardfork: TempoHardfork, section: BlockSection, seen_subblocks: Vec<(PartialValidatorKey, Vec)>, validator_set: Option>, @@ -156,6 +160,7 @@ where ) -> Self { Self { incentive_gas_used: 0, + hardfork: chain_spec.tempo_hardfork_at(evm.block().timestamp.to::()), validator_set: ctx.validator_set, non_payment_gas_left: ctx.general_gas_limit, non_shared_gas_left: evm.block().gas_limit - ctx.shared_gas_limit, @@ -373,6 +378,21 @@ where } } + /// Returns whether `tx` qualifies for the payment lane under the active hardfork. + /// + /// T5+: TIP-1045 classification ([`is_payment_v2`]). + /// Pre-T5: legacy TIP-20 prefix-only check ([`is_payment_v1`]). + /// + /// [`is_payment_v1`]: TempoTxEnvelope::is_payment_v1 + /// [`is_payment_v2`]: TempoTxEnvelope::is_payment_v2 + pub(crate) fn is_payment(&self, tx: &TempoTxEnvelope) -> bool { + if self.hardfork.is_t5() { + tx.is_payment_v2() + } else { + tx.is_payment_v1() + } + } + pub(crate) fn validate_tx( &self, tx: &TempoTxEnvelope, @@ -409,7 +429,7 @@ where match self.section { BlockSection::StartOfBlock | BlockSection::NonShared => { if gas_used > self.non_shared_gas_left - || (!tx.is_payment_v1() && gas_used > self.non_payment_gas_left) + || (!self.is_payment(tx) && gas_used > self.non_payment_gas_left) { // Assume that this transaction wants to make use of gas incentive section // @@ -666,6 +686,7 @@ mod tests { }; use std::sync::Arc; use tempo_chainspec::spec::DEV; + use tempo_contracts::precompiles::PATH_USD_ADDRESS; use tempo_primitives::{ SubBlockMetadata, TempoSignature, TempoTransaction, TempoTxType, subblock::{SubBlockVersion, TEMPO_SUBBLOCK_NONCE_KEY_PREFIX}, @@ -686,6 +707,19 @@ mod tests { TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())) } + fn create_tip20_empty_calldata_tx() -> TempoTxEnvelope { + let tx = TxLegacy { + chain_id: Some(1), + nonce: 0, + gas_price: 1, + gas_limit: 21000, + to: TxKind::Call(PATH_USD_ADDRESS), + value: U256::ZERO, + input: Bytes::new(), + }; + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())) + } + #[test] fn test_build_receipt() { let builder = TempoReceiptBuilder; @@ -1087,6 +1121,29 @@ mod tests { ); } + #[test] + fn test_is_payment_uses_v2_from_t5() { + let tx = create_tip20_empty_calldata_tx(); + assert!( + tx.is_payment_v1(), + "pre-T5 prefix check accepts TIP-20 target" + ); + assert!( + !tx.is_payment_v2(), + "T5 classifier rejects empty calldata per TIP-1045" + ); + + let chainspec = test_chainspec(); + let mut db = State::builder().with_bundle_update().build(); + let pre_t5_executor = TestExecutorBuilder::default().build(&mut db, &chainspec); + assert!(pre_t5_executor.is_payment(&tx)); + + let chainspec = DEV.clone(); + let mut db = State::builder().with_bundle_update().build(); + let t5_executor = TestExecutorBuilder::default().build(&mut db, &chainspec); + assert!(!t5_executor.is_payment(&tx)); + } + #[test] fn test_validate_tx() { let chainspec = test_chainspec(); diff --git a/crates/primitives/src/address.rs b/crates/primitives/src/address.rs index e7342be360..99de3ea144 100644 --- a/crates/primitives/src/address.rs +++ b/crates/primitives/src/address.rs @@ -2,7 +2,7 @@ use alloy_primitives::{Address, FixedBytes, hex}; /// TIP20 token address prefix (12 bytes) /// The full address is: TIP20_TOKEN_PREFIX (12 bytes) || derived_bytes (8 bytes) -const TIP20_TOKEN_PREFIX: [u8; 12] = hex!("20C000000000000000000000"); +pub const TIP20_TOKEN_PREFIX: [u8; 12] = hex!("20C000000000000000000000"); /// Returns `true` if `addr` has the TIP-20 token prefix. /// diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 356ad574ad..3ad8b946d3 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -1,5 +1,5 @@ use super::tt_signed::AASigned; -use crate::{TempoTransaction, subblock::PartialValidatorKey}; +use crate::{TempoAddressExt, TempoTransaction, subblock::PartialValidatorKey}; use alloy_consensus::{ EthereumTxEnvelope, SignableTransaction, Signed, Transaction, TxEip1559, TxEip2930, TxEip7702, TxLegacy, TxType, TypedTransaction, @@ -7,13 +7,14 @@ use alloy_consensus::{ error::{UnsupportedTransactionType, ValueError}, transaction::Either, }; -use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256, hex}; +use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; +use alloy_rlp::Encodable; use core::fmt; use tempo_contracts::precompiles::ITIP20; -/// TIP20 payment address prefix (12 bytes for payment classification) -/// Same as TIP20_TOKEN_PREFIX -pub const TIP20_PAYMENT_PREFIX: [u8; 12] = hex!("20C000000000000000000000"); +/// Maximum RLP-encoded size of a `key_authorization` permitted in a payment transaction +/// (TIP-1045). Comfortably fits realistic provisioning payloads with limits and scopes. +pub const KEY_AUTHORIZATION_MAX_RLP_LEN: usize = 1024; /// Fake signature for Tempo system transactions. pub const TEMPO_SYSTEM_TX_SIGNATURE: Signature = Signature::new(U256::ZERO, U256::ZERO, false); @@ -168,7 +169,7 @@ impl TempoTxEnvelope { /// /// # 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. + /// See [`is_payment_v2`](Self::is_payment_v2) for the stricter T5+ variant. /// /// [TIP-20 payment]: pub fn is_payment_v1(&self) -> bool { @@ -181,17 +182,19 @@ impl TempoTxEnvelope { } } - /// Strict [TIP-20 payment]: `0x20c0` prefix, recognized calldata, and NO gas-bearing sidecars. + /// Strict [TIP-20 payment] (TIP-1045): every call matches the payment call allow-list, + /// `access_list` and authorization lists are empty, and key authorization is bounded. /// /// Like [`is_payment_v1`](Self::is_payment_v1), but additionally requires: /// - calldata to match a recognized payment selector with exact ABI-encoded length. - /// - NO access lists or authorization lists are attached. - /// - AA transactions have at least one call. + /// - `access_list` is empty. + /// - `authorization_list` (EIP-7702) is empty. + /// - For AA: `calls` is non-empty, `tempo_authorization_list` is empty, and any + /// `key_authorization` has RLP-encoded length `<= KEY_AUTHORIZATION_MAX_RLP_LEN`. /// /// # NOTE - /// Builder-level classifier, used by the transaction pool and payload builder to prevent DoS of - /// the payment lane. NOT enforced during block validation — a future TIP will enshrine this - /// stricter classification at the protocol level. + /// Used by the transaction pool and payload builder to prevent DoS of the payment lane, + /// and enshrined at the consensus level at the T5 hardfork. /// /// [TIP-20 payment]: pub fn is_payment_v2(&self) -> bool { @@ -214,13 +217,16 @@ impl TempoTxEnvelope { 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 + .key_authorization + .as_ref() + .is_none_or(|auth| auth.length() <= KEY_AUTHORIZATION_MAX_RLP_LEN) } } } @@ -475,7 +481,7 @@ impl From for TempoTypedTransaction { /// Returns `true` if `to` has the TIP-20 payment prefix. #[inline] fn is_tip20_call(to: Option<&Address>) -> bool { - to.is_some_and(|to| to.starts_with(&TIP20_PAYMENT_PREFIX)) + to.is_some_and(|to| to.is_tip20()) } /// Returns `true` if `to` has the TIP-20 payment prefix and `input` is recognized payment @@ -850,8 +856,9 @@ mod tests { ); } - #[test] - fn test_payment_v2_aa_rejects_key_authorization() { + fn aa_with_key_authorization( + limits: Option>, + ) -> TempoTxEnvelope { let calldata = ITIP20::transferCall { to: Address::random(), amount: U256::from(1), @@ -870,21 +877,57 @@ mod tests { key_type: crate::SignatureType::Secp256k1, key_id: Address::random(), expiry: None, - limits: None, + limits, allowed_calls: None, }, signature: PrimitiveSignature::Secp256k1(Signature::test_signature()), }), ..Default::default() }; - let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); + TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())) + } + + #[test] + fn test_payment_v2_aa_accepts_small_key_authorization() { + // TIP-1045: key_authorization is allowed in payment txs as long as it's bounded. + let envelope = aa_with_key_authorization(None); + assert!(envelope.is_payment_v1()); + assert!( + envelope.is_payment_v2(), + "V2 must accept AA tx with small key_authorization" + ); + } + + #[test] + fn test_payment_v2_aa_rejects_oversized_key_authorization() { + use crate::transaction::key_authorization::TokenLimit; + // Pad `limits` with enough entries to push the RLP encoding past the 1 KB cap. + let limits = (0..32) + .map(|i| TokenLimit { + token: Address::repeat_byte(i as u8), + limit: U256::from(u128::MAX), + period: 1, + }) + .collect::>(); + let envelope = aa_with_key_authorization(Some(limits)); + let key_authorization = envelope + .as_aa() + .unwrap() + .tx() + .key_authorization + .as_ref() + .unwrap(); + assert!( + key_authorization.length() > KEY_AUTHORIZATION_MAX_RLP_LEN, + "test fixture must exceed the key_authorization size cap" + ); assert!( envelope.is_payment_v1(), - "V1 ignores side-effect fields (backwards compat)" + "V1 ignores key_authorization size" ); assert!( !envelope.is_payment_v2(), - "V2 must reject AA tx with key_authorization" + "V2 must reject AA tx with key_authorization larger than {KEY_AUTHORIZATION_MAX_RLP_LEN} bytes", ); } diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs index f2775beee5..ae7c0ea696 100644 --- a/crates/primitives/src/transaction/mod.rs +++ b/crates/primitives/src/transaction/mod.rs @@ -12,8 +12,9 @@ pub use tt_signature::{ derive_p256_address, }; +pub use crate::address::TIP20_TOKEN_PREFIX as TIP20_PAYMENT_PREFIX; pub use alloy_eips::eip7702::Authorization; -pub use envelope::{TIP20_PAYMENT_PREFIX, TempoTxEnvelope, TempoTxType, TempoTypedTransaction}; +pub use envelope::{TempoTxEnvelope, TempoTxType, TempoTypedTransaction}; pub use key_authorization::{ CallScope, KeyAuthorization, KeyAuthorizationChainIdError, SelectorRule, SignedKeyAuthorization, TokenLimit, From cd348d580f5b2d0175a6564b4a7691aececed48f Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 7 May 2026 09:20:57 +0200 Subject: [PATCH 52/68] style: cleanup --- crates/primitives/src/transaction/envelope.rs | 44 +++++-------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 3ad8b946d3..f066b9740a 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -523,7 +523,7 @@ impl reth_rpc_convert::TryIntoSimTx for alloy_rpc_types_eth::Tr mod tests { use super::*; use crate::transaction::{ - Call, TempoSignedAuthorization, TempoTransaction, + Call, TempoSignedAuthorization, TempoTransaction, TokenLimit, key_authorization::{KeyAuthorization, SignedKeyAuthorization}, tt_signature::PrimitiveSignature, }; @@ -856,9 +856,7 @@ mod tests { ); } - fn aa_with_key_authorization( - limits: Option>, - ) -> TempoTxEnvelope { + fn aa_with_key_authorization(limits: Option>) -> TempoTxEnvelope { let calldata = ITIP20::transferCall { to: Address::random(), amount: U256::from(1), @@ -888,19 +886,12 @@ mod tests { } #[test] - fn test_payment_v2_aa_accepts_small_key_authorization() { - // TIP-1045: key_authorization is allowed in payment txs as long as it's bounded. + fn test_payment_v2_aa_accepts_bounded_key_authorization() { + // TIP-1045: key auth is allowed in payment txs as long as it's bounded. let envelope = aa_with_key_authorization(None); assert!(envelope.is_payment_v1()); - assert!( - envelope.is_payment_v2(), - "V2 must accept AA tx with small key_authorization" - ); - } + assert!(envelope.is_payment_v2(), "V2 must accept bounded key auth"); - #[test] - fn test_payment_v2_aa_rejects_oversized_key_authorization() { - use crate::transaction::key_authorization::TokenLimit; // Pad `limits` with enough entries to push the RLP encoding past the 1 KB cap. let limits = (0..32) .map(|i| TokenLimit { @@ -910,25 +901,12 @@ mod tests { }) .collect::>(); let envelope = aa_with_key_authorization(Some(limits)); - let key_authorization = envelope - .as_aa() - .unwrap() - .tx() - .key_authorization - .as_ref() - .unwrap(); - assert!( - key_authorization.length() > KEY_AUTHORIZATION_MAX_RLP_LEN, - "test fixture must exceed the key_authorization size cap" - ); - assert!( - envelope.is_payment_v1(), - "V1 ignores key_authorization size" - ); - assert!( - !envelope.is_payment_v2(), - "V2 must reject AA tx with key_authorization larger than {KEY_AUTHORIZATION_MAX_RLP_LEN} bytes", - ); + assert!(envelope.is_payment_v1(), "V1 ignores key auth size"); + assert!(!envelope.is_payment_v2(), "V2 must reject huge key auth"); + + let tx = envelope.as_aa().unwrap().tx(); + let key_auth = tx.key_authorization.as_ref().unwrap(); + assert!(key_auth.length() > KEY_AUTHORIZATION_MAX_RLP_LEN); } #[test] From 157941917b5dbe01b3d13f2c02bf846b02766d33 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <90208954+0xrusowsky@users.noreply.github.com> Date: Thu, 7 May 2026 09:29:26 +0200 Subject: [PATCH 53/68] docs: changelog Recognized `approve` as a TIP-20 payment selector. --- .changelog/keen-cows-climb.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changelog/keen-cows-climb.md diff --git a/.changelog/keen-cows-climb.md b/.changelog/keen-cows-climb.md new file mode 100644 index 0000000000..06270c69fb --- /dev/null +++ b/.changelog/keen-cows-climb.md @@ -0,0 +1,7 @@ +--- +tempo-evm: minor +tempo-primitives: minor +tempo-contracts: patch +--- + +Enshrined the stricter TIP-1045 payment classifier (`is_payment_v2`) at the T5 hardfork for consensus-level payment lane validation. Relaxed the v2 classifier to allow bounded `key_authorization` (RLP length ≤ 1024 bytes). From 9c3cbd948d34629838570f3e6b5fad9e3c80db65 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 7 May 2026 09:30:51 +0200 Subject: [PATCH 54/68] style: fmt --- crates/primitives/src/transaction/envelope.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index f066b9740a..2a2becf9a4 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -219,14 +219,14 @@ impl TempoTxEnvelope { !tx.calls.is_empty() && 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 .key_authorization .as_ref() .is_none_or(|auth| auth.length() <= KEY_AUTHORIZATION_MAX_RLP_LEN) + && tx + .calls + .iter() + .all(|call| is_tip20_payment(call.to.to(), &call.input)) } } } From 9934128bff9dd671b5b400a6ff2a996f485d4cce Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 7 May 2026 16:15:31 +0200 Subject: [PATCH 55/68] feat: enshrine TIP20 channel escrow into the payment lane --- .../src/precompiles/tip20_channel_escrow.rs | 112 +++++++++++++++ crates/precompiles/src/error.rs | 5 +- .../src/tip20_channel_escrow/dispatch.rs | 4 +- crates/primitives/src/transaction/envelope.rs | 133 +++++++++++++++--- tips/tip-1045.md | 6 +- 5 files changed, 235 insertions(+), 25 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index c92eafe7f0..23821ee5bb 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -3,6 +3,7 @@ pub use ITIP20ChannelEscrow::{ ITIP20ChannelEscrowEvents as TIP20ChannelEscrowEvent, }; use alloy_primitives::{Address, address}; +use alloy_sol_types::{SolCall, SolType}; pub const TIP20_CHANNEL_ESCROW_ADDRESS: Address = address!("0x4D50500000000000000000000000000000000000"); @@ -165,6 +166,36 @@ crate::sol! { } } +impl ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls { + /// Returns `true` if `input` matches one of the recognized [TIP-20 channel escrow payment] + /// selectors: `open`, `topUp`, `settle`, `close` + /// + /// # NOTES + /// - Only validates calldata; caller must check the that `to == TIP20_CHANNEL_ESCROW_ADDRESS`. + /// - Static-only calls require exact ABI-encoded length. + /// - Dynamic calls require ABI decoding and total calldata length <= 2048 bytes. + /// + /// [TIP-20 channel escrow payment]: + pub fn is_payment(input: &[u8]) -> bool { + fn is_call(input: &[u8]) -> bool { + if input.first_chunk::<4>() != Some(&C::SELECTOR) { + return false; + } + + if let Some(canonical_size) = as SolType>::ENCODED_SIZE { + input.len() == 4 + canonical_size + } else { + input.len() <= 2048 && C::abi_decode_validate(input).is_ok() + } + } + + is_call::(input) + || is_call::(input) + || is_call::(input) + || is_call::(input) + } +} + impl TIP20ChannelEscrowError { pub const fn channel_already_exists() -> Self { Self::ChannelAlreadyExists(ITIP20ChannelEscrow::ChannelAlreadyExists {}) @@ -226,3 +257,84 @@ impl TIP20ChannelEscrowError { Self::TransferFailed(ITIP20ChannelEscrow::TransferFailed {}) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{vec, vec::Vec}; + use alloy_primitives::{B256, aliases::U96}; + + fn descriptor() -> ITIP20ChannelEscrow::ChannelDescriptor { + ITIP20ChannelEscrow::ChannelDescriptor { + payer: Address::random(), + payee: Address::random(), + token: Address::random(), + salt: B256::random(), + authorizedSigner: Address::random(), + } + } + + #[rustfmt::skip] + fn payment_calldatas() -> [Vec; 4] { + let descriptor = descriptor(); + [ + ITIP20ChannelEscrow::openCall { payee: Address::random(), token: Address::random(), deposit: U96::from(1), salt: B256::random(), authorizedSigner: Address::random() }.abi_encode(), + ITIP20ChannelEscrow::topUpCall { descriptor: descriptor.clone(), additionalDeposit: U96::from(1) }.abi_encode(), + ITIP20ChannelEscrow::settleCall { descriptor: descriptor.clone(), cumulativeAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode(), + ITIP20ChannelEscrow::closeCall { descriptor, cumulativeAmount: U96::from(1), captureAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode(), + ] + } + + #[test] + fn test_is_payment() { + for calldata in payment_calldatas() { + assert!(ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &calldata + )); + } + + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &ITIP20ChannelEscrow::requestCloseCall { + descriptor: descriptor() + } + .abi_encode(), + )); + + let mut unknown = payment_calldatas()[0].clone(); + unknown[..4].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &unknown + )); + } + + #[test] + fn test_is_payment_rejects_malformed_dynamic_calldata() { + let mut calldata = ITIP20ChannelEscrow::settleCall { + descriptor: descriptor(), + cumulativeAmount: U96::from(1), + signature: vec![1, 2, 3].into(), + } + .abi_encode(); + // Corrupt the dynamic `signature` offset word. + calldata[4 + 6 * 32 + 31] = 0; + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &calldata + )); + + let mut oversized = ITIP20ChannelEscrow::settleCall { + descriptor: descriptor(), + cumulativeAmount: U96::from(1), + signature: vec![0; 2048].into(), + } + .abi_encode(); + assert!(oversized.len() > 2048); + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &oversized + )); + + oversized.truncate(4); + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &oversized + )); + } +} 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/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/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 2a2becf9a4..5178154f73 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -10,7 +10,9 @@ use alloy_consensus::{ use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; use alloy_rlp::Encodable; use core::fmt; -use tempo_contracts::precompiles::ITIP20; +use tempo_contracts::precompiles::{ + ITIP20, ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls, TIP20_CHANNEL_ESCROW_ADDRESS, +}; /// Maximum RLP-encoded size of a `key_authorization` permitted in a payment transaction /// (TIP-1045). Comfortably fits realistic provisioning payloads with limits and scopes. @@ -199,20 +201,20 @@ impl TempoTxEnvelope { /// [TIP-20 payment]: pub fn is_payment_v2(&self) -> bool { match self { - Self::Legacy(tx) => is_tip20_payment(tx.tx().to.to(), &tx.tx().input), + Self::Legacy(tx) => is_tip1045_call(tx.tx().to.to(), &tx.tx().input), 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_tip1045_call(tx.to.to(), &tx.input) } 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_tip1045_call(tx.to.to(), &tx.input) } 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_tip1045_call(Some(&tx.to), &tx.input) } Self::AA(tx) => { let tx = tx.tx(); @@ -226,7 +228,7 @@ impl TempoTxEnvelope { && tx .calls .iter() - .all(|call| is_tip20_payment(call.to.to(), &call.input)) + .all(|call| is_tip1045_call(call.to.to(), &call.input)) } } } @@ -484,11 +486,18 @@ fn is_tip20_call(to: Option<&Address>) -> bool { to.is_some_and(|to| to.is_tip20()) } -/// Returns `true` if `to` has the TIP-20 payment prefix and `input` is recognized payment -/// calldata (selector + exact ABI-encoded length). +/// Returns `true` if the call is in the TIP-1045 payment lane allow-list. #[inline] -fn is_tip20_payment(to: Option<&Address>, input: &[u8]) -> bool { - is_tip20_call(to) && ITIP20::ITIP20Calls::is_payment(input) +fn is_tip1045_call(to: Option<&Address>, input: &[u8]) -> bool { + match to { + // TIP20 call + payment calldata constraints + Some(to) if to.is_tip20() => ITIP20::ITIP20Calls::is_payment(input), + // TIP20ChannelEscrow call + payment calldata constraints + Some(to) if *to == TIP20_CHANNEL_ESCROW_ADDRESS => { + ITIP20ChannelEscrowCalls::is_payment(input) + } + _ => false, + } } #[cfg(feature = "rpc")] @@ -532,8 +541,9 @@ mod tests { eip2930::{AccessList, AccessListItem}, eip7702::SignedAuthorization, }; - use alloy_primitives::{Bytes, Signature, TxKind, U256, address}; + use alloy_primitives::{Bytes, Signature, TxKind, U256, address, aliases::U96}; use alloy_sol_types::SolCall; + use tempo_contracts::precompiles::ITIP20ChannelEscrow; const PAYMENT_TKN: Address = address!("20c0000000000000000000000000000000000001"); @@ -554,40 +564,73 @@ mod tests { ] } + fn channel_descriptor() -> ITIP20ChannelEscrow::ChannelDescriptor { + ITIP20ChannelEscrow::ChannelDescriptor { + payer: Address::random(), + payee: Address::random(), + token: PAYMENT_TKN, + salt: B256::random(), + authorizedSigner: Address::random(), + } + } + + #[rustfmt::skip] + fn channel_escrow_payment_calldatas() -> [Bytes; 4] { + let descriptor = channel_descriptor(); + [ + ITIP20ChannelEscrow::openCall { payee: Address::random(), token: PAYMENT_TKN, deposit: U96::from(1), salt: B256::random(), authorizedSigner: Address::random() }.abi_encode().into(), + ITIP20ChannelEscrow::topUpCall { descriptor: descriptor.clone(), additionalDeposit: U96::from(1) }.abi_encode().into(), + ITIP20ChannelEscrow::settleCall { descriptor: descriptor.clone(), cumulativeAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode().into(), + ITIP20ChannelEscrow::closeCall { descriptor, cumulativeAmount: U96::from(1), captureAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode().into(), + ] + } + /// Returns one envelope per tx type, all targeting `PAYMENT_TKN` with the given calldata. fn payment_envelopes(calldata: Bytes) -> [TempoTxEnvelope; 5] { + payment_envelopes_to(PAYMENT_TKN, calldata) + } + + /// Returns one envelope per tx type, all targeting `to` with the given calldata. + fn payment_envelopes_to(to: Address, calldata: Bytes) -> [TempoTxEnvelope; 5] { let legacy = TempoTxEnvelope::Legacy(Signed::new_unhashed( TxLegacy { - to: TxKind::Call(PAYMENT_TKN), + to: TxKind::Call(to), input: calldata.clone(), ..Default::default() }, Signature::test_signature(), )); let [eip2930, eip1559, eip7702, aa] = - payment_envelopes_with_access_list(calldata, AccessList::default()); + payment_envelopes_with_access_list_to(to, calldata, AccessList::default()); [legacy, eip2930, eip1559, eip7702, aa] } /// Like [`payment_envelopes`], but with `access_list` set. Supported by: Eip2930, Eip1559, Eip7702, AA. + fn payment_envelopes_with_access_list( + calldata: Bytes, + access_list: AccessList, + ) -> [TempoTxEnvelope; 4] { + payment_envelopes_with_access_list_to(PAYMENT_TKN, calldata, access_list) + } + #[rustfmt::skip] - fn payment_envelopes_with_access_list(calldata: Bytes, access_list: AccessList) -> [TempoTxEnvelope; 4] { + fn payment_envelopes_with_access_list_to(to: Address, calldata: Bytes, access_list: AccessList) -> [TempoTxEnvelope; 4] { [ TempoTxEnvelope::Eip2930(Signed::new_unhashed( - TxEip2930 { to: TxKind::Call(PAYMENT_TKN), input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, + TxEip2930 { to: TxKind::Call(to), input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, Signature::test_signature(), )), TempoTxEnvelope::Eip1559(Signed::new_unhashed( - TxEip1559 { to: TxKind::Call(PAYMENT_TKN), input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, + TxEip1559 { to: TxKind::Call(to), input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, Signature::test_signature(), )), TempoTxEnvelope::Eip7702(Signed::new_unhashed( - TxEip7702 { to: PAYMENT_TKN, input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, + TxEip7702 { to, input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, Signature::test_signature(), )), TempoTxEnvelope::AA(TempoTransaction { fee_token: Some(PAYMENT_TKN), - calls: vec![Call { to: TxKind::Call(PAYMENT_TKN), value: U256::ZERO, input: calldata }], + calls: vec![Call { to: TxKind::Call(to), value: U256::ZERO, input: calldata }], access_list, ..Default::default() }.into_signed(Signature::test_signature().into())), @@ -776,6 +819,60 @@ mod tests { } } + #[test] + fn test_payment_v2_accepts_valid_channel_escrow_calldata() { + for calldata in channel_escrow_payment_calldatas() { + for envelope in payment_envelopes_to(TIP20_CHANNEL_ESCROW_ADDRESS, calldata) { + assert!(!envelope.is_payment_v1(), "V1 only accepts TIP-20 prefix"); + assert!( + envelope.is_payment_v2(), + "V2 must accept valid TIP20ChannelEscrow calldata" + ); + } + } + } + + #[test] + fn test_payment_v2_rejects_channel_escrow_calldata_to_tip20() { + for calldata in channel_escrow_payment_calldatas() { + for envelope in payment_envelopes_to(PAYMENT_TKN, calldata) { + assert!(envelope.is_payment_v1(), "V1 accepts TIP-20 prefix"); + assert!(!envelope.is_payment_v2(), "V2 only accepts allowed combos"); + } + } + } + + #[test] + fn test_payment_v2_rejects_invalid_channel_escrow_dynamic_calldata() { + let mut corrupted_calldata = ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor(), + cumulativeAmount: U96::ONE, + signature: vec![1, 2, 3].into(), + } + .abi_encode(); + // Corrupt the dynamic `signature` offset word. + corrupted_calldata[4 + 6 * 32 + 31] = 0; + + for envelope in + payment_envelopes_to(TIP20_CHANNEL_ESCROW_ADDRESS, corrupted_calldata.into()) + { + assert!(!envelope.is_payment_v2(), "V2 must reject malformed ABI"); + } + + // Calldata > 2KB + let long_calldata = ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor(), + cumulativeAmount: U96::ONE, + signature: vec![0; 2048].into(), + } + .abi_encode(); + assert!(long_calldata.len() > 2048); + + for envelope in payment_envelopes_to(TIP20_CHANNEL_ESCROW_ADDRESS, long_calldata.into()) { + assert!(!envelope.is_payment_v2(), "V2 must reject large calldata"); + } + } + #[test] fn test_payment_v2_rejects_empty_calldata() { for envelope in payment_envelopes(Bytes::new()) { diff --git a/tips/tip-1045.md b/tips/tip-1045.md index a340790664..38bad0a85b 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -42,10 +42,10 @@ A call qualifies as a payment call if, for some entry in the payment call allow- 3. The first 4 bytes of its calldata equal the entry's selector. 4. Its calldata satisfies the size and encoding constraints derived from the entry's signature, as defined below. -Calldata MUST be the canonical ABI encoding for the entry's signature. Additional constraints depend on whether the entry's parameters contain any dynamic ABI types (`bytes`, `string`, dynamic arrays, or tuples/arrays transitively containing them): +Calldata MUST satisfy the ABI encoding constraints for the entry's signature. Additional constraints depend on whether the entry's parameters contain any dynamic ABI types (`bytes`, `string`, dynamic arrays, or tuples/arrays transitively containing them): - **Static-only entries**: calldata MUST have no trailing bytes; equivalently, its length MUST be the 4-byte selector plus one 32-byte ABI word per parameter. -- **Entries with any dynamic type**: calldata's total length MUST be `<= 2048` bytes. +- **Entries with any dynamic type**: calldata MUST be valid ABI-decodable calldata for the matched signature and its total length MUST be `<= 2048` bytes. The initial payment call allow-list is: @@ -99,7 +99,7 @@ Before T5, only the legacy TIP-20 address prefix check is enforced at the consen 1. **Classification completeness**: Every transaction MUST be classified as exactly one of payment or general — never both, never neither. 2. **Allow-list strictness**: A transaction containing any call that does not match an allow-listed `(target, selector, calldata constraint)` entry MUST be classified as general and subject to `general_gas_limit`. -3. **Canonical calldata**: Static-only entries MUST have exactly the static ABI-encoded length. Entries with dynamic types MUST use canonical ABI encoding and MUST be at most 2048 bytes. Any malformed encoding, trailing bytes, or size violation MUST be classified as general. +3. **ABI calldata validity**: Static-only entries MUST have exactly the static ABI-encoded length; any malformed encoding, trailing bytes, or size violation MUST be classified as general. Likewise, entries with dynamic types MUST be valid ABI-decodable calldata for the matched signature and MUST be at most 2048 bytes; otherwise, they MUST be classified as general. 4. **No contract creation**: Any transaction or AA call that creates a new contract MUST be classified as general. 5. **AA atomicity**: An AA transaction is a payment only if `calls` is non-empty and **all** of its calls independently match the payment call allow-list. A single non-qualifying call MUST cause the entire transaction to be classified as general. 6. **Bounded key authorization**: Any transaction whose `key_authorization` is present and whose RLP encoding exceeds 1024 bytes MUST be classified as general. No consensus cap is placed on the overall transaction size. From dddd2ef7a3e679e40efb033b5e4e4f6afda67ae8 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 23 Apr 2026 16:19:20 +0530 Subject: [PATCH 56/68] 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 1cab6639c1..54dbece121 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, @@ -458,6 +459,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(()) } @@ -1608,7 +1612,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 8c0792017d..3b5416d71c 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 { @@ -1138,6 +1157,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!( @@ -1165,6 +1191,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 dd2ed6889bd903781d0d1806251dbb07823990c8 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 23 Apr 2026 20:04:12 +0530 Subject: [PATCH 57/68] 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 54dbece121..488dbcce50 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -409,8 +409,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 // @@ -459,7 +463,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)?; } @@ -513,10 +517,14 @@ where } else { self.validate_tx(recovered.tx(), block_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()), block_gas_used, @@ -1612,6 +1620,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 2c55232ee0..2533273b7d 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -308,6 +308,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; @@ -481,7 +482,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( @@ -501,7 +502,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 3b5416d71c..2d4c10903b 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 60a160085f..a04a88b9db 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. @@ -61,7 +63,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()) @@ -77,7 +80,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(), @@ -121,8 +125,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: @@ -661,7 +669,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, @@ -698,7 +706,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(pooled_tx.is_payment()); + assert!(pooled_tx.is_payment(false)); } #[test] @@ -723,7 +731,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(!pooled_tx.is_payment()); + assert!(!pooled_tx.is_payment(false)); } #[test] @@ -733,7 +741,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 07ef8732dfa87d7309cb99c2d5c0367ad58b83bc Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Fri, 24 Apr 2026 19:07:21 +0530 Subject: [PATCH 58/68] 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 c31872803896b30de0ebd9df81b8bc62c811b733 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 17:53:12 +0530 Subject: [PATCH 59/68] 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 2d4c10903b..993a057710 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 68c68124bfcb449136b43244e774a7f779e2e93f Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 18:37:20 +0530 Subject: [PATCH 60/68] 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 e43e05ea55e50306bcd25ee7c3d34b39004b6496 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 18:49:36 +0530 Subject: [PATCH 61/68] 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 b103676383f210308b74b9354ffbe1f06802a733 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 19:22:35 +0530 Subject: [PATCH 62/68] 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 c0747edad102ae13808b1bf3626237fd310c0446 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Mon, 27 Apr 2026 19:42:58 +0530 Subject: [PATCH 63/68] 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 31db1541a98221c13547263ca3ce57aaa3a9818e Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 29 Apr 2026 15:18:43 +0530 Subject: [PATCH 64/68] 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 488dbcce50..5dd00f4603 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -409,12 +409,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 // @@ -517,14 +513,10 @@ where } else { self.validate_tx(recovered.tx(), block_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()), block_gas_used, 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 2533273b7d..2c55232ee0 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -308,7 +308,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; @@ -482,7 +481,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( @@ -502,7 +501,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 a04a88b9db..60a160085f 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. @@ -63,8 +61,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()) @@ -80,8 +77,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(), @@ -125,12 +121,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: @@ -669,7 +661,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, @@ -706,7 +698,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(pooled_tx.is_payment(false)); + assert!(pooled_tx.is_payment()); } #[test] @@ -731,7 +723,7 @@ mod tests { ); let pooled_tx = TempoPooledTransaction::new(recovered); - assert!(!pooled_tx.is_payment(false)); + assert!(!pooled_tx.is_payment()); } #[test] @@ -741,43 +733,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 bc277446699b156d8517607636a6f17cbec9bd6e Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 29 Apr 2026 16:18:02 +0530 Subject: [PATCH 65/68] 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 9060b76d2c440dc95ff8a30081a58c7bb8989610 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Fri, 1 May 2026 18:30:21 +0530 Subject: [PATCH 66/68] refactor(tip-1034): remove escrow expiry Amp-Thread-ID: https://ampcode.com/threads/T-019de37f-1c81-72dd-984c-8460e5729fd5 --- .../src/precompiles/tip20_channel_escrow.rs | 25 +--- .../src/tip20_channel_escrow/mod.rs | 115 +++++++----------- 2 files changed, 50 insertions(+), 90 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index 6ad8834e8c..c92eafe7f0 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -23,7 +23,6 @@ crate::sol! { struct ChannelState { uint96 settled; uint96 deposit; - uint32 expiresAt; uint32 closeData; } @@ -40,8 +39,7 @@ crate::sol! { address token, uint96 deposit, bytes32 salt, - address authorizedSigner, - uint32 expiresAt + address authorizedSigner ) external returns (bytes32 channelId); @@ -55,8 +53,7 @@ crate::sol! { function topUp( ChannelDescriptor calldata descriptor, - uint96 additionalDeposit, - uint32 newExpiresAt + uint96 additionalDeposit ) external; @@ -109,8 +106,7 @@ crate::sol! { address token, address authorizedSigner, bytes32 salt, - uint96 deposit, - uint32 expiresAt + uint96 deposit ); event Settled( @@ -127,8 +123,7 @@ crate::sol! { address indexed payer, address indexed payee, uint96 additionalDeposit, - uint96 newDeposit, - uint32 newExpiresAt + uint96 newDeposit ); event CloseRequested( @@ -152,8 +147,6 @@ crate::sol! { address indexed payee ); - event ChannelExpired(bytes32 indexed channelId, address indexed payer, address indexed payee); - error ChannelAlreadyExists(); error ChannelNotFound(); error ChannelFinalized(); @@ -162,8 +155,6 @@ crate::sol! { error InvalidPayee(); error InvalidToken(); error ZeroDeposit(); - error InvalidExpiry(); - error ChannelExpiredError(); error InvalidSignature(); error AmountExceedsDeposit(); error AmountNotIncreasing(); @@ -207,14 +198,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 5bfd492379..f5ed1e846c 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -6,18 +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}; +use tempo_precompiles_macros::{Storable, contract}; const FINALIZED_CLOSE_DATA: u32 = 1; @@ -36,7 +36,6 @@ static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); struct PackedChannelState { settled: U96, deposit: U96, - expires_at: u32, close_data: u32, } @@ -57,7 +56,6 @@ impl PackedChannelState { ITIP20ChannelEscrow::ChannelState { settled: self.settled, deposit: self.deposit, - expiresAt: self.expires_at, closeData: self.close_data, } } @@ -89,9 +87,6 @@ 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 channel_id = self.compute_channel_id_inner( msg_sender, @@ -108,7 +103,6 @@ impl TIP20ChannelEscrow { 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( @@ -125,7 +119,6 @@ impl TIP20ChannelEscrow { authorizedSigner: call.authorizedSigner, salt: call.salt, deposit: call.deposit, - expiresAt: call.expiresAt, }, ))?; batch.commit(); @@ -147,9 +140,6 @@ impl TIP20ChannelEscrow { 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 = call.cumulativeAmount; if cumulative > state.deposit { @@ -214,12 +204,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(); @@ -231,9 +215,6 @@ impl TIP20ChannelEscrow { U256::from(call.additionalDeposit), )?; } - if call.newExpiresAt != 0 { - state.expires_at = call.newExpiresAt; - } if had_close_request { state.close_data = 0; } @@ -254,7 +235,6 @@ impl TIP20ChannelEscrow { payee: call.descriptor.payee, additionalDeposit: call.additionalDeposit, newDeposit: state.deposit, - newExpiresAt: state.expires_at, }))?; batch.commit(); @@ -326,9 +306,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, @@ -352,18 +329,10 @@ impl TIP20ChannelEscrow { 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( @@ -398,7 +367,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()); } @@ -417,13 +386,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, @@ -502,10 +464,6 @@ 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, @@ -571,11 +529,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())?; @@ -607,9 +561,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}, @@ -672,7 +626,6 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let now = StorageCtx::default().timestamp().to::(); let channel_id = escrow.open( payer, @@ -682,7 +635,6 @@ mod tests { deposit: abi_u96(300), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 1_000, }, )?; @@ -727,7 +679,6 @@ mod tests { deposit: abi_u96(1), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 2_000, }, ); assert_eq!( @@ -754,7 +705,6 @@ mod tests { 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, @@ -764,7 +714,6 @@ mod tests { deposit: abi_u96(100), salt, authorizedSigner: Address::ZERO, - expiresAt: expires_at, }, )?; @@ -779,14 +728,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(()) }) @@ -804,7 +751,6 @@ mod tests { deposit: abi_u96(1), salt: B256::ZERO, authorizedSigner: Address::ZERO, - expiresAt: 2, } .abi_encode(), Address::ZERO, @@ -828,7 +774,6 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let now = StorageCtx::default().timestamp().to::(); escrow.open( payer, ITIP20ChannelEscrow::openCall { @@ -837,7 +782,6 @@ mod tests { deposit: abi_u96(100), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 1_000, }, )?; @@ -873,7 +817,6 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let now = StorageCtx::default().timestamp().to::(); escrow.open( payer, ITIP20ChannelEscrow::openCall { @@ -882,7 +825,6 @@ mod tests { deposit: abi_u96(100), salt, authorizedSigner: Address::ZERO, - expiresAt: now + 1_000, }, )?; @@ -906,4 +848,39 @@ mod tests { Ok(()) }) } + + #[test] + fn test_withdraw_requires_close_request() -> 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 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, + }, + )?; + + let result = escrow.withdraw(payer, ITIP20ChannelEscrow::withdrawCall { descriptor }); + assert_eq!( + result.unwrap_err(), + TIP20ChannelEscrowError::close_not_ready().into() + ); + Ok(()) + }) + } } From 290a090bb0b211e38ccfcf9870fc62894db2130b Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Thu, 7 May 2026 19:16:04 +0530 Subject: [PATCH 67/68] fix(tip-1034): sync escrow implementation with spec (#3837) Stacks on #3704.\n\nSyncs the Rust TIP-1034 escrow precompile and ABI with the updated spec/reference implementation: expiring-nonce channel IDs, operator settlement, terminal state deletion, and same-transaction reopen protection.\n\nAdds focused coverage for the escrow spec drift. Payment-lane classifier changes are intentionally left out and will land in a separate PR. --- crates/alloy/src/rpc/reth_compat.rs | 70 ++- .../src/precompiles/tip20_channel_escrow.rs | 26 +- crates/precompiles/src/error.rs | 5 +- .../src/tip20_channel_escrow/dispatch.rs | 4 +- .../src/tip20_channel_escrow/mod.rs | 589 +++++++++++++++--- crates/revm/src/handler.rs | 19 +- crates/revm/src/tx.rs | 142 ++++- 7 files changed, 737 insertions(+), 118 deletions(-) diff --git a/crates/alloy/src/rpc/reth_compat.rs b/crates/alloy/src/rpc/reth_compat.rs index cdaeb52e27..bb1b68294f 100644 --- a/crates/alloy/src/rpc/reth_compat.rs +++ b/crates/alloy/src/rpc/reth_compat.rs @@ -1,7 +1,7 @@ use crate::rpc::{TempoHeaderResponse, TempoTransactionRequest}; -use alloy_consensus::{EthereumTxEnvelope, TxEip4844, error::ValueError}; +use alloy_consensus::{EthereumTxEnvelope, SignableTransaction, TxEip4844, error::ValueError}; use alloy_network::{NetworkTransactionBuilder, TxSigner}; -use alloy_primitives::{Address, B256, Bytes, Signature}; +use alloy_primitives::{Address, B256, Bytes, Signature, keccak256}; use core::num::NonZeroU64; use reth_evm::EvmEnv; use reth_primitives_traits::SealedHeader; @@ -99,6 +99,7 @@ impl TryIntoTxEnv for TempoTransaction evm_env: &EvmEnv, ) -> Result { let caller_addr = self.inner.from.unwrap_or_default(); + let channel_open_context_hash = simulation_channel_open_context_hash(&self, caller_addr); let fee_payer = if self.fee_payer_signature.is_some() { // Try to recover the fee payer address from the signature. @@ -132,6 +133,7 @@ impl TryIntoTxEnv for TempoTransaction Ok(TempoTxEnv { fee_token, is_system_tx: false, + channel_open_context_hash: Some(channel_open_context_hash), fee_payer, tempo_tx_env: if !calls.is_empty() || !tempo_authorization_list.is_empty() @@ -198,6 +200,32 @@ impl TryIntoTxEnv for TempoTransaction } } +fn simulation_channel_open_context_hash(req: &TempoTransactionRequest, sender: Address) -> B256 { + >::try_into_sim_tx(req.clone()) + .map(|tx| channel_open_context_hash_from_sim_tx(&tx, sender)) + .unwrap_or_else(|_| B256::repeat_byte(0x01)) +} + +fn channel_open_context_hash_from_signable(tx: &T, sender: Address) -> B256 +where + T: SignableTransaction, +{ + let mut buf = Vec::with_capacity(tx.payload_len_for_signature() + sender.as_slice().len()); + tx.encode_for_signing(&mut buf); + buf.extend_from_slice(sender.as_slice()); + keccak256(buf) +} + +fn channel_open_context_hash_from_sim_tx(tx: &TempoTxEnvelope, sender: Address) -> B256 { + match tx { + TempoTxEnvelope::Legacy(tx) => channel_open_context_hash_from_signable(tx.tx(), sender), + TempoTxEnvelope::Eip2930(tx) => channel_open_context_hash_from_signable(tx.tx(), sender), + TempoTxEnvelope::Eip1559(tx) => channel_open_context_hash_from_signable(tx.tx(), sender), + TempoTxEnvelope::Eip7702(tx) => channel_open_context_hash_from_signable(tx.tx(), sender), + TempoTxEnvelope::AA(tx) => tx.expiring_nonce_hash(sender), + } +} + /// Creates a mock AA signature for gas estimation based on key type hints /// /// - `key_type`: The primitive signature type (secp256k1, P256, WebAuthn) @@ -389,6 +417,44 @@ mod tests { assert_eq!(estimated_calls, built_calls); } + #[test] + fn test_try_into_tx_env_sets_channel_open_context_hash_for_rpc_simulation() { + let sender = address!("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let target = address!("0x2222222222222222222222222222222222222222"); + + let req = TempoTransactionRequest { + inner: TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(target)), + nonce: Some(0), + gas: Some(100_000), + max_fee_per_gas: Some(1_000_000_000), + max_priority_fee_per_gas: Some(1_000_000), + chain_id: Some(4217), + ..Default::default() + }, + ..Default::default() + }; + + let evm_env = EvmEnv::default(); + let tx_env = req + .clone() + .try_into_tx_env(&evm_env) + .expect("try_into_tx_env"); + let expected = channel_open_context_hash_from_sim_tx( + &>::try_into_sim_tx(req) + .expect("try_into_sim_tx"), + sender, + ); + + assert_eq!(tx_env.channel_open_context_hash(), Some(expected)); + assert_ne!( + tx_env.channel_open_context_hash(), + Some(B256::ZERO), + "RPC simulations must seed a non-zero context hash so TIP20ChannelEscrow.open() does not treat it as unset" + ); + } + #[test] fn test_webauthn_size_clamped_to_max() { // Attempt to create a signature with u32::MAX size (would be ~4GB without fix) diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index c92eafe7f0..9722c380ca 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -15,15 +15,17 @@ crate::sol! { struct ChannelDescriptor { address payer; address payee; + address operator; address token; bytes32 salt; address authorizedSigner; + bytes32 expiringNonceHash; } struct ChannelState { uint96 settled; uint96 deposit; - uint32 closeData; + uint32 closeRequestedAt; } struct Channel { @@ -36,6 +38,7 @@ crate::sol! { function open( address payee, + address operator, address token, uint96 deposit, bytes32 salt, @@ -84,9 +87,11 @@ crate::sol! { function computeChannelId( address payer, address payee, + address operator, address token, bytes32 salt, - address authorizedSigner + address authorizedSigner, + bytes32 expiringNonceHash ) external view @@ -103,9 +108,11 @@ crate::sol! { bytes32 indexed channelId, address indexed payer, address indexed payee, + address operator, address token, address authorizedSigner, bytes32 salt, + bytes32 expiringNonceHash, uint96 deposit ); @@ -149,12 +156,13 @@ crate::sol! { error ChannelAlreadyExists(); error ChannelNotFound(); - error ChannelFinalized(); error NotPayer(); error NotPayee(); + error NotPayeeOrOperator(); error InvalidPayee(); error InvalidToken(); error ZeroDeposit(); + error ExpiringNonceHashNotSet(); error InvalidSignature(); error AmountExceedsDeposit(); error AmountNotIncreasing(); @@ -174,10 +182,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 {}) } @@ -186,6 +190,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 {}) } @@ -198,6 +206,10 @@ impl TIP20ChannelEscrowError { Self::ZeroDeposit(ITIP20ChannelEscrow::ZeroDeposit {}) } + pub const fn expiring_nonce_hash_not_set() -> Self { + Self::ExpiringNonceHashNotSet(ITIP20ChannelEscrow::ExpiringNonceHashNotSet {}) + } + pub const fn invalid_signature() -> Self { Self::InvalidSignature(ITIP20ChannelEscrow::InvalidSignature {}) } 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/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 f5ed1e846c..e098e7e52b 100644 --- a/crates/precompiles/src/tip20_channel_escrow/mod.rs +++ b/crates/precompiles/src/tip20_channel_escrow/mod.rs @@ -19,8 +19,6 @@ pub use tempo_contracts::precompiles::{ }; 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; @@ -36,7 +34,7 @@ static VERSION_HASH: LazyLock = LazyLock::new(|| keccak256(b"1")); struct PackedChannelState { settled: U96, deposit: U96, - close_data: u32, + close_requested_at: u32, } impl PackedChannelState { @@ -44,19 +42,15 @@ 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_requested_at != 0).then_some(self.close_requested_at) } fn to_sol(self) -> ITIP20ChannelEscrow::ChannelState { ITIP20ChannelEscrow::ChannelState { settled: self.settled, deposit: self.deposit, - closeData: self.close_data, + closeRequestedAt: self.close_requested_at, } } } @@ -64,6 +58,11 @@ impl PackedChannelState { #[contract(addr = TIP20_CHANNEL_ESCROW_ADDRESS)] pub struct TIP20ChannelEscrow { channel_states: Mapping, + + // WARNING: transient storage slots must remain after persistent storage fields until the + // `contract` macro supports independent persistent/transient layouts. + opened_this_tx: Mapping, + channel_open_context_hash: B256, } impl TIP20ChannelEscrow { @@ -71,6 +70,16 @@ impl TIP20ChannelEscrow { self.__initialize() } + /// Seeds the enclosing transaction's replay-protected context hash for `open` calls. + /// + /// The handler seeds `keccak256(encode_for_signing || sender)` for every real transaction + /// type. The value is stored in transient storage so batched `open` calls share the same + /// transaction-derived hash and the context is automatically cleared before the next + /// transaction. If this is not called, `open` reads zero from transient storage and reverts. + pub fn set_channel_open_context_hash(&mut self, hash: B256) -> Result<()> { + self.channel_open_context_hash.t_write(hash) + } + pub fn open( &mut self, msg_sender: Address, @@ -88,14 +97,19 @@ impl TIP20ChannelEscrow { return Err(TIP20ChannelEscrowError::zero_deposit().into()); } + let expiring_nonce_hash = self.enclosing_channel_open_context_hash()?; let channel_id = self.compute_channel_id_inner( msg_sender, call.payee, + call.operator, call.token, call.salt, call.authorizedSigner, + expiring_nonce_hash, )?; - if self.channel_states[channel_id].read()?.exists() { + if self.channel_states[channel_id].read()?.exists() + || self.opened_this_tx[channel_id].t_read()? + { return Err(TIP20ChannelEscrowError::channel_already_exists().into()); } @@ -103,21 +117,24 @@ impl TIP20ChannelEscrow { self.channel_states[channel_id].write(PackedChannelState { settled: U96::ZERO, deposit, - close_data: 0, + close_requested_at: 0, })?; TIP20Token::from_address(call.token)?.system_transfer_from( msg_sender, 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, + expiringNonceHash: expiring_nonce_hash, deposit: call.deposit, }, ))?; @@ -134,11 +151,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 state.is_finalized() { - return Err(TIP20ChannelEscrowError::channel_finalized().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; @@ -194,9 +210,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 @@ -216,7 +229,7 @@ impl TIP20ChannelEscrow { )?; } if had_close_request { - state.close_data = 0; + state.close_requested_at = 0; } self.channel_states[channel_id].write(state)?; @@ -252,20 +265,13 @@ 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. let close_requested_at = self.now_u32(); let batch = self.storage.checkpoint(); - state.close_data = close_requested_at; + state.close_requested_at = close_requested_at; self.channel_states[channel_id].write(state)?; self.emit_event(TIP20ChannelEscrowEvent::CloseRequested( ITIP20ChannelEscrow::CloseRequested { @@ -286,14 +292,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; @@ -323,9 +326,7 @@ 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].delete()?; let mut token = TIP20Token::from_address(call.descriptor.token)?; if !delta.is_zero() { @@ -355,14 +356,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() @@ -377,8 +375,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].delete()?; if !refund.is_zero() { TIP20Token::from_address(call.descriptor.token)?.system_transfer_from( self.address, @@ -439,9 +436,11 @@ impl TIP20ChannelEscrow { self.compute_channel_id_inner( call.payer, call.payee, + call.operator, call.token, call.salt, call.authorizedSigner, + call.expiringNonceHash, ) } @@ -468,27 +467,41 @@ impl TIP20ChannelEscrow { self.compute_channel_id_inner( descriptor.payer, descriptor.payee, + descriptor.operator, descriptor.token, descriptor.salt, descriptor.authorizedSigner, + descriptor.expiringNonceHash, ) } + fn enclosing_channel_open_context_hash(&self) -> Result { + let hash = self.channel_open_context_hash.t_read()?; + if hash.is_zero() { + return Err(TIP20ChannelEscrowError::expiring_nonce_hash_not_set().into()); + } + Ok(hash) + } + fn compute_channel_id_inner( &self, payer: Address, payee: Address, + operator: Address, token: Address, salt: B256, authorized_signer: Address, + expiring_nonce_hash: B256, ) -> Result { self.storage.keccak256( &( payer, payee, + operator, token, salt, authorized_signer, + expiring_nonce_hash, self.address, U256::from(self.storage.chain_id()), ) @@ -581,19 +594,47 @@ mod tests { fn descriptor( payer: Address, payee: Address, + operator: Address, token: Address, salt: B256, authorized_signer: Address, + expiring_nonce_hash: B256, ) -> ITIP20ChannelEscrow::ChannelDescriptor { ITIP20ChannelEscrow::ChannelDescriptor { payer, payee, + operator, + token, + salt, + authorizedSigner: authorized_signer, + expiringNonceHash: expiring_nonce_hash, + } + } + + fn open_call( + payee: Address, + operator: Address, + token: Address, + deposit: u128, + salt: B256, + authorized_signer: Address, + ) -> ITIP20ChannelEscrow::openCall { + ITIP20ChannelEscrow::openCall { + payee, + operator, token, + deposit: abi_u96(deposit), salt, authorizedSigner: authorized_signer, } } + fn seed_expiring_nonce_hash(escrow: &mut TIP20ChannelEscrow) -> Result { + let hash = B256::random(); + escrow.set_channel_open_context_hash(hash)?; + Ok(hash) + } + #[test] fn test_selector_coverage() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); @@ -611,7 +652,40 @@ mod tests { } #[test] - fn test_open_settle_close_flow_and_tombstone() -> eyre::Result<()> { + fn test_open_requires_expiring_nonce_hash() -> 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(100u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + + let result = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 1, + B256::random(), + Address::ZERO, + ), + ); + assert_eq!( + result.unwrap_err(), + TIP20ChannelEscrowError::expiring_nonce_hash_not_set().into() + ); + Ok(()) + }) + } + + #[test] + fn test_open_settle_close_flow_deletes_state_and_same_tx_reopen_guard() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); let payer_signer = PrivateKeySigner::random(); let payer = payer_signer.address(); @@ -626,16 +700,18 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; let channel_id = escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(300), + Address::ZERO, + token.address(), + 300, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; let digest = escrow.get_voucher_digest(ITIP20ChannelEscrow::getVoucherDigestCall { @@ -645,7 +721,15 @@ 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, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); escrow.settle( payee, ITIP20ChannelEscrow::settleCall { @@ -654,36 +738,269 @@ mod tests { signature: signature.clone(), }, )?; + + let close_digest = + escrow.get_voucher_digest(ITIP20ChannelEscrow::getVoucherDigestCall { + channelId: channel_id, + cumulativeAmount: abi_u96(500), + })?; + let close_signature = + Bytes::copy_from_slice(&payer_signer.sign_hash_sync(&close_digest)?.as_bytes()); escrow.close( payee, ITIP20ChannelEscrow::closeCall { descriptor: channel_descriptor.clone(), - cumulativeAmount: abi_u96(120), - captureAmount: abi_u96(120), - signature, + cumulativeAmount: abi_u96(500), + captureAmount: abi_u96(200), + signature: close_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); + assert!(state.deposit.is_zero()); + assert!(state.settled.is_zero()); + assert_eq!(state.closeRequestedAt, 0); let reopen_result = escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( + payee, + Address::ZERO, + token.address(), + 1, + salt, + Address::ZERO, + ), + ); + assert_eq!( + reopen_result.unwrap_err(), + TIP20ChannelEscrowError::channel_already_exists().into() + ); + + let new_expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let reopened_channel_id = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 1, + salt, + Address::ZERO, + ), + )?; + assert_ne!(channel_id, reopened_channel_id); + assert_ne!(expiring_nonce_hash, new_expiring_nonce_hash); + + let reopened_state = + escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { + channelId: reopened_channel_id, + })?; + assert_eq!(reopened_state.deposit, abi_u96(1)); + + Ok(()) + }) + } + + #[test] + fn test_expiring_nonce_hash_and_operator_participate_in_channel_id() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); + let payer = Address::random(); + let payee = Address::random(); + let operator = 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 escrow = TIP20ChannelEscrow::new(); + + let hash_a = B256::random(); + let hash_b = B256::random(); + let without_operator = + escrow.compute_channel_id(ITIP20ChannelEscrow::computeChannelIdCall { + payer, payee, + operator: Address::ZERO, + token: token.address(), + salt, + authorizedSigner: Address::ZERO, + expiringNonceHash: hash_a, + })?; + let with_operator = + escrow.compute_channel_id(ITIP20ChannelEscrow::computeChannelIdCall { + payer, + payee, + operator, + token: token.address(), + salt, + authorizedSigner: Address::ZERO, + expiringNonceHash: hash_a, + })?; + let with_other_hash = + escrow.compute_channel_id(ITIP20ChannelEscrow::computeChannelIdCall { + payer, + payee, + operator: Address::ZERO, token: token.address(), - deposit: abi_u96(1), salt, authorizedSigner: Address::ZERO, + expiringNonceHash: hash_b, + })?; + + assert_ne!(without_operator, with_operator); + assert_ne!(without_operator, with_other_hash); + Ok(()) + }) + } + + #[test] + fn test_multiple_opens_same_transaction() -> 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 hash = seed_expiring_nonce_hash(&mut escrow)?; + let first = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 10, + salt, + Address::ZERO, + ), + )?; + let second = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 10, + B256::random(), + Address::ZERO, + ), + )?; + assert_ne!(first, second); + + let other_hash = seed_expiring_nonce_hash(&mut escrow)?; + let same_descriptor_other_tx_hash = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 10, + salt, + Address::ZERO, + ), + )?; + assert_ne!(first, same_descriptor_other_tx_hash); + assert_ne!(hash, other_hash); + + Ok(()) + }) + } + + #[test] + fn test_settle_allows_operator_and_rejects_unrelated_sender() -> 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(); + + StorageCtx::enter(&mut storage, || { + let token = TIP20Setup::path_usd(payer) + .with_issuer(payer) + .with_mint(payer, U256::from(200u128)) + .apply()?; + let mut escrow = TIP20ChannelEscrow::new(); + escrow.initialize()?; + + let salt = B256::random(); + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let channel_id = escrow.open( + payer, + open_call(payee, operator, token.address(), 100, salt, Address::ZERO), + )?; + let channel_descriptor = descriptor( + payer, + payee, + operator, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); + 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()); + + escrow.settle( + operator, + ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor, + cumulativeAmount: abi_u96(40), + signature, + }, + )?; + let state = escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { + channelId: channel_id, + })?; + assert_eq!(state.settled, abi_u96(40)); + + let salt = B256::random(); + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 10, + salt, + Address::ZERO, + ), + )?; + let descriptor_without_operator = descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); + let result = escrow.settle( + Address::random(), + ITIP20ChannelEscrow::settleCall { + descriptor: descriptor_without_operator, + cumulativeAmount: abi_u96(1), + signature: Bytes::copy_from_slice(&Signature::test_signature().as_bytes()), }, ); assert_eq!( - reopen_result.unwrap_err(), - TIP20ChannelEscrowError::channel_already_exists().into() + result.unwrap_err(), + TIP20ChannelEscrowError::not_payee_or_operator().into() ); Ok(()) @@ -705,24 +1022,40 @@ mod tests { let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let descriptor = descriptor(payer, payee, token.address(), salt, Address::ZERO); + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let descriptor = descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(100), + Address::ZERO, + token.address(), + 100, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; + escrow.storage.set_timestamp(U256::from(1_000u64)); escrow.request_close( payer, ITIP20ChannelEscrow::requestCloseCall { descriptor: descriptor.clone(), }, )?; + let requested = escrow.get_channel(ITIP20ChannelEscrow::getChannelCall { + descriptor: descriptor.clone(), + })?; + assert_eq!(requested.state.closeRequestedAt, 1_000); + escrow.top_up( payer, ITIP20ChannelEscrow::topUpCall { @@ -732,7 +1065,7 @@ mod tests { )?; let channel = escrow.get_channel(ITIP20ChannelEscrow::getChannelCall { descriptor })?; - assert_eq!(channel.state.closeData, 0); + assert_eq!(channel.state.closeRequestedAt, 0); assert_eq!(channel.state.deposit, 125); Ok(()) @@ -747,6 +1080,7 @@ 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, @@ -774,21 +1108,31 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(100), + Address::ZERO, + token.address(), + 100, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; let result = escrow.settle( payee, ITIP20ChannelEscrow::settleCall { - descriptor: descriptor(payer, payee, token.address(), salt, Address::ZERO), + descriptor: descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ), cumulativeAmount: abi_u96(10), signature: Bytes::copy_from_slice( &Signature::test_signature().as_bytes()[..64], @@ -817,15 +1161,17 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(100), + Address::ZERO, + token.address(), + 100, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; let mut keychain_signature = Vec::with_capacity(1 + 20 + 65); @@ -836,7 +1182,15 @@ mod tests { let result = escrow.settle( payee, ITIP20ChannelEscrow::settleCall { - descriptor: descriptor(payer, payee, token.address(), salt, Address::ZERO), + descriptor: descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ), cumulativeAmount: abi_u96(10), signature: keychain_signature.into(), }, @@ -849,6 +1203,65 @@ mod tests { }) } + #[test] + fn test_withdraw_after_grace_deletes_state() -> 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 expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let channel_id = escrow.open( + payer, + open_call( + payee, + Address::ZERO, + token.address(), + 100, + salt, + Address::ZERO, + ), + )?; + let descriptor = descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); + + escrow.storage.set_timestamp(U256::from(1_000u64)); + escrow.request_close( + payer, + ITIP20ChannelEscrow::requestCloseCall { + descriptor: descriptor.clone(), + }, + )?; + escrow + .storage + .set_timestamp(U256::from(1_000u64 + CLOSE_GRACE_PERIOD)); + escrow.withdraw(payer, ITIP20ChannelEscrow::withdrawCall { descriptor })?; + + let state = escrow.get_channel_state(ITIP20ChannelEscrow::getChannelStateCall { + channelId: channel_id, + })?; + assert!(state.deposit.is_zero()); + assert!(state.settled.is_zero()); + assert_eq!(state.closeRequestedAt, 0); + + Ok(()) + }) + } + #[test] fn test_withdraw_requires_close_request() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5); @@ -863,16 +1276,26 @@ mod tests { .apply()?; let mut escrow = TIP20ChannelEscrow::new(); escrow.initialize()?; - let descriptor = descriptor(payer, payee, token.address(), salt, Address::ZERO); + let expiring_nonce_hash = seed_expiring_nonce_hash(&mut escrow)?; + let descriptor = descriptor( + payer, + payee, + Address::ZERO, + token.address(), + salt, + Address::ZERO, + expiring_nonce_hash, + ); escrow.open( payer, - ITIP20ChannelEscrow::openCall { + open_call( payee, - token: token.address(), - deposit: abi_u96(100), + Address::ZERO, + token.address(), + 100, salt, - authorizedSigner: Address::ZERO, - }, + Address::ZERO, + ), )?; let result = escrow.withdraw(payer, ITIP20ChannelEscrow::withdrawCall { descriptor }); diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index fe53647ffe..cba6234bff 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -52,6 +52,7 @@ use tempo_precompiles::{ }, tip_fee_manager::TipFeeManager, tip20::{ITIP20::InsufficientBalance, TIP20Error, TIP20Token}, + tip20_channel_escrow::TIP20ChannelEscrow, }; use tempo_primitives::{ TempoAddressExt, @@ -396,14 +397,15 @@ impl TempoEvmHandler { } impl TempoEvmHandler { - fn seed_tx_origin( + fn seed_precompile_tx_context( &self, evm: &mut TempoEvm, ) -> Result<(), EVMError> { let ctx = evm.ctx_mut(); + let channel_open_context_hash = ctx.tx.channel_open_context_hash(); - // Seed tx.origin in keychain transient storage for both regular execution and - // RPC simulations (`eth_call` / `eth_estimateGas`) that go through handler execution. + // Seed transient precompile transaction context for both regular execution and RPC + // simulations (`eth_call` / `eth_estimateGas`) that go through handler execution. StorageCtx::enter_evm( &mut ctx.journaled_state, &ctx.block, @@ -411,7 +413,14 @@ impl TempoEvmHandler { &ctx.tx, || { let mut keychain = AccountKeychain::new(); - keychain.set_tx_origin(ctx.tx.caller()) + keychain.set_tx_origin(ctx.tx.caller())?; + + if let Some(channel_open_context_hash) = channel_open_context_hash { + let mut channel_escrow = TIP20ChannelEscrow::new(); + channel_escrow.set_channel_open_context_hash(channel_open_context_hash)?; + } + + Ok::<(), TempoPrecompileError>(()) }, ) .map_err(|e| EVMError::Custom(e.to_string())) @@ -868,7 +877,7 @@ where evm: &mut Self::Evm, init_gas: &mut InitialAndFloorGas, ) -> Result<(), Self::Error> { - self.seed_tx_origin(evm)?; + self.seed_precompile_tx_context(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 17cbd8e4fd..4254eb6f3e 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -1,7 +1,9 @@ use crate::TempoInvalidTransaction; -use alloy_consensus::{EthereumTxEnvelope, TxEip4844, Typed2718, crypto::secp256k1}; +use alloy_consensus::{ + EthereumTxEnvelope, SignableTransaction, TxEip4844, Typed2718, crypto::secp256k1, +}; use alloy_evm::{FromRecoveredTx, FromTxWithEncoded, IntoTxEnv, TransactionEnvMut}; -use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; +use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256, keccak256}; use core::num::NonZeroU64; use revm::context::{ Transaction, TxEnv, @@ -84,6 +86,11 @@ pub struct TempoTxEnv { /// Whether the transaction is a system transaction. pub is_system_tx: bool, + /// Replay-protected context hash used to derive channel escrow IDs for `open`. + /// + /// Synthetic transaction environments used by tests and simulations may leave this unset. + pub channel_open_context_hash: Option, + /// Optional fee payer specified for the transaction. /// /// - Some(Some(address)) corresponds to a successfully recovered fee payer @@ -117,6 +124,14 @@ impl TempoTxEnv { .is_some_and(|aa| aa.subblock_transaction) } + /// Returns the replay-protected hash used to derive channel escrow IDs for `open`. + /// + /// This is `keccak256(encode_for_signing || sender)` for every real transaction type. For + /// Tempo AA transactions, this matches the existing expiring nonce hash helper. + pub fn channel_open_context_hash(&self) -> Option { + self.channel_open_context_hash + } + /// Returns the first top-level call in the transaction. pub fn first_call(&self) -> Option<(&TxKind, &[u8])> { if let Some(aa) = self.tempo_tx_env.as_ref() { @@ -157,6 +172,16 @@ impl From for TempoTxEnv { } } +fn channel_open_context_hash(tx: &T, sender: Address) -> B256 +where + T: SignableTransaction, +{ + let mut buf = Vec::with_capacity(tx.payload_len_for_signature() + sender.as_slice().len()); + tx.encode_for_signing(&mut buf); + buf.extend_from_slice(sender.as_slice()); + keccak256(buf) +} + impl Transaction for TempoTxEnv { type AccessListItem<'a> = &'a AccessListItem; type Authorization<'a> = &'a Either; @@ -264,7 +289,19 @@ impl IntoTxEnv for TempoTxEnv { impl FromRecoveredTx> for TempoTxEnv { fn from_recovered_tx(tx: &EthereumTxEnvelope, sender: Address) -> Self { - TxEnv::from_recovered_tx(tx, sender).into() + let channel_open_context_hash = match tx { + EthereumTxEnvelope::Legacy(tx) => channel_open_context_hash(tx.tx(), sender), + EthereumTxEnvelope::Eip2930(tx) => channel_open_context_hash(tx.tx(), sender), + EthereumTxEnvelope::Eip1559(tx) => channel_open_context_hash(tx.tx(), sender), + EthereumTxEnvelope::Eip4844(tx) => channel_open_context_hash(tx.tx(), sender), + EthereumTxEnvelope::Eip7702(tx) => channel_open_context_hash(tx.tx(), sender), + }; + + Self { + inner: TxEnv::from_recovered_tx(tx, sender), + channel_open_context_hash: Some(channel_open_context_hash), + ..Default::default() + } } } @@ -337,6 +374,7 @@ impl FromRecoveredTx for TempoTxEnv { }, fee_token: *fee_token, is_system_tx: false, + channel_open_context_hash: Some(aa_signed.expiring_nonce_hash(caller)), fee_payer: fee_payer_signature.map(|sig| { secp256k1::recover_signer(&sig, tx.fee_payer_signature_hash(caller)).ok() }), @@ -376,12 +414,25 @@ impl FromRecoveredTx for TempoTxEnv { inner: TxEnv::from_recovered_tx(inner.tx(), sender), fee_token: None, is_system_tx: tx.is_system_tx(), + channel_open_context_hash: Some(channel_open_context_hash(inner.tx(), sender)), fee_payer: None, tempo_tx_env: None, // Non-AA transaction }, - 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::Eip2930(tx) => Self { + inner: TxEnv::from_recovered_tx(tx.tx(), sender), + channel_open_context_hash: Some(channel_open_context_hash(tx.tx(), sender)), + ..Default::default() + }, + TempoTxEnvelope::Eip1559(tx) => Self { + inner: TxEnv::from_recovered_tx(tx.tx(), sender), + channel_open_context_hash: Some(channel_open_context_hash(tx.tx(), sender)), + ..Default::default() + }, + TempoTxEnvelope::Eip7702(tx) => Self { + inner: TxEnv::from_recovered_tx(tx.tx(), sender), + channel_open_context_hash: Some(channel_open_context_hash(tx.tx(), sender)), + ..Default::default() + }, TempoTxEnvelope::AA(tx) => Self::from_recovered_tx(tx, sender), } } @@ -411,17 +462,21 @@ impl FromTxWithEncoded for TempoTxEnv { #[cfg(test)] mod tests { + use alloy_consensus::{Signed, TxLegacy, transaction::TxHashRef}; use alloy_evm::FromRecoveredTx; - use alloy_primitives::{Address, Bytes, Signature, TxKind, U256}; + use alloy_primitives::{Address, Bytes, Signature, TxKind, U256, keccak256}; use core::num::NonZeroU64; use proptest::prelude::*; use revm::context::{Transaction, TxEnv, result::InvalidTransaction}; - use tempo_primitives::transaction::{ - Call, calc_gas_balance_spending, - tempo_transaction::TEMPO_EXPIRING_NONCE_KEY, - tt_signature::{PrimitiveSignature, TempoSignature}, - tt_signed::AASigned, - validate_calls, + use tempo_primitives::{ + TempoTxEnvelope, + transaction::{ + Call, calc_gas_balance_spending, + tempo_transaction::TEMPO_EXPIRING_NONCE_KEY, + tt_signature::{PrimitiveSignature, TempoSignature}, + tt_signed::AASigned, + validate_calls, + }, }; use crate::{TempoInvalidTransaction, TempoTxEnv}; @@ -528,18 +583,25 @@ mod tests { AASigned::new_unhashed(tx, sig) }; - // Expiring nonce tx: expiring_nonce_hash should be Some and match direct computation + // Expiring nonce tx: expiring_nonce_hash should be Some and match direct computation. + // Channel opens reuse the same encode_for_signing||sender hash. let expiring_signed = make_aa_signed(TEMPO_EXPIRING_NONCE_KEY); let expiring_env = TempoTxEnv::from_recovered_tx(&expiring_signed, caller); let tempo_env = expiring_env.tempo_tx_env.as_ref().unwrap(); - let expected_hash = expiring_signed.expiring_nonce_hash(caller); + let expected_expiring_nonce_hash = expiring_signed.expiring_nonce_hash(caller); assert_eq!( tempo_env.expiring_nonce_hash, - Some(expected_hash), + Some(expected_expiring_nonce_hash), "expiring nonce tx must have expiring_nonce_hash set" ); + assert_eq!( + expiring_env.channel_open_context_hash(), + Some(expected_expiring_nonce_hash), + "expiring nonce channel opens must reuse expiring_nonce_hash" + ); - // Regular 2D nonce tx: expiring_nonce_hash should be None + // Regular 2D nonce tx: expiring_nonce_hash should be None and channel opens still use + // the same encode_for_signing||sender construction. let regular_signed = make_aa_signed(U256::from(42)); let regular_env = super::TempoTxEnv::from_recovered_tx(®ular_signed, caller); let regular_tempo_env = regular_env.tempo_tx_env.as_ref().unwrap(); @@ -547,6 +609,52 @@ mod tests { regular_tempo_env.expiring_nonce_hash, None, "regular 2D nonce tx must NOT have expiring_nonce_hash" ); + assert_eq!( + regular_env.channel_open_context_hash(), + Some(regular_signed.expiring_nonce_hash(caller)), + "non-expiring AA channel opens must use encode_for_signing||sender" + ); + } + + #[test] + fn test_legacy_channel_open_context_hash_uses_encoded_signing_payload_and_sender() { + let caller = Address::repeat_byte(0xAA); + let tx = TxLegacy { + chain_id: Some(1), + nonce: 7, + gas_price: 1, + gas_limit: 21_000, + to: TxKind::Call(Address::repeat_byte(0x42)), + value: U256::ZERO, + input: Bytes::new(), + }; + let envelope = + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())); + let tx_hash = *envelope.tx_hash(); + let TempoTxEnvelope::Legacy(signed) = &envelope else { + unreachable!() + }; + + let tx_env = super::TempoTxEnv::from_recovered_tx(&envelope, caller); + let signature_hash = signed.signature_hash(); + assert_ne!( + signature_hash, tx_hash, + "legacy signature hash is the unsigned signing hash, not the signed tx hash" + ); + + let mut signature_hash_and_sender = [0u8; 52]; + signature_hash_and_sender[..32].copy_from_slice(signature_hash.as_slice()); + signature_hash_and_sender[32..].copy_from_slice(caller.as_slice()); + let signature_hash_context = keccak256(signature_hash_and_sender); + let encoded_payload_context = super::channel_open_context_hash(signed.tx(), caller); + assert_ne!( + encoded_payload_context, signature_hash_context, + "channel opens must use the encoded signing payload, not signature_hash||sender" + ); + assert_eq!( + tx_env.channel_open_context_hash(), + Some(encoded_payload_context) + ); } #[test] From 78218d54fa1f96e98cf86b617709ac87065c8ff6 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 7 May 2026 17:34:56 +0200 Subject: [PATCH 68/68] fix: merge conflicts --- crates/contracts/src/precompiles/tip20_channel_escrow.rs | 7 +++++-- crates/primitives/src/transaction/envelope.rs | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index eb9f8aa008..3e67306f24 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -3,6 +3,7 @@ pub use ITIP20ChannelEscrow::{ ITIP20ChannelEscrowEvents as TIP20ChannelEscrowEvent, }; use alloy_primitives::{Address, address}; +use alloy_sol_types::{SolCall, SolType}; pub const TIP20_CHANNEL_ESCROW_ADDRESS: Address = address!("0x4D50500000000000000000000000000000000000"); @@ -279,9 +280,11 @@ mod tests { ITIP20ChannelEscrow::ChannelDescriptor { payer: Address::random(), payee: Address::random(), + operator: Address::random(), token: Address::random(), salt: B256::random(), authorizedSigner: Address::random(), + expiringNonceHash: B256::random(), } } @@ -289,7 +292,7 @@ mod tests { fn payment_calldatas() -> [Vec; 4] { let descriptor = descriptor(); [ - ITIP20ChannelEscrow::openCall { payee: Address::random(), token: Address::random(), deposit: U96::from(1), salt: B256::random(), authorizedSigner: Address::random() }.abi_encode(), + ITIP20ChannelEscrow::openCall { payee: Address::random(), operator: Address::random(), token: Address::random(), deposit: U96::from(1), salt: B256::random(), authorizedSigner: Address::random() }.abi_encode(), ITIP20ChannelEscrow::topUpCall { descriptor: descriptor.clone(), additionalDeposit: U96::from(1) }.abi_encode(), ITIP20ChannelEscrow::settleCall { descriptor: descriptor.clone(), cumulativeAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode(), ITIP20ChannelEscrow::closeCall { descriptor, cumulativeAmount: U96::from(1), captureAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode(), @@ -327,7 +330,7 @@ mod tests { } .abi_encode(); // Corrupt the dynamic `signature` offset word. - calldata[4 + 6 * 32 + 31] = 0; + calldata[4 + 8 * 32 + 31] = 0; assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( &calldata )); diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 5178154f73..6d3e933e2a 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -568,9 +568,11 @@ mod tests { ITIP20ChannelEscrow::ChannelDescriptor { payer: Address::random(), payee: Address::random(), + operator: Address::random(), token: PAYMENT_TKN, salt: B256::random(), authorizedSigner: Address::random(), + expiringNonceHash: B256::random(), } } @@ -578,7 +580,7 @@ mod tests { fn channel_escrow_payment_calldatas() -> [Bytes; 4] { let descriptor = channel_descriptor(); [ - ITIP20ChannelEscrow::openCall { payee: Address::random(), token: PAYMENT_TKN, deposit: U96::from(1), salt: B256::random(), authorizedSigner: Address::random() }.abi_encode().into(), + ITIP20ChannelEscrow::openCall { payee: Address::random(), operator: Address::random(), token: PAYMENT_TKN, deposit: U96::from(1), salt: B256::random(), authorizedSigner: Address::random() }.abi_encode().into(), ITIP20ChannelEscrow::topUpCall { descriptor: descriptor.clone(), additionalDeposit: U96::from(1) }.abi_encode().into(), ITIP20ChannelEscrow::settleCall { descriptor: descriptor.clone(), cumulativeAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode().into(), ITIP20ChannelEscrow::closeCall { descriptor, cumulativeAmount: U96::from(1), captureAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode().into(), @@ -851,7 +853,7 @@ mod tests { } .abi_encode(); // Corrupt the dynamic `signature` offset word. - corrupted_calldata[4 + 6 * 32 + 31] = 0; + corrupted_calldata[4 + 8 * 32 + 31] = 0; for envelope in payment_envelopes_to(TIP20_CHANNEL_ESCROW_ADDRESS, corrupted_calldata.into())