Skip to content

feat(tip-1028): implement address-level receive policies#3800

Open
0xKitsune wants to merge 33 commits intomainfrom
kit/tip-1028-impl
Open

feat(tip-1028): implement address-level receive policies#3800
0xKitsune wants to merge 33 commits intomainfrom
kit/tip-1028-impl

Conversation

@0xKitsune
Copy link
Copy Markdown
Contributor

@0xKitsune 0xKitsune commented May 4, 2026

This PR Implements TIP-1028 receive policies, token filters, and receipt based escrow for blocked TIP-20 transfers and mints.

Changes are gated behind the T6 hardfork. Unit and e2e coverage is also added for receipt lifecycle, claim authorization, recovery address flows, and escrow balance accounting.

Ref #3791

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

⚠️ Changelog not found.

A changelog entry is required before merging. We've generated a suggested changelog based on your changes:

Preview
---
tempo-contracts: minor
tempo-precompiles: minor
tempo-evm: minor
tempo-node: patch
tempo-e2e: patch
---

Added TIP-1028 escrow precompile for blocked transfer recovery and extended TIP-403 registry with receive policy support, deployed at the T6 hardfork boundary.

Add changelog to commit this to your branch.

Comment thread crates/contracts/src/precompiles/mod.rs Outdated
Comment thread crates/contracts/src/precompiles/mod.rs Outdated
Comment thread crates/contracts/src/precompiles/escrow.rs Outdated
Comment thread crates/contracts/src/precompiles/tip20.rs Outdated
Comment thread crates/contracts/src/precompiles/tip403_registry.rs
Comment thread crates/precompiles/src/tip20/mod.rs Outdated
Copy link
Copy Markdown
Contributor

@0xrusowsky 0xrusowsky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall i think this is a great start.

i think we should lean more into the Recipient struct to simplify and offload some of the cross-precompile logic out of the TIP20 transfer/mint paths, similar to what we did with virtual addresses.

i'll open a draft with a proposal to build on top of this foundation

Comment thread crates/precompiles/src/tip20/mod.rs Outdated
Comment thread crates/contracts/src/precompiles/tip1028_escrow.rs Outdated
Comment thread crates/precompiles/src/tip1028_escrow/mod.rs Outdated
@0xKitsune 0xKitsune force-pushed the kit/tip-1028-impl branch from 1f764de to b90d340 Compare May 6, 2026 17:00
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

📊 Tempo Precompiles Coverage

precompiles

Coverage: 5683/8142 lines (69.80%)

File details
File Lines Coverage
src/account_keychain/dispatch.rs 30/68 44.12%
src/account_keychain/mod.rs 274/736 37.23%
src/address_registry/dispatch.rs 31/33 93.94%
src/address_registry/mod.rs 50/56 89.29%
src/error.rs 39/116 33.62%
src/ip_validation.rs 10/10 100.00%
src/lib.rs 182/221 82.35%
src/nonce/dispatch.rs 9/10 90.00%
src/nonce/mod.rs 46/61 75.41%
src/signature_verifier/dispatch.rs 19/20 95.00%
src/signature_verifier/mod.rs 13/17 76.47%
src/stablecoin_dex/dispatch.rs 92/93 98.92%
src/stablecoin_dex/mod.rs 876/918 95.42%
src/stablecoin_dex/order.rs 110/161 68.32%
src/stablecoin_dex/orderbook.rs 157/216 72.69%
src/storage/evm.rs 192/221 86.88%
src/storage/hashmap.rs 0/158 0.00%
src/storage/mod.rs 27/27 100.00%
src/storage/packing.rs 68/93 73.12%
src/storage/thread_local.rs 165/227 72.69%
src/storage/types/array.rs 0/72 0.00%
src/storage/types/bytes_like.rs 79/162 48.77%
src/storage/types/mapping.rs 27/48 56.25%
src/storage/types/mod.rs 67/91 73.63%
src/storage/types/primitives.rs 21/24 87.50%
src/storage/types/set.rs 28/192 14.58%
src/storage/types/slot.rs 55/81 67.90%
src/storage/types/vec.rs 101/246 41.06%
src/tip1028_escrow/dispatch.rs 0/16 0.00%
src/tip1028_escrow/mod.rs 0/167 0.00%
src/tip20/dispatch.rs 149/165 90.30%
src/tip20/mod.rs 638/832 76.68%
src/tip20/rewards.rs 238/252 94.44%
src/tip20/roles.rs 107/110 97.27%
src/tip20_factory/dispatch.rs 17/18 94.44%
src/tip20_factory/mod.rs 105/125 84.00%
src/tip403_registry/dispatch.rs 58/74 78.38%
src/tip403_registry/mod.rs 334/459 72.77%
src/tip_fee_manager/amm.rs 285/364 78.30%
src/tip_fee_manager/dispatch.rs 81/83 97.59%
src/tip_fee_manager/mod.rs 71/136 52.21%
src/validator_config/dispatch.rs 38/52 73.08%
src/validator_config/mod.rs 171/227 75.33%
src/validator_config_v2/dispatch.rs 71/73 97.26%
src/validator_config_v2/mod.rs 552/611 90.34%

contracts

Coverage: 1/271 lines (0.37%)

File details
File Lines Coverage
src/lib.rs 1/1 100.00%
src/precompiles/account_keychain.rs 0/40 0.00%
src/precompiles/address_registry.rs 0/12 0.00%
src/precompiles/escrow.rs 0/18 0.00%
src/precompiles/nonce.rs 0/15 0.00%
src/precompiles/signature_verifier.rs 0/3 0.00%
src/precompiles/stablecoin_dex.rs 0/18 0.00%
src/precompiles/tip20.rs 0/61 0.00%
src/precompiles/tip20_factory.rs 0/9 0.00%
src/precompiles/tip403_registry.rs 0/24 0.00%
src/precompiles/tip_fee_manager.rs 0/18 0.00%
src/precompiles/validator_config.rs 0/13 0.00%
src/precompiles/validator_config_v2.rs 0/39 0.00%

Total: 5684/8413 lines (67.56%)

📦 Download full HTML report

@0xKitsune 0xKitsune marked this pull request as ready for review May 7, 2026 04:39
@0xKitsune 0xKitsune requested a review from Zygimantass as a code owner May 7, 2026 05:27
@0xKitsune 0xKitsune added the cyclops Trigger Cyclops PR audit label May 7, 2026
}

function blockedReceiptBalance(address token, address recoveryContract, uint8 receiptVersion, bytes calldata receipt) external view returns (uint256 amount);
function claimBlocked(address token, address recoveryContract, uint8 receiptVersion, bytes calldata receipt, address to) external;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should we rename to simply claim?

return Err(TIP1028EscrowError::invalid_receipt_claim().into());
}

let guard = self.storage.checkpoint();
Copy link
Copy Markdown
Contributor

@0xrusowsky 0xrusowsky May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to checkpoint here? afaict any error after this point propagates to the precompile boundary, so revm would revert the whole frame (as usual).

this is only necessary when there's a caller that swallows the error and continues, which doesn't seem to be the case

Comment on lines 429 to +431
let to = Recipient::resolve(call.to)?;
self._mint(msg_sender, &to, call.amount)?;
self.check_role(msg_sender, *ISSUER_ROLE)?;
self.validate_mint(&to)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is technically breaking if validate_mint() fails:

BEFORE inside fn _mint():
1. check_role(..)?;
2. total_supply()?; // SLOAD
3. validate_mint(..)?;

NOW
1. check_role(..)?;
2. validate_mint(..)?;
3. total_supply()?;

that's in my previous proposal to enhance Receiver i was performing the inbound validation inside _mint()

Copy link
Copy Markdown
Contributor

@0xrusowsky 0xrusowsky May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proposed fix 06ea050 (#3845)

let recovery = registry.receive_policy_recovery(to.target)?;
let recipient = to.virtual_addr.unwrap_or(to.target);

let guard = self.storage.checkpoint();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we checkpoint here? same question as before, afaik we always propagate errors?


/// Validates the TIP-1028 receive-policy check for the destination address. If the receive
/// policy prohibits the action, the funds are escrowed.
fn validate_or_escrow_funds(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nits:

  • can we document what when the return type means "whether the funds were escrowed or not", and explicitly explain that the caller should not move the funds if that's the case?
  • can we maybe rename the fn to indicate what we are validating? personally i like validate_inbound_or_escrow but if we want a shorter version we could also use validate_inbound or validate_receive_policies

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the refactor with this function is an elegant way to simplify, i really like it.
we only need to fix the re-execution issue on mints, but this is great.

Comment on lines +51 to +52
/// Account receive policy configuration.
address_receive_config: Mapping<Address, ReceivePolicyConfig>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we keep consistent naming with pre-existing vars and use "policy"?

Suggested change
/// Account receive policy configuration.
address_receive_config: Mapping<Address, ReceivePolicyConfig>,
/// Account-level receive policy configuration.
receive_policy_config: Mapping<Address, ReceivePolicyConfig>,
}

/// Returns `account`'s receive-policy configuration.
pub fn receive_policy(
&self,
call: ITIP403Registry::receivePolicyCall,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's flatten the call and take account: Address directly

msg_sender: Address,
call: ITIP403Registry::setReceivePolicyCall,
) -> Result<()> {
if msg_sender == ESCROW_ADDRESS {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this also verify the recovery address?

it shouldn't be a TIP20 nor the ESCROW precompile

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix proposal: 3c27ebd (#3845)

Comment thread tips/verify/lib/tempo-std
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this requires a PR on its own.

Copy link
Copy Markdown

@tempoxyz-bot tempoxyz-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️ Cyclops Review

Audited at c3b5f75778de; head drift detected. Findings re-checked against the current head c131127. The previously-flagged "missing T6 bytecode deploy for ESCROW_ADDRESS" callout has been fixed in this PR (crates/evm/src/block.rs:457-462 + new test_apply_pre_execution_deploys_escrow_code) and is dropped.

Body-only finding (target file is in the PR but the affected lines are outside the diff)

🚨 [SECURITY] burn_blocked permanently destroys escrowed funds — ESCROW_ADDRESS missing from protected-address allow-list

Severity: Medium
File: crates/precompiles/src/tip20/mod.rs:537 (existing burn_blocked guard, unchanged in this PR) interacting with new escrow custody at crates/precompiles/src/tip20/mod.rs:1073-1118

After T6, validate_or_escrow_funds parks blocked transfers/mints at ESCROW_ADDRESS. burn_blocked only protects TIP_FEE_MANAGER_ADDRESS | STABLECOIN_DEX_ADDRESS; ESCROW_ADDRESS is not in the list. For tokens whose transfer policy treats ESCROW_ADDRESS as unauthorized as a sender (REJECT_ALL_POLICY_ID, custom whitelists that omit escrow, blacklists that include it — exactly the tokens most likely to use TIP-1028), an account holding BURN_BLOCKED_ROLE can call burn_blocked(ESCROW_ADDRESS, amount) and zero out custody backing unrelated user receipts. Subsequent claimBlocked calls then revert with InsufficientEscrowBalance. Verified at the current head — the guard is still:

if matches!(call.from, TIP_FEE_MANAGER_ADDRESS | STABLECOIN_DEX_ADDRESS) {
    return Err(TIP20Error::protected_address().into());
}

Recommended Fix: Add ESCROW_ADDRESS to the protected list and mirror test_unable_to_burn_blocked_from_protected_address for it.

Reviewer Callouts
  • Other system-address allow-lists: Grep every site that special-cases TIP_FEE_MANAGER_ADDRESS / STABLECOIN_DEX_ADDRESS and decide whether ESCROW_ADDRESS belongs there too (snapshot tools, fee accounting invariants, admin levers). The verified impactful site is burn_blocked, but a sweep is the right hygiene.
  • Recovery key compromise of an existing receipt is irrevocable: receipt_key binds recovery_address at store time. If the receiver later rotates recovery_address away from a compromised contract, receipts already in escrow remain bound to the old recovery. Confirm intended TIP-1028 semantics.
  • DEX swap output silently routes to escrow: swap_exact_amount_in/out, withdraw, and other DEX paths deliver token_out to sender via TIP20Token::transfer(DEX, sender, amount). After T6, if sender configured a receive policy that rejects the DEX address, the output goes to escrow but the DEX functions still return Ok(amount). Composing contracts will operate on stale balances. Spec/UX call.
  • Mint / TransferWithMemo events suppressed on escrow path: When mint/transfer-with-memo escrow-routes, only Transfer(*, ESCROW, *) fires (plus TransferBlocked for transfers). Off-chain indexers tracking supply/memos must combine TIP-1028 events to reconstruct intent.
  • Stale (resolved): "Missing T6 bytecode deploy for ESCROW_ADDRESS" — fixed in the current head; no action.


/// Releases escrowed funds to `to`. Self-recovery skips policy checks. Redirects
/// revalidate the transfer and receive policies and meter the spending limit when set.
pub(crate) fn release_from_escrow(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [ISSUE] release_from_escrow ignores token pause state

Existing TIP-20 explicitly documents the pause invariant in transfer_fee_pre_tx: "no other token transfers can occur after a pause event" except transfer_fee_post_tx refunds. validate_transfer, _burn, validate_mint, transfer_fee_pre_tx, and reward-claim paths all call check_not_paused. release_from_escrow does not — so claims and redirects move funds even while the token is paused. This silently breaks the issuer's incident-response guarantee for already-escrowed funds.

Recommended Fix: Either gate release_from_escrow on self.check_not_paused()? at entry, or — if escrow release is intentionally a second pause exception — document it next to the existing fee-refund exception comment and add a regression test that exercises the paused-claim case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proposed fix: 67cfee6 (#3845)

let destination = Recipient::resolve(to)?;
destination.validate()?;

if destination.target != receiver {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [ISSUE] Redirected claims bypass the sender-side TIP-403 transfer policy

This branch checks only that destination is recipient-authorized under the token transfer policy and that the destination's receive policy accepts originator as the sender. It does not check that receiver is sender-authorized under the token transfer policy. With a compound policy that whitelists only {originator} as sender and {receiver, destination} as recipients, a normal transfer(receiver → destination) reverts with PolicyForbids, but routing the same value through escrow + claimBlocked(to=destination) succeeds — turning TIP-1028 release into a compliance/freeze bypass for blocked funds.

Metering further uses receiver as the actor (line 1150) while validate_receive_policy is called with originator as the sender — three distinct identities are treated as "the sender" along the redirect path.

Recommended Fix: Make redirects policy-equivalent to receiver → destination:

if destination.target != receiver {
    self.ensure_transfer_authorized(receiver, destination.target)?; // sender-side check on receiver
    TIP403Registry::new()
        .validate_receive_policy(self.address, receiver, destination.target)?;
    if meter_spending_limit {
        self.check_and_update_spending_limit(receiver, amount)?;
    }
}

or document explicitly in the TIP-1028 spec why redirects are intentionally weaker than direct transfers.


💡 [SUGGESTION] Mint redirected via escrow downgrades from mint_recipient to recipient policy

is_authorized_as(.., destination.target, AuthRole::recipient()) is used here even for receipts whose kind == MINT. The original mint was gated by validate_mint's AuthRole::mint_recipient() check, so a claimBlocked redirect of an escrowed mint can deliver freshly-minted units to an address that is permitted as a regular recipient but not as a mint recipient. Probably acceptable (the mint is "complete" at custody time), but it changes the TIP-403 invariant slightly — call out in the TIP-1028 spec or, if mint_recipient is meant to gate the delivery address, preserve kind in the receipt and use mint_recipient when redirecting MINT receipts.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i previously discussed this with @malleshpai and i believe current impl is correct, and this would be overreaching with validations. however, it would be nice if we got an explicit ACK that we can disregard this concern

Comment thread crates/precompiles/src/tip20/mod.rs
}

/// Sets the caller's TIP-1028 receive policy.
pub fn set_receive_policy(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 [SUGGESTION] setReceivePolicy does not validate recoveryAddress

This function rejects msg_sender == ESCROW_ADDRESS and msg_sender.is_virtual() but applies no analogous check on call.recoveryAddress. A user can set recoveryAddress to ESCROW_ADDRESS, a TIP-20-prefixed address, a virtual address, or any other address that can never appear as the msg_sender of a claim_blocked call — any receipts later created with that recovery become permanently unclaimable (the only auth branch is msg_sender == recovery_address). Combined with the recovery=self spending-limit bypass flagged above, validating this field is the cleanest defense.

Recommended Fix: Reject recoveryAddress == ESCROW_ADDRESS, recoveryAddress.is_tip20(), recoveryAddress.is_virtual(), and (per the security finding) recoveryAddress == msg_sender. Keep Address::ZERO allowed as the self-recovery sentinel.

Copy link
Copy Markdown
Contributor

@0xrusowsky 0xrusowsky May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valid. already flagged here #3800 (comment)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix proposal: 3c27ebd (#3845)

@0xrusowsky
Copy link
Copy Markdown
Contributor

🚨 [SECURITY] burn_blocked permanently destroys escrowed funds — ESCROW_ADDRESS missing from protected-address allow-list

proposed fix 3067470 (#3845)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cyclops Trigger Cyclops PR audit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants