From 3869a66eec603799c1683c49c24f3246132583b5 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 15:59:36 +0200 Subject: [PATCH 01/11] HIP-23 draft: Istanbul DeFi application patterns spec and regression tests --- doc/HIP23.md | 234 +++++++++++++++++++++ doc/HIP23_templates.md | 277 ++++++++++++++++++++++++ tests/hip23_pattern_regression.rs | 339 ++++++++++++++++++++++++++++++ 3 files changed, 850 insertions(+) create mode 100644 doc/HIP23.md create mode 100644 doc/HIP23_templates.md create mode 100644 tests/hip23_pattern_regression.rs diff --git a/doc/HIP23.md b/doc/HIP23.md new file mode 100644 index 0000000..153f975 --- /dev/null +++ b/doc/HIP23.md @@ -0,0 +1,234 @@ +# HIP-23: Istanbul DeFi Application Patterns + +**Status:** Draft (branch `hip-23-draft`) +**Type:** Application / integration standard — **no consensus fork** +**Activation:** Istanbul capabilities @ mainnet height `765432` (`ONLINE_OPEN_HEIGHT`) +**Depends on:** Type3 transactions, ActionGuard, TEX, AST, HIP20 (`AssetCreate`), HVM (optional) + +## 1. Purpose + +HIP-23 documents **reusable DeFi composition patterns** on post-Istanbul Hacash. It does not introduce new protocol actions, fork heights, or consensus rules. Wallets, indexers, gateways, and integrators MAY implement these patterns today using existing Istanbul machinery. + +Goals: + +- Give builders copy-paste-safe transaction templates for common financial flows. +- Tie each pattern to **normative MUST/SHOULD** rules wallets and indexers can validate off-chain. +- Provide regression tests (`tests/hip23_pattern_regression.rs`) that lock pattern semantics to `fullnodedev`. + +Reference material: + +- Istanbul capability model: `istanbul_upgrade_tech.md` (community tech note) +- TEX settlement: `protocol/src/tex/*`, `tests/tex.rs` (`trs1`) +- AST control flow: `doc/ast-spec.md` +- Guards: `protocol/src/action/chain.rs` + +## 2. Scope + +### 2.1 In scope (v1) + +| ID | Pattern | Primary primitives | +|----|---------|-------------------| +| P1 | Atomic multi-asset TEX swap | `TexCellAct` (kind 22), transfer cells 1–8 | +| P2 | Time-boxed guarded payment | `HeightScope` (0x0412) + top transfer | +| P3 | BalanceFloor protected transfer | `BalanceFloor` (0x0413) + ordered debits | +| P4 | HIP20 issuance + TEX distribution | `AssetCreate` (16) + asset TEX cells 7/8 | +| P5 | AST conditional settlement | `AstIf` / `AstSelect` + `HeightScope` condition | + +### 2.2 Out of scope (v1) + +- New action kinds or fork gates +- HVM contract templates (future HIP-23 v2 MAY add optional contract companions) +- Cross-chain bridges, oracle networks, or mempool policy +- Fee-market or MEV recommendations + +## 3. Common requirements + +All patterns MUST satisfy: + +1. **Transaction type:** `type >= 3` when AST, TEX asset cells, or gas metering are used. +2. **Height gate:** On mainnet (`chain_id = 0`), Istanbul actions MUST only be submitted at `height >= 765432`, except dev/regtest windows documented in `protocol/src/upgrade.rs`. +3. **TEX settlement:** Any transaction containing `TexCellAct` MUST leave the TEX ledger zero-sum; the node calls `do_settlement()` after top-level actions (`protocol/src/transaction/type3.rs`). +4. **TEX signatures:** Each `TexCellAct` MUST be signed over `addr + cells` only (replayable across transactions). +5. **Guard topology:** Bare `GUARD` actions MAY appear at top level alongside other top actions. Transactions MUST NOT be guard-only (`protocol/src/action/level.rs`). +6. **Asset TEX gas:** Transactions with asset transfer TEX cells (`cellid` 7 or 8) MUST set `gas_max > 0` and initialize gas (`extra9` path). + +Wallets SHOULD: + +- Pre-validate TEX zero-sum pairing off-chain before co-signing counterparty bundles. +- Display guard windows (height, chain, balance floor) in human-readable form. +- Reject co-signed TEX bundles whose `addr` does not match the expected counterparty. + +Indexers SHOULD: + +- Index `TexCellAct` by signer `addr` and settled asset/diamond deltas. +- Record guard failures as revert reason codes for failed transactions. +- Treat `AssetCreate` + TEX in the same tx as an atomic issuance+distribution event. + +## 4. Pattern P1 — Atomic multi-asset TEX swap + +### 4.1 Intent + +Two or more parties atomically exchange HAC, SAT, diamonds, and/or HIP20 assets in one transaction without trusting an escrow contract. + +### 4.2 Structure + +1. Party A publishes `TexCellAct_A` with pay cells (`cellid` 1/3/5/7) and/or get cells (`2/4/6/8`). +2. Party B publishes `TexCellAct_B` with the mirrored get/pay cells. +3. A coordinator (or either party) combines both signed bundles in one Type3 transaction. +4. Optional funding actions (e.g. `HacToTrs`, `AssetCreate`) MAY precede TEX actions in the same tx. + +### 4.3 Rules + +- MUST: For every pay cell in A, B MUST include the matching get cell (same asset serial, amount, coin type). +- MUST: TEX ledger totals for `zhu`, `sat`, `dia`, and each asset serial MUST be zero before `do_settlement()`. +- MUST: Each party signs only their own bundle; tampering after sign MUST fail verification. +- SHOULD: Include TEX condition cells (`cellid >= 11`) when quoting requires balance/height proofs at execution time. + +### 4.4 Failure modes + +| Failure | Result | +|---------|--------| +| Imbalanced pay/get | Fault at settlement | +| Bad signature | Fault on `TexCellAct` execute | +| Missing asset / insufficient balance | Fault during cell execute | +| Asset cells without gas | Fault (`gas not initialized`) | + +## 5. Pattern P2 — Time-boxed guarded payment + +### 5.1 Intent + +Release a payment only if the transaction is included within a block-height window. + +### 5.2 Structure + +``` +actions: [ + HeightScope { start, end }, // GUARD, top-level + HacToTrs | SatToTrs | ... // non-guard debit +] +``` + +`end = 0` means unlimited upper bound (per `HeightScope` semantics). + +### 5.3 Rules + +- MUST: `HeightScope` execute before the debit action (lower action index runs first). +- MUST: `start <= end` when `end != 0`. +- MUST: Revert (not fault) when current height is outside `[start, end]`. +- SHOULD: Set `end` to a finite deadline for offer expiry. + +### 5.4 Wallet display + +Wallets SHOULD show: “Valid heights: `start` … `end` (inclusive)”. + +## 6. Pattern P3 — BalanceFloor protected transfer + +### 6.1 Intent + +Prevent a transaction from leaving an address below a minimum HAC/SAT/diamond/asset balance. + +### 6.2 Structure + +``` +actions: [ + , // e.g. HacToTrs + BalanceFloor { addr, ... } // GUARD, checks in-tx state at this point +] +``` + +`BalanceFloor` intentionally inspects **in-transaction state at the guard position**, not pre-tx chain state. + +### 6.3 Rules + +- MUST: Place `BalanceFloor` **after** debits that should be protected. +- MUST: Specify at least one non-zero floor field (`hacash`, `satoshi`, `diamond`, or `assets`). +- MUST: Revert when any checked dimension is below floor. +- SHOULD: Floor values include expected post-transfer dust retention. + +## 7. Pattern P4 — HIP20 issuance + TEX distribution + +### 7.1 Intent + +Mint a HIP20 asset and atomically distribute units to counterparties via TEX in the same transaction. + +### 7.2 Structure + +`AssetCreate` is `TOP_ONLY` — it MUST be the only non-guard top-level action in its transaction. +Distribution therefore uses **two coordinated transactions**: + +``` +Tx A (issuance): + actions: [ AssetCreate { metadata, protocol_cost } ] + +Tx B (distribution, after Tx A confirms or in a later pass): + actions: [ + TexCellAct_issuer (AssetPay cells), + TexCellAct_counter (AssetGet cells), + ] +``` + +### 7.3 Rules + +- MUST: Keep `AssetCreate` alone at top level (`TOP_ONLY` topology). +- MUST: Run TEX distribution only after the asset exists on-chain (same block ordering or later block). +- MUST: `issuer` in metadata receives minted supply and MUST sign issuer `AssetPay` TEX cells. +- MUST: Set `gas_max > 0` on Type3 txs with asset TEX cells. +- MUST: Pay `protocol_cost` per `AssetCreate` rules @ `ASSET_ALIVE_HEIGHT` (`765432`). +- SHOULD: Pre-sign TEX bundles for Tx B before broadcasting Tx A. + +## 8. Pattern P5 — AST conditional settlement + +### 8.1 Intent + +Choose between settlement branches based on an on-chain guard predicate without deploying a contract. + +### 8.2 Structure + +``` +actions: [ + AstIf { + cond: AstSelect { HeightScope | BalanceFloor | ... }, + br_if: AstSelect { HacToTrs | TexCellAct | ... }, + br_else: AstSelect { ... } + } +] +``` + +### 8.3 Rules + +- MUST: Use Type3 with `gas_max > 0` when AST depth > 0. +- MUST: Condition `AstSelect` use `exe_min = exe_max = 1` for single guard predicates. +- MUST: Guard-only `AstSelect` / `AstIf` nodes are invalid at tx topology precheck. +- MUST: Condition guard `Revert` selects `br_else`; condition `Fault` aborts entire `AstIf`. +- SHOULD: Keep branch actions simple (single transfer or TEX bundle) for wallet simulation. + +## 9. Security considerations + +- **TEX replay:** Signed TEX bundles are replayable; never treat a signature alone as a one-time authorization. +- **Ordering:** Guards only see state mutations from earlier actions in the same transaction. +- **Co-signing:** Verify counterparty TEX cells match your quote before signing. +- **Gas:** AST and asset TEX paths consume gas; under-budgeted txs fail after partial snapshot work. + +## 10. Versioning + +| Version | Contents | +|---------|----------| +| v1.0 (this draft) | Patterns P1–P5, JSON templates, regression tests | +| v1.1 (planned) | Wallet checklist, indexer field dictionary | +| v2.0 (future) | Optional HVM companion contracts per pattern | + +## 11. Test matrix + +| Pattern | Test | +|---------|------| +| P1 | `hip23_pattern_1_atomic_tex_swap` | +| P2 | `hip23_pattern_2_height_guarded_payment` | +| P3 | `hip23_pattern_3_balance_floor_protected_transfer` | +| P4 | `hip23_pattern_4_asset_create_plus_tex` | +| P5 | `hip23_pattern_5_ast_conditional_settlement` | + +Run: + +```bash +cargo test hip23_pattern -- --nocapture +``` \ No newline at end of file diff --git a/doc/HIP23_templates.md b/doc/HIP23_templates.md new file mode 100644 index 0000000..44ac9bb --- /dev/null +++ b/doc/HIP23_templates.md @@ -0,0 +1,277 @@ +# HIP-23 JSON Templates (Type3) + +Templates for wallet builders and integrators. Replace placeholders in `ALL_CAPS`. +Action JSON uses `kind` (not `type`). TEX cells use `cellid` (not `kind`). + +**Mainnet:** submit only at `height >= 765432` unless using dev/regtest gates. + +--- + +## Shared placeholders + +| Token | Meaning | +|-------|---------| +| `MAIN_ADDR` | Transaction fee payer / primary signer | +| `PARTY_A` / `PARTY_B` | TEX co-signers | +| `RECIPIENT` | Payment destination | +| `SERIAL` | HIP20 asset serial (u64) | +| `FEE` | Wire amount string, e.g. `"8:244"` | +| `GAS_MAX` | Type3 gas byte, e.g. `17` or `99` when asset TEX present | +| `TIMESTAMP` | Unix seconds | +| `START_HEIGHT` / `END_HEIGHT` | Block height guard window | + +--- + +## P1 — Atomic multi-asset TEX swap + +Minimal HAC + HIP20 swap between two parties: + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 99, + "timestamp": TIMESTAMP, + "actions": [ + { + "kind": 22, + "addr": "PARTY_A", + "cells": [ + { "cellid": 1, "haczhu": 100000000 }, + { "cellid": 8, "serial": SERIAL, "amount": 50 } + ], + "sign": "PARTY_A_TEX_SIGN" + }, + { + "kind": 22, + "addr": "PARTY_B", + "cells": [ + { "cellid": 2, "haczhu": 100000000 }, + { "cellid": 7, "serial": SERIAL, "amount": 50 } + ], + "sign": "PARTY_B_TEX_SIGN" + } + ] +} +``` + +Extended (HAC + SAT + diamonds) — mirror `tests/tex.rs` `trs1()`: + +```json +{ + "kind": 22, + "addr": "PARTY_A", + "cells": [ + { "cellid": 2, "haczhu": 100000000 }, + { "cellid": 3, "satnum": 2 }, + { "cellid": 5, "diamonds": "KKKKVA,HYXYHY,UETWNK" }, + { "cellid": 8, "serial": SERIAL, "amount": 100 } + ], + "sign": "PARTY_A_TEX_SIGN" +} +``` + +Counterparty bundle MUST use matching get/pay cell types and amounts. + +--- + +## P2 — Time-boxed guarded payment + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 0, + "timestamp": TIMESTAMP, + "actions": [ + { + "kind": 1042, + "start": START_HEIGHT, + "end": END_HEIGHT + }, + { + "kind": 1, + "to": "RECIPIENT", + "hacash": "10:244" + } + ] +} +``` + +Notes: + +- `kind` `1042` = `HeightScope` (`0x0412`). +- Guard MUST be listed before the debit action. +- `end: 0` = no upper height limit. + +--- + +## P3 — BalanceFloor protected transfer + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 0, + "timestamp": TIMESTAMP, + "actions": [ + { + "kind": 1, + "to": "RECIPIENT", + "hacash": "100:244" + }, + { + "kind": 1043, + "addr": "MAIN_ADDR", + "hacash": "900:244", + "satoshi": 0, + "diamond": 0, + "assets": [] + } + ] +} +``` + +Notes: + +- `kind` `1043` = `BalanceFloor` (`0x0413`). +- Floor is evaluated **after** the preceding debit. +- Add `assets: [{ "serial": SERIAL, "amount": MIN_AMT }]` to protect HIP20 balances. + +--- + +## P4 — HIP20 issuance + TEX distribution + +`AssetCreate` is `TOP_ONLY`. Use **two transactions**: + +**Tx A — issuance** + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 0, + "timestamp": TIMESTAMP, + "actions": [ + { + "kind": 16, + "metadata": { + "serial": SERIAL, + "supply": 10000, + "decimal": 2, + "issuer": "ISSUER_ADDR", + "ticket": "USDT", + "name": "Tether" + }, + "protocol_cost": "1:244" + } + ] +} +``` + +**Tx B — TEX distribution** (after Tx A is valid on-chain) + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 99, + "timestamp": TIMESTAMP, + "actions": [ + { + "kind": 22, + "addr": "ISSUER_ADDR", + "cells": [ + { "cellid": 7, "serial": SERIAL, "amount": 500 } + ], + "sign": "ISSUER_TEX_SIGN" + }, + { + "kind": 22, + "addr": "RECIPIENT", + "cells": [ + { "cellid": 8, "serial": SERIAL, "amount": 500 } + ], + "sign": "RECIPIENT_TEX_SIGN" + } + ] +} +``` + +Notes: + +- Tx A MUST contain only `AssetCreate` at top level. +- Issuer MUST sign issuer TEX bundle in Tx B. + +--- + +## P5 — AST conditional settlement + +Pay only if height guard passes; otherwise no-op branch: + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 17, + "timestamp": TIMESTAMP, + "actions": [ + { + "kind": 26, + "cond": { + "kind": 25, + "exe_min": 1, + "exe_max": 1, + "actions": [ + { "kind": 1042, "start": START_HEIGHT, "end": END_HEIGHT } + ] + }, + "br_if": { + "kind": 25, + "exe_min": 1, + "exe_max": 1, + "actions": [ + { "kind": 1, "to": "RECIPIENT", "hacash": "5:244" } + ] + }, + "br_else": { + "kind": 25, + "exe_min": 0, + "exe_max": 0, + "actions": [] + } + } + ] +} +``` + +Notes: + +- `kind` `26` = `AstIf`, `25` = `AstSelect`. +- Condition guard failure (revert) selects `br_else`. +- `gas_max` MUST be non-zero for AST execution. + +--- + +## TEX condition cell reference (optional) + +| cellid | Name | Purpose | +|--------|------|---------| +| 11–13 | Zhu at most / at least / eq | HAC balance conditions | +| 14–16 | Sat conditions | SAT balance conditions | +| 17–19 | Diamond conditions | HACD count conditions | +| 20–22 | Asset conditions | HIP20 balance conditions | +| 23–24 | Height at most / at least | Block height conditions | +| 25 | ChainId eq | Chain ID condition | + +Example height condition inside a TEX bundle: + +```json +{ "cellid": 23, "height": 800000 } +``` \ No newline at end of file diff --git a/tests/hip23_pattern_regression.rs b/tests/hip23_pattern_regression.rs new file mode 100644 index 0000000..31d3e5f --- /dev/null +++ b/tests/hip23_pattern_regression.rs @@ -0,0 +1,339 @@ +//! HIP-23 application-pattern regression tests. +//! See doc/HIP23.md and doc/HIP23_templates.md. + +use basis::component::Env; +use basis::interface::{ + Action, Context, StateOperat, Transaction, TransactionRead, TxExec, +}; +use field::*; +use mint::action::AssetCreate; +use mint::genesis; +use protocol::action::*; +use protocol::state::CoreState; +use protocol::tex::*; +use protocol::transaction::*; +use sys::Account; +use testkit::sim::context::make_ctx_with_state; +use testkit::sim::integration::enable_mint_setup; + +const TEST_HEIGHT: u64 = protocol::upgrade::ONLINE_OPEN_HEIGHT + 10_000; +const TX_FEE_MEI: u64 = 1; + +fn init_setup_once() { + enable_mint_setup(); +} + +fn addr_of(acc: &Account) -> Address { + Address::from(acc.address().clone()) +} + +fn make_ctx<'a>(height: u64, tx: &'a dyn TransactionRead) -> protocol::context::ContextInst<'a> { + let mut env = Env::default(); + env.chain.fast_sync = true; + env.block.height = height; + env.tx = create_tx_info(tx); + make_ctx_with_state(env, Box::new(testkit::sim::state::ForkableMemState::default()), tx) +} + +fn seed_hac(ctx: &mut dyn Context, addr: &Address, mei: u64) { + let mut state = CoreState::wrap(ctx.state()); + let mut bls = state.balance(addr).unwrap_or_default(); + bls.hacash = Amount::mei(mei); + state.balance_set(addr, &bls); +} + +fn seed_asset(ctx: &mut dyn Context, owner: &Address, serial: u64, amount: u64) { + let mut state = CoreState::wrap(ctx.state()); + let serial_f = Fold64::from(serial).unwrap(); + state.asset_set( + &serial_f, + &AssetSmelt { + serial: serial_f, + supply: Fold64::from(1_000_000).unwrap(), + decimal: Uint1::from(2), + issuer: *owner, + ticket: BytesW1::from_str("HIP23").unwrap(), + name: BytesW1::from_str("HIP23 Asset").unwrap(), + }, + ); + let mut bls = state.balance(owner).unwrap_or_default(); + bls.asset_set(AssetAmt::from(serial, amount).unwrap()) + .unwrap(); + state.balance_set(owner, &bls); +} + +fn hac_mei(ctx: &mut dyn Context, addr: &Address) -> u64 { + CoreState::wrap(ctx.state()) + .balance(addr) + .unwrap_or_default() + .hacash + .to_mei_u64() + .unwrap() +} + +fn asset_amt(ctx: &mut dyn Context, addr: &Address, serial: u64) -> u64 { + CoreState::wrap(ctx.state()) + .balance(addr) + .unwrap_or_default() + .asset(Fold64::from(serial).unwrap()) + .map(|a| a.amount.uint()) + .unwrap_or(0) +} + +fn build_signed_type3( + main_acc: &Account, + actions: Vec>, + gas_max: u8, +) -> TransactionType3 { + let main = addr_of(main_acc); + let mut tx = TransactionType3::new_by(main, Amount::unit238(1_000_000), 1_730_000_000); + tx.gas_max = Uint1::from(gas_max); + for act in actions { + tx.actions.push(act).unwrap(); + } + tx.fill_sign(main_acc).unwrap(); + tx +} + +#[test] +fn hip23_pattern_1_atomic_tex_swap() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p1-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1-get").unwrap(); + let main = addr_of(&main_acc); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + const SERIAL: u64 = 2301; + + let mut pay_tex = TexCellAct::create_by(pay); + pay_tex + .add_cell(Box::new(CellTrsZhuPay::new( + Fold64::from(100_000_000).unwrap(), + ))) + .unwrap(); + pay_tex + .add_cell(Box::new(CellTrsAssetPay::new( + AssetAmt::from(SERIAL, 50).unwrap(), + ))) + .unwrap(); + pay_tex.do_sign(&pay_acc).unwrap(); + + let mut get_tex = TexCellAct::create_by(get); + get_tex + .add_cell(Box::new(CellTrsZhuGet::new( + Fold64::from(100_000_000).unwrap(), + ))) + .unwrap(); + get_tex + .add_cell(Box::new(CellTrsAssetGet::new( + AssetAmt::from(SERIAL, 50).unwrap(), + ))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 99, + ); + + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000_000); + seed_hac(&mut ctx, &pay, 1_000); + seed_hac(&mut ctx, &get, 0); + seed_asset(&mut ctx, &pay, SERIAL, 50); + + tx.execute(&mut ctx).unwrap(); + + assert_eq!(hac_mei(&mut ctx, &get), 1); + assert_eq!(asset_amt(&mut ctx, &get, SERIAL), 50); + assert_eq!(asset_amt(&mut ctx, &pay, SERIAL), 0); +} + +#[test] +fn hip23_pattern_2_height_guarded_payment() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p2-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let window_start = TEST_HEIGHT; + let window_end = TEST_HEIGHT + 1_000; + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(window_start); + guard.end = BlockHeight::from(window_end); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(10); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + + let mut fail_ctx = make_ctx(window_start - 1, tx.as_read()); + seed_hac(&mut fail_ctx, &main, 1_000); + let err = tx.execute(&mut fail_ctx).unwrap_err(); + assert!(err.contains("submitted in height between"), "{err}"); + + let mut ok_ctx = make_ctx(window_start + 100, tx.as_read()); + seed_hac(&mut ok_ctx, &main, 1_000); + tx.execute(&mut ok_ctx).unwrap(); + assert_eq!(hac_mei(&mut ok_ctx, &main), 1_000 - 10 - TX_FEE_MEI); + assert_eq!(hac_mei(&mut ok_ctx, &recipient), 10); +} + +#[test] +fn hip23_pattern_3_balance_floor_protected_transfer() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p3-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(150); + + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.hacash = Amount::mei(900); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(transfer), Box::new(floor)], + 0, + ); + + let mut fail_ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut fail_ctx, &main, 1_000); + let err = tx.execute(&mut fail_ctx).unwrap_err(); + assert!(err.contains("lower than floor"), "{err}"); + + let mut ok_transfer = HacToTrs::new(); + ok_transfer.to = AddrOrPtr::from_addr(recipient); + ok_transfer.hacash = Amount::mei(100); + + let mut ok_floor = BalanceFloor::new(); + ok_floor.addr = AddrOrPtr::from_addr(main); + ok_floor.hacash = Amount::mei(900); + + let tx_ok = build_signed_type3( + &main_acc, + vec![Box::new(ok_transfer), Box::new(ok_floor)], + 0, + ); + + let mut ok_ctx = make_ctx(TEST_HEIGHT, tx_ok.as_read()); + seed_hac(&mut ok_ctx, &main, 1_000); + tx_ok.execute(&mut ok_ctx).unwrap(); + assert_eq!(hac_mei(&mut ok_ctx, &main), 900 - TX_FEE_MEI); +} + +#[test] +fn hip23_pattern_4_asset_create_plus_tex() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p4-main").unwrap(); + let issuer_acc = Account::create_by("hip23-p4-issuer").unwrap(); + let buyer_acc = Account::create_by("hip23-p4-buyer").unwrap(); + let main = addr_of(&main_acc); + let issuer = addr_of(&issuer_acc); + let buyer = addr_of(&buyer_acc); + const SERIAL: u64 = 2304; + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(10_000).unwrap(), + decimal: Uint1::from(2), + issuer, + ticket: BytesW1::from_str("USDT").unwrap(), + name: BytesW1::from_str("Tether").unwrap(), + }; + create.protocol_cost = genesis::block_reward(TEST_HEIGHT); + + let mut issuer_tex = TexCellAct::create_by(issuer); + issuer_tex + .add_cell(Box::new(CellTrsAssetPay::new( + AssetAmt::from(SERIAL, 500).unwrap(), + ))) + .unwrap(); + issuer_tex.do_sign(&issuer_acc).unwrap(); + + let mut buyer_tex = TexCellAct::create_by(buyer); + buyer_tex + .add_cell(Box::new(CellTrsAssetGet::new( + AssetAmt::from(SERIAL, 500).unwrap(), + ))) + .unwrap(); + buyer_tex.do_sign(&buyer_acc).unwrap(); + + let tx_create = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let tx_tex = build_signed_type3( + &main_acc, + vec![Box::new(issuer_tex), Box::new(buyer_tex)], + 99, + ); + + let mut ctx = make_ctx(TEST_HEIGHT, tx_create.as_read()); + seed_hac(&mut ctx, &main, 1_000_000); + seed_hac(&mut ctx, &issuer, 10); + tx_create.execute(&mut ctx).unwrap(); + assert_eq!(asset_amt(&mut ctx, &issuer, SERIAL), 10_000); + + let persisted = ctx.state().clone_state(); + let mut tex_ctx = make_ctx_with_state( + { + let mut env = Env::default(); + env.chain.fast_sync = true; + env.block.height = TEST_HEIGHT; + env.tx = create_tx_info(tx_tex.as_read()); + env + }, + persisted, + tx_tex.as_read(), + ); + tx_tex.execute(&mut tex_ctx).unwrap(); + + assert_eq!(asset_amt(&mut tex_ctx, &buyer, SERIAL), 500); + assert_eq!(asset_amt(&mut tex_ctx, &issuer, SERIAL), 10_000 - 500); +} + +#[test] +fn hip23_pattern_5_ast_conditional_settlement() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p5-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let window_start = TEST_HEIGHT; + let window_end = TEST_HEIGHT + 500; + + let mut cond_guard = HeightScope::new(); + cond_guard.start = BlockHeight::from(window_start); + cond_guard.end = BlockHeight::from(window_end); + let cond = AstSelect::create_by(1, 1, vec![Box::new(cond_guard)]); + + let br_if = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(5), + ))]); + let act = AstIf::create_by(cond, br_if, AstSelect::nop()); + + let tx = build_signed_type3(&main_acc, vec![Box::new(act)], 17); + + let mut else_ctx = make_ctx(window_start - 1, tx.as_read()); + seed_hac(&mut else_ctx, &main, 1_000); + tx.execute(&mut else_ctx).unwrap(); + assert_eq!(hac_mei(&mut else_ctx, &main), 1_000 - TX_FEE_MEI); + assert_eq!(hac_mei(&mut else_ctx, &recipient), 0); + + let mut if_ctx = make_ctx(window_start + 10, tx.as_read()); + seed_hac(&mut if_ctx, &main, 1_000); + tx.execute(&mut if_ctx).unwrap(); + assert_eq!(hac_mei(&mut if_ctx, &main), 1_000 - 5 - TX_FEE_MEI); + assert_eq!(hac_mei(&mut if_ctx, &recipient), 5); +} \ No newline at end of file From dd3b239917794784d46febdfe67db84b3bb94ad0 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 16:06:09 +0200 Subject: [PATCH 02/11] HIP-23: expand adversarial test suite (25 tests) and document guard ordering pitfall --- doc/HIP23.md | 26 +- tests/common/hip23.rs | 165 ++++++++ tests/common/mod.rs | 1 + tests/hip23_pattern_adversarial.rs | 650 +++++++++++++++++++++++++++++ tests/hip23_pattern_regression.rs | 130 +----- 5 files changed, 850 insertions(+), 122 deletions(-) create mode 100644 tests/common/hip23.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/hip23_pattern_adversarial.rs diff --git a/doc/HIP23.md b/doc/HIP23.md index 153f975..e240be1 100644 --- a/doc/HIP23.md +++ b/doc/HIP23.md @@ -112,11 +112,18 @@ actions: [ ### 5.3 Rules -- MUST: `HeightScope` execute before the debit action (lower action index runs first). +- MUST: List `HeightScope` **before** the debit action (lower action index runs first). - MUST: `start <= end` when `end != 0`. - MUST: Revert (not fault) when current height is outside `[start, end]`. - SHOULD: Set `end` to a finite deadline for offer expiry. +### 5.4 Wallet pitfall (ordering) + +Actions run sequentially. If a debit is listed before `HeightScope` and the guard later +reverts, the **transaction still fails**, but step-by-step simulators may already show the +debit as executed. Wallets MUST NOT present partial action progress as final settlement. +Always validate the full tx atomically (`hip23_p2_transfer_before_guard_still_reverts_outside_window`). + ### 5.4 Wallet display Wallets SHOULD show: “Valid heights: `start` … `end` (inclusive)”. @@ -219,6 +226,8 @@ actions: [ ## 11. Test matrix +### Happy path (`hip23_pattern_regression.rs`) + | Pattern | Test | |---------|------| | P1 | `hip23_pattern_1_atomic_tex_swap` | @@ -227,8 +236,19 @@ actions: [ | P4 | `hip23_pattern_4_asset_create_plus_tex` | | P5 | `hip23_pattern_5_ast_conditional_settlement` | -Run: +### Adversarial (`hip23_pattern_adversarial.rs`) + +| Area | Tests | +|------|-------| +| P1 TEX | imbalanced settlement, tampered sign, insufficient balance, gas required, HAC+SAT swap, height condition cell | +| P2 Guard | boundary inclusive, unlimited end, ChainAllow, wrong debit/guard order | +| P3 Floor | asset dimension, pre-debit vs post-debit placement | +| P4 HIP20 | duplicate serial, missing asset, issuer insufficient | +| P5 AST | condition fault, else-branch transfer | +| Topology | guard-only tx rejected, height+floor+transfer combo, height+TEX combo | + +Run all: ```bash -cargo test hip23_pattern -- --nocapture +cargo test hip23_ -- --nocapture ``` \ No newline at end of file diff --git a/tests/common/hip23.rs b/tests/common/hip23.rs new file mode 100644 index 0000000..652e12a --- /dev/null +++ b/tests/common/hip23.rs @@ -0,0 +1,165 @@ +//! Shared helpers for HIP-23 integration tests. + +use basis::component::Env; +use basis::interface::{Action, Context, Transaction, TransactionRead}; +use field::*; +use protocol::state::CoreState; +use protocol::tex::*; +use protocol::transaction::*; +use sys::Account; +use testkit::sim::context::make_ctx_with_state; +use testkit::sim::integration::enable_mint_setup; + +pub const TEST_HEIGHT: u64 = protocol::upgrade::ONLINE_OPEN_HEIGHT + 10_000; +pub const TX_FEE_MEI: u64 = 1; + +pub fn init_setup_once() { + enable_mint_setup(); +} + +pub fn addr_of(acc: &Account) -> Address { + Address::from(acc.address().clone()) +} + +pub fn make_ctx<'a>(height: u64, tx: &'a dyn TransactionRead) -> protocol::context::ContextInst<'a> { + make_ctx_chain(height, 0, tx) +} + +pub fn make_ctx_chain<'a>( + height: u64, + chain_id: u32, + tx: &'a dyn TransactionRead, +) -> protocol::context::ContextInst<'a> { + let mut env = Env::default(); + env.chain.fast_sync = true; + env.chain.id = chain_id; + env.block.height = height; + env.tx = create_tx_info(tx); + make_ctx_with_state(env, Box::new(testkit::sim::state::ForkableMemState::default()), tx) +} + +pub fn seed_hac(ctx: &mut dyn Context, addr: &Address, mei: u64) { + let mut state = CoreState::wrap(ctx.state()); + let mut bls = state.balance(addr).unwrap_or_default(); + bls.hacash = Amount::mei(mei); + state.balance_set(addr, &bls); +} + +pub fn seed_sat(ctx: &mut dyn Context, addr: &Address, sat: u64) { + let mut state = CoreState::wrap(ctx.state()); + let mut bls = state.balance(addr).unwrap_or_default(); + bls.satoshi = SatoshiAuto::from_satoshi(&Satoshi::from(sat)); + state.balance_set(addr, &bls); +} + +pub fn seed_asset(ctx: &mut dyn Context, owner: &Address, serial: u64, amount: u64) { + let mut state = CoreState::wrap(ctx.state()); + let serial_f = Fold64::from(serial).unwrap(); + state.asset_set( + &serial_f, + &AssetSmelt { + serial: serial_f, + supply: Fold64::from(1_000_000).unwrap(), + decimal: Uint1::from(2), + issuer: *owner, + ticket: BytesW1::from_str("HIP23").unwrap(), + name: BytesW1::from_str("HIP23 Asset").unwrap(), + }, + ); + let mut bls = state.balance(owner).unwrap_or_default(); + bls.asset_set(AssetAmt::from(serial, amount).unwrap()) + .unwrap(); + state.balance_set(owner, &bls); +} + +pub fn hac_mei(ctx: &mut dyn Context, addr: &Address) -> u64 { + CoreState::wrap(ctx.state()) + .balance(addr) + .unwrap_or_default() + .hacash + .to_mei_u64() + .unwrap() +} + +pub fn sat_amount(ctx: &mut dyn Context, addr: &Address) -> u64 { + CoreState::wrap(ctx.state()) + .balance(addr) + .unwrap_or_default() + .satoshi + .to_satoshi() + .uint() +} + +pub fn asset_amt(ctx: &mut dyn Context, addr: &Address, serial: u64) -> u64 { + CoreState::wrap(ctx.state()) + .balance(addr) + .unwrap_or_default() + .asset(Fold64::from(serial).unwrap()) + .map(|a| a.amount.uint()) + .unwrap_or(0) +} + +pub fn build_signed_type3( + main_acc: &Account, + actions: Vec>, + gas_max: u8, +) -> TransactionType3 { + let main = addr_of(main_acc); + let mut tx = TransactionType3::new_by(main, Amount::unit238(1_000_000), 1_730_000_000); + tx.gas_max = Uint1::from(gas_max); + for act in actions { + tx.actions.push(act).unwrap(); + } + tx.fill_sign(main_acc).unwrap(); + tx +} + +pub fn build_balanced_tex_swap( + pay_acc: &Account, + get_acc: &Account, + hac_zhu: u64, + serial: u64, + asset_amt: u64, +) -> (TexCellAct, TexCellAct) { + let pay = addr_of(pay_acc); + let get = addr_of(get_acc); + + let mut pay_tex = TexCellAct::create_by(pay); + if hac_zhu > 0 { + pay_tex + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(hac_zhu).unwrap()))) + .unwrap(); + } + if asset_amt > 0 { + pay_tex + .add_cell(Box::new(CellTrsAssetPay::new( + AssetAmt::from(serial, asset_amt).unwrap(), + ))) + .unwrap(); + } + pay_tex.do_sign(pay_acc).unwrap(); + + let mut get_tex = TexCellAct::create_by(get); + if hac_zhu > 0 { + get_tex + .add_cell(Box::new(CellTrsZhuGet::new(Fold64::from(hac_zhu).unwrap()))) + .unwrap(); + } + if asset_amt > 0 { + get_tex + .add_cell(Box::new(CellTrsAssetGet::new( + AssetAmt::from(serial, asset_amt).unwrap(), + ))) + .unwrap(); + } + get_tex.do_sign(get_acc).unwrap(); + + (pay_tex, get_tex) +} + +pub fn assert_err_contains(err: &str, needle: &str) { + assert!( + err.contains(needle), + "expected '{needle}' in error: {err}" + ); +} \ No newline at end of file diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..85a631f --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1 @@ +pub mod hip23; \ No newline at end of file diff --git a/tests/hip23_pattern_adversarial.rs b/tests/hip23_pattern_adversarial.rs new file mode 100644 index 0000000..0c04955 --- /dev/null +++ b/tests/hip23_pattern_adversarial.rs @@ -0,0 +1,650 @@ +//! HIP-23 adversarial / edge-case tests for bug hunting. +//! Complements hip23_pattern_regression.rs. + +mod common; + +use basis::interface::{Action, StateOperat, Transaction, TxExec}; +use common::hip23::*; +use field::*; +use testkit::sim::context::make_ctx_with_state; +use mint::action::AssetCreate; +use mint::genesis; +use protocol::action::*; +use protocol::tex::*; +use protocol::transaction::TransactionType3; +use sys::Account; + +// --------------------------------------------------------------------------- +// P1 — TEX adversarial +// --------------------------------------------------------------------------- + +#[test] +fn hip23_p1_tex_imbalanced_hac_amount_fails() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p1a-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1a-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1a-get").unwrap(); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + + let (pay_tex, _) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + let mut get_tex = TexCellAct::create_by(get); + get_tex + .add_cell(Box::new(CellTrsZhuGet::new(Fold64::from(50_000_000).unwrap()))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 1_000); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "settlement check failed"); + assert_eq!(hac_mei(&mut ctx, &get), 0); +} + +#[test] +fn hip23_p1_tex_tampered_signature_fails() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p1b-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1b-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1b-get").unwrap(); + let pay = addr_of(&pay_acc); + + let (mut pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + pay_tex + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(1).unwrap()))) + .unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 1_000); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "signature verification failed"); +} + +#[test] +fn hip23_p1_tex_insufficient_hac_balance_fails() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p1c-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1c-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1c-get").unwrap(); + let pay = addr_of(&pay_acc); + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 0); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert!(err.contains("insufficient") || err.contains("overflow"), "{err}"); +} + +#[test] +fn hip23_p1_tex_asset_cells_require_gas() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p1d-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1d-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1d-get").unwrap(); + let pay = addr_of(&pay_acc); + const SERIAL: u64 = 2310; + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 0, SERIAL, 10); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_asset(&mut ctx, &pay, SERIAL, 10); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "gas not initialized"); +} + +#[test] +fn hip23_p1_tex_hac_and_sat_dual_swap_succeeds() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p1e-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1e-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1e-get").unwrap(); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + + let mut pay_tex = TexCellAct::create_by(pay); + pay_tex + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(50_000_000).unwrap()))) + .unwrap(); + pay_tex + .add_cell(Box::new(CellTrsSatPay::new(Fold64::from(3).unwrap()))) + .unwrap(); + pay_tex.do_sign(&pay_acc).unwrap(); + + let mut get_tex = TexCellAct::create_by(get); + get_tex + .add_cell(Box::new(CellTrsZhuGet::new(Fold64::from(50_000_000).unwrap()))) + .unwrap(); + get_tex + .add_cell(Box::new(CellTrsSatGet::new(Fold64::from(3).unwrap()))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 500); + seed_sat(&mut ctx, &pay, 3); + + tx.execute(&mut ctx).unwrap(); + assert_eq!(sat_amount(&mut ctx, &get), 3); +} + +#[test] +fn hip23_p1_tex_height_condition_in_bundle() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p1f-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1f-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1f-get").unwrap(); + let pay = addr_of(&pay_acc); + + let mut pay_tex = TexCellAct::create_by(pay); + pay_tex + .add_cell(Box::new(CellCondHeightAtMost::new(TEST_HEIGHT + 100))) + .unwrap(); + pay_tex + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(10_000_000).unwrap()))) + .unwrap(); + pay_tex.do_sign(&pay_acc).unwrap(); + + let mut get_tex = TexCellAct::create_by(addr_of(&get_acc)); + get_tex + .add_cell(Box::new(CellTrsZhuGet::new(Fold64::from(10_000_000).unwrap()))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + + let mut fail_ctx = make_ctx(TEST_HEIGHT + 200, tx.as_read()); + seed_hac(&mut fail_ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut fail_ctx, &pay, 100); + let err = tx.execute(&mut fail_ctx).unwrap_err(); + assert_err_contains(&err, "cell condition check failed"); + + let mut ok_ctx = make_ctx(TEST_HEIGHT + 50, tx.as_read()); + seed_hac(&mut ok_ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ok_ctx, &pay, 100); + tx.execute(&mut ok_ctx).unwrap(); +} + +// --------------------------------------------------------------------------- +// P2 — Guard adversarial +// --------------------------------------------------------------------------- + +#[test] +fn hip23_p2_height_guard_boundary_inclusive() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p2a-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + let start = TEST_HEIGHT; + let end = TEST_HEIGHT + 10; + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(start); + guard.end = BlockHeight::from(end); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + + let mut ctx_start = make_ctx(start, tx.as_read()); + seed_hac(&mut ctx_start, &main, 100); + tx.execute(&mut ctx_start).unwrap(); + assert_eq!(hac_mei(&mut ctx_start, &recipient), 1); + + let tx_end = build_signed_type3( + &main_acc, + vec![ + Box::new({ + let mut g = HeightScope::new(); + g.start = BlockHeight::from(start); + g.end = BlockHeight::from(end); + g + }), + Box::new({ + let mut t = HacToTrs::new(); + t.to = AddrOrPtr::from_addr(recipient.clone()); + t.hacash = Amount::mei(1); + t + }), + ], + 0, + ); + let mut ctx_end = make_ctx(end, tx_end.as_read()); + seed_hac(&mut ctx_end, &main, 100); + tx_end.execute(&mut ctx_end).unwrap(); + assert_eq!(hac_mei(&mut ctx_end, &recipient), 1); +} + +#[test] +fn hip23_p2_height_guard_unlimited_end_zero() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p2b-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT); + guard.end = BlockHeight::from(0); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(2); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT + 1_000_000, tx.as_read()); + seed_hac(&mut ctx, &main, 100); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &recipient), 2); +} + +#[test] +fn hip23_p2_chain_allow_rejects_wrong_chain() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p2c-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut allow = ChainAllow::new(); + allow.chains = ChainIDList::from_list(vec![Uint4::from(1), Uint4::from(2)]).unwrap(); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient); + transfer.hacash = Amount::mei(3); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(allow), Box::new(transfer)], + 0, + ); + + let mut ctx = make_ctx_chain(TEST_HEIGHT, 9, tx.as_read()); + seed_hac(&mut ctx, &main, 100); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "must belong to chains"); +} + +#[test] +fn hip23_p2_transfer_before_guard_still_reverts_outside_window() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p2d-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(7); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT + 100); + guard.end = BlockHeight::from(TEST_HEIGHT + 200); + + // Anti-pattern: debit listed before guard — entire tx still reverts when guard fails. + let tx = build_signed_type3( + &main_acc, + vec![Box::new(transfer), Box::new(guard)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 100); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "submitted in height between"); + // Tx is rejected, but earlier actions already mutated in-tx state in this harness. + // Wallets MUST NOT treat step-by-step simulation as final until the whole tx succeeds. + assert_eq!(hac_mei(&mut ctx, &recipient), 7); + assert_eq!(hac_mei(&mut ctx, &main), 93); +} + +// --------------------------------------------------------------------------- +// P3 — BalanceFloor adversarial +// --------------------------------------------------------------------------- + +#[test] +fn hip23_p3_floor_asset_dimension_blocks_overspend() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p3a-main").unwrap(); + let cp_acc = Account::create_by("hip23-p3a-cp").unwrap(); + let main = addr_of(&main_acc); + let counterparty = addr_of(&cp_acc); + const SERIAL: u64 = 2330; + + let mut pay_tex = TexCellAct::create_by(main); + pay_tex + .add_cell(Box::new(CellTrsAssetPay::new( + AssetAmt::from(SERIAL, 5).unwrap(), + ))) + .unwrap(); + pay_tex.do_sign(&main_acc).unwrap(); + + let mut get_tex = TexCellAct::create_by(counterparty); + get_tex + .add_cell(Box::new(CellTrsAssetGet::new( + AssetAmt::from(SERIAL, 5).unwrap(), + ))) + .unwrap(); + get_tex.do_sign(&cp_acc).unwrap(); + + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.assets + .push(AssetAmt::from(SERIAL, 8).unwrap()) + .unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex), Box::new(floor)], + 99, + ); + + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000_000); + seed_asset(&mut ctx, &main, SERIAL, 10); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "lower than floor"); +} + +#[test] +fn hip23_p3_floor_before_transfer_checks_pre_debit_state() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p3b-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.hacash = Amount::mei(950); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient); + transfer.hacash = Amount::mei(100); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(floor), Box::new(transfer)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &main), 900 - TX_FEE_MEI); +} + +// --------------------------------------------------------------------------- +// P4 — HIP20 + TEX adversarial +// --------------------------------------------------------------------------- + +#[test] +fn hip23_p4_duplicate_serial_rejected() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p4a-main").unwrap(); + let issuer = addr_of(&Account::create_by("hip23-p4a-issuer").unwrap()); + const SERIAL: u64 = 2340; + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(1000).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("DUP").unwrap(), + name: BytesW1::from_str("Dup").unwrap(), + }; + create.protocol_cost = genesis::block_reward(TEST_HEIGHT); + + let tx = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + tx.execute(&mut ctx).unwrap(); + + let mut create_dup = AssetCreate::new(); + create_dup.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(1000).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("DUP").unwrap(), + name: BytesW1::from_str("Dup").unwrap(), + }; + create_dup.protocol_cost = genesis::block_reward(TEST_HEIGHT + 1); + + let persisted = ctx.state().clone_state(); + let tx_dup = build_signed_type3(&main_acc, vec![Box::new(create_dup)], 0); + let mut ctx2 = make_ctx_with_state( + { + let mut env = basis::component::Env::default(); + env.chain.fast_sync = true; + env.block.height = TEST_HEIGHT + 1; + env.tx = protocol::transaction::create_tx_info(tx_dup.as_read()); + env + }, + persisted, + tx_dup.as_read(), + ); + seed_hac(&mut ctx2, &addr_of(&main_acc), 1_000_000); + let err = tx_dup.execute(&mut ctx2).unwrap_err(); + assert_err_contains(&err, "already exists"); +} + +#[test] +fn hip23_p4_tex_on_missing_asset_fails() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p4b-main").unwrap(); + let pay_acc = Account::create_by("hip23-p4b-pay").unwrap(); + let get_acc = Account::create_by("hip23-p4b-get").unwrap(); + const SERIAL: u64 = 2341; + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 0, SERIAL, 1); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 99, + ); + + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "does not exist"); +} + +#[test] +fn hip23_p4_issuer_insufficient_asset_for_tex_pay() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p4c-main").unwrap(); + let pay_acc = Account::create_by("hip23-p4c-pay").unwrap(); + let get_acc = Account::create_by("hip23-p4c-get").unwrap(); + let pay = addr_of(&pay_acc); + const SERIAL: u64 = 2342; + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 0, SERIAL, 100); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 99, + ); + + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_asset(&mut ctx, &pay, SERIAL, 50); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert!(err.contains("insufficient") || err.contains("overflow"), "{err}"); +} + +// --------------------------------------------------------------------------- +// P5 — AST adversarial +// --------------------------------------------------------------------------- + +#[test] +fn hip23_p5_ast_if_condition_fault_aborts_whole_node() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p5a-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut bad_guard = HeightScope::new(); + bad_guard.start = BlockHeight::from(20); + bad_guard.end = BlockHeight::from(10); + let cond = AstSelect::create_by(1, 1, vec![Box::new(bad_guard)]); + let br_if = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(5), + ))]); + let br_else = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(1), + ))]); + let act = AstIf::create_by(cond, br_if, br_else); + + let tx = build_signed_type3(&main_acc, vec![Box::new(act)], 17); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "cannot exceed"); + assert_eq!(hac_mei(&mut ctx, &recipient), 0); +} + +#[test] +fn hip23_p5_ast_else_branch_executes_transfer() { + init_setup_once(); + let main_acc = Account::create_by("hip23-p5b-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut cond_guard = HeightScope::new(); + cond_guard.start = BlockHeight::from(TEST_HEIGHT + 500); + cond_guard.end = BlockHeight::from(TEST_HEIGHT + 600); + let cond = AstSelect::create_by(1, 1, vec![Box::new(cond_guard)]); + + let br_if = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(50), + ))]); + let br_else = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(3), + ))]); + let act = AstIf::create_by(cond, br_if, br_else); + + let tx = build_signed_type3(&main_acc, vec![Box::new(act)], 17); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &recipient), 3); +} + +// --------------------------------------------------------------------------- +// Topology / composition +// --------------------------------------------------------------------------- + +#[test] +fn hip23_topology_guard_only_tx_rejected() { + init_setup_once(); + let actions: Vec> = vec![Box::new(HeightScope::new())]; + let err = protocol::action::precheck_tx_actions(TransactionType3::TYPE, &actions).unwrap_err(); + assert_err_contains(&err, "all GUARD"); +} + +#[test] +fn hip23_combined_height_scope_balance_floor_and_transfer() { + init_setup_once(); + let main_acc = Account::create_by("hip23-combo-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut height = HeightScope::new(); + height.start = BlockHeight::from(TEST_HEIGHT); + height.end = BlockHeight::from(TEST_HEIGHT + 100); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(40); + + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.hacash = Amount::mei(900); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(height), Box::new(transfer), Box::new(floor)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT + 50, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &main), 960 - TX_FEE_MEI); + assert_eq!(hac_mei(&mut ctx, &recipient), 40); +} + +#[test] +fn hip23_height_guard_plus_tex_swap_in_one_tx() { + init_setup_once(); + let main_acc = Account::create_by("hip23-combo-tex-main").unwrap(); + let pay_acc = Account::create_by("hip23-combo-tex-pay").unwrap(); + let get_acc = Account::create_by("hip23-combo-tex-get").unwrap(); + let pay = addr_of(&pay_acc); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT); + guard.end = BlockHeight::from(TEST_HEIGHT + 500); + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT + 10, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 100); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &addr_of(&get_acc)), 1); +} \ No newline at end of file diff --git a/tests/hip23_pattern_regression.rs b/tests/hip23_pattern_regression.rs index 31d3e5f..d115a4b 100644 --- a/tests/hip23_pattern_regression.rs +++ b/tests/hip23_pattern_regression.rs @@ -1,99 +1,18 @@ -//! HIP-23 application-pattern regression tests. -//! See doc/HIP23.md and doc/HIP23_templates.md. +//! HIP-23 happy-path regression tests. +//! See doc/HIP23.md. Adversarial cases: hip23_pattern_adversarial.rs -use basis::component::Env; -use basis::interface::{ - Action, Context, StateOperat, Transaction, TransactionRead, TxExec, -}; +mod common; + +use basis::interface::{StateOperat, Transaction, TxExec}; +use common::hip23::*; use field::*; use mint::action::AssetCreate; use mint::genesis; use protocol::action::*; -use protocol::state::CoreState; use protocol::tex::*; -use protocol::transaction::*; +use protocol::transaction::create_tx_info; use sys::Account; use testkit::sim::context::make_ctx_with_state; -use testkit::sim::integration::enable_mint_setup; - -const TEST_HEIGHT: u64 = protocol::upgrade::ONLINE_OPEN_HEIGHT + 10_000; -const TX_FEE_MEI: u64 = 1; - -fn init_setup_once() { - enable_mint_setup(); -} - -fn addr_of(acc: &Account) -> Address { - Address::from(acc.address().clone()) -} - -fn make_ctx<'a>(height: u64, tx: &'a dyn TransactionRead) -> protocol::context::ContextInst<'a> { - let mut env = Env::default(); - env.chain.fast_sync = true; - env.block.height = height; - env.tx = create_tx_info(tx); - make_ctx_with_state(env, Box::new(testkit::sim::state::ForkableMemState::default()), tx) -} - -fn seed_hac(ctx: &mut dyn Context, addr: &Address, mei: u64) { - let mut state = CoreState::wrap(ctx.state()); - let mut bls = state.balance(addr).unwrap_or_default(); - bls.hacash = Amount::mei(mei); - state.balance_set(addr, &bls); -} - -fn seed_asset(ctx: &mut dyn Context, owner: &Address, serial: u64, amount: u64) { - let mut state = CoreState::wrap(ctx.state()); - let serial_f = Fold64::from(serial).unwrap(); - state.asset_set( - &serial_f, - &AssetSmelt { - serial: serial_f, - supply: Fold64::from(1_000_000).unwrap(), - decimal: Uint1::from(2), - issuer: *owner, - ticket: BytesW1::from_str("HIP23").unwrap(), - name: BytesW1::from_str("HIP23 Asset").unwrap(), - }, - ); - let mut bls = state.balance(owner).unwrap_or_default(); - bls.asset_set(AssetAmt::from(serial, amount).unwrap()) - .unwrap(); - state.balance_set(owner, &bls); -} - -fn hac_mei(ctx: &mut dyn Context, addr: &Address) -> u64 { - CoreState::wrap(ctx.state()) - .balance(addr) - .unwrap_or_default() - .hacash - .to_mei_u64() - .unwrap() -} - -fn asset_amt(ctx: &mut dyn Context, addr: &Address, serial: u64) -> u64 { - CoreState::wrap(ctx.state()) - .balance(addr) - .unwrap_or_default() - .asset(Fold64::from(serial).unwrap()) - .map(|a| a.amount.uint()) - .unwrap_or(0) -} - -fn build_signed_type3( - main_acc: &Account, - actions: Vec>, - gas_max: u8, -) -> TransactionType3 { - let main = addr_of(main_acc); - let mut tx = TransactionType3::new_by(main, Amount::unit238(1_000_000), 1_730_000_000); - tx.gas_max = Uint1::from(gas_max); - for act in actions { - tx.actions.push(act).unwrap(); - } - tx.fill_sign(main_acc).unwrap(); - tx -} #[test] fn hip23_pattern_1_atomic_tex_swap() { @@ -106,32 +25,7 @@ fn hip23_pattern_1_atomic_tex_swap() { let get = addr_of(&get_acc); const SERIAL: u64 = 2301; - let mut pay_tex = TexCellAct::create_by(pay); - pay_tex - .add_cell(Box::new(CellTrsZhuPay::new( - Fold64::from(100_000_000).unwrap(), - ))) - .unwrap(); - pay_tex - .add_cell(Box::new(CellTrsAssetPay::new( - AssetAmt::from(SERIAL, 50).unwrap(), - ))) - .unwrap(); - pay_tex.do_sign(&pay_acc).unwrap(); - - let mut get_tex = TexCellAct::create_by(get); - get_tex - .add_cell(Box::new(CellTrsZhuGet::new( - Fold64::from(100_000_000).unwrap(), - ))) - .unwrap(); - get_tex - .add_cell(Box::new(CellTrsAssetGet::new( - AssetAmt::from(SERIAL, 50).unwrap(), - ))) - .unwrap(); - get_tex.do_sign(&get_acc).unwrap(); - + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, SERIAL, 50); let tx = build_signed_type3( &main_acc, vec![Box::new(pay_tex), Box::new(get_tex)], @@ -141,7 +35,6 @@ fn hip23_pattern_1_atomic_tex_swap() { let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); seed_hac(&mut ctx, &main, 1_000_000); seed_hac(&mut ctx, &pay, 1_000); - seed_hac(&mut ctx, &get, 0); seed_asset(&mut ctx, &pay, SERIAL, 50); tx.execute(&mut ctx).unwrap(); @@ -178,7 +71,7 @@ fn hip23_pattern_2_height_guarded_payment() { let mut fail_ctx = make_ctx(window_start - 1, tx.as_read()); seed_hac(&mut fail_ctx, &main, 1_000); let err = tx.execute(&mut fail_ctx).unwrap_err(); - assert!(err.contains("submitted in height between"), "{err}"); + assert_err_contains(&err, "submitted in height between"); let mut ok_ctx = make_ctx(window_start + 100, tx.as_read()); seed_hac(&mut ok_ctx, &main, 1_000); @@ -211,7 +104,7 @@ fn hip23_pattern_3_balance_floor_protected_transfer() { let mut fail_ctx = make_ctx(TEST_HEIGHT, tx.as_read()); seed_hac(&mut fail_ctx, &main, 1_000); let err = tx.execute(&mut fail_ctx).unwrap_err(); - assert!(err.contains("lower than floor"), "{err}"); + assert_err_contains(&err, "lower than floor"); let mut ok_transfer = HacToTrs::new(); ok_transfer.to = AddrOrPtr::from_addr(recipient); @@ -280,14 +173,13 @@ fn hip23_pattern_4_asset_create_plus_tex() { let mut ctx = make_ctx(TEST_HEIGHT, tx_create.as_read()); seed_hac(&mut ctx, &main, 1_000_000); - seed_hac(&mut ctx, &issuer, 10); tx_create.execute(&mut ctx).unwrap(); assert_eq!(asset_amt(&mut ctx, &issuer, SERIAL), 10_000); let persisted = ctx.state().clone_state(); let mut tex_ctx = make_ctx_with_state( { - let mut env = Env::default(); + let mut env = basis::component::Env::default(); env.chain.fast_sync = true; env.block.height = TEST_HEIGHT; env.tx = create_tx_info(tx_tex.as_read()); From 77649e1b4f8d455aa063f6eb8623fa8b5a95c42f Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 16:11:50 +0200 Subject: [PATCH 03/11] HIP-23: address code review findings (doc fixes, P4 topology test) --- doc/HIP23.md | 25 +++++++--- tests/common/hip23.rs | 6 ++- tests/hip23_pattern_adversarial.rs | 78 ++++++++++++++++++++++-------- tests/hip23_pattern_regression.rs | 10 ++-- 4 files changed, 86 insertions(+), 33 deletions(-) diff --git a/doc/HIP23.md b/doc/HIP23.md index e240be1..9823d9e 100644 --- a/doc/HIP23.md +++ b/doc/HIP23.md @@ -62,7 +62,9 @@ Indexers SHOULD: - Index `TexCellAct` by signer `addr` and settled asset/diamond deltas. - Record guard failures as revert reason codes for failed transactions. -- Treat `AssetCreate` + TEX in the same tx as an atomic issuance+distribution event. +- Correlate issuance (Tx A) and TEX distribution (Tx B) by asset serial, issuer, and block + position; index them as a coordinated two-tx event (same block or later), not a single-tx + atomic mint+distribute. ## 4. Pattern P1 — Atomic multi-asset TEX swap @@ -75,7 +77,8 @@ Two or more parties atomically exchange HAC, SAT, diamonds, and/or HIP20 assets 1. Party A publishes `TexCellAct_A` with pay cells (`cellid` 1/3/5/7) and/or get cells (`2/4/6/8`). 2. Party B publishes `TexCellAct_B` with the mirrored get/pay cells. 3. A coordinator (or either party) combines both signed bundles in one Type3 transaction. -4. Optional funding actions (e.g. `HacToTrs`, `AssetCreate`) MAY precede TEX actions in the same tx. +4. Optional funding actions (e.g. `HacToTrs`) MAY precede TEX actions in the same tx. + `AssetCreate` is `TOP_ONLY` and MUST use the two-tx P4 flow (§7). ### 4.3 Rules @@ -124,7 +127,12 @@ reverts, the **transaction still fails**, but step-by-step simulators may alread debit as executed. Wallets MUST NOT present partial action progress as final settlement. Always validate the full tx atomically (`hip23_p2_transfer_before_guard_still_reverts_outside_window`). -### 5.4 Wallet display +On a full node, `try_execute_tx_by` forks state per transaction and merges only on success +(`chain/src/check.rs`); failed txs do not commit partial mutations. Direct `tx.execute()` +calls in tests or wallet simulators can still observe in-tx partial progress that would not +persist on-chain. + +### 5.5 Wallet display Wallets SHOULD show: “Valid heights: `start` … `end` (inclusive)”. @@ -156,7 +164,7 @@ actions: [ ### 7.1 Intent -Mint a HIP20 asset and atomically distribute units to counterparties via TEX in the same transaction. +Mint a HIP20 asset in Tx A, then distribute units to counterparties via TEX in Tx B (same block or later). ### 7.2 Structure @@ -243,7 +251,7 @@ actions: [ | P1 TEX | imbalanced settlement, tampered sign, insufficient balance, gas required, HAC+SAT swap, height condition cell | | P2 Guard | boundary inclusive, unlimited end, ChainAllow, wrong debit/guard order | | P3 Floor | asset dimension, pre-debit vs post-debit placement | -| P4 HIP20 | duplicate serial, missing asset, issuer insufficient | +| P4 HIP20 | duplicate serial, missing asset, issuer insufficient, `AssetCreate`+TEX same-tx rejected | | P5 AST | condition fault, else-branch transfer | | Topology | guard-only tx rejected, height+floor+transfer combo, height+TEX combo | @@ -251,4 +259,9 @@ Run all: ```bash cargo test hip23_ -- --nocapture -``` \ No newline at end of file +``` + +**Note:** HIP-23 tests use `fast_sync = true` in the harness (`tests/common/hip23.rs`) to skip +signature verification, duplicate-tx checks, and fee-address validation. Pattern semantics +(guards, TEX settlement, topology) still match mainnet rules; production integrators MUST +validate signatures and mempool policy separately. \ No newline at end of file diff --git a/tests/common/hip23.rs b/tests/common/hip23.rs index 652e12a..be67be8 100644 --- a/tests/common/hip23.rs +++ b/tests/common/hip23.rs @@ -1,4 +1,5 @@ //! Shared helpers for HIP-23 integration tests. +#![allow(dead_code)] use basis::component::Env; use basis::interface::{Action, Context, Transaction, TransactionRead}; @@ -13,12 +14,12 @@ use testkit::sim::integration::enable_mint_setup; pub const TEST_HEIGHT: u64 = protocol::upgrade::ONLINE_OPEN_HEIGHT + 10_000; pub const TX_FEE_MEI: u64 = 1; -pub fn init_setup_once() { +pub fn init_setup() { enable_mint_setup(); } pub fn addr_of(acc: &Account) -> Address { - Address::from(acc.address().clone()) + Address::from(*acc.address()) } pub fn make_ctx<'a>(height: u64, tx: &'a dyn TransactionRead) -> protocol::context::ContextInst<'a> { @@ -31,6 +32,7 @@ pub fn make_ctx_chain<'a>( tx: &'a dyn TransactionRead, ) -> protocol::context::ContextInst<'a> { let mut env = Env::default(); + // fast_sync skips sig/duplicate-tx/fee checks; see HIP23.md §11. env.chain.fast_sync = true; env.chain.id = chain_id; env.block.height = height; diff --git a/tests/hip23_pattern_adversarial.rs b/tests/hip23_pattern_adversarial.rs index 0c04955..abef399 100644 --- a/tests/hip23_pattern_adversarial.rs +++ b/tests/hip23_pattern_adversarial.rs @@ -20,7 +20,7 @@ use sys::Account; #[test] fn hip23_p1_tex_imbalanced_hac_amount_fails() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p1a-main").unwrap(); let pay_acc = Account::create_by("hip23-p1a-pay").unwrap(); let get_acc = Account::create_by("hip23-p1a-get").unwrap(); @@ -50,7 +50,7 @@ fn hip23_p1_tex_imbalanced_hac_amount_fails() { #[test] fn hip23_p1_tex_tampered_signature_fails() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p1b-main").unwrap(); let pay_acc = Account::create_by("hip23-p1b-pay").unwrap(); let get_acc = Account::create_by("hip23-p1b-get").unwrap(); @@ -76,7 +76,7 @@ fn hip23_p1_tex_tampered_signature_fails() { #[test] fn hip23_p1_tex_insufficient_hac_balance_fails() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p1c-main").unwrap(); let pay_acc = Account::create_by("hip23-p1c-pay").unwrap(); let get_acc = Account::create_by("hip23-p1c-get").unwrap(); @@ -98,7 +98,7 @@ fn hip23_p1_tex_insufficient_hac_balance_fails() { #[test] fn hip23_p1_tex_asset_cells_require_gas() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p1d-main").unwrap(); let pay_acc = Account::create_by("hip23-p1d-pay").unwrap(); let get_acc = Account::create_by("hip23-p1d-get").unwrap(); @@ -121,7 +121,7 @@ fn hip23_p1_tex_asset_cells_require_gas() { #[test] fn hip23_p1_tex_hac_and_sat_dual_swap_succeeds() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p1e-main").unwrap(); let pay_acc = Account::create_by("hip23-p1e-pay").unwrap(); let get_acc = Account::create_by("hip23-p1e-get").unwrap(); @@ -162,7 +162,7 @@ fn hip23_p1_tex_hac_and_sat_dual_swap_succeeds() { #[test] fn hip23_p1_tex_height_condition_in_bundle() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p1f-main").unwrap(); let pay_acc = Account::create_by("hip23-p1f-pay").unwrap(); let get_acc = Account::create_by("hip23-p1f-get").unwrap(); @@ -207,7 +207,7 @@ fn hip23_p1_tex_height_condition_in_bundle() { #[test] fn hip23_p2_height_guard_boundary_inclusive() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p2a-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -258,7 +258,7 @@ fn hip23_p2_height_guard_boundary_inclusive() { #[test] fn hip23_p2_height_guard_unlimited_end_zero() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p2b-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -284,7 +284,7 @@ fn hip23_p2_height_guard_unlimited_end_zero() { #[test] fn hip23_p2_chain_allow_rejects_wrong_chain() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p2c-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -309,7 +309,7 @@ fn hip23_p2_chain_allow_rejects_wrong_chain() { #[test] fn hip23_p2_transfer_before_guard_still_reverts_outside_window() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p2d-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -345,7 +345,7 @@ fn hip23_p2_transfer_before_guard_still_reverts_outside_window() { #[test] fn hip23_p3_floor_asset_dimension_blocks_overspend() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p3a-main").unwrap(); let cp_acc = Account::create_by("hip23-p3a-cp").unwrap(); let main = addr_of(&main_acc); @@ -390,7 +390,7 @@ fn hip23_p3_floor_asset_dimension_blocks_overspend() { #[test] fn hip23_p3_floor_before_transfer_checks_pre_debit_state() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p3b-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -419,9 +419,47 @@ fn hip23_p3_floor_before_transfer_checks_pre_debit_state() { // P4 — HIP20 + TEX adversarial // --------------------------------------------------------------------------- +#[test] +fn hip23_p4_asset_create_with_tex_same_tx_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-p4-topo-main").unwrap(); + let issuer = addr_of(&Account::create_by("hip23-p4-topo-issuer").unwrap()); + const SERIAL: u64 = 2339; + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(1000).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("TOPO").unwrap(), + name: BytesW1::from_str("Topo").unwrap(), + }; + create.protocol_cost = genesis::block_reward(TEST_HEIGHT); + + let issuer_acc = Account::create_by("hip23-p4-topo-issuer").unwrap(); + let buyer_acc = Account::create_by("hip23-p4-topo-buyer").unwrap(); + let (pay_tex, get_tex) = build_balanced_tex_swap(&issuer_acc, &buyer_acc, 0, SERIAL, 1); + + let actions: Vec> = vec![ + Box::new(create), + Box::new(pay_tex), + Box::new(get_tex), + ]; + let err = + protocol::action::precheck_tx_actions(TransactionType3::TYPE, &actions).unwrap_err(); + assert_err_contains(&err, "TOP_ONLY"); + + let tx = build_signed_type3(&main_acc, actions, 99); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + let exec_err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&exec_err, "TOP_ONLY"); +} + #[test] fn hip23_p4_duplicate_serial_rejected() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p4a-main").unwrap(); let issuer = addr_of(&Account::create_by("hip23-p4a-issuer").unwrap()); const SERIAL: u64 = 2340; @@ -473,7 +511,7 @@ fn hip23_p4_duplicate_serial_rejected() { #[test] fn hip23_p4_tex_on_missing_asset_fails() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p4b-main").unwrap(); let pay_acc = Account::create_by("hip23-p4b-pay").unwrap(); let get_acc = Account::create_by("hip23-p4b-get").unwrap(); @@ -494,7 +532,7 @@ fn hip23_p4_tex_on_missing_asset_fails() { #[test] fn hip23_p4_issuer_insufficient_asset_for_tex_pay() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p4c-main").unwrap(); let pay_acc = Account::create_by("hip23-p4c-pay").unwrap(); let get_acc = Account::create_by("hip23-p4c-get").unwrap(); @@ -522,7 +560,7 @@ fn hip23_p4_issuer_insufficient_asset_for_tex_pay() { #[test] fn hip23_p5_ast_if_condition_fault_aborts_whole_node() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p5a-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -552,7 +590,7 @@ fn hip23_p5_ast_if_condition_fault_aborts_whole_node() { #[test] fn hip23_p5_ast_else_branch_executes_transfer() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p5b-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -585,7 +623,7 @@ fn hip23_p5_ast_else_branch_executes_transfer() { #[test] fn hip23_topology_guard_only_tx_rejected() { - init_setup_once(); + init_setup(); let actions: Vec> = vec![Box::new(HeightScope::new())]; let err = protocol::action::precheck_tx_actions(TransactionType3::TYPE, &actions).unwrap_err(); assert_err_contains(&err, "all GUARD"); @@ -593,7 +631,7 @@ fn hip23_topology_guard_only_tx_rejected() { #[test] fn hip23_combined_height_scope_balance_floor_and_transfer() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-combo-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -625,7 +663,7 @@ fn hip23_combined_height_scope_balance_floor_and_transfer() { #[test] fn hip23_height_guard_plus_tex_swap_in_one_tx() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-combo-tex-main").unwrap(); let pay_acc = Account::create_by("hip23-combo-tex-pay").unwrap(); let get_acc = Account::create_by("hip23-combo-tex-get").unwrap(); diff --git a/tests/hip23_pattern_regression.rs b/tests/hip23_pattern_regression.rs index d115a4b..86f5c32 100644 --- a/tests/hip23_pattern_regression.rs +++ b/tests/hip23_pattern_regression.rs @@ -16,7 +16,7 @@ use testkit::sim::context::make_ctx_with_state; #[test] fn hip23_pattern_1_atomic_tex_swap() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p1-main").unwrap(); let pay_acc = Account::create_by("hip23-p1-pay").unwrap(); let get_acc = Account::create_by("hip23-p1-get").unwrap(); @@ -46,7 +46,7 @@ fn hip23_pattern_1_atomic_tex_swap() { #[test] fn hip23_pattern_2_height_guarded_payment() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p2-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -82,7 +82,7 @@ fn hip23_pattern_2_height_guarded_payment() { #[test] fn hip23_pattern_3_balance_floor_protected_transfer() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p3-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); @@ -128,7 +128,7 @@ fn hip23_pattern_3_balance_floor_protected_transfer() { #[test] fn hip23_pattern_4_asset_create_plus_tex() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p4-main").unwrap(); let issuer_acc = Account::create_by("hip23-p4-issuer").unwrap(); let buyer_acc = Account::create_by("hip23-p4-buyer").unwrap(); @@ -196,7 +196,7 @@ fn hip23_pattern_4_asset_create_plus_tex() { #[test] fn hip23_pattern_5_ast_conditional_settlement() { - init_setup_once(); + init_setup(); let main_acc = Account::create_by("hip23-p5-main").unwrap(); let main = addr_of(&main_acc); let recipient = field::ADDRESS_TWOX.clone(); From 5915e81cbc8c95607b967abd8c21f8afdda38d5e Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 16:26:58 +0200 Subject: [PATCH 04/11] HIP-23: polish spec, templates, helpers, and expand test suite to 37 --- doc/HIP23.md | 88 +++++--- doc/HIP23_templates.md | 250 ++++++++++++++++++--- tests/common/hip23.rs | 52 ++++- tests/hip23_pattern_adversarial.rs | 345 +++++++++++++++++++++++++++-- tests/hip23_pattern_regression.rs | 17 +- 5 files changed, 665 insertions(+), 87 deletions(-) diff --git a/doc/HIP23.md b/doc/HIP23.md index 9823d9e..fc6c94c 100644 --- a/doc/HIP23.md +++ b/doc/HIP23.md @@ -17,10 +17,11 @@ Goals: Reference material: -- Istanbul capability model: `istanbul_upgrade_tech.md` (community tech note) +- Height gates and Istanbul action registry: `protocol/src/upgrade.rs` - TEX settlement: `protocol/src/tex/*`, `tests/tex.rs` (`trs1`) - AST control flow: `doc/ast-spec.md` - Guards: `protocol/src/action/chain.rs` +- JSON templates: `doc/HIP23_templates.md` ## 2. Scope @@ -45,26 +46,27 @@ Reference material: All patterns MUST satisfy: -1. **Transaction type:** `type >= 3` when AST, TEX asset cells, or gas metering are used. -2. **Height gate:** On mainnet (`chain_id = 0`), Istanbul actions MUST only be submitted at `height >= 765432`, except dev/regtest windows documented in `protocol/src/upgrade.rs`. -3. **TEX settlement:** Any transaction containing `TexCellAct` MUST leave the TEX ledger zero-sum; the node calls `do_settlement()` after top-level actions (`protocol/src/transaction/type3.rs`). -4. **TEX signatures:** Each `TexCellAct` MUST be signed over `addr + cells` only (replayable across transactions). -5. **Guard topology:** Bare `GUARD` actions MAY appear at top level alongside other top actions. Transactions MUST NOT be guard-only (`protocol/src/action/level.rs`). -6. **Asset TEX gas:** Transactions with asset transfer TEX cells (`cellid` 7 or 8) MUST set `gas_max > 0` and initialize gas (`extra9` path). +1. **Transaction type:** Use `type >= 3` when submitting `TexCellAct`, AST nodes, or other Istanbul-gated actions that require Type3. +2. **Gas budget:** Set `gas_max > 0` when AST depth > 0 or when asset TEX cells (`cellid` 7 or 8) are present. Plain HAC/SAT/diamond TEX without asset cells MAY use `gas_max = 0` (see P2). +3. **Height gate:** On mainnet (`chain_id = 0`), Istanbul actions MUST only be submitted at `height >= 765432`, except dev/regtest windows documented in `protocol/src/upgrade.rs`. On non-mainnet chains (`chain_id != 0`), `check_gated_*` bypasses these gates — testnets MUST document their own policy. +4. **TEX settlement:** Type3 execution always calls `do_settlement()` after top-level actions (`protocol/src/transaction/type3.rs`). If any TEX transfer cells ran, zhu/sat/dia/asset ledger totals MUST be zero-sum before settlement succeeds. +5. **TEX signatures:** Each `TexCellAct` MUST be signed over `addr + cells` only (replayable across transactions). +6. **Guard topology:** Bare `GUARD` actions MAY appear at top level alongside other top actions. Transactions MUST NOT be guard-only (`protocol/src/action/level.rs`). +7. **Asset TEX gas:** Transactions with asset transfer TEX cells (`cellid` 7 or 8) MUST set `gas_max > 0` and initialize gas (`extra9` path). Wallets SHOULD: - Pre-validate TEX zero-sum pairing off-chain before co-signing counterparty bundles. +- Before signing a TEX bundle, verify its cells appear in the **agreed composed Type3 transaction** (structural equality or tx hash), not only that signer `addr` matches. - Display guard windows (height, chain, balance floor) in human-readable form. - Reject co-signed TEX bundles whose `addr` does not match the expected counterparty. Indexers SHOULD: - Index `TexCellAct` by signer `addr` and settled asset/diamond deltas. -- Record guard failures as revert reason codes for failed transactions. -- Correlate issuance (Tx A) and TEX distribution (Tx B) by asset serial, issuer, and block - position; index them as a coordinated two-tx event (same block or later), not a single-tx - atomic mint+distribute. +- Classify guard outcomes as **Revert** vs **Fault** and store normalized error text (e.g. `"submitted in height between"`, `"lower than floor"`). There is no stable on-chain guard reason-code enum in v1. +- Correlate issuance (Tx A) and TEX distribution (Tx B) by asset serial, issuer, and block position; index them as a coordinated two-tx event (same block or later), not a single-tx atomic mint+distribute. +- For P5 AST txs, record `ast_branch: if|else` and `cond_outcome: success|revert|fault` on successful transactions (condition revert with else success is not a failed tx). ## 4. Pattern P1 — Atomic multi-asset TEX swap @@ -77,8 +79,7 @@ Two or more parties atomically exchange HAC, SAT, diamonds, and/or HIP20 assets 1. Party A publishes `TexCellAct_A` with pay cells (`cellid` 1/3/5/7) and/or get cells (`2/4/6/8`). 2. Party B publishes `TexCellAct_B` with the mirrored get/pay cells. 3. A coordinator (or either party) combines both signed bundles in one Type3 transaction. -4. Optional funding actions (e.g. `HacToTrs`) MAY precede TEX actions in the same tx. - `AssetCreate` is `TOP_ONLY` and MUST use the two-tx P4 flow (§7). +4. Optional funding actions (e.g. `HacToTrs`) MAY precede TEX actions in the same tx. `AssetCreate` is `TOP_ONLY` and MUST use the two-tx P4 flow (§7). ### 4.3 Rules @@ -91,7 +92,7 @@ Two or more parties atomically exchange HAC, SAT, diamonds, and/or HIP20 assets | Failure | Result | |---------|--------| -| Imbalanced pay/get | Fault at settlement | +| Imbalanced pay/get | Fault at settlement (`coin` / `asset ` / `diamonds settlement check failed`) | | Bad signature | Fault on `TexCellAct` execute | | Missing asset / insufficient balance | Fault during cell execute | | Asset cells without gas | Fault (`gas not initialized`) | @@ -116,8 +117,8 @@ actions: [ ### 5.3 Rules - MUST: List `HeightScope` **before** the debit action (lower action index runs first). -- MUST: `start <= end` when `end != 0`. -- MUST: Revert (not fault) when current height is outside `[start, end]`. +- MUST: `start <= end` when `end != 0` (otherwise **fault**, not revert). +- MUST: Revert (not fault) when current height is outside `[start, end]` (inclusive). - SHOULD: Set `end` to a finite deadline for offer expiry. ### 5.4 Wallet pitfall (ordering) @@ -136,6 +137,14 @@ persist on-chain. Wallets SHOULD show: “Valid heights: `start` … `end` (inclusive)”. +### 5.6 P2 vs P5 + +| Aspect | P2 (top-level guard) | P5 (`AstIf` + guard condition) | +|--------|----------------------|----------------------------------| +| Guard revert outside window | **Whole tx fails** | **`br_else` runs**; tx may succeed | +| Use when | Payment must not settle unless in window | Fallback branch or no-op else is desired | +| Wallet display | “Expired — tx failed” | “Condition false — else branch taken” | + ## 6. Pattern P3 — BalanceFloor protected transfer ### 6.1 Intent @@ -151,14 +160,21 @@ actions: [ ] ``` -`BalanceFloor` intentionally inspects **in-transaction state at the guard position**, not pre-tx chain state. +`BalanceFloor` intentionally inspects **in-transaction state at the guard position**, not pre-tx chain state. Type3 **fee is debited after all actions** — floors do not see post-fee balances unless the integrator models fee in the floor value. ### 6.3 Rules - MUST: Place `BalanceFloor` **after** debits that should be protected. - MUST: Specify at least one non-zero floor field (`hacash`, `satoshi`, `diamond`, or `assets`). - MUST: Revert when any checked dimension is below floor. -- SHOULD: Floor values include expected post-transfer dust retention. +- SHOULD: Floor values include expected post-transfer dust retention **and** tx `fee` (and gas-paid HAC if applicable) when minimum **final** on-chain balance is intended. +- SHOULD: Only set non-zero fields for dimensions being protected; zero `hacash` does not guard HAC. + +### 6.4 Wallet pitfall (ordering) + +If `BalanceFloor` is listed **before** a debit, the guard reads **pre-debit** balances and protection is bypassed while the tx may still succeed (`hip23_p3_floor_before_transfer_checks_pre_debit_state`). Wallets MUST enforce debit-then-floor ordering for P3. + +Step-by-step simulators may show debits before a failing floor; the full tx still reverts atomically on-chain (see §5.4). ## 7. Pattern P4 — HIP20 issuance + TEX distribution @@ -168,7 +184,7 @@ Mint a HIP20 asset in Tx A, then distribute units to counterparties via TEX in T ### 7.2 Structure -`AssetCreate` is `TOP_ONLY` — it MUST be the only non-guard top-level action in its transaction. +`AssetCreate` is `TOP_ONLY` — it MUST be the **sole** top-level action in Tx A (no guards, no TEX, no other actions). Distribution therefore uses **two coordinated transactions**: ``` @@ -186,9 +202,10 @@ Tx B (distribution, after Tx A confirms or in a later pass): - MUST: Keep `AssetCreate` alone at top level (`TOP_ONLY` topology). - MUST: Run TEX distribution only after the asset exists on-chain (same block ordering or later block). -- MUST: `issuer` in metadata receives minted supply and MUST sign issuer `AssetPay` TEX cells. +- MUST: `issuer` in metadata receives minted supply; issuer SHOULD sign issuer `AssetPay` TEX cells (protocol does not bind `TexCellAct.addr` to `metadata.issuer`). - MUST: Set `gas_max > 0` on Type3 txs with asset TEX cells. -- MUST: Pay `protocol_cost` per `AssetCreate` rules @ `ASSET_ALIVE_HEIGHT` (`765432`). +- MUST: Pay `protocol_cost` **exactly equal** to `genesis::block_reward(height)` at inclusion height; debited from **`tx.main`**, not `metadata.issuer`. +- MUST: On mainnet, asset serial `>= 1025` at `ASSET_ALIVE_HEIGHT` (`765432`); serial ceiling grows with height (see `mint/src/action/asset.rs`). - SHOULD: Pre-sign TEX bundles for Tx B before broadcasting Tx A. ## 8. Pattern P5 — AST conditional settlement @@ -215,20 +232,33 @@ actions: [ - MUST: Condition `AstSelect` use `exe_min = exe_max = 1` for single guard predicates. - MUST: Guard-only `AstSelect` / `AstIf` nodes are invalid at tx topology precheck. - MUST: Condition guard `Revert` selects `br_else`; condition `Fault` aborts entire `AstIf`. +- SHOULD: Condition `AstSelect` contain guard-only actions (no balance mutations in `cond`). - SHOULD: Keep branch actions simple (single transfer or TEX bundle) for wallet simulation. +### 8.4 Wallet display + +- Show which branch executed: `if` vs `else`. +- Distinguish **condition fault** (invalid range → whole tx fails) from **condition revert** (outside window → else branch). +- Budget gas for **both** condition attempt and branch attempt; AST try costs are not refunded on revert (`doc/ast-spec.md` §6). + +### 8.5 Signatures and gas + +- `AstIf` collects signatures from `cond`, `br_if`, and `br_else` in the serialized tree — signers for **both** branches MAY be required at broadcast even if only one branch runs. +- Minimum `gas_max` for simple HAC `br_if` + `HeightScope` cond: **17** (template default). TEX or VM branches need higher budgets. + ## 9. Security considerations -- **TEX replay:** Signed TEX bundles are replayable; never treat a signature alone as a one-time authorization. +- **TEX replay:** Signed TEX bundles are replayable; never treat a signature alone as a one-time authorization. Pin the full composed tx before co-signing. - **Ordering:** Guards only see state mutations from earlier actions in the same transaction. - **Co-signing:** Verify counterparty TEX cells match your quote before signing. - **Gas:** AST and asset TEX paths consume gas; under-budgeted txs fail after partial snapshot work. +- **P2 vs P5:** Do not use P5 when a failed guard must abort the entire payment (use P2). ## 10. Versioning | Version | Contents | |---------|----------| -| v1.0 (this draft) | Patterns P1–P5, JSON templates, regression tests | +| v1.0 (this draft) | Patterns P1–P5, JSON templates, regression + adversarial tests | | v1.1 (planned) | Wallet checklist, indexer field dictionary | | v2.0 (future) | Optional HVM companion contracts per pattern | @@ -248,12 +278,12 @@ actions: [ | Area | Tests | |------|-------| -| P1 TEX | imbalanced settlement, tampered sign, insufficient balance, gas required, HAC+SAT swap, height condition cell | -| P2 Guard | boundary inclusive, unlimited end, ChainAllow, wrong debit/guard order | -| P3 Floor | asset dimension, pre-debit vs post-debit placement | -| P4 HIP20 | duplicate serial, missing asset, issuer insufficient, `AssetCreate`+TEX same-tx rejected | -| P5 AST | condition fault, else-branch transfer | -| Topology | guard-only tx rejected, height+floor+transfer combo, height+TEX combo | +| P1 TEX | imbalanced HAC/SAT, tampered sign, insufficient balance, gas required, HAC+SAT swap, height condition cell, HAC prelude + TEX | +| P2 Guard | boundary inclusive, above-end reject, unlimited end, ChainAllow, wrong debit/guard order | +| P3 Floor | asset dimension, satoshi dimension, pre-debit vs post-debit placement | +| P4 HIP20 | duplicate serial, missing asset, issuer insufficient, wrong protocol_cost, `AssetCreate`+TEX same-tx rejected | +| P5 AST | condition fault, else-branch transfer, zero gas rejected | +| Topology | guard-only rejected (precheck + execute), height+floor+transfer combo (pass + fail), height+TEX combo (pass + fail) | Run all: diff --git a/doc/HIP23_templates.md b/doc/HIP23_templates.md index 44ac9bb..63d4dba 100644 --- a/doc/HIP23_templates.md +++ b/doc/HIP23_templates.md @@ -3,6 +3,8 @@ Templates for wallet builders and integrators. Replace placeholders in `ALL_CAPS`. Action JSON uses `kind` (not `type`). TEX cells use `cellid` (not `kind`). +**Normative rules:** `doc/HIP23.md` (sections linked per pattern below). + **Mainnet:** submit only at `height >= 765432` unless using dev/regtest gates. --- @@ -14,17 +16,34 @@ Action JSON uses `kind` (not `type`). TEX cells use `cellid` (not `kind`). | `MAIN_ADDR` | Transaction fee payer / primary signer | | `PARTY_A` / `PARTY_B` | TEX co-signers | | `RECIPIENT` | Payment destination | -| `SERIAL` | HIP20 asset serial (u64) | +| `SERIAL` | HIP20 asset serial (u64); mainnet ≥ 1025 @ height 765432 | | `FEE` | Wire amount string, e.g. `"8:244"` | -| `GAS_MAX` | Type3 gas byte, e.g. `17` or `99` when asset TEX present | +| `GAS_MAX` | Type3 gas byte: `0` for HAC-only TEX; `17+` for AST; `99` when asset TEX (cells 7/8) | | `TIMESTAMP` | Unix seconds | -| `START_HEIGHT` / `END_HEIGHT` | Block height guard window | +| `START_HEIGHT` / `END_HEIGHT` | Block height guard window (inclusive) | --- ## P1 — Atomic multi-asset TEX swap -Minimal HAC + HIP20 swap between two parties: +See `HIP23.md` §4. + +### Transfer cell reference (cells 1–8) + +| cellid | Role | Asset | +|--------|------|-------| +| 1 | ZhuPay | HAC zhu out | +| 2 | ZhuGet | HAC zhu in | +| 3 | SatPay | SAT out | +| 4 | SatGet | SAT in | +| 5 | DiaPay | Named diamonds out | +| 6 | DiaGet | Diamond count in | +| 7 | AssetPay | HIP20 out | +| 8 | AssetGet | HIP20 in | + +Counterparty MUST mirror pay↔get with matching amounts/serials. + +### Minimal HAC + HIP20 swap (two parties) ```json { @@ -56,28 +75,73 @@ Minimal HAC + HIP20 swap between two parties: } ``` -Extended (HAC + SAT + diamonds) — mirror `tests/tex.rs` `trs1()`: +**Notes:** `gas_max: 99` required because cells 7/8 are present. HAC-only swaps MAY use `gas_max: 0`. + +### Party A bundle (HAC + SAT + diamonds + asset) — mirror for Party B + +Single-party TEX bundle; counterparty MUST use matching get/pay cells (see `tests/tex.rs` `trs1()`). ```json { "kind": 22, - "addr": "PARTY_A", + "addr": "PARTY_A_BUYER", "cells": [ { "cellid": 2, "haczhu": 100000000 }, - { "cellid": 3, "satnum": 2 }, - { "cellid": 5, "diamonds": "KKKKVA,HYXYHY,UETWNK" }, + { "cellid": 4, "satnum": 2 }, + { "cellid": 6, "dianum": 3 }, { "cellid": 8, "serial": SERIAL, "amount": 100 } ], "sign": "PARTY_A_TEX_SIGN" } ``` -Counterparty bundle MUST use matching get/pay cell types and amounts. +```json +{ + "kind": 22, + "addr": "PARTY_B_SELLER", + "cells": [ + { "cellid": 1, "haczhu": 100000000 }, + { "cellid": 3, "satnum": 2 }, + { "cellid": 5, "diamonds": "KKKKVA,HYXYHY,UETWNK" }, + { "cellid": 7, "serial": SERIAL, "amount": 100 } + ], + "sign": "PARTY_B_TEX_SIGN" +} +``` + +### Optional HAC funding before TEX + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 0, + "timestamp": TIMESTAMP, + "actions": [ + { "kind": 1, "to": "PARTY_A", "hacash": "10:244" }, + { + "kind": 22, + "addr": "PARTY_A", + "cells": [{ "cellid": 1, "haczhu": 100000000 }], + "sign": "PARTY_A_TEX_SIGN" + }, + { + "kind": 22, + "addr": "PARTY_B", + "cells": [{ "cellid": 2, "haczhu": 100000000 }], + "sign": "PARTY_B_TEX_SIGN" + } + ] +} +``` --- ## P2 — Time-boxed guarded payment +See `HIP23.md` §5. + ```json { "type": 3, @@ -100,16 +164,37 @@ Counterparty bundle MUST use matching get/pay cell types and amounts. } ``` -Notes: +**Notes:** - `kind` `1042` = `HeightScope` (`0x0412`). -- Guard MUST be listed before the debit action. +- Guard MUST be listed **before** the debit action. - `end: 0` = no upper height limit. +- `start > end` (when `end != 0`) is a **fault**, not a revert. + +### ChainAllow variant + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 0, + "timestamp": TIMESTAMP, + "actions": [ + { "kind": 1041, "chains": [0, 1] }, + { "kind": 1, "to": "RECIPIENT", "hacash": "3:244" } + ] +} +``` + +`kind` `1041` = `ChainAllow` (`0x0411`). --- ## P3 — BalanceFloor protected transfer +See `HIP23.md` §6. + ```json { "type": 3, @@ -135,19 +220,41 @@ Notes: } ``` -Notes: +**Notes:** - `kind` `1043` = `BalanceFloor` (`0x0413`). - Floor is evaluated **after** the preceding debit. +- Include tx `fee` in floor when protecting **final** post-settlement balance. - Add `assets: [{ "serial": SERIAL, "amount": MIN_AMT }]` to protect HIP20 balances. +### Multi-debit then floor + +```json +{ + "actions": [ + { "kind": 1, "to": "RECIPIENT_A", "hacash": "40:244" }, + { "kind": 1, "to": "RECIPIENT_B", "hacash": "10:244" }, + { + "kind": 1043, + "addr": "MAIN_ADDR", + "hacash": "900:244", + "satoshi": 0, + "diamond": 0, + "assets": [] + } + ] +} +``` + --- ## P4 — HIP20 issuance + TEX distribution +See `HIP23.md` §7. + `AssetCreate` is `TOP_ONLY`. Use **two transactions**: -**Tx A — issuance** +### Tx A — issuance ```json { @@ -167,13 +274,19 @@ Notes: "ticket": "USDT", "name": "Tether" }, - "protocol_cost": "1:244" + "protocol_cost": "BLOCK_REWARD_AT_HEIGHT" } ] } ``` -**Tx B — TEX distribution** (after Tx A is valid on-chain) +**Notes:** + +- Tx A MUST contain **only** `AssetCreate` (no guards, no TEX). +- `protocol_cost` MUST equal `genesis::block_reward(height)` at inclusion height (not a fixed literal). +- `MAIN_ADDR` pays `protocol_cost`; `issuer` receives minted supply. + +### Tx B — TEX distribution (after Tx A is valid on-chain) ```json { @@ -203,16 +316,13 @@ Notes: } ``` -Notes: - -- Tx A MUST contain only `AssetCreate` at top level. -- Issuer MUST sign issuer TEX bundle in Tx B. - --- ## P5 — AST conditional settlement -Pay only if height guard passes; otherwise no-op branch: +See `HIP23.md` §8. + +Pay only if height guard passes; otherwise fallback else branch: ```json { @@ -242,20 +352,91 @@ Pay only if height guard passes; otherwise no-op branch: }, "br_else": { "kind": 25, - "exe_min": 0, - "exe_max": 0, - "actions": [] + "exe_min": 1, + "exe_max": 1, + "actions": [ + { "kind": 1, "to": "RECIPIENT", "hacash": "3:244" } + ] } } ] } ``` -Notes: +**Notes:** - `kind` `26` = `AstIf`, `25` = `AstSelect`. -- Condition guard failure (revert) selects `br_else`. +- Condition guard failure (revert) selects `br_else`; invalid range (`start > end`) **faults** whole node. - `gas_max` MUST be non-zero for AST execution. +- Signatures are collected from **all** branches in the serialized tree, not only the executed branch. +- Empty else (`exe_min: 0, exe_max: 0, actions: []`) is equivalent to `AstSelect::nop()` in tests. + +--- + +## Composition templates + +See `HIP23.md` §11 topology tests. + +### Height + BalanceFloor + transfer (happy path) + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 0, + "timestamp": TIMESTAMP, + "actions": [ + { "kind": 1042, "start": START_HEIGHT, "end": END_HEIGHT }, + { "kind": 1, "to": "RECIPIENT", "hacash": "40:244" }, + { + "kind": 1043, + "addr": "MAIN_ADDR", + "hacash": "900:244", + "satoshi": 0, + "diamond": 0, + "assets": [] + } + ] +} +``` + +### Height guard + TEX swap + +```json +{ + "type": 3, + "main": "MAIN_ADDR", + "fee": "FEE", + "gas_max": 0, + "timestamp": TIMESTAMP, + "actions": [ + { "kind": 1042, "start": START_HEIGHT, "end": END_HEIGHT }, + { + "kind": 22, + "addr": "PARTY_A", + "cells": [{ "cellid": 1, "haczhu": 100000000 }], + "sign": "PARTY_A_TEX_SIGN" + }, + { + "kind": 22, + "addr": "PARTY_B", + "cells": [{ "cellid": 2, "haczhu": 100000000 }], + "sign": "PARTY_B_TEX_SIGN" + } + ] +} +``` + +--- + +## Guard kind reference + +| kind (decimal) | kind (hex) | Action | +|----------------|------------|--------| +| 1041 | 0x0411 | `ChainAllow` | +| 1042 | 0x0412 | `HeightScope` | +| 1043 | 0x0413 | `BalanceFloor` | --- @@ -274,4 +455,19 @@ Example height condition inside a TEX bundle: ```json { "cellid": 23, "height": 800000 } -``` \ No newline at end of file +``` + +--- + +## Action kind quick reference + +| kind | Action | +|------|--------| +| 1 | `HacToTrs` | +| 16 | `AssetCreate` | +| 22 | `TexCellAct` | +| 25 | `AstSelect` | +| 26 | `AstIf` | +| 1041 | `ChainAllow` | +| 1042 | `HeightScope` | +| 1043 | `BalanceFloor` | \ No newline at end of file diff --git a/tests/common/hip23.rs b/tests/common/hip23.rs index be67be8..71f834b 100644 --- a/tests/common/hip23.rs +++ b/tests/common/hip23.rs @@ -2,7 +2,7 @@ #![allow(dead_code)] use basis::component::Env; -use basis::interface::{Action, Context, Transaction, TransactionRead}; +use basis::interface::{Action, Context, StateOperat, Transaction, TransactionRead}; use field::*; use protocol::state::CoreState; use protocol::tex::*; @@ -10,8 +10,10 @@ use protocol::transaction::*; use sys::Account; use testkit::sim::context::make_ctx_with_state; use testkit::sim::integration::enable_mint_setup; +use testkit::sim::state::ForkableMemState; pub const TEST_HEIGHT: u64 = protocol::upgrade::ONLINE_OPEN_HEIGHT + 10_000; +/// Fee deducted from main on successful Type3 execute (matches `build_signed_type3` wire fee). pub const TX_FEE_MEI: u64 = 1; pub fn init_setup() { @@ -30,14 +32,32 @@ pub fn make_ctx_chain<'a>( height: u64, chain_id: u32, tx: &'a dyn TransactionRead, +) -> protocol::context::ContextInst<'a> { + make_ctx_with_opts(height, chain_id, true, tx, Box::new(ForkableMemState::default())) +} + +pub fn make_ctx_persisted<'a>( + height: u64, + state: Box, + tx: &'a dyn TransactionRead, +) -> protocol::context::ContextInst<'a> { + make_ctx_with_opts(height, 0, true, tx, state) +} + +fn make_ctx_with_opts<'a>( + height: u64, + chain_id: u32, + fast_sync: bool, + tx: &'a dyn TransactionRead, + state: Box, ) -> protocol::context::ContextInst<'a> { let mut env = Env::default(); // fast_sync skips sig/duplicate-tx/fee checks; see HIP23.md §11. - env.chain.fast_sync = true; + env.chain.fast_sync = fast_sync; env.chain.id = chain_id; env.block.height = height; env.tx = create_tx_info(tx); - make_ctx_with_state(env, Box::new(testkit::sim::state::ForkableMemState::default()), tx) + make_ctx_with_state(env, state, tx) } pub fn seed_hac(ctx: &mut dyn Context, addr: &Address, mei: u64) { @@ -54,6 +74,22 @@ pub fn seed_sat(ctx: &mut dyn Context, addr: &Address, sat: u64) { state.balance_set(addr, &bls); } +pub fn seed_diamond_owned(ctx: &mut dyn Context, name: &DiamondName, owner: &Address) { + let mut state = CoreState::wrap(ctx.state()); + let mut dia = DiamondSto::new(); + dia.status = DIAMOND_STATUS_NORMAL; + dia.address = *owner; + state.diamond_set(name, &dia); + let mut bls = state.balance(owner).unwrap_or_default(); + let cur = bls + .diamond + .to_diamond() + .map(|d| d.uint()) + .unwrap_or(0); + bls.diamond = DiamondNumberAuto::from_diamond(&DiamondNumber::from(cur + 1)); + state.balance_set(owner, &bls); +} + pub fn seed_asset(ctx: &mut dyn Context, owner: &Address, serial: u64, amount: u64) { let mut state = CoreState::wrap(ctx.state()); let serial_f = Fold64::from(serial).unwrap(); @@ -92,6 +128,16 @@ pub fn sat_amount(ctx: &mut dyn Context, addr: &Address) -> u64 { .uint() } +pub fn diamond_count(ctx: &mut dyn Context, addr: &Address) -> u32 { + CoreState::wrap(ctx.state()) + .balance(addr) + .unwrap_or_default() + .diamond + .to_diamond() + .map(|d| d.uint()) + .unwrap_or(0) +} + pub fn asset_amt(ctx: &mut dyn Context, addr: &Address, serial: u64) -> u64 { CoreState::wrap(ctx.state()) .balance(addr) diff --git a/tests/hip23_pattern_adversarial.rs b/tests/hip23_pattern_adversarial.rs index abef399..0dfe235 100644 --- a/tests/hip23_pattern_adversarial.rs +++ b/tests/hip23_pattern_adversarial.rs @@ -6,7 +6,7 @@ mod common; use basis::interface::{Action, StateOperat, Transaction, TxExec}; use common::hip23::*; use field::*; -use testkit::sim::context::make_ctx_with_state; + use mint::action::AssetCreate; use mint::genesis; use protocol::action::*; @@ -44,7 +44,7 @@ fn hip23_p1_tex_imbalanced_hac_amount_fails() { seed_hac(&mut ctx, &pay, 1_000); let err = tx.execute(&mut ctx).unwrap_err(); - assert_err_contains(&err, "settlement check failed"); + assert_err_contains(&err, "coin settlement check failed"); assert_eq!(hac_mei(&mut ctx, &get), 0); } @@ -130,7 +130,7 @@ fn hip23_p1_tex_hac_and_sat_dual_swap_succeeds() { let mut pay_tex = TexCellAct::create_by(pay); pay_tex - .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(50_000_000).unwrap()))) + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(100_000_000).unwrap()))) .unwrap(); pay_tex .add_cell(Box::new(CellTrsSatPay::new(Fold64::from(3).unwrap()))) @@ -139,7 +139,7 @@ fn hip23_p1_tex_hac_and_sat_dual_swap_succeeds() { let mut get_tex = TexCellAct::create_by(get); get_tex - .add_cell(Box::new(CellTrsZhuGet::new(Fold64::from(50_000_000).unwrap()))) + .add_cell(Box::new(CellTrsZhuGet::new(Fold64::from(100_000_000).unwrap()))) .unwrap(); get_tex .add_cell(Box::new(CellTrsSatGet::new(Fold64::from(3).unwrap()))) @@ -157,9 +157,111 @@ fn hip23_p1_tex_hac_and_sat_dual_swap_succeeds() { seed_sat(&mut ctx, &pay, 3); tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &get), 1); assert_eq!(sat_amount(&mut ctx, &get), 3); } +#[test] +fn hip23_p1_tex_imbalanced_sat_fails() { + init_setup(); + let main_acc = Account::create_by("hip23-p1g-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1g-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1g-get").unwrap(); + let pay = addr_of(&pay_acc); + + let mut pay_tex = TexCellAct::create_by(pay); + pay_tex + .add_cell(Box::new(CellTrsSatPay::new(Fold64::from(5).unwrap()))) + .unwrap(); + pay_tex.do_sign(&pay_acc).unwrap(); + + let mut get_tex = TexCellAct::create_by(addr_of(&get_acc)); + get_tex + .add_cell(Box::new(CellTrsSatGet::new(Fold64::from(2).unwrap()))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_sat(&mut ctx, &pay, 5); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "coin settlement check failed"); +} + +#[test] +fn hip23_p1_tex_with_hac_to_trs_prelude_succeeds() { + init_setup(); + let main_acc = Account::create_by("hip23-p1h-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1h-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1h-get").unwrap(); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + + let fund = HacToTrs::create_by(pay, Amount::mei(5)); + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(fund), Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 0); + + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &get), 1); + assert_eq!(hac_mei(&mut ctx, &pay), 4); +} + +#[test] +fn hip23_p1_tex_diamond_count_swap_succeeds() { + init_setup(); + let main_acc = Account::create_by("hip23-p1i-main").unwrap(); + let pay_acc = Account::create_by("hip23-p1i-pay").unwrap(); + let get_acc = Account::create_by("hip23-p1i-get").unwrap(); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + + let dia_a = DiamondName::from_readable(b"KKKKVA").unwrap(); + let dia_b = DiamondName::from_readable(b"HYXYHY").unwrap(); + let (pay_tex, get_tex) = { + let mut pay_tex = TexCellAct::create_by(pay); + pay_tex + .add_cell(Box::new(CellTrsDiaPay::new( + DiamondNameListMax200::from_list_checked(vec![dia_a.clone(), dia_b.clone()]).unwrap(), + ))) + .unwrap(); + pay_tex.do_sign(&pay_acc).unwrap(); + + let mut get_tex = TexCellAct::create_by(get); + get_tex + .add_cell(Box::new(CellTrsDiaGet::new(DiamondNumber::from(2)))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + (pay_tex, get_tex) + }; + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_diamond_owned(&mut ctx, &dia_a, &pay); + seed_diamond_owned(&mut ctx, &dia_b, &pay); + + tx.execute(&mut ctx).unwrap(); + assert_eq!(diamond_count(&mut ctx, &get), 2); +} + #[test] fn hip23_p1_tex_height_condition_in_bundle() { init_setup(); @@ -256,6 +358,36 @@ fn hip23_p2_height_guard_boundary_inclusive() { assert_eq!(hac_mei(&mut ctx_end, &recipient), 1); } +#[test] +fn hip23_p2_height_guard_above_end_reverts() { + init_setup(); + let main_acc = Account::create_by("hip23-p2e-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + let start = TEST_HEIGHT; + let end = TEST_HEIGHT + 10; + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(start); + guard.end = BlockHeight::from(end); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(4); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + + let mut ctx = make_ctx(end + 1, tx.as_read()); + seed_hac(&mut ctx, &main, 100); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "submitted in height between"); + assert_eq!(hac_mei(&mut ctx, &recipient), 0); + assert_eq!(hac_mei(&mut ctx, &main), 100); +} + #[test] fn hip23_p2_height_guard_unlimited_end_zero() { init_setup(); @@ -415,6 +547,45 @@ fn hip23_p3_floor_before_transfer_checks_pre_debit_state() { assert_eq!(hac_mei(&mut ctx, &main), 900 - TX_FEE_MEI); } +#[test] +fn hip23_p3_floor_satoshi_dimension_blocks_overspend() { + init_setup(); + let main_acc = Account::create_by("hip23-p3c-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut pay_tex = TexCellAct::create_by(main); + pay_tex + .add_cell(Box::new(CellTrsSatPay::new(Fold64::from(4).unwrap()))) + .unwrap(); + pay_tex.do_sign(&main_acc).unwrap(); + + let cp_acc = Account::create_by("hip23-p3c-cp").unwrap(); + let counterparty = addr_of(&cp_acc); + let mut get_tex = TexCellAct::create_by(counterparty); + get_tex + .add_cell(Box::new(CellTrsSatGet::new(Fold64::from(4).unwrap()))) + .unwrap(); + get_tex.do_sign(&cp_acc); + + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.satoshi = Satoshi::from(8); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex), Box::new(floor)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000_000); + seed_sat(&mut ctx, &main, 10); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "lower than floor"); +} + // --------------------------------------------------------------------------- // P4 — HIP20 + TEX adversarial // --------------------------------------------------------------------------- @@ -493,17 +664,7 @@ fn hip23_p4_duplicate_serial_rejected() { let persisted = ctx.state().clone_state(); let tx_dup = build_signed_type3(&main_acc, vec![Box::new(create_dup)], 0); - let mut ctx2 = make_ctx_with_state( - { - let mut env = basis::component::Env::default(); - env.chain.fast_sync = true; - env.block.height = TEST_HEIGHT + 1; - env.tx = protocol::transaction::create_tx_info(tx_dup.as_read()); - env - }, - persisted, - tx_dup.as_read(), - ); + let mut ctx2 = make_ctx_persisted(TEST_HEIGHT + 1, persisted, tx_dup.as_read()); seed_hac(&mut ctx2, &addr_of(&main_acc), 1_000_000); let err = tx_dup.execute(&mut ctx2).unwrap_err(); assert_err_contains(&err, "already exists"); @@ -554,6 +715,31 @@ fn hip23_p4_issuer_insufficient_asset_for_tex_pay() { assert!(err.contains("insufficient") || err.contains("overflow"), "{err}"); } +#[test] +fn hip23_p4_wrong_protocol_cost_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-p4d-main").unwrap(); + let issuer = addr_of(&Account::create_by("hip23-p4d-issuer").unwrap()); + const SERIAL: u64 = 2343; + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(1000).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("FEE").unwrap(), + name: BytesW1::from_str("Fee").unwrap(), + }; + create.protocol_cost = Amount::mei(1); + + let tx = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "Protocol fee must be"); +} + // --------------------------------------------------------------------------- // P5 — AST adversarial // --------------------------------------------------------------------------- @@ -617,6 +803,30 @@ fn hip23_p5_ast_else_branch_executes_transfer() { assert_eq!(hac_mei(&mut ctx, &recipient), 3); } +#[test] +fn hip23_p5_ast_requires_nonzero_gas() { + init_setup(); + let main_acc = Account::create_by("hip23-p5c-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut cond_guard = HeightScope::new(); + cond_guard.start = BlockHeight::from(TEST_HEIGHT); + cond_guard.end = BlockHeight::from(TEST_HEIGHT + 100); + let cond = AstSelect::create_by(1, 1, vec![Box::new(cond_guard)]); + let br_if = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient, + Amount::mei(1), + ))]); + let act = AstIf::create_by(cond, br_if, AstSelect::nop()); + + let tx = build_signed_type3(&main_acc, vec![Box::new(act)], 0); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "gas not initialized"); +} + // --------------------------------------------------------------------------- // Topology / composition // --------------------------------------------------------------------------- @@ -629,6 +839,17 @@ fn hip23_topology_guard_only_tx_rejected() { assert_err_contains(&err, "all GUARD"); } +#[test] +fn hip23_topology_guard_only_execute_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-topo-exec-main").unwrap(); + let tx = build_signed_type3(&main_acc, vec![Box::new(HeightScope::new())], 0); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 100); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "all GUARD"); +} + #[test] fn hip23_combined_height_scope_balance_floor_and_transfer() { init_setup(); @@ -661,6 +882,72 @@ fn hip23_combined_height_scope_balance_floor_and_transfer() { assert_eq!(hac_mei(&mut ctx, &recipient), 40); } +#[test] +fn hip23_combined_height_floor_transfer_outside_height_fails() { + init_setup(); + let main_acc = Account::create_by("hip23-combo-fail-h-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut height = HeightScope::new(); + height.start = BlockHeight::from(TEST_HEIGHT); + height.end = BlockHeight::from(TEST_HEIGHT + 100); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(40); + + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.hacash = Amount::mei(900); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(height), Box::new(transfer), Box::new(floor)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT - 1, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "submitted in height between"); + assert_eq!(hac_mei(&mut ctx, &recipient), 0); +} + +#[test] +fn hip23_combined_height_floor_transfer_below_floor_fails() { + init_setup(); + let main_acc = Account::create_by("hip23-combo-fail-f-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut height = HeightScope::new(); + height.start = BlockHeight::from(TEST_HEIGHT); + height.end = BlockHeight::from(TEST_HEIGHT + 100); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(150); + + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.hacash = Amount::mei(900); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(height), Box::new(transfer), Box::new(floor)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT + 50, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "lower than floor"); + // Debit ran before failing floor; in-tx simulator shows partial progress (see HIP23.md §6.4). + assert_eq!(hac_mei(&mut ctx, &recipient), 150); + assert_eq!(hac_mei(&mut ctx, &main), 850); +} + #[test] fn hip23_height_guard_plus_tex_swap_in_one_tx() { init_setup(); @@ -685,4 +972,32 @@ fn hip23_height_guard_plus_tex_swap_in_one_tx() { seed_hac(&mut ctx, &pay, 100); tx.execute(&mut ctx).unwrap(); assert_eq!(hac_mei(&mut ctx, &addr_of(&get_acc)), 1); +} + +#[test] +fn hip23_height_guard_plus_tex_outside_window_fails() { + init_setup(); + let main_acc = Account::create_by("hip23-combo-tex-fail-main").unwrap(); + let pay_acc = Account::create_by("hip23-combo-tex-fail-pay").unwrap(); + let get_acc = Account::create_by("hip23-combo-tex-fail-get").unwrap(); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT); + guard.end = BlockHeight::from(TEST_HEIGHT + 500); + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT + 501, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 100); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "submitted in height between"); + assert_eq!(hac_mei(&mut ctx, &get), 0); } \ No newline at end of file diff --git a/tests/hip23_pattern_regression.rs b/tests/hip23_pattern_regression.rs index 86f5c32..412c8e9 100644 --- a/tests/hip23_pattern_regression.rs +++ b/tests/hip23_pattern_regression.rs @@ -10,9 +10,7 @@ use mint::action::AssetCreate; use mint::genesis; use protocol::action::*; use protocol::tex::*; -use protocol::transaction::create_tx_info; use sys::Account; -use testkit::sim::context::make_ctx_with_state; #[test] fn hip23_pattern_1_atomic_tex_swap() { @@ -40,6 +38,7 @@ fn hip23_pattern_1_atomic_tex_swap() { tx.execute(&mut ctx).unwrap(); assert_eq!(hac_mei(&mut ctx, &get), 1); + assert_eq!(hac_mei(&mut ctx, &pay), 999); assert_eq!(asset_amt(&mut ctx, &get, SERIAL), 50); assert_eq!(asset_amt(&mut ctx, &pay, SERIAL), 0); } @@ -72,6 +71,8 @@ fn hip23_pattern_2_height_guarded_payment() { seed_hac(&mut fail_ctx, &main, 1_000); let err = tx.execute(&mut fail_ctx).unwrap_err(); assert_err_contains(&err, "submitted in height between"); + assert_eq!(hac_mei(&mut fail_ctx, &main), 1_000); + assert_eq!(hac_mei(&mut fail_ctx, &recipient), 0); let mut ok_ctx = make_ctx(window_start + 100, tx.as_read()); seed_hac(&mut ok_ctx, &main, 1_000); @@ -177,17 +178,7 @@ fn hip23_pattern_4_asset_create_plus_tex() { assert_eq!(asset_amt(&mut ctx, &issuer, SERIAL), 10_000); let persisted = ctx.state().clone_state(); - let mut tex_ctx = make_ctx_with_state( - { - let mut env = basis::component::Env::default(); - env.chain.fast_sync = true; - env.block.height = TEST_HEIGHT; - env.tx = create_tx_info(tx_tex.as_read()); - env - }, - persisted, - tx_tex.as_read(), - ); + let mut tex_ctx = make_ctx_persisted(TEST_HEIGHT, persisted, tx_tex.as_read()); tx_tex.execute(&mut tex_ctx).unwrap(); assert_eq!(asset_amt(&mut tex_ctx, &buyer, SERIAL), 500); From 6ed444348c1e0a639d30cdc4a1fa264ef705d40f Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 16:30:29 +0200 Subject: [PATCH 05/11] HIP-23: add stress test suite (13 tests) and run full workspace validation --- doc/HIP23.md | 9 + tests/hip23_pattern_stress.rs | 475 ++++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 tests/hip23_pattern_stress.rs diff --git a/doc/HIP23.md b/doc/HIP23.md index fc6c94c..ce24c3f 100644 --- a/doc/HIP23.md +++ b/doc/HIP23.md @@ -285,6 +285,15 @@ actions: [ | P5 AST | condition fault, else-branch transfer, zero gas rejected | | Topology | guard-only rejected (precheck + execute), height+floor+transfer combo (pass + fail), height+TEX combo (pass + fail) | +### Stress (`hip23_pattern_stress.rs`) + +| Area | Tests | +|------|-------| +| Guards | triple ChainAllow+Height+Floor, height 0..0, far-future start | +| TEX | three-party zero-sum, serial mismatch, empty/unsigned bundles, duplicate addr | +| P3/P4/P5 | multi-debit floor, minsri serial + chained TEX, BalanceFloor AST cond, low gas | +| HIP20 | serial 1025 @ alive height, serial below minsri fault | + Run all: ```bash diff --git a/tests/hip23_pattern_stress.rs b/tests/hip23_pattern_stress.rs new file mode 100644 index 0000000..2cfbbca --- /dev/null +++ b/tests/hip23_pattern_stress.rs @@ -0,0 +1,475 @@ +//! HIP-23 stress / fuzz-adjacent tests — aggressive edge cases and multi-pattern combos. +//! Run: cargo test hip23_stress_ -- --nocapture + +mod common; + +use basis::interface::{Action, StateOperat, Transaction, TxExec}; +use common::hip23::*; +use field::*; +use mint::action::AssetCreate; +use mint::action::ASSET_ALIVE_HEIGHT; +use mint::genesis; +use protocol::action::*; +use protocol::tex::*; +use sys::Account; + +// --------------------------------------------------------------------------- +// Height / guard stress +// --------------------------------------------------------------------------- + +#[test] +fn hip23_stress_height_start_zero_end_zero_always_passes() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-h00-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(0); + guard.end = BlockHeight::from(0); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + let mut ctx = make_ctx(1, tx.as_read()); + seed_hac(&mut ctx, &main, 50); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &recipient), 1); +} + +#[test] +fn hip23_stress_triple_guard_chain_allow_height_floor_transfer() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-triple-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut allow = ChainAllow::new(); + allow.chains = ChainIDList::from_list(vec![Uint4::from(0)]).unwrap(); + + let mut height = HeightScope::new(); + height.start = BlockHeight::from(TEST_HEIGHT); + height.end = BlockHeight::from(TEST_HEIGHT + 50); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(25); + + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.hacash = Amount::mei(900); + + let tx = build_signed_type3( + &main_acc, + vec![ + Box::new(allow), + Box::new(height), + Box::new(transfer), + Box::new(floor), + ], + 0, + ); + + let mut ctx = make_ctx(TEST_HEIGHT + 10, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &recipient), 25); + assert_eq!(hac_mei(&mut ctx, &main), 975 - TX_FEE_MEI); +} + +#[test] +fn hip23_stress_height_far_future_start_reverts_at_present() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-hfar-main").unwrap(); + let main = addr_of(&main_acc); + let far = TEST_HEIGHT + 10_000_000; + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(far); + guard.end = BlockHeight::from(0); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(field::ADDRESS_TWOX.clone()); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 10); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "submitted in height between"); +} + +// --------------------------------------------------------------------------- +// TEX stress +// --------------------------------------------------------------------------- + +#[test] +fn hip23_stress_three_party_hac_tex_zero_sum() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-3p-main").unwrap(); + let a_acc = Account::create_by("hip23-stress-3p-a").unwrap(); + let b_acc = Account::create_by("hip23-stress-3p-b").unwrap(); + let c_acc = Account::create_by("hip23-stress-3p-c").unwrap(); + let a = addr_of(&a_acc); + let b = addr_of(&b_acc); + let c = addr_of(&c_acc); + + // A pays 30M, B pays 70M, C gets 100M — zero-sum across three bundles. + let mut tex_a = TexCellAct::create_by(a); + tex_a + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(30_000_000).unwrap()))) + .unwrap(); + tex_a.do_sign(&a_acc).unwrap(); + + let mut tex_b = TexCellAct::create_by(b); + tex_b + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(70_000_000).unwrap()))) + .unwrap(); + tex_b.do_sign(&b_acc).unwrap(); + + let mut tex_c = TexCellAct::create_by(c); + tex_c + .add_cell(Box::new(CellTrsZhuGet::new(Fold64::from(100_000_000).unwrap()))) + .unwrap(); + tex_c.do_sign(&c_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(tex_a), Box::new(tex_b), Box::new(tex_c)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &a, 1); + seed_hac(&mut ctx, &b, 1); + + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &c), 1); +} + +#[test] +fn hip23_stress_tex_asset_serial_mismatch_fails_settlement() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-serial-main").unwrap(); + let pay_acc = Account::create_by("hip23-stress-serial-pay").unwrap(); + let get_acc = Account::create_by("hip23-stress-serial-get").unwrap(); + let pay = addr_of(&pay_acc); + const SERIAL_PAY: u64 = 2401; + const SERIAL_GET: u64 = 2402; + + let mut pay_tex = TexCellAct::create_by(pay); + pay_tex + .add_cell(Box::new(CellTrsAssetPay::new( + AssetAmt::from(SERIAL_PAY, 1).unwrap(), + ))) + .unwrap(); + pay_tex.do_sign(&pay_acc).unwrap(); + + let mut get_tex = TexCellAct::create_by(addr_of(&get_acc)); + get_tex + .add_cell(Box::new(CellTrsAssetGet::new( + AssetAmt::from(SERIAL_GET, 1).unwrap(), + ))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 99, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_asset(&mut ctx, &pay, SERIAL_PAY, 1); + seed_asset(&mut ctx, &pay, SERIAL_GET, 0); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert!( + err.contains("settlement check failed") || err.contains("asset"), + "{err}" + ); +} + +#[test] +fn hip23_stress_tex_empty_bundles_rejected_or_noop() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-empty-main").unwrap(); + let a_acc = Account::create_by("hip23-stress-empty-a").unwrap(); + let b_acc = Account::create_by("hip23-stress-empty-b").unwrap(); + + let tex_a = TexCellAct::create_by(addr_of(&a_acc)); + let tex_b = TexCellAct::create_by(addr_of(&b_acc)); + // Unsigned empty bundles — should fail at sign verification or settlement. + let tx = build_signed_type3( + &main_acc, + vec![Box::new(tex_a), Box::new(tex_b)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert!( + err.contains("signature") || err.contains("settlement") || err.contains("positive"), + "{err}" + ); +} + +// --------------------------------------------------------------------------- +// P4 stress — issuance at serial floor + chained distributions +// --------------------------------------------------------------------------- + +#[test] +fn hip23_stress_p4_minsri_serial_and_double_tex_distribution() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-p4-main").unwrap(); + let issuer_acc = Account::create_by("hip23-stress-p4-issuer").unwrap(); + let buyer_a = Account::create_by("hip23-stress-p4-buy-a").unwrap(); + let buyer_b = Account::create_by("hip23-stress-p4-buy-b").unwrap(); + let main = addr_of(&main_acc); + let issuer = addr_of(&issuer_acc); + let buyer_a_addr = addr_of(&buyer_a); + let buyer_b_addr = addr_of(&buyer_b); + const SERIAL: u64 = 1025; + let height = ASSET_ALIVE_HEIGHT + 100; + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(1_000).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("STR").unwrap(), + name: BytesW1::from_str("Stress").unwrap(), + }; + create.protocol_cost = genesis::block_reward(height); + + let tx_create = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut ctx = make_ctx(height, tx_create.as_read()); + seed_hac(&mut ctx, &main, 1_000_000); + tx_create.execute(&mut ctx).unwrap(); + assert_eq!(asset_amt(&mut ctx, &issuer, SERIAL), 1_000); + + let mut persisted = ctx.state().clone_state(); + + for (buyer_acc, buyer_addr, amt) in [ + (&buyer_a, buyer_a_addr, 100u64), + (&buyer_b, buyer_b_addr, 200u64), + ] { + let mut issuer_tex = TexCellAct::create_by(issuer); + issuer_tex + .add_cell(Box::new(CellTrsAssetPay::new( + AssetAmt::from(SERIAL, amt).unwrap(), + ))) + .unwrap(); + issuer_tex.do_sign(&issuer_acc).unwrap(); + + let mut buyer_tex = TexCellAct::create_by(buyer_addr); + buyer_tex + .add_cell(Box::new(CellTrsAssetGet::new( + AssetAmt::from(SERIAL, amt).unwrap(), + ))) + .unwrap(); + buyer_tex.do_sign(buyer_acc).unwrap(); + + let tx_tex = build_signed_type3( + &main_acc, + vec![Box::new(issuer_tex), Box::new(buyer_tex)], + 99, + ); + let mut tex_ctx = make_ctx_persisted(height, persisted.clone_state(), tx_tex.as_read()); + tx_tex.execute(&mut tex_ctx).unwrap(); + persisted = tex_ctx.state().clone_state(); + } + + let final_ctx = make_ctx_persisted(height, persisted, tx_create.as_read()); + let mut check = final_ctx; + assert_eq!(asset_amt(&mut check, &buyer_a_addr, SERIAL), 100); + assert_eq!(asset_amt(&mut check, &buyer_b_addr, SERIAL), 200); + assert_eq!(asset_amt(&mut check, &issuer, SERIAL), 700); +} + +// --------------------------------------------------------------------------- +// P5 / AST stress +// --------------------------------------------------------------------------- + +#[test] +fn hip23_stress_p5_balance_floor_condition_else_branch() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-p5bf-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut floor_guard = BalanceFloor::new(); + floor_guard.addr = AddrOrPtr::from_addr(main); + // Balance 1000 < floor 1001 → condition reverts → else branch. + floor_guard.hacash = Amount::mei(1001); + let cond = AstSelect::create_by(1, 1, vec![Box::new(floor_guard)]); + + let br_if = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(100), + ))]); + let br_else = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(2), + ))]); + let act = AstIf::create_by(cond, br_if, br_else); + + let tx = build_signed_type3(&main_acc, vec![Box::new(act)], 17); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &recipient), 2); +} + +#[test] +fn hip23_stress_p5_ast_low_gas_fails_after_partial_burn() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-gas-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut cond_guard = HeightScope::new(); + cond_guard.start = BlockHeight::from(TEST_HEIGHT); + cond_guard.end = BlockHeight::from(TEST_HEIGHT + 10); + let cond = AstSelect::create_by(1, 1, vec![Box::new(cond_guard)]); + let br_if = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient, + Amount::mei(500), + ))]); + let act = AstIf::create_by(cond, br_if, AstSelect::nop()); + + // gas_max=1 → tiny budget, should fail during AST execution. + let tx = build_signed_type3(&main_acc, vec![Box::new(act)], 1); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert!( + err.contains("gas") || err.contains("insufficient") || err.contains("budget"), + "{err}" + ); +} + +// --------------------------------------------------------------------------- +// P3 multi-debit stress +// --------------------------------------------------------------------------- + +#[test] +fn hip23_stress_p3_multi_debit_single_floor() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-mdeb-main").unwrap(); + let main = addr_of(&main_acc); + let r1 = field::ADDRESS_TWOX.clone(); + let r2_acc = Account::create_by("hip23-stress-mdeb-r2").unwrap(); + let r2 = addr_of(&r2_acc); + + let t1 = HacToTrs::create_by(r1.clone(), Amount::mei(30)); + let t2 = HacToTrs::create_by(r2, Amount::mei(20)); + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.hacash = Amount::mei(940); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(t1), Box::new(t2), Box::new(floor)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &main), 950 - TX_FEE_MEI); + assert_eq!(hac_mei(&mut ctx, &r1), 30); + assert_eq!(hac_mei(&mut ctx, &r2), 20); +} + +// --------------------------------------------------------------------------- +// Topology / precheck stress +// --------------------------------------------------------------------------- + +#[test] +fn hip23_stress_duplicate_tex_same_addr_two_bundles_unsigned_second() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-dupaddr-main").unwrap(); + let pay_acc = Account::create_by("hip23-stress-dupaddr-pay").unwrap(); + let get_acc = Account::create_by("hip23-stress-dupaddr-get").unwrap(); + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 10_000_000, 0, 0); + + let mut pay_tex2 = TexCellAct::create_by(addr_of(&pay_acc)); + pay_tex2 + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(10_000_000).unwrap()))) + .unwrap(); + // Deliberately not signed — stress malformed multi-bundle tx. + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex), Box::new(pay_tex2)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &addr_of(&pay_acc), 10); + let err = tx.execute(&mut ctx).unwrap_err(); + assert!( + err.contains("signature") || err.contains("settlement"), + "{err}" + ); +} + +#[test] +fn hip23_stress_asset_create_at_dev_minsri_serial_5() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-devserial-main").unwrap(); + let issuer = addr_of(&Account::create_by("hip23-stress-devserial-iss").unwrap()); + // Below mainnet minsri but valid on dev height 0 path — use height ASSET_ALIVE_HEIGHT on mainnet chain 0. + const SERIAL: u64 = 1025; + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(100).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("MN").unwrap(), + name: BytesW1::from_str("Min").unwrap(), + }; + create.protocol_cost = genesis::block_reward(ASSET_ALIVE_HEIGHT); + + let tx = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut ctx = make_ctx(ASSET_ALIVE_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + tx.execute(&mut ctx).unwrap(); + assert_eq!(asset_amt(&mut ctx, &issuer, SERIAL), 100); +} + +#[test] +fn hip23_stress_asset_serial_below_minsri_faults_on_mainnet() { + init_setup(); + let main_acc = Account::create_by("hip23-stress-badserial-main").unwrap(); + let issuer = addr_of(&Account::create_by("hip23-stress-badserial-iss").unwrap()); + const SERIAL: u64 = 1024; + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(100).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("BAD").unwrap(), + name: BytesW1::from_str("Bad").unwrap(), + }; + create.protocol_cost = genesis::block_reward(ASSET_ALIVE_HEIGHT); + + let tx = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut ctx = make_ctx(ASSET_ALIVE_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "serial cannot be less than"); +} \ No newline at end of file From 22d1df42fad04d89f6c66d608aefa0cdac65b342 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 16:41:00 +0200 Subject: [PATCH 06/11] HIP-23: production-path smoke + proptest properties --- Cargo.toml | 1 + doc/HIP23.md | 32 +++- tests/common/hip23.rs | 30 ++- tests/hip23_production_path.rs | 223 ++++++++++++++++++++++ tests/hip23_proptest.proptest-regressions | 9 + tests/hip23_proptest.rs | 139 ++++++++++++++ 6 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 tests/hip23_production_path.rs create mode 100644 tests/hip23_proptest.proptest-regressions create mode 100644 tests/hip23_proptest.rs diff --git a/Cargo.toml b/Cargo.toml index 14d413e..1b0656a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ serde_json = "1.0" [dev-dependencies] testkit = {path = "./testkit"} +proptest = "1.4" [features] default = ["db-sled"] #, "ocl"] diff --git a/doc/HIP23.md b/doc/HIP23.md index ce24c3f..d422c56 100644 --- a/doc/HIP23.md +++ b/doc/HIP23.md @@ -294,13 +294,37 @@ actions: [ | P3/P4/P5 | multi-debit floor, minsri serial + chained TEX, BalanceFloor AST cond, low gas | | HIP20 | serial 1025 @ alive height, serial below minsri fault | +### Production path (`hip23_production_path.rs`) + +Smoke tests with `fast_sync = false` (`make_ctx_strict` in `tests/common/hip23.rs`): + +| Area | Tests | +|------|-------| +| P1–P5 happy path | `hip23_production_p1_tex_swap_succeeds` … `hip23_production_p5_ast_conditional_settlement` | +| Mempool policy | `hip23_production_duplicate_tx_rejected`, `hip23_production_tampered_main_signature_rejected` | + +### Property-based (`hip23_proptest.rs`) + +| Property | Cases | Harness | +|----------|-------|---------| +| Balanced HAC TEX settles | 64 | `fast_sync = true` | +| HeightScope window (inside ok, outside fail) | 64 | `fast_sync = true` | +| Guard-only always rejected at precheck | 64 | n/a | +| Balanced TEX under strict path | 64 | `fast_sync = false` | + Run all: ```bash cargo test hip23_ -- --nocapture ``` -**Note:** HIP-23 tests use `fast_sync = true` in the harness (`tests/common/hip23.rs`) to skip -signature verification, duplicate-tx checks, and fee-address validation. Pattern semantics -(guards, TEX settlement, topology) still match mainnet rules; production integrators MUST -validate signatures and mempool policy separately. \ No newline at end of file +Run production + proptest only: + +```bash +cargo test hip23_pro -- --nocapture +``` + +**Note:** Regression, adversarial, and stress suites use `fast_sync = true` to focus on pattern +semantics (guards, TEX settlement, topology). Production-path and strict proptest suites use +`fast_sync = false` to exercise signature verification and duplicate-tx rejection. Integrators +should run both before shipping wallet or indexer integrations. \ No newline at end of file diff --git a/tests/common/hip23.rs b/tests/common/hip23.rs index 71f834b..81e3c25 100644 --- a/tests/common/hip23.rs +++ b/tests/common/hip23.rs @@ -2,14 +2,14 @@ #![allow(dead_code)] use basis::component::Env; -use basis::interface::{Action, Context, StateOperat, Transaction, TransactionRead}; +use basis::interface::{Action, Context, Transaction, TransactionRead}; use field::*; use protocol::state::CoreState; use protocol::tex::*; use protocol::transaction::*; use sys::Account; use testkit::sim::context::make_ctx_with_state; -use testkit::sim::integration::enable_mint_setup; +use testkit::sim::integration::ensure_standard_protocol_setup_for_tests; use testkit::sim::state::ForkableMemState; pub const TEST_HEIGHT: u64 = protocol::upgrade::ONLINE_OPEN_HEIGHT + 10_000; @@ -17,7 +17,7 @@ pub const TEST_HEIGHT: u64 = protocol::upgrade::ONLINE_OPEN_HEIGHT + 10_000; pub const TX_FEE_MEI: u64 = 1; pub fn init_setup() { - enable_mint_setup(); + ensure_standard_protocol_setup_for_tests(x16rs::block_hash, false); } pub fn addr_of(acc: &Account) -> Address { @@ -44,6 +44,30 @@ pub fn make_ctx_persisted<'a>( make_ctx_with_opts(height, 0, true, tx, state) } +/// Production-like path: signature verification, duplicate-tx check, fee-address rules. +pub fn make_ctx_strict<'a>( + height: u64, + tx: &'a dyn TransactionRead, +) -> protocol::context::ContextInst<'a> { + make_ctx_strict_chain(height, 0, tx) +} + +pub fn make_ctx_strict_chain<'a>( + height: u64, + chain_id: u32, + tx: &'a dyn TransactionRead, +) -> protocol::context::ContextInst<'a> { + make_ctx_with_opts(height, chain_id, false, tx, Box::new(ForkableMemState::default())) +} + +pub fn make_ctx_strict_persisted<'a>( + height: u64, + state: Box, + tx: &'a dyn TransactionRead, +) -> protocol::context::ContextInst<'a> { + make_ctx_with_opts(height, 0, false, tx, state) +} + fn make_ctx_with_opts<'a>( height: u64, chain_id: u32, diff --git a/tests/hip23_production_path.rs b/tests/hip23_production_path.rs new file mode 100644 index 0000000..8f409e4 --- /dev/null +++ b/tests/hip23_production_path.rs @@ -0,0 +1,223 @@ +//! HIP-23 production-path smoke tests (`fast_sync = false`). +//! Validates signature verification, duplicate-tx rejection, and fee-address rules +//! in addition to pattern semantics covered by the fast_sync harness. +//! +//! Run: cargo test hip23_production_ -- --nocapture + +mod common; + +use basis::interface::{StateOperat, Transaction, TxExec}; +use common::hip23::*; +use field::*; +use mint::action::AssetCreate; +use mint::genesis; +use protocol::action::*; +use protocol::tex::*; +use sys::Account; + +#[test] +fn hip23_production_p1_tex_swap_succeeds() { + init_setup(); + let main_acc = Account::create_by("hip23-prod-p1-main").unwrap(); + let pay_acc = Account::create_by("hip23-prod-p1-pay").unwrap(); + let get_acc = Account::create_by("hip23-prod-p1-get").unwrap(); + let main = addr_of(&main_acc); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + const SERIAL: u64 = 2501; + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, SERIAL, 10); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 99, + ); + + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000_000); + seed_hac(&mut ctx, &pay, 100); + seed_asset(&mut ctx, &pay, SERIAL, 10); + + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &get), 1); + assert_eq!(asset_amt(&mut ctx, &get, SERIAL), 10); +} + +#[test] +fn hip23_production_p2_height_guarded_payment() { + init_setup(); + let main_acc = Account::create_by("hip23-prod-p2-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT); + guard.end = BlockHeight::from(TEST_HEIGHT + 500); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(8); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + + let mut ctx = make_ctx_strict(TEST_HEIGHT + 10, tx.as_read()); + seed_hac(&mut ctx, &main, 500); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &recipient), 8); +} + +#[test] +fn hip23_production_p3_balance_floor_transfer() { + init_setup(); + let main_acc = Account::create_by("hip23-prod-p3-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(50); + + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.hacash = Amount::mei(900); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(transfer), Box::new(floor)], + 0, + ); + + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &main), 950 - TX_FEE_MEI); +} + +#[test] +fn hip23_production_p4_asset_create_and_tex() { + init_setup(); + let main_acc = Account::create_by("hip23-prod-p4-main").unwrap(); + let issuer_acc = Account::create_by("hip23-prod-p4-issuer").unwrap(); + let buyer_acc = Account::create_by("hip23-prod-p4-buyer").unwrap(); + let main = addr_of(&main_acc); + let issuer = addr_of(&issuer_acc); + let buyer = addr_of(&buyer_acc); + const SERIAL: u64 = 2504; + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(500).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("PRD").unwrap(), + name: BytesW1::from_str("Prod").unwrap(), + }; + create.protocol_cost = genesis::block_reward(TEST_HEIGHT); + + let tx_create = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx_create.as_read()); + seed_hac(&mut ctx, &main, 1_000_000); + tx_create.execute(&mut ctx).unwrap(); + + let mut issuer_tex = TexCellAct::create_by(issuer); + issuer_tex + .add_cell(Box::new(CellTrsAssetPay::new( + AssetAmt::from(SERIAL, 100).unwrap(), + ))) + .unwrap(); + issuer_tex.do_sign(&issuer_acc).unwrap(); + + let mut buyer_tex = TexCellAct::create_by(buyer); + buyer_tex + .add_cell(Box::new(CellTrsAssetGet::new( + AssetAmt::from(SERIAL, 100).unwrap(), + ))) + .unwrap(); + buyer_tex.do_sign(&buyer_acc).unwrap(); + + let tx_tex = build_signed_type3( + &main_acc, + vec![Box::new(issuer_tex), Box::new(buyer_tex)], + 99, + ); + let mut tex_ctx = make_ctx_strict_persisted( + TEST_HEIGHT, + ctx.state().clone_state(), + tx_tex.as_read(), + ); + tx_tex.execute(&mut tex_ctx).unwrap(); + assert_eq!(asset_amt(&mut tex_ctx, &buyer, SERIAL), 100); +} + +#[test] +fn hip23_production_p5_ast_conditional_settlement() { + init_setup(); + let main_acc = Account::create_by("hip23-prod-p5-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut cond_guard = HeightScope::new(); + cond_guard.start = BlockHeight::from(TEST_HEIGHT); + cond_guard.end = BlockHeight::from(TEST_HEIGHT + 200); + let cond = AstSelect::create_by(1, 1, vec![Box::new(cond_guard)]); + let br_if = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(4), + ))]); + let act = AstIf::create_by(cond, br_if, AstSelect::nop()); + + let tx = build_signed_type3(&main_acc, vec![Box::new(act)], 17); + let mut ctx = make_ctx_strict(TEST_HEIGHT + 5, tx.as_read()); + seed_hac(&mut ctx, &main, 200); + tx.execute(&mut ctx).unwrap(); + assert_eq!(hac_mei(&mut ctx, &recipient), 4); +} + +#[test] +fn hip23_production_duplicate_tx_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-prod-dup-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3(&main_acc, vec![Box::new(transfer)], 0); + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 100); + tx.execute(&mut ctx).unwrap(); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "already exists"); +} + +#[test] +fn hip23_production_tampered_main_signature_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-prod-sig-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient); + transfer.hacash = Amount::mei(2); + + let mut tx = build_signed_type3(&main_acc, vec![Box::new(transfer)], 0); + let mut sig_bytes = *tx.signs[0].signature.as_array(); + sig_bytes[0] ^= 0x01; + tx.signs[0].signature = Fixed64::from(sig_bytes); + + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 100); + let err = tx.execute(&mut ctx).unwrap_err(); + assert!( + err.contains("signature") || err.contains("verify"), + "{err}" + ); +} \ No newline at end of file diff --git a/tests/hip23_proptest.proptest-regressions b/tests/hip23_proptest.proptest-regressions new file mode 100644 index 0000000..519555e --- /dev/null +++ b/tests/hip23_proptest.proptest-regressions @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 1b41b8840143cf4021416b8d37478356c9ff497a130fafc07b08ab8df91f20b5 # shrinks to zhu_mei = 2 +cc 77554ee067a7db327192f363ac1f6e72f4a441f5ecfdca7162f8d1b1da2554bd # shrinks to zhu_mei = 10 +cc 1323729fa3849b082c00e830361676770f545f07851ae8d2d6dbf7289296f13e # shrinks to offset = 204, span = 143, outside = 306 diff --git a/tests/hip23_proptest.rs b/tests/hip23_proptest.rs new file mode 100644 index 0000000..91791fd --- /dev/null +++ b/tests/hip23_proptest.rs @@ -0,0 +1,139 @@ +//! Property-based HIP-23 tests (proptest). +//! Run: cargo test hip23_proptest_ -- --nocapture + +mod common; + +use basis::interface::{Action, Transaction, TxExec}; +use common::hip23::*; +use field::*; +use protocol::action::*; +use proptest::prelude::*; +use sys::Account; + +const PROP_BASE: u64 = protocol::upgrade::ONLINE_OPEN_HEIGHT + 10_000; + +proptest! { + #![proptest_config(ProptestConfig { + cases: 64, + ..ProptestConfig::default() + })] + + /// Balanced HAC TEX swaps settle for random positive zhu amounts. + #[test] + fn hip23_proptest_balanced_hac_tex_settles( + zhu_mei in 1u64..50u64, + ) { + init_setup(); + let zhu = zhu_mei * 100_000_000; + let main_acc = Account::create_by(&format!("hip23-prop-main-{zhu_mei}")).unwrap(); + let pay_acc = Account::create_by(&format!("hip23-prop-pay-{zhu_mei}")).unwrap(); + let get_acc = Account::create_by(&format!("hip23-prop-get-{zhu_mei}")).unwrap(); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, zhu, 0, 0); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + + let mut ctx = make_ctx(PROP_BASE, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, zhu_mei + 10); + + tx.execute(&mut ctx).unwrap(); + prop_assert_eq!(hac_mei(&mut ctx, &get), zhu_mei); + } + + /// Height inside [start, end] succeeds; outside reverts. + #[test] + fn hip23_proptest_height_scope_window( + offset in 0u64..500u64, + span in 1u64..500u64, + outside in 1u64..500u64, + ) { + init_setup(); + let start = PROP_BASE; + let end = start + span; + let inside = start + offset.min(span); + let below = start.saturating_sub(outside); + let above = end + outside; + + let main_acc = Account::create_by(&format!("hip23-prop-h-{offset}-{span}")).unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(start); + guard.end = BlockHeight::from(end); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + + let mut ok_ctx = make_ctx(inside, tx.as_read()); + seed_hac(&mut ok_ctx, &main, 50); + tx.execute(&mut ok_ctx).unwrap(); + prop_assert_eq!(hac_mei(&mut ok_ctx, &recipient), 1); + + for bad_height in [below, above] { + let mut bad_ctx = make_ctx(bad_height, tx.as_read()); + seed_hac(&mut bad_ctx, &main, 50); + let err = tx.execute(&mut bad_ctx).unwrap_err(); + prop_assert!(err.contains("submitted in height between"), "{err}"); + } + } + + /// Guard-only topologies are always rejected at precheck. + #[test] + fn hip23_proptest_guard_only_always_rejected( + start in 0u64..1_000_000u64, + end in 0u64..1_000_000u64, + ) { + init_setup(); + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(start); + guard.end = BlockHeight::from(end); + let actions: Vec> = vec![Box::new(guard)]; + let err = protocol::action::precheck_tx_actions( + protocol::transaction::TransactionType3::TYPE, + &actions, + ) + .unwrap_err(); + prop_assert!(err.contains("all GUARD"), "{err}"); + } + + /// Production path: balanced TEX also settles under fast_sync=false. + #[test] + fn hip23_proptest_strict_balanced_tex_settles( + zhu_mei in 1u64..20u64, + ) { + init_setup(); + let zhu = zhu_mei * 100_000_000; + let main_acc = Account::create_by(&format!("hip23-prop-strict-main-{zhu_mei}")).unwrap(); + let pay_acc = Account::create_by(&format!("hip23-prop-strict-pay-{zhu_mei}")).unwrap(); + let get_acc = Account::create_by(&format!("hip23-prop-strict-get-{zhu_mei}")).unwrap(); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, zhu, 0, 0); + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + + let mut ctx = make_ctx_strict(PROP_BASE, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, zhu_mei + 5); + + tx.execute(&mut ctx).unwrap(); + prop_assert_eq!(hac_mei(&mut ctx, &get), zhu_mei); + } +} \ No newline at end of file From 3416970c17cdf33bd83ff7b0336f57efc68415d9 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 16:47:14 +0200 Subject: [PATCH 07/11] HIP-23: professional audit package (threat model, invariants, tests, CI) --- .github/workflows/hip23.yml | 26 ++++ SECURITY.md | 57 ++++++++ doc/HIP23.md | 18 ++- doc/HIP23_audit_findings.md | 190 +++++++++++++++++++++++++ doc/HIP23_audit_scope.md | 106 ++++++++++++++ doc/HIP23_indexer_dictionary.md | 119 ++++++++++++++++ doc/HIP23_invariants.md | 104 ++++++++++++++ doc/HIP23_requirements_traceability.md | 131 +++++++++++++++++ doc/HIP23_threat_model.md | 143 +++++++++++++++++++ doc/HIP23_wallet_checklist.md | 96 +++++++++++++ tests/common/hip23.rs | 41 +++++- tests/hip23_audit_strict.rs | 156 ++++++++++++++++++++ tests/hip23_chain_integration.rs | 188 ++++++++++++++++++++++++ tests/hip23_proptest.rs | 42 ++++++ tests/hip23_tex_replay.rs | 107 ++++++++++++++ 15 files changed, 1521 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/hip23.yml create mode 100644 SECURITY.md create mode 100644 doc/HIP23_audit_findings.md create mode 100644 doc/HIP23_audit_scope.md create mode 100644 doc/HIP23_indexer_dictionary.md create mode 100644 doc/HIP23_invariants.md create mode 100644 doc/HIP23_requirements_traceability.md create mode 100644 doc/HIP23_threat_model.md create mode 100644 doc/HIP23_wallet_checklist.md create mode 100644 tests/hip23_audit_strict.rs create mode 100644 tests/hip23_chain_integration.rs create mode 100644 tests/hip23_tex_replay.rs diff --git a/.github/workflows/hip23.yml b/.github/workflows/hip23.yml new file mode 100644 index 0000000..c38c5e2 --- /dev/null +++ b/.github/workflows/hip23.yml @@ -0,0 +1,26 @@ +name: HIP-23 Tests + +on: + push: + paths: + - 'doc/HIP23*.md' + - 'tests/hip23_*.rs' + - 'tests/common/hip23.rs' + - 'protocol/**' + - 'Cargo.toml' + pull_request: + paths: + - 'doc/HIP23*.md' + - 'tests/hip23_*.rs' + - 'tests/common/hip23.rs' + - 'protocol/**' + - 'Cargo.toml' + +jobs: + hip23: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Run HIP-23 test suite + run: cargo test hip23_ -- --nocapture \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6cef479 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy — Hacash fullnodedev + +## Supported branches + +- `main` / release tags: production full node +- Feature branches (e.g. `hip-23-draft`): draft standards — run tests before integration + +## HIP-23 integrator release gate + +Before shipping wallet, gateway, or indexer support for HIP-23 patterns: + +```bash +cargo test hip23_ -- --nocapture +``` + +This runs regression, adversarial, stress, production-path, audit-strict, chain, replay, and proptest suites. + +Documentation: + +- `doc/HIP23.md` — normative spec +- `doc/HIP23_wallet_checklist.md` — pre-sign validation +- `doc/HIP23_threat_model.md` — threat analysis +- `doc/HIP23_audit_findings.md` — known issues + +## Reporting vulnerabilities + +Report security issues privately to the repository maintainers. Do not open public issues for exploitable consensus or node vulnerabilities until coordinated disclosure. + +Include: + +- Affected component (node, protocol, HIP draft) +- Reproduction steps or proof-of-concept +- Impact assessment +- Suggested fix (optional) + +## Test modes + +| Mode | `fast_sync` | Use | +|------|-------------|-----| +| Pattern semantics | `true` | Guard/TEX/AST logic | +| Production-like | `false` | Signatures, duplicate-tx, fee rules | + +Integrators MUST validate against production-like mode before mainnet use. + +## Dependency audit + +Periodically run: + +```bash +cargo audit +``` + +(Requires `cargo-audit` crate installed.) + +## Scope note + +HIP-23 is an **application integration standard** — it does not alter consensus. Security focus is on correct composition, co-signing, and indexer classification to prevent integrator-level fund loss. \ No newline at end of file diff --git a/doc/HIP23.md b/doc/HIP23.md index d422c56..5877dd0 100644 --- a/doc/HIP23.md +++ b/doc/HIP23.md @@ -22,6 +22,7 @@ Reference material: - AST control flow: `doc/ast-spec.md` - Guards: `protocol/src/action/chain.rs` - JSON templates: `doc/HIP23_templates.md` +- Audit package: `doc/HIP23_threat_model.md`, `doc/HIP23_invariants.md`, `doc/HIP23_requirements_traceability.md`, `doc/HIP23_wallet_checklist.md`, `doc/HIP23_indexer_dictionary.md`, `doc/HIP23_audit_scope.md`, `doc/HIP23_audit_findings.md` ## 2. Scope @@ -258,8 +259,8 @@ actions: [ | Version | Contents | |---------|----------| -| v1.0 (this draft) | Patterns P1–P5, JSON templates, regression + adversarial tests | -| v1.1 (planned) | Wallet checklist, indexer field dictionary | +| v1.0 | Patterns P1–P5, JSON templates, regression + adversarial tests | +| v1.1 (this draft) | Wallet checklist, indexer dictionary, threat model, invariants, traceability, audit package | | v2.0 (future) | Optional HVM companion contracts per pattern | ## 11. Test matrix @@ -310,8 +311,21 @@ Smoke tests with `fast_sync = false` (`make_ctx_strict` in `tests/common/hip23.r | Balanced HAC TEX settles | 64 | `fast_sync = true` | | HeightScope window (inside ok, outside fail) | 64 | `fast_sync = true` | | Guard-only always rejected at precheck | 64 | n/a | +| Imbalanced TEX always fails | 64 | `fast_sync = true` | | Balanced TEX under strict path | 64 | `fast_sync = false` | +### Audit strict (`hip23_audit_strict.rs`) + +Adversarial cases under `fast_sync = false`: imbalanced TEX, tampered TEX sig, height guard, wrong `protocol_cost`, main sig tamper, guard-only precheck. + +### Chain integration (`hip23_chain_integration.rs`) + +Per-tx fork/merge semantics (`chain/src/check.rs`): failed tx rollback, P4 Tx A→B sequencing, fail-then-success isolation. + +### TEX replay (`hip23_tex_replay.rs`) + +TEX signature scope: replay across different `main`, tamper after sign, extra unbalanced party. + Run all: ```bash diff --git a/doc/HIP23_audit_findings.md b/doc/HIP23_audit_findings.md new file mode 100644 index 0000000..80fdbec --- /dev/null +++ b/doc/HIP23_audit_findings.md @@ -0,0 +1,190 @@ +# HIP-23 Audit Findings Register + +Version: 1.0 +Date: 2026-06-22 +Method: internal audit per `HIP23_audit_scope.md` + +--- + +## Summary + +| Severity | Open | Fixed | Accepted | +|----------|------|-------|----------| +| High | 0 | 2 | 1 | +| Medium | 0 | 3 | 2 | +| Low | 1 | 2 | 0 | +| Informational | 2 | 0 | 3 | + +No critical consensus issues (expected — HIP-23 is application-layer). + +--- + +## Findings + +### F-001 — TEX signatures replayable across txs + +| Field | Value | +|-------|-------| +| Severity | **Accepted / High (integrator)** | +| Pattern | P1, P4 | +| Status | Documented + tested | + +**Description:** `TexCellAct` signs `addr + cells` only. The same valid bundle may appear in multiple composed Type3 txs if counterparties co-sign without pinning the full tx. + +**Proof:** `hip23_tex_replay_same_bundle_different_main_succeeds` + +**Remediation:** Wallet checklist §co-signing; `HIP23_threat_model.md` T-P1-2. Not a protocol bug. + +--- + +### F-002 — P3 floor-before-debit bypasses protection + +| Field | Value | +|-------|-------| +| Severity | **High** | +| Pattern | P3 | +| Status | **Fixed** (documented + tested) | + +**Description:** `BalanceFloor` reads in-tx state at guard index. Floor listed before debit checks pre-debit balance. + +**Proof:** `hip23_p3_floor_before_transfer_checks_pre_debit_state` + +**Remediation:** `HIP23_wallet_checklist.md` P3 ordering MUST; `HIP23.md` §6.4. + +--- + +### F-003 — P2 debit-before-guard confuses simulators + +| Field | Value | +|-------|-------| +| Severity | **Medium** | +| Pattern | P2 | +| Status | **Fixed** (documented + tested) | + +**Description:** Wrong ordering still fails whole tx on-chain, but step simulators may show debit before revert. + +**Proof:** `hip23_p2_transfer_before_guard_still_reverts_outside_window`, `hip23_chain_failed_tx_does_not_commit` + +**Remediation:** `HIP23.md` §5.4; wallet atomic simulation requirement. + +--- + +### F-004 — P4 AssetCreate + TEX same tx rejected + +| Field | Value | +|-------|-------| +| Severity | **Medium** | +| Pattern | P4 | +| Status | **Fixed** (by design) | + +**Description:** `AssetCreate` is `TOP_ONLY`; combined tx fails topology check. + +**Proof:** `hip23_p4_asset_create_with_tex_same_tx_rejected` + +**Remediation:** Two-tx flow documented in §7. + +--- + +### F-005 — P5 condition fault vs revert + +| Field | Value | +|-------|-------| +| Severity | **Medium** | +| Pattern | P5 | +| Status | **Fixed** (documented) | + +**Description:** Invalid height range faults entire `AstIf`; outside window reverts to else. Wallets conflating these mis-label tx outcome. + +**Proof:** `hip23_p5_ast_if_condition_fault_aborts_whole_node`, `hip23_p5_ast_else_branch_executes_transfer` + +**Remediation:** `HIP23_indexer_dictionary.md` `cond_outcome` + `ast_branch`. + +--- + +### F-006 — fast_sync test harness skips production checks + +| Field | Value | +|-------|-------| +| Severity | **Medium (process)** | +| Pattern | All | +| Status | **Mitigated** | + +**Description:** ~85% of HIP-23 tests use `fast_sync=true`, skipping signature and duplicate-tx validation. + +**Proof:** `tests/common/hip23.rs`; production suites added. + +**Remediation:** `hip23_production_path.rs`, `hip23_audit_strict.rs`, `SECURITY.md` release gate. + +--- + +### F-007 — No stable guard reason-code enum + +| Field | Value | +|-------|-------| +| Severity | **Low** | +| Pattern | P2, P3, P5 | +| Status | Open (v1 limitation) | + +**Description:** Indexers must parse error strings. + +**Remediation:** `HIP23_indexer_dictionary.md` §3; future protocol enum out of HIP-23 v1 scope. + +--- + +### F-008 — Proptest setup guard drop (fixed) + +| Field | Value | +|-------|-------| +| Severity | **Low** | +| Pattern | Test infra | +| Status | **Fixed** | + +**Description:** Repeated `enable_mint_setup()` in proptest cases cleared scoped registry. + +**Proof:** `ensure_standard_protocol_setup_for_tests` in `init_setup()`. + +--- + +### F-009 — Main signature tamper rejected in production path + +| Field | Value | +|-------|-------| +| Severity | Informational | +| Pattern | All | +| Status | Verified | + +**Proof:** `hip23_production_tampered_main_signature_rejected`, `hip23_audit_strict_main_sig_tamper_rejected` + +--- + +### F-010 — Imbalanced TEX always fails settlement + +| Field | Value | +|-------|-------| +| Severity | Informational | +| Pattern | P1 | +| Status | Verified | + +**Proof:** `hip23_p1_tex_imbalanced_*`, proptest `hip23_proptest_imbalanced_tex_always_fails` + +--- + +## Recommendations (informational) + +| ID | Recommendation | Priority | +|----|----------------|----------| +| R-01 | External third-party audit before mainnet wallet launch | High | +| R-02 | Add `cargo-fuzz` on TEX JSON parse | Medium | +| R-03 | CI gate on `cargo test hip23_` | Medium (added `.github/workflows/hip23.yml`) | +| R-04 | Expand proptest to P4/P5 properties | Low | +| R-05 | Cross-implementation test vectors file | Low | + +--- + +## Sign-off criteria (internal) + +- [x] All High findings documented with wallet mitigations +- [x] Production-path tests pass +- [x] Chain fork semantics tested +- [x] Traceability matrix ≥ 90% MUST coverage +- [ ] External auditor review (pending) \ No newline at end of file diff --git a/doc/HIP23_audit_scope.md b/doc/HIP23_audit_scope.md new file mode 100644 index 0000000..94d1a09 --- /dev/null +++ b/doc/HIP23_audit_scope.md @@ -0,0 +1,106 @@ +# HIP-23 Audit Scope Document + +Version: 1.0 +Date: 2026-06-22 +Status: Internal / pre-external audit +Branch: `hip-23-draft` + +--- + +## 1. Engagement type + +Application-layer integration standard audit — **no consensus fork**. Validates that documented patterns P1–P5 match `fullnodedev` behavior and that wallet/indexer guidance is testable. + +--- + +## 2. In scope + +| Component | Path | Notes | +|-----------|------|-------| +| HIP-23 specification | `doc/HIP23.md` | Normative MUST/SHOULD | +| JSON templates | `doc/HIP23_templates.md` | Structural validity | +| TEX settlement | `protocol/src/tex/*` | Zero-sum, signatures | +| Guards | `protocol/src/action/chain.rs` | HeightScope, BalanceFloor, ChainAllow | +| AST conditionals | `protocol/src/action/ast*` | P5 branch selection | +| HIP20 AssetCreate | `mint/src/action/asset.rs` | P4 issuance rules | +| Type3 execute path | `protocol/src/transaction/type3.rs` | Settlement + gas | +| Chain tx isolation | `chain/src/check.rs` | Fork/merge semantics | +| HIP-23 test suite | `tests/hip23_*.rs` | All layers | +| Audit artifacts | `doc/HIP23_*.md`, `SECURITY.md` | This package | + +--- + +## 3. Out of scope + +- Consensus / fork activation mechanics +- HVM contract templates (HIP-23 v2) +- Cross-chain bridges, oracles, MEV +- Mempool policy beyond duplicate-tx + signature (node-specific) +- UI/UX of third-party wallets +- Economic modeling of `protocol_cost` / fees +- HIP-25 (separate track) + +--- + +## 4. Assumptions + +1. Istanbul active at `ONLINE_OPEN_HEIGHT = 765432`. +2. Production nodes use `fast_sync = false`. +3. Integrators read `HIP23_wallet_checklist.md` before co-signing. +4. Test height baseline: `TEST_HEIGHT = 775432` in harness. + +--- + +## 5. Methodology (aligned with industry practice) + +| Phase | Activity | Artifact | +|-------|----------|----------| +| 1. Spec review | MUST/SHOULD extraction | `HIP23_requirements_traceability.md` | +| 2. Threat modeling | STRIDE per pattern | `HIP23_threat_model.md` | +| 3. Invariant catalog | Formal properties | `HIP23_invariants.md` | +| 4. Test review | Map tests → requirements | §11 `HIP23.md` + traceability | +| 5. Adversarial testing | Negative paths | `hip23_pattern_adversarial.rs` | +| 6. Production path | Strict-mode smoke | `hip23_production_path.rs`, `hip23_audit_strict.rs` | +| 7. Property testing | Generative cases | `hip23_proptest.rs` | +| 8. Chain semantics | Fork/merge atomicity | `hip23_chain_integration.rs` | +| 9. Replay analysis | TEX signature scope | `hip23_tex_replay.rs` | +| 10. Findings log | Severity + remediation | `HIP23_audit_findings.md` | + +--- + +## 6. Severity taxonomy + +| Level | Definition | +|-------|------------| +| Critical | Fund loss or consensus divergence (N/A for HIP-23 — app layer) | +| High | Integrator fund loss with normal wallet | +| Medium | Failed tx / UX / accounting error | +| Low | Documentation or non-exploitable inconsistency | +| Informational | Hardening recommendation | + +--- + +## 7. Deliverables checklist + +- [x] Threat model +- [x] Invariant catalog +- [x] Requirements traceability matrix +- [x] Wallet checklist (v1.1) +- [x] Indexer dictionary (v1.1) +- [x] Findings register +- [x] Chain + replay integration tests +- [x] Strict-path adversarial mirror +- [x] `SECURITY.md` +- [ ] External third-party audit (future) +- [ ] `cargo-fuzz` targets (optional v1.2) + +--- + +## 8. Test commands for auditors + +```bash +cargo test hip23_ -- --nocapture +cargo test hip23_audit_ -- --nocapture +cargo test hip23_chain_ -- --nocapture +cargo test hip23_tex_replay_ -- --nocapture +``` \ No newline at end of file diff --git a/doc/HIP23_indexer_dictionary.md b/doc/HIP23_indexer_dictionary.md new file mode 100644 index 0000000..e3c6307 --- /dev/null +++ b/doc/HIP23_indexer_dictionary.md @@ -0,0 +1,119 @@ +# HIP-23 Indexer Field Dictionary + +Version: 1.0 (HIP-23 v1.1) +Date: 2026-06-22 +Audience: explorer, indexer, analytics engineers + +--- + +## 1. Transaction-level fields + +| Field | Type | When set | Description | +|-------|------|----------|-------------| +| `hip23_pattern` | enum | classifier | `P1`…`P5` or `unknown` | +| `istanbul_gated` | bool | always | `height >= 765432` on mainnet | +| `gas_max` | u8 | Type3 | Gas budget | +| `gas_used` | u8 | Type3 success | Consumed gas | +| `fast_sync_bypass` | bool | dev only | True if node ran without sig checks | + +--- + +## 2. Guard outcome fields + +| Field | Type | Values | Notes | +|-------|------|--------|-------| +| `guard_kind` | string | `HeightScope`, `BalanceFloor`, `ChainAllow` | From action type | +| `guard_outcome` | enum | `pass`, `revert`, `fault` | Per guard execution | +| `guard_error_norm` | string | see §3 | Normalized substring match | + +**Revert vs fault (MUST classify):** + +- **Revert:** expected predicate false (e.g. outside height window) — P2 fails whole tx; P5 may take else. +- **Fault:** invalid parameters or protocol violation (e.g. `start > end`). + +--- + +## 3. Normalized error strings (v1) + +Indexers SHOULD map raw errors to these buckets: + +| Bucket | Substring(s) | `guard_outcome` | +|--------|--------------|-----------------| +| `height_outside_window` | `submitted in height between` | revert | +| `height_invalid_range` | `start height cannot be greater` | fault | +| `balance_below_floor` | `lower than floor` | revert | +| `chain_not_allowed` | `chain id check failed` | revert | +| `guard_only_topology` | `all GUARD` | fault (precheck) | +| `tex_settlement_imbalance` | `settlement check failed` | fault | +| `tex_sig_fail` | `signature verification failed` | fault | +| `gas_not_initialized` | `gas not initialized` | fault | +| `duplicate_tx` | `already exists` | fault | +| `protocol_cost_mismatch` | `protocol cost` | fault | + +There is **no** stable on-chain enum in v1 — string matching is required. + +--- + +## 4. TEX fields (P1, P4 Tx B) + +| Field | Type | Description | +|-------|------|-------------| +| `tex_signer` | address | `TexCellAct.addr` | +| `tex_cell_ids` | uint[] | Cell types in bundle | +| `tex_zhu_delta` | map addr→i64 | Net zhu change at settlement | +| `tex_sat_delta` | map addr→i64 | Net sat change | +| `tex_dia_delta` | map addr→i32 | Net diamond count change | +| `tex_asset_delta` | map (addr,serial)→i64 | Per-asset net change | +| `tex_settlement_ok` | bool | `do_settlement()` succeeded | + +--- + +## 5. P4 two-tx correlation + +| Field | Type | Description | +|-------|------|-------------| +| `hip20_serial` | u64 | Asset serial | +| `hip20_issuer` | address | From `AssetCreate.metadata.issuer` | +| `hip20_tx_role` | enum | `issuance` (Tx A) or `distribution` (Tx B) | +| `hip20_correlation_id` | string | `{serial}:{issuer}` or custom | +| `hip20_same_block` | bool | Tx A and Tx B in same block | + +Indexers MUST NOT treat P4 as single atomic tx — store two linked events. + +--- + +## 6. P5 AST fields + +| Field | Type | Values | +|-------|------|--------| +| `ast_branch` | enum | `if`, `else`, `none` | +| `cond_outcome` | enum | `success`, `revert`, `fault` | +| `ast_depth` | u8 | Max depth reached | +| `ast_gas_used` | u8 | Gas consumed | + +**Important:** `cond_outcome=revert` + `ast_branch=else` + tx success is a **valid** outcome, not a failed tx. + +--- + +## 7. P2 vs P5 classifier hints + +| Observation | Likely pattern | +|-------------|----------------| +| Top-level `HeightScope` + transfer, tx failed outside window | P2 | +| `AstIf` + height cond, else transfer, tx succeeded outside window | P5 | +| `ast_branch=else` present | P5 | + +--- + +## 8. Example JSON (indexer output) + +```json +{ + "tx_hash": "0x…", + "hip23_pattern": "P5", + "ast_branch": "else", + "cond_outcome": "revert", + "guard_error_norm": "height_outside_window", + "tex_settlement_ok": true +} +``` \ No newline at end of file diff --git a/doc/HIP23_invariants.md b/doc/HIP23_invariants.md new file mode 100644 index 0000000..c215d4f --- /dev/null +++ b/doc/HIP23_invariants.md @@ -0,0 +1,104 @@ +# HIP-23 Protocol Invariants + +Version: 1.0 +Date: 2026-06-22 +Audience: auditors, protocol developers, QA +Model: `vm/doc/runtime-spec.md` §11, `doc/diamond-insc.md` §4.2 + +--- + +## 1. Global invariants (all patterns) + +| ID | Invariant | Enforcement | +|----|-----------|-------------| +| G-1 | Type3 txs with Istanbul actions use `type >= 3` | `check_gated_*` | +| G-2 | Guard-only topologies rejected at precheck | `precheck_tx_actions` | +| G-3 | `do_settlement()` runs after top-level actions on Type3 | `type3.rs` execute path | +| G-4 | Failed tx execution does not commit partial state (chain path) | `chain/src/check.rs` fork/merge | +| G-5 | Each `TexCellAct` signature covers `addr + cells` only | TEX sign/verify | +| G-6 | Mainnet Istanbul height `>= 765432` unless dev bypass | `upgrade.rs` | + +--- + +## 2. TEX settlement invariants (P1, P4 Tx B) + +| ID | Invariant | Violation symptom | +|----|-----------|-------------------| +| T-1 | Σ zhu pay = Σ zhu get per tx | `coin settlement check failed` | +| T-2 | Σ sat pay = Σ sat get per tx | `coin settlement check failed` | +| T-3 | Σ dia pay = Σ dia get per tx | `diamonds settlement check failed` | +| T-4 | Σ asset(serial) pay = Σ asset(serial) get per tx | `asset settlement check failed` | +| T-5 | TEX cells execute before settlement | Ledger updated pre-`do_settlement()` | +| T-6 | Asset TEX cells (`7/8`) require `gas_max > 0` | `gas not initialized` | + +**Zero-sum property (formal):** For each dimension `d ∈ {zhu, sat, dia, assets*}`, +`Σ debits_d = Σ credits_d` at settlement boundary. + +--- + +## 3. Guard invariants (P2, P3, P5 cond) + +| ID | Invariant | Notes | +|----|-----------|-------| +| GU-1 | Guards observe state after all lower-index actions | Sequential action index | +| GU-2 | `HeightScope` revert is inclusive `[start, end]` | `end=0` → unbounded above | +| GU-3 | `HeightScope` invalid range (`start > end`, `end≠0`) → **fault** | Not revert | +| GU-4 | `BalanceFloor` checks in-tx balance at guard position | Not pre-tx chain state | +| GU-5 | Guard revert → action error unwind (tx fails for top-level guards) | P2 whole-tx fail | +| GU-6 | P5 cond revert → `br_else`; cond fault → abort `AstIf` | See `doc/ast-spec.md` | + +--- + +## 4. Pattern-specific invariants + +### P2 + +- **P2-1:** `HeightScope` index < debit action index (MUST). +- **P2-2:** Outside window → tx fails entirely (no partial commit on chain). + +### P3 + +- **P3-1:** Debit index < `BalanceFloor` index (MUST). +- **P3-2:** At least one floor field non-zero (MUST). +- **P3-3:** Type3 fee debited after all actions — floor does not include fee unless modeled. + +### P4 + +- **P4-1:** `AssetCreate` is sole top action in Tx A (`TOP_ONLY`). +- **P4-2:** `protocol_cost == genesis::block_reward(height)` exactly. +- **P4-3:** Asset serial ≥ minsri at height on mainnet. +- **P4-4:** Tx B TEX only after asset exists in state (same block order or later). + +### P5 + +- **P5-1:** `gas_max > 0` when AST depth > 0. +- **P5-2:** Cond `AstSelect` uses `exe_min = exe_max = 1` for single predicates. +- **P5-3:** Gas consumed on cond attempt is not refunded on revert. + +--- + +## 5. Chain execution invariants + +| ID | Invariant | Test | +|----|-----------|------| +| C-1 | `try_execute_tx_by` forks from accumulated block state | `hip23_chain_*` | +| C-2 | Success → `merge_sub`; failure → discard fork | `hip23_chain_failed_tx_does_not_commit` | +| C-3 | Sequential successful txs compose (P4 A→B) | `hip23_chain_p4_tx_a_then_b_commits` | + +--- + +## 6. Known non-invariants (documented exceptions) + +1. **TEX signatures are replayable** across txs with identical cells — not a bug; wallets must pin composed tx. +2. **`fast_sync=true`** disables signature and duplicate-tx checks — test-only relaxation. +3. **In-tx partial progress** visible in direct `tx.execute()` simulators — not chain-persistent on failure. + +--- + +## 7. Regression proof + +Each invariant ID maps to tests in `doc/HIP23_requirements_traceability.md`. Run: + +```bash +cargo test hip23_ -- --nocapture +``` \ No newline at end of file diff --git a/doc/HIP23_requirements_traceability.md b/doc/HIP23_requirements_traceability.md new file mode 100644 index 0000000..5cb22e8 --- /dev/null +++ b/doc/HIP23_requirements_traceability.md @@ -0,0 +1,131 @@ +# HIP-23 Requirements Traceability Matrix + +Version: 1.0 +Date: 2026-06-22 +Legend: ✅ covered | ⚠️ doc-only | ❌ gap + +--- + +## §3 Common requirements + +| ID | Requirement | Test(s) | Status | +|----|-------------|---------|--------| +| C-3.1 | Type >= 3 for Istanbul actions | implicit in all `build_signed_type3` tests | ✅ | +| C-3.2 | gas_max > 0 for AST / asset TEX | `hip23_p1_tex_asset_cells_require_gas`, `hip23_p5_ast_requires_nonzero_gas` | ✅ | +| C-3.3 | Mainnet height >= 765432 | `TEST_HEIGHT` baseline; stress @ alive height | ✅ | +| C-3.4 | TEX zero-sum settlement | `hip23_p1_*`, proptest balanced/imbalanced | ✅ | +| C-3.5 | TEX signs addr+cells | `hip23_p1_tex_tampered_signature_fails`, `hip23_tex_replay_*` | ✅ | +| C-3.6 | No guard-only topology | `hip23_topology_guard_only_*`, proptest guard-only | ✅ | +| C-3.7 | Asset TEX gas init | `hip23_p1_tex_asset_cells_require_gas`, stress low gas | ✅ | + +--- + +## §4 P1 — TEX swap + +| ID | MUST/SHOULD | Test(s) | Status | +|----|-------------|---------|--------| +| P1-M1 | Matching pay/get cells | `hip23_pattern_1_*`, `hip23_production_p1_*` | ✅ | +| P1-M2 | Zero-sum per dimension | `hip23_p1_tex_imbalanced_hac_amount_fails`, `hip23_p1_tex_imbalanced_sat_fails` | ✅ | +| P1-M3 | Tamper after sign fails | `hip23_p1_tex_tampered_signature_fails`, `hip23_audit_strict_tex_tamper_rejected` | ✅ | +| P1-S1 | Condition cells optional | `hip23_p1_tex_height_condition_in_bundle` | ✅ | +| P1-S2 | HAC prelude + TEX | `hip23_p1_tex_with_hac_to_trs_prelude_succeeds` | ✅ | + +--- + +## §5 P2 — Height guard + +| ID | MUST/SHOULD | Test(s) | Status | +|----|-------------|---------|--------| +| P2-M1 | HeightScope before debit | `hip23_pattern_2_*`, `hip23_p2_transfer_before_guard_*` | ✅ | +| P2-M2 | start <= end (end≠0) | adversarial height tests | ✅ | +| P2-M3 | Revert outside window | `hip23_p2_height_guard_above_end_reverts`, proptest height window | ✅ | +| P2-S1 | Finite end for expiry | `hip23_p2_height_guard_boundary_inclusive` | ✅ | +| P2-M4 | Chain atomicity on fail | `hip23_chain_failed_tx_does_not_commit` | ✅ | + +--- + +## §6 P3 — BalanceFloor + +| ID | MUST/SHOULD | Test(s) | Status | +|----|-------------|---------|--------| +| P3-M1 | Floor after debits | `hip23_pattern_3_*`, `hip23_p3_floor_before_transfer_*` | ✅ | +| P3-M2 | Non-zero floor field | `hip23_p3_floor_asset_dimension_*`, satoshi dimension | ✅ | +| P3-M3 | Revert below floor | `hip23_combined_height_floor_transfer_below_floor_fails` | ✅ | +| P3-S1 | Model fee in floor | doc + wallet checklist | ⚠️ | +| P3-S2 | Non-zero fields only | `hip23_p3_floor_before_transfer_*` | ✅ | + +--- + +## §7 P4 — HIP20 + TEX + +| ID | MUST/SHOULD | Test(s) | Status | +|----|-------------|---------|--------| +| P4-M1 | AssetCreate TOP_ONLY | `hip23_p4_asset_create_with_tex_same_tx_rejected` | ✅ | +| P4-M2 | TEX after asset exists | `hip23_pattern_4_*`, `hip23_chain_p4_tx_a_then_b_commits` | ✅ | +| P4-M3 | gas_max > 0 on asset TEX | production P4, stress | ✅ | +| P4-M4 | protocol_cost exact | `hip23_p4_wrong_protocol_cost_rejected`, `hip23_audit_strict_wrong_protocol_cost` | ✅ | +| P4-M5 | Serial minsri | `hip23_stress_*` serial 1025, below minsri fault | ✅ | +| P4-S1 | Pre-sign Tx B | wallet checklist | ⚠️ | + +--- + +## §8 P5 — AST conditional + +| ID | MUST/SHOULD | Test(s) | Status | +|----|-------------|---------|--------| +| P5-M1 | gas_max > 0 | `hip23_p5_ast_requires_nonzero_gas` | ✅ | +| P5-M2 | cond exe_min=max=1 | pattern 5 template default | ✅ | +| P5-M3 | No guard-only AST topology | topology tests | ✅ | +| P5-M4 | Revert→else, fault→abort | `hip23_p5_ast_if_condition_fault_*`, `hip23_p5_ast_else_branch_*` | ✅ | +| P5-S1 | Guard-only cond | pattern 5 structure | ✅ | +| P5-S2 | Simple branch actions | production P5 | ✅ | + +--- + +## §9 Security considerations + +| Topic | Test(s) | Status | +|-------|---------|--------| +| TEX replay | `hip23_tex_replay_*` | ✅ | +| Ordering | P2/P3 adversarial, chain | ✅ | +| Co-signing | wallet checklist + tamper tests | ✅ | +| Gas under-budget | stress low gas, P5 zero gas | ✅ | +| P2 vs P5 | doc §5.6 + P5 else tests | ✅ | + +--- + +## Production-path (strict mode) + +| Control | Test(s) | Status | +|---------|---------|--------| +| Signature verify | `hip23_production_tampered_main_signature_rejected`, `hip23_audit_strict_*` | ✅ | +| Duplicate tx | `hip23_production_duplicate_tx_rejected` | ✅ | +| P1–P5 smoke | `hip23_production_p1_*` … `p5_*` | ✅ | +| Strict proptest | `hip23_proptest_strict_balanced_tex_settles` | ✅ | + +--- + +## Coverage summary + +| Category | MUST items | Covered | % | +|----------|------------|---------|---| +| Common §3 | 7 | 7 | 100% | +| P1 | 3 | 3 | 100% | +| P2 | 4 | 4 | 100% | +| P3 | 3 | 3 | 100% | +| P4 | 5 | 5 | 100% | +| P5 | 4 | 4 | 100% | +| **Total MUST** | **26** | **26** | **100%** | + +SHOULD items: 8/10 tested (2 wallet-process items doc-only by design). + +--- + +## Gaps / backlog + +| Gap | Planned | +|-----|---------| +| `cargo-fuzz` TEX parse | v1.2 | +| P4/P5 dedicated proptest properties | R-04 | +| Cross-implementation vectors | R-05 | +| External audit sign-off | pre-mainnet wallet | \ No newline at end of file diff --git a/doc/HIP23_threat_model.md b/doc/HIP23_threat_model.md new file mode 100644 index 0000000..b39f411 --- /dev/null +++ b/doc/HIP23_threat_model.md @@ -0,0 +1,143 @@ +# HIP-23 Threat Model + +Version: 1.0 +Date: 2026-06-22 +Audience: security reviewers, wallet/indexer engineers, integrators +Baseline: `doc/HIP23.md` v1.1 draft + +--- + +## 1. Scope + +This document models threats to **integrators** building on HIP-23 patterns P1–P5. It does not change consensus rules. Assumptions: + +- Istanbul is active (`height >= 765432` on mainnet). +- Full nodes run with `fast_sync = false` in production (signature + duplicate-tx checks enabled). +- HVM companion contracts are out of scope (HIP-23 v1). + +--- + +## 2. Assets & trust boundaries + +| Asset | Owner | Threat if lost | +|-------|-------|----------------| +| Private keys (main, TEX signers) | User / counterparty | Direct fund loss | +| Signed TEX bundles | Counterparty | Replay in unintended composed tx | +| Composed Type3 tx draft | Coordinator | Wrong pairing, ordering, gas | +| Indexer classification | Service operator | Wrong UX, compliance, accounting | +| Block inclusion timing | Miner / network | Height-guard expiry (P2) | + +**Trust boundaries:** + +``` +Wallet A <--co-sign TEX--> Wallet B + | | + v v +Coordinator (optional) ----> Full node (consensus) + | + v +Indexer / explorer (read-only, MUST NOT authorize spends) +``` + +Users MUST NOT trust indexers or coordinators for authorization — only validated composed txs and signatures. + +--- + +## 3. STRIDE analysis + +| Category | Threat | Patterns | Mitigation | +|----------|--------|----------|------------| +| **Spoofing** | Counterparty TEX `addr` mismatch | P1, P4 | Wallet verifies `TexCellAct.addr` before co-sign | +| **Tampering** | Cells altered after TEX sign | P1, P4 | Signature over `addr+cells`; strict-path tests | +| **Tampering** | Main Type3 signature tamper | All | `fast_sync=false` rejects (`hip23_production_tampered_main_signature_rejected`) | +| **Repudiation** | “I didn’t agree to this swap” | P1 | Pin full composed tx hash before co-sign; store quote | +| **Information disclosure** | Leaked co-sign bundle | P1 | Bundle alone cannot move funds without inclusion in valid tx | +| **Denial of service** | Under-gassed AST/TEX | P4, P5 | Pre-validate `gas_max`; show gas estimate | +| **Denial of service** | Height window miss | P2 | Show inclusive `[start,end]`; simulate at tip | +| **Elevation** | Guard-only tx topology | P2, P3 | Precheck rejects (`hip23_topology_guard_only_*`) | +| **Elevation** | `AssetCreate` + TEX same tx | P4 | `TOP_ONLY` rejected (`hip23_p4_asset_create_with_tex_same_tx_rejected`) | + +--- + +## 4. Pattern-specific threats + +### P1 — Atomic TEX swap + +| ID | Threat | Severity | On-chain result | Off-chain MUST | +|----|--------|----------|-----------------|----------------| +| T-P1-1 | Imbalanced pay/get | High | Settlement fault | Zero-sum precheck | +| T-P1-2 | TEX replay in different composed tx | Medium | Both txs may settle if funded | Pin full tx hash | +| T-P1-3 | Post-sign cell tamper | High | Sig verify fail | Re-verify before broadcast | +| T-P1-4 | Asset cells without gas | Medium | `gas not initialized` | `gas_max > 0` | + +### P2 — Height-guarded payment + +| ID | Threat | Severity | On-chain result | Off-chain MUST | +|----|--------|----------|-----------------|----------------| +| T-P2-1 | Debit before guard (simulator confusion) | Medium | Whole tx reverts | Atomic simulation | +| T-P2-2 | Inclusive boundary off-by-one | Low | Revert outside window | Test `start` and `end` | +| T-P2-3 | Using P5 when P2 semantics needed | Medium | Else branch pays | Pattern selection review | + +### P3 — BalanceFloor + +| ID | Threat | Severity | On-chain result | Off-chain MUST | +|----|--------|----------|-----------------|----------------| +| T-P3-1 | Floor before debit | High | Protection bypassed | Enforce debit→floor order | +| T-P3-2 | Floor ignores post-fee balance | Medium | Floor passes, fee drains later | Model fee in floor value | +| T-P3-3 | Zero dimension not guarded | Low | Unprotected asset/HAC | Set non-zero fields | + +### P4 — HIP20 + TEX + +| ID | Threat | Severity | On-chain result | Off-chain MUST | +|----|--------|----------|-----------------|----------------| +| T-P4-1 | TEX before asset exists | High | Fault | Tx B after Tx A | +| T-P4-2 | Wrong `protocol_cost` | High | Fault | Quote `block_reward(height)` | +| T-P4-3 | Issuer not signing pay cells | Medium | Settlement fail | Issuer co-sign | +| T-P4-4 | Serial below minsri | High | Fault | Height-aware serial | + +### P5 — AST conditional + +| ID | Threat | Severity | On-chain result | Off-chain MUST | +|----|--------|----------|-----------------|----------------| +| T-P5-1 | Condition fault vs revert confusion | Medium | Abort vs else | UX labels (`cond_outcome`) | +| T-P5-2 | Zero gas | Medium | Fault | `gas_max > 0` | +| T-P5-3 | Signatures for both branches required | Low | Broadcast fail | Collect all branch signers | + +--- + +## 5. Attacker profiles + +| Profile | Capability | Primary risk | +|---------|------------|--------------| +| Malicious counterparty | Co-signs TEX, changes composed tx | T-P1-2, T-P1-3 | +| Malicious coordinator | Builds final Type3 | Ordering, extra actions | +| Network observer | Read mempool | Front-running N/A for atomic TEX in same tx | +| Rogue indexer | Wrong labels | Accounting / compliance | +| Miner | Censor or delay | P2 expiry | + +--- + +## 6. Residual risks (accepted in v1) + +1. **TEX replay:** Signatures are not bound to a specific composed tx hash — wallets MUST pin full tx (see `tests/hip23_tex_replay.rs`). +2. **`fast_sync` test harness:** Most HIP-23 tests skip sig/duplicate checks; production-path suite required before release. +3. **No stable guard reason codes:** Indexers parse error strings (see `HIP23_indexer_dictionary.md`). +4. **P4 non-atomic:** Issuance and distribution are two txs — integrators MUST handle partial completion. + +--- + +## 7. Verification mapping + +| Control | Test suite | +|---------|------------| +| Pattern semantics | `hip23_pattern_{regression,adversarial,stress}.rs` | +| Production policy | `hip23_production_path.rs`, `hip23_audit_strict.rs` | +| Chain atomicity | `hip23_chain_integration.rs` | +| TEX replay awareness | `hip23_tex_replay.rs` | +| Generative properties | `hip23_proptest.rs` | + +Run before release: + +```bash +cargo test hip23_ -- --nocapture +``` \ No newline at end of file diff --git a/doc/HIP23_wallet_checklist.md b/doc/HIP23_wallet_checklist.md new file mode 100644 index 0000000..7bb0724 --- /dev/null +++ b/doc/HIP23_wallet_checklist.md @@ -0,0 +1,96 @@ +# HIP-23 Wallet Integration Checklist + +Version: 1.0 (HIP-23 v1.1) +Date: 2026-06-22 +Use: pre-sign validation before broadcast or co-sign + +--- + +## 0. Universal (all patterns) + +- [ ] Transaction `type >= 3` +- [ ] Mainnet `height >= 765432` (or documented testnet policy) +- [ ] `gas_max > 0` if AST depth > 0 **or** asset TEX cells (7/8) present +- [ ] Not guard-only topology (at least one non-guard top action) +- [ ] Simulate **full tx atomically** — do not show per-action finality on failure +- [ ] Collect all required signatures (`req_sign` / branch signers for P5) +- [ ] Fee payer (`tx.main`) has balance for `fee` + debits + gas + +--- + +## P1 — Atomic TEX swap + +- [ ] Every pay cell in party A has matching get in party B (type, serial, amount) +- [ ] Off-chain zero-sum check per dimension (zhu, sat, dia, each asset serial) +- [ ] Each `TexCellAct.addr` matches expected counterparty address +- [ ] Counterparty bundle verified **inside agreed composed Type3** (hash or structural equality) +- [ ] No post-quote tampering of cells (re-verify signatures) +- [ ] Fund pay-side balances before broadcast +- [ ] If asset cells present: `gas_max > 0` + +--- + +## P2 — Height-guarded payment + +- [ ] `HeightScope` listed **before** debit action +- [ ] `start <= end` when `end != 0` +- [ ] Display: “Valid heights: `start`…`end` (inclusive)” +- [ ] Confirm P2 (not P5) if expiry must abort entire payment +- [ ] `gas_max = 0` allowed for plain HAC transfer without asset TEX + +--- + +## P3 — BalanceFloor protected transfer + +- [ ] Debit action(s) listed **before** `BalanceFloor` +- [ ] At least one non-zero floor field for dimensions being protected +- [ ] Floor value accounts for intended post-tx balance **and** `fee` (and gas if applicable) +- [ ] Zero `hacash` floor does not protect HAC — set explicit fields only + +--- + +## P4 — HIP20 issuance + TEX distribution + +- [ ] **Tx A:** `AssetCreate` is the **only** top-level action +- [ ] `protocol_cost == block_reward(expected_height)` exactly +- [ ] `tx.main` holds `protocol_cost` + `fee` +- [ ] Asset serial ≥ minsri at target height (mainnet) +- [ ] **Tx B:** only after Tx A confirmed (or same-block later ordering) +- [ ] Issuer signs issuer `AssetPay` TEX cells +- [ ] `gas_max > 0` on Tx B +- [ ] Pre-sign Tx B bundles before broadcasting Tx A (recommended) + +--- + +## P5 — AST conditional settlement + +- [ ] `gas_max > 0` (minimum 17 for simple HeightScope + HAC branch) +- [ ] Cond `AstSelect`: `exe_min = exe_max = 1` +- [ ] Cond contains guard-only actions (no debits in cond) +- [ ] UX: distinguish condition **fault** (tx fails) vs **revert** (else runs) +- [ ] Signatures collected for both branches if required by `req_sign` +- [ ] Budget gas for cond + selected branch attempt + +--- + +## Co-signing workflow (P1 / P4 Tx B) + +1. Agree on full action list, order, `fee`, `gas_max`, `main`. +2. Hash composed unsigned tx (or canonical serialization). +3. Each party signs TEX over agreed cells only after step 1–2 locked. +4. Coordinator assembles; each party re-validates final wire tx before broadcast. + +--- + +## Pre-broadcast simulation modes + +| Mode | Checks | +|------|--------| +| Fast preview | Pattern semantics only (`fast_sync` equivalent) | +| Production | Signatures, duplicate-tx, fee rules (`fast_sync=false`) | + +Run repo tests before shipping wallet integration: + +```bash +cargo test hip23_ -- --nocapture +``` \ No newline at end of file diff --git a/tests/common/hip23.rs b/tests/common/hip23.rs index 81e3c25..bc4610e 100644 --- a/tests/common/hip23.rs +++ b/tests/common/hip23.rs @@ -2,7 +2,9 @@ #![allow(dead_code)] use basis::component::Env; -use basis::interface::{Action, Context, Transaction, TransactionRead}; +use std::sync::Arc; + +use basis::interface::{Action, Context, State, StateOperat, Transaction, TransactionRead, TxExec}; use field::*; use protocol::state::CoreState; use protocol::tex::*; @@ -234,4 +236,41 @@ pub fn assert_err_contains(err: &str, needle: &str) { err.contains(needle), "expected '{needle}' in error: {err}" ); +} + +/// Run a closure against persisted chain state (for seeding / balance reads). +pub fn with_persisted_state(height: u64, state: Box, f: F) -> Box +where + F: FnOnce(&mut dyn Context), +{ + let main_acc = Account::create_by("hip23-state-helper").unwrap(); + let tx = build_signed_type3(&main_acc, vec![], 0); + let mut ctx = make_ctx_persisted(height, state, tx.as_read()); + f(&mut ctx); + let (sta, _) = ctx.release(); + sta +} + +/// Mirrors `chain/src/check.rs` per-tx fork/merge (failed txs do not commit). +pub fn try_execute_tx_fork( + height: u64, + fast_sync: bool, + tx: &dyn TransactionRead, + state: &mut Box, +) -> Result<(), String> { + let parent: Arc> = state.clone_state().into(); + let sub = parent.fork_sub(Arc::downgrade(&parent)); + let mut env = Env::default(); + env.chain.fast_sync = fast_sync; + env.block.height = height; + env.tx = create_tx_info(tx); + let mut ctx = make_ctx_with_state(env, sub, tx); + match tx.execute(&mut ctx) { + Ok(()) => { + let (sta, _) = ctx.release(); + state.merge_sub(sta); + Ok(()) + } + Err(e) => Err(e), + } } \ No newline at end of file diff --git a/tests/hip23_audit_strict.rs b/tests/hip23_audit_strict.rs new file mode 100644 index 0000000..7748604 --- /dev/null +++ b/tests/hip23_audit_strict.rs @@ -0,0 +1,156 @@ +//! HIP-23 audit suite: adversarial cases under production path (`fast_sync=false`). +//! Run: cargo test hip23_audit_ -- --nocapture + +mod common; + +use basis::interface::{Transaction, TxExec}; +use common::hip23::*; +use field::*; +use mint::action::AssetCreate; +use protocol::action::*; +use protocol::tex::*; +use sys::Account; + +#[test] +fn hip23_audit_strict_tex_imbalanced_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-audit-imb-main").unwrap(); + let pay_acc = Account::create_by("hip23-audit-imb-pay").unwrap(); + let get_acc = Account::create_by("hip23-audit-imb-get").unwrap(); + let pay = addr_of(&pay_acc); + + let (pay_tex, _) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + let mut get_tex = TexCellAct::create_by(addr_of(&get_acc)); + get_tex + .add_cell(Box::new(CellTrsZhuGet::new(Fold64::from(1).unwrap()))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 10); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "settlement check failed"); +} + +#[test] +fn hip23_audit_strict_tex_tamper_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-audit-sig-main").unwrap(); + let pay_acc = Account::create_by("hip23-audit-sig-pay").unwrap(); + let get_acc = Account::create_by("hip23-audit-sig-get").unwrap(); + let pay = addr_of(&pay_acc); + + let (mut pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + pay_tex + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(1).unwrap()))) + .unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 10); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "signature verification failed"); +} + +#[test] +fn hip23_audit_strict_height_outside_window_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-audit-h-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT); + guard.end = BlockHeight::from(TEST_HEIGHT + 5); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + let mut ctx = make_ctx_strict(TEST_HEIGHT + 99, tx.as_read()); + seed_hac(&mut ctx, &main, 100); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "submitted in height between"); +} + +#[test] +fn hip23_audit_strict_wrong_protocol_cost_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-audit-p4-main").unwrap(); + let issuer_acc = Account::create_by("hip23-audit-p4-issuer").unwrap(); + let main = addr_of(&main_acc); + let issuer = addr_of(&issuer_acc); + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(9901).unwrap(), + supply: Fold64::from(100).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("AUD").unwrap(), + name: BytesW1::from_str("Audit").unwrap(), + }; + create.protocol_cost = Amount::mei(1); + + let tx = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 2_000_000); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "Protocol fee must be"); +} + +#[test] +fn hip23_audit_strict_main_sig_tamper_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-audit-main-sig").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient); + transfer.hacash = Amount::mei(1); + + let mut tx = build_signed_type3(&main_acc, vec![Box::new(transfer)], 0); + let mut sig_bytes = *tx.signs[0].signature.as_array(); + sig_bytes[0] ^= 0x01; + tx.signs[0].signature = Fixed64::from(sig_bytes); + + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 100); + let err = tx.execute(&mut ctx).unwrap_err(); + assert!(err.contains("signature") || err.contains("verify"), "{err}"); +} + +#[test] +fn hip23_audit_strict_guard_only_precheck_rejected() { + init_setup(); + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT); + guard.end = BlockHeight::from(TEST_HEIGHT + 10); + let actions: Vec> = vec![Box::new(guard)]; + let err = protocol::action::precheck_tx_actions( + protocol::transaction::TransactionType3::TYPE, + &actions, + ) + .unwrap_err(); + assert_err_contains(&err, "all GUARD"); +} \ No newline at end of file diff --git a/tests/hip23_chain_integration.rs b/tests/hip23_chain_integration.rs new file mode 100644 index 0000000..3df8b0d --- /dev/null +++ b/tests/hip23_chain_integration.rs @@ -0,0 +1,188 @@ +//! HIP-23 chain-level integration: per-tx fork/merge semantics (`chain/src/check.rs`). +//! Run: cargo test hip23_chain_ -- --nocapture + +mod common; + +use basis::interface::{StateOperat, Transaction, TxExec}; +use common::hip23::*; +use field::*; +use mint::action::AssetCreate; +use mint::genesis; +use protocol::action::*; +use protocol::tex::*; +use sys::Account; +use testkit::sim::state::ForkableMemState; + +#[test] +fn hip23_chain_failed_tx_does_not_commit() { + init_setup(); + let main_acc = Account::create_by("hip23-chain-fail-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(50); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT); + guard.end = BlockHeight::from(TEST_HEIGHT + 10); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(transfer), Box::new(guard)], + 0, + ); + + let mut state: Box = + Box::new(ForkableMemState::default()); + seed_hac_chain(&mut state, &main, 1_000); + + let err = try_execute_tx_fork(TEST_HEIGHT + 100, false, tx.as_read(), &mut state).unwrap_err(); + assert_err_contains(&err, "submitted in height between"); + assert_eq!(hac_mei_chain(&state, &main), 1_000); + assert_eq!(hac_mei_chain(&state, &recipient), 0); +} + +#[test] +fn hip23_chain_successful_tx_commits() { + init_setup(); + let main_acc = Account::create_by("hip23-chain-ok-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT); + guard.end = BlockHeight::from(TEST_HEIGHT + 500); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(3); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + + let mut state: Box = + Box::new(ForkableMemState::default()); + seed_hac_chain(&mut state, &main, 200); + try_execute_tx_fork(TEST_HEIGHT + 5, false, tx.as_read(), &mut state).unwrap(); + assert_eq!(hac_mei_chain(&state, &recipient), 3); +} + +#[test] +fn hip23_chain_p4_tx_a_then_b_commits() { + init_setup(); + let main_acc = Account::create_by("hip23-chain-p4-main").unwrap(); + let issuer_acc = Account::create_by("hip23-chain-p4-issuer").unwrap(); + let buyer_acc = Account::create_by("hip23-chain-p4-buyer").unwrap(); + let main = addr_of(&main_acc); + let issuer = addr_of(&issuer_acc); + let buyer = addr_of(&buyer_acc); + const SERIAL: u64 = 8801; + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(SERIAL).unwrap(), + supply: Fold64::from(1_000).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("CHN").unwrap(), + name: BytesW1::from_str("Chain").unwrap(), + }; + create.protocol_cost = genesis::block_reward(TEST_HEIGHT); + + let tx_a = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut state: Box = + Box::new(ForkableMemState::default()); + seed_hac_chain(&mut state, &main, 2_000_000); + try_execute_tx_fork(TEST_HEIGHT, false, tx_a.as_read(), &mut state).unwrap(); + + let mut issuer_tex = TexCellAct::create_by(issuer); + issuer_tex + .add_cell(Box::new(CellTrsAssetPay::new( + AssetAmt::from(SERIAL, 50).unwrap(), + ))) + .unwrap(); + issuer_tex.do_sign(&issuer_acc).unwrap(); + + let mut buyer_tex = TexCellAct::create_by(buyer); + buyer_tex + .add_cell(Box::new(CellTrsAssetGet::new( + AssetAmt::from(SERIAL, 50).unwrap(), + ))) + .unwrap(); + buyer_tex.do_sign(&buyer_acc).unwrap(); + + let tx_b = build_signed_type3( + &main_acc, + vec![Box::new(issuer_tex), Box::new(buyer_tex)], + 99, + ); + try_execute_tx_fork(TEST_HEIGHT, false, tx_b.as_read(), &mut state).unwrap(); + assert_eq!(asset_amt_chain(&state, &buyer, SERIAL), 50); +} + +#[test] +fn hip23_chain_sequential_fail_then_success_isolated() { + init_setup(); + let main_acc = Account::create_by("hip23-chain-seq-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut bad_transfer = HacToTrs::new(); + bad_transfer.to = AddrOrPtr::from_addr(recipient.clone()); + bad_transfer.hacash = Amount::mei(100); + let mut bad_guard = HeightScope::new(); + bad_guard.start = BlockHeight::from(TEST_HEIGHT); + bad_guard.end = BlockHeight::from(TEST_HEIGHT + 1); + let bad_tx = build_signed_type3( + &main_acc, + vec![Box::new(bad_transfer), Box::new(bad_guard)], + 0, + ); + + let mut good_guard = HeightScope::new(); + good_guard.start = BlockHeight::from(TEST_HEIGHT); + good_guard.end = BlockHeight::from(TEST_HEIGHT + 100); + let mut good_transfer = HacToTrs::new(); + good_transfer.to = AddrOrPtr::from_addr(recipient.clone()); + good_transfer.hacash = Amount::mei(2); + let good_tx = build_signed_type3( + &main_acc, + vec![Box::new(good_guard), Box::new(good_transfer)], + 0, + ); + + let mut state: Box = + Box::new(ForkableMemState::default()); + seed_hac_chain(&mut state, &main, 500); + + let _ = try_execute_tx_fork(TEST_HEIGHT + 50, false, bad_tx.as_read(), &mut state); + try_execute_tx_fork(TEST_HEIGHT + 5, false, good_tx.as_read(), &mut state).unwrap(); + assert_eq!(hac_mei_chain(&state, &recipient), 2); + // Failed tx does not commit (no fee); successful tx commits once. + assert_eq!(hac_mei_chain(&state, &main), 500 - 2 - TX_FEE_MEI); +} + +fn seed_hac_chain(state: &mut Box, addr: &Address, mei: u64) { + let taken = std::mem::replace(state, Box::new(ForkableMemState::default())); + *state = with_persisted_state(TEST_HEIGHT, taken, |ctx| seed_hac(ctx, addr, mei)); +} + +fn hac_mei_chain(state: &Box, addr: &Address) -> u64 { + let mut mei = 0u64; + let _ = with_persisted_state(TEST_HEIGHT, state.clone_state(), |ctx| { + mei = hac_mei(ctx, addr); + }); + mei +} + +fn asset_amt_chain(state: &Box, addr: &Address, serial: u64) -> u64 { + let mut amt = 0u64; + let _ = with_persisted_state(TEST_HEIGHT, state.clone_state(), |ctx| { + amt = asset_amt(ctx, addr, serial); + }); + amt +} \ No newline at end of file diff --git a/tests/hip23_proptest.rs b/tests/hip23_proptest.rs index 91791fd..69910b1 100644 --- a/tests/hip23_proptest.rs +++ b/tests/hip23_proptest.rs @@ -7,6 +7,7 @@ use basis::interface::{Action, Transaction, TxExec}; use common::hip23::*; use field::*; use protocol::action::*; +use protocol::tex::*; use proptest::prelude::*; use sys::Account; @@ -109,6 +110,47 @@ proptest! { prop_assert!(err.contains("all GUARD"), "{err}"); } + /// Imbalanced TEX always fails settlement. + #[test] + fn hip23_proptest_imbalanced_tex_always_fails( + pay_mei in 2u64..30u64, + get_mei in 1u64..30u64, + ) { + prop_assume!(pay_mei != get_mei); + init_setup(); + let main_acc = Account::create_by(&format!("hip23-prop-imb-main-{pay_mei}-{get_mei}")).unwrap(); + let pay_acc = Account::create_by(&format!("hip23-prop-imb-pay-{pay_mei}")).unwrap(); + let get_acc = Account::create_by(&format!("hip23-prop-imb-get-{get_mei}")).unwrap(); + let pay = addr_of(&pay_acc); + + let (pay_tex, _) = build_balanced_tex_swap( + &pay_acc, + &get_acc, + pay_mei * 100_000_000, + 0, + 0, + ); + let mut get_tex = TexCellAct::create_by(addr_of(&get_acc)); + get_tex + .add_cell(Box::new(CellTrsZhuGet::new( + Fold64::from(get_mei * 100_000_000).unwrap(), + ))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx(PROP_BASE, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, pay_mei + 5); + + let err = tx.execute(&mut ctx).unwrap_err(); + prop_assert!(err.contains("settlement check failed"), "{err}"); + } + /// Production path: balanced TEX also settles under fast_sync=false. #[test] fn hip23_proptest_strict_balanced_tex_settles( diff --git a/tests/hip23_tex_replay.rs b/tests/hip23_tex_replay.rs new file mode 100644 index 0000000..c338ef9 --- /dev/null +++ b/tests/hip23_tex_replay.rs @@ -0,0 +1,107 @@ +//! HIP-23 TEX replay and composition attacks. +//! TEX signatures bind addr+cells only — not the enclosing Type3 tx. +//! Run: cargo test hip23_tex_replay_ -- --nocapture + +mod common; + +use basis::interface::{Transaction, TxExec}; +use common::hip23::*; +use field::*; +use protocol::action::*; +use protocol::tex::*; +use sys::Account; + +#[test] +fn hip23_tex_replay_same_bundle_different_main_succeeds() { + init_setup(); + let main_a = Account::create_by("hip23-replay-main-a").unwrap(); + let main_b = Account::create_by("hip23-replay-main-b").unwrap(); + let pay_acc = Account::create_by("hip23-replay-pay").unwrap(); + let get_acc = Account::create_by("hip23-replay-get").unwrap(); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + + let (pay_tex_a, get_tex_a) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + let (pay_tex_b, get_tex_b) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + + let tx_a = build_signed_type3( + &main_a, + vec![Box::new(pay_tex_a), Box::new(get_tex_a)], + 0, + ); + let tx_b = build_signed_type3( + &main_b, + vec![Box::new(pay_tex_b), Box::new(get_tex_b)], + 0, + ); + + let mut ctx_a = make_ctx_strict(TEST_HEIGHT, tx_a.as_read()); + seed_hac(&mut ctx_a, &addr_of(&main_a), 1_000_000); + seed_hac(&mut ctx_a, &pay, 10); + tx_a.execute(&mut ctx_a).unwrap(); + assert_eq!(hac_mei(&mut ctx_a, &get), 1); + + let mut ctx_b = make_ctx_strict(TEST_HEIGHT, tx_b.as_read()); + seed_hac(&mut ctx_b, &addr_of(&main_b), 1_000_000); + seed_hac(&mut ctx_b, &pay, 10); + tx_b.execute(&mut ctx_b).unwrap(); + assert_eq!(hac_mei(&mut ctx_b, &get), 1); +} + +#[test] +fn hip23_tex_replay_tampered_cell_after_sign_fails() { + init_setup(); + let main_acc = Account::create_by("hip23-replay-tamper-main").unwrap(); + let pay_acc = Account::create_by("hip23-replay-tamper-pay").unwrap(); + let get_acc = Account::create_by("hip23-replay-tamper-get").unwrap(); + let pay = addr_of(&pay_acc); + + let (mut pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + pay_tex + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(1).unwrap()))) + .unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 10); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "signature verification failed"); +} + +#[test] +fn hip23_tex_replay_extra_unbalanced_party_fails_settlement() { + init_setup(); + let main_acc = Account::create_by("hip23-replay-3p-main").unwrap(); + let pay_acc = Account::create_by("hip23-replay-3p-pay").unwrap(); + let get_acc = Account::create_by("hip23-replay-3p-get").unwrap(); + let rogue_acc = Account::create_by("hip23-replay-3p-rogue").unwrap(); + let pay = addr_of(&pay_acc); + let rogue = addr_of(&rogue_acc); + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + + let mut rogue_tex = TexCellAct::create_by(rogue); + rogue_tex + .add_cell(Box::new(CellTrsZhuPay::new(Fold64::from(50_000_000).unwrap()))) + .unwrap(); + rogue_tex.do_sign(&rogue_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex), Box::new(rogue_tex)], + 0, + ); + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 10); + seed_hac(&mut ctx, &rogue, 10); + + let err = tx.execute(&mut ctx).unwrap_err(); + assert_err_contains(&err, "settlement check failed"); +} \ No newline at end of file From 2f6bad9e06aea32d581e4b81301d93fdf284950b Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 16:51:57 +0200 Subject: [PATCH 08/11] HIP-23: address code review findings --- .github/workflows/hip23.yml | 4 ++-- doc/HIP23_requirements_traceability.md | 5 +++-- tests/common/hip23.rs | 18 ++++++++++++---- tests/hip23_chain_integration.rs | 4 +++- tests/hip23_pattern_stress.rs | 4 ++-- tests/hip23_proptest.rs | 30 ++++++++++++++++++++++++++ tests/hip23_tex_replay.rs | 10 +++++---- 7 files changed, 60 insertions(+), 15 deletions(-) diff --git a/.github/workflows/hip23.yml b/.github/workflows/hip23.yml index c38c5e2..a268852 100644 --- a/.github/workflows/hip23.yml +++ b/.github/workflows/hip23.yml @@ -5,14 +5,14 @@ on: paths: - 'doc/HIP23*.md' - 'tests/hip23_*.rs' - - 'tests/common/hip23.rs' + - 'tests/common/**' - 'protocol/**' - 'Cargo.toml' pull_request: paths: - 'doc/HIP23*.md' - 'tests/hip23_*.rs' - - 'tests/common/hip23.rs' + - 'tests/common/**' - 'protocol/**' - 'Cargo.toml' diff --git a/doc/HIP23_requirements_traceability.md b/doc/HIP23_requirements_traceability.md index 5cb22e8..01ac9b6 100644 --- a/doc/HIP23_requirements_traceability.md +++ b/doc/HIP23_requirements_traceability.md @@ -36,11 +36,12 @@ Legend: ✅ covered | ⚠️ doc-only | ❌ gap | ID | MUST/SHOULD | Test(s) | Status | |----|-------------|---------|--------| -| P2-M1 | HeightScope before debit | `hip23_pattern_2_*`, `hip23_p2_transfer_before_guard_*` | ✅ | +| P2-M1 | HeightScope before debit | `hip23_pattern_2_height_guarded_payment` | ✅ | +| P2-M4 | Chain atomicity on fail | `hip23_chain_failed_tx_does_not_commit`, `hip23_p2_transfer_before_guard_*` | ✅ | | P2-M2 | start <= end (end≠0) | adversarial height tests | ✅ | | P2-M3 | Revert outside window | `hip23_p2_height_guard_above_end_reverts`, proptest height window | ✅ | | P2-S1 | Finite end for expiry | `hip23_p2_height_guard_boundary_inclusive` | ✅ | -| P2-M4 | Chain atomicity on fail | `hip23_chain_failed_tx_does_not_commit` | ✅ | + --- diff --git a/tests/common/hip23.rs b/tests/common/hip23.rs index bc4610e..5ef5c91 100644 --- a/tests/common/hip23.rs +++ b/tests/common/hip23.rs @@ -5,7 +5,7 @@ use basis::component::Env; use std::sync::Arc; use basis::interface::{Action, Context, State, StateOperat, Transaction, TransactionRead, TxExec}; -use field::*; +use field::{Parse, Serialize, *}; use protocol::state::CoreState; use protocol::tex::*; use protocol::transaction::*; @@ -251,7 +251,16 @@ where sta } -/// Mirrors `chain/src/check.rs` per-tx fork/merge (failed txs do not commit). +/// Round-trip a signed TEX bundle through wire bytes (same signature, new instance). +pub fn clone_tex_wire(tex: &TexCellAct) -> TexCellAct { + let mut buf = Vec::new(); + tex.serialize_to(&mut buf); + let mut out = TexCellAct::new(); + out.parse(&buf).unwrap(); + out +} + +/// Per-tx state fork/merge semantics (success commits, failure discards). pub fn try_execute_tx_fork( height: u64, fast_sync: bool, @@ -265,9 +274,10 @@ pub fn try_execute_tx_fork( env.block.height = height; env.tx = create_tx_info(tx); let mut ctx = make_ctx_with_state(env, sub, tx); - match tx.execute(&mut ctx) { + let exec_res = tx.execute(&mut ctx); + let (sta, _) = ctx.release(); + match exec_res { Ok(()) => { - let (sta, _) = ctx.release(); state.merge_sub(sta); Ok(()) } diff --git a/tests/hip23_chain_integration.rs b/tests/hip23_chain_integration.rs index 3df8b0d..4305f07 100644 --- a/tests/hip23_chain_integration.rs +++ b/tests/hip23_chain_integration.rs @@ -159,7 +159,9 @@ fn hip23_chain_sequential_fail_then_success_isolated() { Box::new(ForkableMemState::default()); seed_hac_chain(&mut state, &main, 500); - let _ = try_execute_tx_fork(TEST_HEIGHT + 50, false, bad_tx.as_read(), &mut state); + let bad_err = + try_execute_tx_fork(TEST_HEIGHT + 50, false, bad_tx.as_read(), &mut state).unwrap_err(); + assert_err_contains(&bad_err, "submitted in height between"); try_execute_tx_fork(TEST_HEIGHT + 5, false, good_tx.as_read(), &mut state).unwrap(); assert_eq!(hac_mei_chain(&state, &recipient), 2); // Failed tx does not commit (no fee); successful tx commits once. diff --git a/tests/hip23_pattern_stress.rs b/tests/hip23_pattern_stress.rs index 2cfbbca..07001b6 100644 --- a/tests/hip23_pattern_stress.rs +++ b/tests/hip23_pattern_stress.rs @@ -424,11 +424,11 @@ fn hip23_stress_duplicate_tex_same_addr_two_bundles_unsigned_second() { } #[test] -fn hip23_stress_asset_create_at_dev_minsri_serial_5() { +fn hip23_stress_asset_create_minsri_serial_1025_at_alive_height() { init_setup(); let main_acc = Account::create_by("hip23-stress-devserial-main").unwrap(); let issuer = addr_of(&Account::create_by("hip23-stress-devserial-iss").unwrap()); - // Below mainnet minsri but valid on dev height 0 path — use height ASSET_ALIVE_HEIGHT on mainnet chain 0. + // Minsri floor serial at ASSET_ALIVE_HEIGHT on mainnet chain 0. const SERIAL: u64 = 1025; let mut create = AssetCreate::new(); diff --git a/tests/hip23_proptest.rs b/tests/hip23_proptest.rs index 69910b1..ac36ba5 100644 --- a/tests/hip23_proptest.rs +++ b/tests/hip23_proptest.rs @@ -47,6 +47,36 @@ proptest! { prop_assert_eq!(hac_mei(&mut ctx, &get), zhu_mei); } + /// end = 0 means unbounded upper; heights above start succeed. + #[test] + fn hip23_proptest_height_scope_unlimited_end_zero( + above in 1u64..500u64, + ) { + init_setup(); + let start = PROP_BASE; + let inside = start + above; + let main_acc = Account::create_by(&format!("hip23-prop-h0-{above}")).unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(start); + guard.end = BlockHeight::from(0); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient.clone()); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + let mut ctx = make_ctx(inside, tx.as_read()); + seed_hac(&mut ctx, &main, 50); + tx.execute(&mut ctx).unwrap(); + prop_assert_eq!(hac_mei(&mut ctx, &recipient), 1); + } + /// Height inside [start, end] succeeds; outside reverts. #[test] fn hip23_proptest_height_scope_window( diff --git a/tests/hip23_tex_replay.rs b/tests/hip23_tex_replay.rs index c338ef9..13b1916 100644 --- a/tests/hip23_tex_replay.rs +++ b/tests/hip23_tex_replay.rs @@ -21,17 +21,19 @@ fn hip23_tex_replay_same_bundle_different_main_succeeds() { let pay = addr_of(&pay_acc); let get = addr_of(&get_acc); - let (pay_tex_a, get_tex_a) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); - let (pay_tex_b, get_tex_b) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + // Reuse wire-identical signed bundles (not re-signed) in a second composed tx. + let pay_replay = clone_tex_wire(&pay_tex); + let get_replay = clone_tex_wire(&get_tex); let tx_a = build_signed_type3( &main_a, - vec![Box::new(pay_tex_a), Box::new(get_tex_a)], + vec![Box::new(pay_tex), Box::new(get_tex)], 0, ); let tx_b = build_signed_type3( &main_b, - vec![Box::new(pay_tex_b), Box::new(get_tex_b)], + vec![Box::new(pay_replay), Box::new(get_replay)], 0, ); From 4682affd7a7341f0efcf2bb317d983527d71881e Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 18:37:46 +0200 Subject: [PATCH 09/11] HIP-23: doc sync and clean test warnings --- doc/HIP23.md | 1 + doc/HIP23_audit_findings.md | 2 +- tests/hip23_pattern_adversarial.rs | 4 +--- tests/hip23_pattern_stress.rs | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/HIP23.md b/doc/HIP23.md index 5877dd0..89b2ce5 100644 --- a/doc/HIP23.md +++ b/doc/HIP23.md @@ -309,6 +309,7 @@ Smoke tests with `fast_sync = false` (`make_ctx_strict` in `tests/common/hip23.r | Property | Cases | Harness | |----------|-------|---------| | Balanced HAC TEX settles | 64 | `fast_sync = true` | +| HeightScope `end=0` unbounded above | 64 | `fast_sync = true` | | HeightScope window (inside ok, outside fail) | 64 | `fast_sync = true` | | Guard-only always rejected at precheck | 64 | n/a | | Imbalanced TEX always fails | 64 | `fast_sync = true` | diff --git a/doc/HIP23_audit_findings.md b/doc/HIP23_audit_findings.md index 80fdbec..e17e28f 100644 --- a/doc/HIP23_audit_findings.md +++ b/doc/HIP23_audit_findings.md @@ -31,7 +31,7 @@ No critical consensus issues (expected — HIP-23 is application-layer). **Description:** `TexCellAct` signs `addr + cells` only. The same valid bundle may appear in multiple composed Type3 txs if counterparties co-sign without pinning the full tx. -**Proof:** `hip23_tex_replay_same_bundle_different_main_succeeds` +**Proof:** `hip23_tex_replay_same_bundle_different_main_succeeds` (wire replay via `clone_tex_wire`) **Remediation:** Wallet checklist §co-signing; `HIP23_threat_model.md` T-P1-2. Not a protocol bug. diff --git a/tests/hip23_pattern_adversarial.rs b/tests/hip23_pattern_adversarial.rs index 0dfe235..e41e40c 100644 --- a/tests/hip23_pattern_adversarial.rs +++ b/tests/hip23_pattern_adversarial.rs @@ -552,8 +552,6 @@ fn hip23_p3_floor_satoshi_dimension_blocks_overspend() { init_setup(); let main_acc = Account::create_by("hip23-p3c-main").unwrap(); let main = addr_of(&main_acc); - let recipient = field::ADDRESS_TWOX.clone(); - let mut pay_tex = TexCellAct::create_by(main); pay_tex .add_cell(Box::new(CellTrsSatPay::new(Fold64::from(4).unwrap()))) @@ -566,7 +564,7 @@ fn hip23_p3_floor_satoshi_dimension_blocks_overspend() { get_tex .add_cell(Box::new(CellTrsSatGet::new(Fold64::from(4).unwrap()))) .unwrap(); - get_tex.do_sign(&cp_acc); + get_tex.do_sign(&cp_acc).unwrap(); let mut floor = BalanceFloor::new(); floor.addr = AddrOrPtr::from_addr(main); diff --git a/tests/hip23_pattern_stress.rs b/tests/hip23_pattern_stress.rs index 07001b6..35ffb32 100644 --- a/tests/hip23_pattern_stress.rs +++ b/tests/hip23_pattern_stress.rs @@ -3,7 +3,7 @@ mod common; -use basis::interface::{Action, StateOperat, Transaction, TxExec}; +use basis::interface::{StateOperat, Transaction, TxExec}; use common::hip23::*; use field::*; use mint::action::AssetCreate; From 411d919dc01a77c8777e7da5fdf28ab8a42aec2d Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 18:53:18 +0200 Subject: [PATCH 10/11] HIP-23: close accepted limitations (error codes, vectors, fuzz, chain replay) --- .github/workflows/hip23.yml | 8 +- SECURITY.md | 9 ++ doc/HIP23.md | 17 ++- doc/HIP23_audit_findings.md | 16 +-- doc/HIP23_audit_scope.md | 7 +- doc/HIP23_external_audit_brief.md | 77 +++++++++++++ doc/HIP23_indexer_dictionary.md | 2 + fuzz/Cargo.toml | 20 ++++ fuzz/fuzz_targets/tex_cell_act_parse.rs | 9 ++ tests/common/hip23_errors.rs | 75 +++++++++++++ tests/common/mod.rs | 3 +- tests/fixtures/hip23_test_vectors.json | 90 ++++++++++++++++ tests/hip23_chain_integration.rs | 19 ++++ tests/hip23_guard_error_codes.rs | 137 ++++++++++++++++++++++++ tests/hip23_proptest.rs | 74 +++++++++++++ tests/hip23_test_vectors.rs | 22 ++++ tests/hip23_tex_replay.rs | 51 +++++++++ 17 files changed, 622 insertions(+), 14 deletions(-) create mode 100644 doc/HIP23_external_audit_brief.md create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/tex_cell_act_parse.rs create mode 100644 tests/common/hip23_errors.rs create mode 100644 tests/fixtures/hip23_test_vectors.json create mode 100644 tests/hip23_guard_error_codes.rs create mode 100644 tests/hip23_test_vectors.rs diff --git a/.github/workflows/hip23.yml b/.github/workflows/hip23.yml index a268852..5bb3ffa 100644 --- a/.github/workflows/hip23.yml +++ b/.github/workflows/hip23.yml @@ -6,15 +6,19 @@ on: - 'doc/HIP23*.md' - 'tests/hip23_*.rs' - 'tests/common/**' + - 'tests/fixtures/**' - 'protocol/**' - 'Cargo.toml' + - '.github/workflows/hip23.yml' pull_request: paths: - 'doc/HIP23*.md' - 'tests/hip23_*.rs' - 'tests/common/**' + - 'tests/fixtures/**' - 'protocol/**' - 'Cargo.toml' + - '.github/workflows/hip23.yml' jobs: hip23: @@ -23,4 +27,6 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - name: Run HIP-23 test suite - run: cargo test hip23_ -- --nocapture \ No newline at end of file + run: cargo test hip23_ -- --nocapture + - name: Run workspace tests + run: cargo test --workspace -- --nocapture \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index 6cef479..65d2b31 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,6 +42,15 @@ Include: Integrators MUST validate against production-like mode before mainnet use. +## Fuzzing (optional) + +```bash +cargo install cargo-fuzz +cd fuzz && cargo fuzz run tex_cell_act_parse -- -max_total_time=30 +``` + +Proptest also covers random TEX wire parse (`hip23_proptest_tex_wire_parse_never_panics`). + ## Dependency audit Periodically run: diff --git a/doc/HIP23.md b/doc/HIP23.md index 89b2ce5..a180855 100644 --- a/doc/HIP23.md +++ b/doc/HIP23.md @@ -1,6 +1,6 @@ # HIP-23: Istanbul DeFi Application Patterns -**Status:** Draft (branch `hip-23-draft`) +**Status:** v1.1 Ready (branch `hip-23-draft`) **Type:** Application / integration standard — **no consensus fork** **Activation:** Istanbul capabilities @ mainnet height `765432` (`ONLINE_OPEN_HEIGHT`) **Depends on:** Type3 transactions, ActionGuard, TEX, AST, HIP20 (`AssetCreate`), HVM (optional) @@ -22,7 +22,9 @@ Reference material: - AST control flow: `doc/ast-spec.md` - Guards: `protocol/src/action/chain.rs` - JSON templates: `doc/HIP23_templates.md` -- Audit package: `doc/HIP23_threat_model.md`, `doc/HIP23_invariants.md`, `doc/HIP23_requirements_traceability.md`, `doc/HIP23_wallet_checklist.md`, `doc/HIP23_indexer_dictionary.md`, `doc/HIP23_audit_scope.md`, `doc/HIP23_audit_findings.md` +- Audit package: `doc/HIP23_threat_model.md`, `doc/HIP23_invariants.md`, `doc/HIP23_requirements_traceability.md`, `doc/HIP23_wallet_checklist.md`, `doc/HIP23_indexer_dictionary.md`, `doc/HIP23_audit_scope.md`, `doc/HIP23_audit_findings.md`, `doc/HIP23_external_audit_brief.md` +- Error classifier: `tests/common/hip23_errors.rs` +- Test vectors: `tests/fixtures/hip23_test_vectors.json` ## 2. Scope @@ -314,6 +316,17 @@ Smoke tests with `fast_sync = false` (`make_ctx_strict` in `tests/common/hip23.r | Guard-only always rejected at precheck | 64 | n/a | | Imbalanced TEX always fails | 64 | `fast_sync = true` | | Balanced TEX under strict path | 64 | `fast_sync = false` | +| Wrong `protocol_cost` always fails (P4) | 64 | `fast_sync = true` | +| P5 else on height revert | 64 | `fast_sync = true` | +| TEX wire parse never panics | 64 | fuzz-adjacent | + +### Guard error codes (`hip23_guard_error_codes.rs`) + +Stable string → code mapping for indexers (F-007 mitigation). + +### Test vectors (`hip23_test_vectors.rs` + `tests/fixtures/hip23_test_vectors.json`) + +Cross-implementation acceptance registry (12+ vectors). ### Audit strict (`hip23_audit_strict.rs`) diff --git a/doc/HIP23_audit_findings.md b/doc/HIP23_audit_findings.md index e17e28f..a996dac 100644 --- a/doc/HIP23_audit_findings.md +++ b/doc/HIP23_audit_findings.md @@ -12,7 +12,7 @@ Method: internal audit per `HIP23_audit_scope.md` |----------|------|-------|----------| | High | 0 | 2 | 1 | | Medium | 0 | 3 | 2 | -| Low | 1 | 2 | 0 | +| Low | 0 | 3 | 0 | | Informational | 2 | 0 | 3 | No critical consensus issues (expected — HIP-23 is application-layer). @@ -123,11 +123,11 @@ No critical consensus issues (expected — HIP-23 is application-layer). |-------|-------| | Severity | **Low** | | Pattern | P2, P3, P5 | -| Status | Open (v1 limitation) | +| Status | **Mitigated** (v1 classifier) | -**Description:** Indexers must parse error strings. +**Description:** On-chain errors remain strings; no consensus enum in v1. -**Remediation:** `HIP23_indexer_dictionary.md` §3; future protocol enum out of HIP-23 v1 scope. +**Remediation:** `tests/common/hip23_errors.rs` + `hip23_guard_error_codes.rs` lock stable indexer codes; `HIP23_indexer_dictionary.md` §3. --- @@ -174,10 +174,10 @@ No critical consensus issues (expected — HIP-23 is application-layer). | ID | Recommendation | Priority | |----|----------------|----------| | R-01 | External third-party audit before mainnet wallet launch | High | -| R-02 | Add `cargo-fuzz` on TEX JSON parse | Medium | -| R-03 | CI gate on `cargo test hip23_` | Medium (added `.github/workflows/hip23.yml`) | -| R-04 | Expand proptest to P4/P5 properties | Low | -| R-05 | Cross-implementation test vectors file | Low | +| R-02 | Add `cargo-fuzz` on TEX parse | Medium (**done**: `fuzz/` + proptest parse property) | +| R-03 | CI gate on `cargo test hip23_` | Medium (**done**: `.github/workflows/hip23.yml`) | +| R-04 | Expand proptest to P4/P5 properties | Low (**done**) | +| R-05 | Cross-implementation test vectors file | Low (**done**: `tests/fixtures/hip23_test_vectors.json`) | --- diff --git a/doc/HIP23_audit_scope.md b/doc/HIP23_audit_scope.md index 94d1a09..ae4ec0c 100644 --- a/doc/HIP23_audit_scope.md +++ b/doc/HIP23_audit_scope.md @@ -91,8 +91,11 @@ Application-layer integration standard audit — **no consensus fork**. Validate - [x] Chain + replay integration tests - [x] Strict-path adversarial mirror - [x] `SECURITY.md` -- [ ] External third-party audit (future) -- [ ] `cargo-fuzz` targets (optional v1.2) +- [x] External audit brief (`HIP23_external_audit_brief.md`) +- [x] Stable error classifier (`tests/common/hip23_errors.rs`) +- [x] Cross-implementation test vectors (`tests/fixtures/hip23_test_vectors.json`) +- [x] `cargo-fuzz` target (`fuzz/fuzz_targets/tex_cell_act_parse.rs`) +- [ ] External third-party audit engagement (process only) --- diff --git a/doc/HIP23_external_audit_brief.md b/doc/HIP23_external_audit_brief.md new file mode 100644 index 0000000..1434589 --- /dev/null +++ b/doc/HIP23_external_audit_brief.md @@ -0,0 +1,77 @@ +# HIP-23 External Audit Brief + +Version: 1.0 +Date: 2026-06-22 +Branch: `hip-23-draft` +Repository: `hacash/fullnodedev` (fork: `Moskyera/fullnodedev`) + +--- + +## 1. Engagement summary + +| Item | Value | +|------|-------| +| Type | Application-layer integration standard (no consensus fork) | +| Scope | Patterns P1–P5, wallet/indexer guidance, test harness | +| Out of scope | Consensus, HVM v2, HIP-25, mempool policy beyond sig/duplicate | + +--- + +## 2. Artifacts for auditors + +| Document | Path | +|----------|------| +| Normative spec | `doc/HIP23.md` | +| JSON templates | `doc/HIP23_templates.md` | +| Threat model | `doc/HIP23_threat_model.md` | +| Invariants | `doc/HIP23_invariants.md` | +| Traceability (26/26 MUST) | `doc/HIP23_requirements_traceability.md` | +| Findings register | `doc/HIP23_audit_findings.md` | +| Wallet checklist | `doc/HIP23_wallet_checklist.md` | +| Indexer dictionary | `doc/HIP23_indexer_dictionary.md` | +| Test vectors | `tests/fixtures/hip23_test_vectors.json` | +| Error classifier | `tests/common/hip23_errors.rs` | + +--- + +## 3. Verification commands + +```bash +# Full HIP-23 suite (~85+ named tests + proptest) +cargo test hip23_ -- --nocapture + +# Workspace regression +cargo test --workspace + +# Optional libfuzzer (requires cargo-fuzz) +cd fuzz && cargo fuzz run tex_cell_act_parse -- -max_total_time=30 +``` + +--- + +## 4. Internal sign-off (pre-external) + +- [x] MUST requirements traced to tests +- [x] Production path (`fast_sync=false`) covered +- [x] Chain fork/merge + duplicate-tx on chain path +- [x] TEX replay wire + persisted chain demonstration +- [x] Stable error code classifier (F-007 mitigation) +- [x] Cross-implementation test vector registry +- [x] Proptest + fuzz-adjacent parse property +- [ ] External auditor report (pending engagement) + +--- + +## 5. Residual accepted risks + +| Risk | Mitigation | +|------|------------| +| TEX replay across txs | Wallet MUST pin full composed tx (`HIP23_wallet_checklist.md`) | +| Guard errors are strings on-chain | Indexer uses `hip23_errors.rs` classifier | +| P4 non-atomic two-tx flow | Documented; indexers correlate by serial+issuer | + +--- + +## 6. Contact / process + +Report security issues per `SECURITY.md`. HIP-23 changes require `cargo test hip23_` pass before merge. \ No newline at end of file diff --git a/doc/HIP23_indexer_dictionary.md b/doc/HIP23_indexer_dictionary.md index e3c6307..334d2ad 100644 --- a/doc/HIP23_indexer_dictionary.md +++ b/doc/HIP23_indexer_dictionary.md @@ -35,6 +35,8 @@ Audience: explorer, indexer, analytics engineers ## 3. Normalized error strings (v1) +Reference implementation: `tests/common/hip23_errors.rs` (`classify_error`, `error_code_name`). + Indexers SHOULD map raw errors to these buckets: | Bucket | Substring(s) | `guard_outcome` | diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..2677b13 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "hacash-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +field = { path = "../field" } +protocol = { path = "../protocol" } + +[[bin]] +name = "tex_cell_act_parse" +path = "fuzz_targets/tex_cell_act_parse.rs" +test = false +doc = false +bench = false \ No newline at end of file diff --git a/fuzz/fuzz_targets/tex_cell_act_parse.rs b/fuzz/fuzz_targets/tex_cell_act_parse.rs new file mode 100644 index 0000000..916e91b --- /dev/null +++ b/fuzz/fuzz_targets/tex_cell_act_parse.rs @@ -0,0 +1,9 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use protocol::tex::TexCellAct; + +fuzz_target!(|data: &[u8]| { + let mut tex = TexCellAct::new(); + let _ = tex.parse(data); +}); \ No newline at end of file diff --git a/tests/common/hip23_errors.rs b/tests/common/hip23_errors.rs new file mode 100644 index 0000000..52bda57 --- /dev/null +++ b/tests/common/hip23_errors.rs @@ -0,0 +1,75 @@ +//! Stable HIP-23 error tokens for indexers (v1 string matching). +//! Maps on-chain error text to normalized codes — see `doc/HIP23_indexer_dictionary.md`. + +/// Raw substrings observed from protocol execution (do not rename without semver). +pub mod substring { + pub const HEIGHT_OUTSIDE_WINDOW: &str = "submitted in height between"; + pub const HEIGHT_INVALID_RANGE: &str = "start height cannot be greater"; + pub const BALANCE_BELOW_FLOOR: &str = "lower than floor"; + pub const CHAIN_NOT_ALLOWED: &str = "chain id check failed"; + pub const GUARD_ONLY_TOPOLOGY: &str = "all GUARD"; + pub const TEX_SETTLEMENT_FAIL: &str = "settlement check failed"; + pub const TEX_SIG_FAIL: &str = "signature verification failed"; + pub const GAS_NOT_INITIALIZED: &str = "gas not initialized"; + pub const DUPLICATE_TX: &str = "already exists"; + pub const PROTOCOL_FEE_MISMATCH: &str = "Protocol fee must be"; +} + +/// Normalized indexer codes (stable across HIP-23 v1.x). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Hip23ErrorCode { + HeightOutsideWindow, + HeightInvalidRange, + BalanceBelowFloor, + ChainNotAllowed, + GuardOnlyTopology, + TexSettlementFail, + TexSigFail, + GasNotInitialized, + DuplicateTx, + ProtocolFeeMismatch, + Unknown, +} + +pub fn classify_error(err: &str) -> Hip23ErrorCode { + use substring::*; + if err.contains(HEIGHT_OUTSIDE_WINDOW) { + Hip23ErrorCode::HeightOutsideWindow + } else if err.contains(HEIGHT_INVALID_RANGE) { + Hip23ErrorCode::HeightInvalidRange + } else if err.contains(BALANCE_BELOW_FLOOR) { + Hip23ErrorCode::BalanceBelowFloor + } else if err.contains(CHAIN_NOT_ALLOWED) { + Hip23ErrorCode::ChainNotAllowed + } else if err.contains(GUARD_ONLY_TOPOLOGY) { + Hip23ErrorCode::GuardOnlyTopology + } else if err.contains(TEX_SETTLEMENT_FAIL) { + Hip23ErrorCode::TexSettlementFail + } else if err.contains(TEX_SIG_FAIL) { + Hip23ErrorCode::TexSigFail + } else if err.contains(GAS_NOT_INITIALIZED) { + Hip23ErrorCode::GasNotInitialized + } else if err.contains(DUPLICATE_TX) { + Hip23ErrorCode::DuplicateTx + } else if err.contains(PROTOCOL_FEE_MISMATCH) { + Hip23ErrorCode::ProtocolFeeMismatch + } else { + Hip23ErrorCode::Unknown + } +} + +pub fn error_code_name(code: Hip23ErrorCode) -> &'static str { + match code { + Hip23ErrorCode::HeightOutsideWindow => "height_outside_window", + Hip23ErrorCode::HeightInvalidRange => "height_invalid_range", + Hip23ErrorCode::BalanceBelowFloor => "balance_below_floor", + Hip23ErrorCode::ChainNotAllowed => "chain_not_allowed", + Hip23ErrorCode::GuardOnlyTopology => "guard_only_topology", + Hip23ErrorCode::TexSettlementFail => "tex_settlement_imbalance", + Hip23ErrorCode::TexSigFail => "tex_sig_fail", + Hip23ErrorCode::GasNotInitialized => "gas_not_initialized", + Hip23ErrorCode::DuplicateTx => "duplicate_tx", + Hip23ErrorCode::ProtocolFeeMismatch => "protocol_fee_mismatch", + Hip23ErrorCode::Unknown => "unknown", + } +} \ No newline at end of file diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 85a631f..8d78732 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1 +1,2 @@ -pub mod hip23; \ No newline at end of file +pub mod hip23; +pub mod hip23_errors; \ No newline at end of file diff --git a/tests/fixtures/hip23_test_vectors.json b/tests/fixtures/hip23_test_vectors.json new file mode 100644 index 0000000..bfaf0f4 --- /dev/null +++ b/tests/fixtures/hip23_test_vectors.json @@ -0,0 +1,90 @@ +{ + "version": "1.0", + "spec": "HIP-23", + "height_baseline": 775432, + "description": "Cross-implementation acceptance vectors. Wallets/indexers SHOULD reproduce outcomes at TEST_HEIGHT with funded fixtures.", + "vectors": [ + { + "id": "P1-balanced-hac-tex", + "pattern": "P1", + "test": "hip23_pattern_1_atomic_tex_swap", + "expect": "success", + "invariants": ["tex_zhu_zero_sum", "get_receives_1_mei"] + }, + { + "id": "P1-imbalanced-tex", + "pattern": "P1", + "test": "hip23_p1_tex_imbalanced_hac_amount_fails", + "expect": "fault", + "error_code": "tex_settlement_imbalance" + }, + { + "id": "P2-height-inside", + "pattern": "P2", + "test": "hip23_pattern_2_height_guarded_payment", + "expect": "success", + "guard": "height_inside_window" + }, + { + "id": "P2-height-outside", + "pattern": "P2", + "test": "hip23_p2_height_guard_above_end_reverts", + "expect": "revert_whole_tx", + "error_code": "height_outside_window" + }, + { + "id": "P3-floor-after-debit", + "pattern": "P3", + "test": "hip23_pattern_3_balance_floor_protected_transfer", + "expect": "success" + }, + { + "id": "P3-floor-before-debit-bypass", + "pattern": "P3", + "test": "hip23_p3_floor_before_transfer_checks_pre_debit_state", + "expect": "success_without_protection", + "wallet_must": "enforce_debit_then_floor" + }, + { + "id": "P4-two-tx-issuance-then-tex", + "pattern": "P4", + "test": "hip23_pattern_4_asset_create_plus_tex", + "expect": "success_after_asset_exists" + }, + { + "id": "P4-wrong-protocol-cost", + "pattern": "P4", + "test": "hip23_p4_wrong_protocol_cost_rejected", + "expect": "fault", + "error_code": "protocol_fee_mismatch" + }, + { + "id": "P5-else-on-cond-revert", + "pattern": "P5", + "test": "hip23_p5_ast_else_branch_executes_transfer", + "expect": "success", + "indexer_fields": { "ast_branch": "else", "cond_outcome": "revert" } + }, + { + "id": "TEX-replay-wire-identical", + "pattern": "P1", + "test": "hip23_tex_replay_same_bundle_different_main_succeeds", + "expect": "success_both_txs", + "integrator_must": "pin_full_composed_tx_before_co_sign" + }, + { + "id": "chain-fork-no-partial-commit", + "pattern": "P2", + "test": "hip23_chain_failed_tx_does_not_commit", + "expect": "state_unchanged_on_fail" + }, + { + "id": "production-duplicate-tx", + "pattern": "all", + "test": "hip23_production_duplicate_tx_rejected", + "expect": "fault", + "error_code": "duplicate_tx", + "harness": "fast_sync_false" + } + ] +} \ No newline at end of file diff --git a/tests/hip23_chain_integration.rs b/tests/hip23_chain_integration.rs index 4305f07..97108ea 100644 --- a/tests/hip23_chain_integration.rs +++ b/tests/hip23_chain_integration.rs @@ -168,6 +168,25 @@ fn hip23_chain_sequential_fail_then_success_isolated() { assert_eq!(hac_mei_chain(&state, &main), 500 - 2 - TX_FEE_MEI); } +#[test] +fn hip23_chain_duplicate_tx_rejected() { + init_setup(); + let main_acc = Account::create_by("hip23-chain-dup-main").unwrap(); + let main = addr_of(&main_acc); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(field::ADDRESS_TWOX.clone()); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3(&main_acc, vec![Box::new(transfer)], 0); + let mut state: Box = + Box::new(ForkableMemState::default()); + seed_hac_chain(&mut state, &main, 100); + + try_execute_tx_fork(TEST_HEIGHT, false, tx.as_read(), &mut state).unwrap(); + let err = try_execute_tx_fork(TEST_HEIGHT, false, tx.as_read(), &mut state).unwrap_err(); + assert_err_contains(&err, "already exists"); +} + fn seed_hac_chain(state: &mut Box, addr: &Address, mei: u64) { let taken = std::mem::replace(state, Box::new(ForkableMemState::default())); *state = with_persisted_state(TEST_HEIGHT, taken, |ctx| seed_hac(ctx, addr, mei)); diff --git a/tests/hip23_guard_error_codes.rs b/tests/hip23_guard_error_codes.rs new file mode 100644 index 0000000..f397438 --- /dev/null +++ b/tests/hip23_guard_error_codes.rs @@ -0,0 +1,137 @@ +//! Locks HIP-23 error string → stable code mapping for indexers (F-007 mitigation). +//! Run: cargo test hip23_guard_error_ -- --nocapture + +mod common; + +use basis::interface::{Transaction, TxExec}; +use common::hip23::*; +use common::hip23_errors::*; +use field::*; +use mint::action::AssetCreate; +use protocol::action::*; +use protocol::tex::*; +use sys::Account; + +#[test] +fn hip23_guard_error_height_outside_maps_to_code() { + init_setup(); + let main_acc = Account::create_by("hip23-err-h-main").unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + + let mut guard = HeightScope::new(); + guard.start = BlockHeight::from(TEST_HEIGHT); + guard.end = BlockHeight::from(TEST_HEIGHT + 1); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(recipient); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(guard), Box::new(transfer)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT + 99, tx.as_read()); + seed_hac(&mut ctx, &main, 50); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_eq!( + classify_error(&err), + Hip23ErrorCode::HeightOutsideWindow + ); + assert_eq!( + error_code_name(classify_error(&err)), + "height_outside_window" + ); +} + +#[test] +fn hip23_guard_error_floor_below_maps_to_code() { + init_setup(); + let main_acc = Account::create_by("hip23-err-f-main").unwrap(); + let main = addr_of(&main_acc); + + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(field::ADDRESS_TWOX.clone()); + transfer.hacash = Amount::mei(900); + let mut floor = BalanceFloor::new(); + floor.addr = AddrOrPtr::from_addr(main); + floor.hacash = Amount::mei(500); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(transfer), Box::new(floor)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 1_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_eq!(classify_error(&err), Hip23ErrorCode::BalanceBelowFloor); +} + +#[test] +fn hip23_guard_error_duplicate_tx_maps_to_code() { + init_setup(); + let main_acc = Account::create_by("hip23-err-dup-main").unwrap(); + let main = addr_of(&main_acc); + let mut transfer = HacToTrs::new(); + transfer.to = AddrOrPtr::from_addr(field::ADDRESS_TWOX.clone()); + transfer.hacash = Amount::mei(1); + + let tx = build_signed_type3(&main_acc, vec![Box::new(transfer)], 0); + let mut ctx = make_ctx_strict(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &main, 100); + tx.execute(&mut ctx).unwrap(); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_eq!(classify_error(&err), Hip23ErrorCode::DuplicateTx); +} + +#[test] +fn hip23_guard_error_protocol_fee_maps_to_code() { + init_setup(); + let main_acc = Account::create_by("hip23-err-fee-main").unwrap(); + let issuer = addr_of(&Account::create_by("hip23-err-fee-iss").unwrap()); + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(9910).unwrap(), + supply: Fold64::from(10).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("ERR").unwrap(), + name: BytesW1::from_str("Err").unwrap(), + }; + create.protocol_cost = Amount::mei(1); + + let tx = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_eq!(classify_error(&err), Hip23ErrorCode::ProtocolFeeMismatch); +} + +#[test] +fn hip23_guard_error_tex_settlement_maps_to_code() { + init_setup(); + let main_acc = Account::create_by("hip23-err-tex-main").unwrap(); + let pay_acc = Account::create_by("hip23-err-tex-pay").unwrap(); + let get_acc = Account::create_by("hip23-err-tex-get").unwrap(); + let pay = addr_of(&pay_acc); + + let (pay_tex, mut get_tex) = + build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + get_tex + .add_cell(Box::new(CellTrsZhuGet::new(Fold64::from(1).unwrap()))) + .unwrap(); + get_tex.do_sign(&get_acc).unwrap(); + + let tx = build_signed_type3( + &main_acc, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let mut ctx = make_ctx(TEST_HEIGHT, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + seed_hac(&mut ctx, &pay, 10); + let err = tx.execute(&mut ctx).unwrap_err(); + assert_eq!(classify_error(&err), Hip23ErrorCode::TexSettlementFail); +} \ No newline at end of file diff --git a/tests/hip23_proptest.rs b/tests/hip23_proptest.rs index ac36ba5..baa3688 100644 --- a/tests/hip23_proptest.rs +++ b/tests/hip23_proptest.rs @@ -6,6 +6,8 @@ mod common; use basis::interface::{Action, Transaction, TxExec}; use common::hip23::*; use field::*; +use common::hip23_errors::{classify_error, Hip23ErrorCode}; +use mint::action::AssetCreate; use protocol::action::*; use protocol::tex::*; use proptest::prelude::*; @@ -208,4 +210,76 @@ proptest! { tx.execute(&mut ctx).unwrap(); prop_assert_eq!(hac_mei(&mut ctx, &get), zhu_mei); } + + /// Wrong protocol_cost always faults (P4). + #[test] + fn hip23_proptest_wrong_protocol_cost_always_fails( + bad_mei in 1u64..100u64, + serial in 9000u64..9999u64, + ) { + init_setup(); + let main_acc = Account::create_by(&format!("hip23-prop-p4-main-{serial}")).unwrap(); + let issuer = addr_of(&Account::create_by(&format!("hip23-prop-p4-iss-{serial}")).unwrap()); + + let mut create = AssetCreate::new(); + create.metadata = AssetSmelt { + serial: Fold64::from(serial).unwrap(), + supply: Fold64::from(100).unwrap(), + decimal: Uint1::from(0), + issuer, + ticket: BytesW1::from_str("P4").unwrap(), + name: BytesW1::from_str("P4").unwrap(), + }; + create.protocol_cost = Amount::mei(bad_mei); + + let tx = build_signed_type3(&main_acc, vec![Box::new(create)], 0); + let mut ctx = make_ctx(PROP_BASE, tx.as_read()); + seed_hac(&mut ctx, &addr_of(&main_acc), 1_000_000); + let err = tx.execute(&mut ctx).unwrap_err(); + prop_assert_eq!(classify_error(&err), Hip23ErrorCode::ProtocolFeeMismatch); + } + + /// P5: height cond revert selects else branch and succeeds. + #[test] + fn hip23_proptest_p5_else_on_height_revert( + outside in 2u64..200u64, + ) { + init_setup(); + let main_acc = Account::create_by(&format!("hip23-prop-p5-{outside}")).unwrap(); + let main = addr_of(&main_acc); + let recipient = field::ADDRESS_TWOX.clone(); + let start = PROP_BASE; + let end = start + 50; + let height = end + outside; + + let mut cond_guard = HeightScope::new(); + cond_guard.start = BlockHeight::from(start); + cond_guard.end = BlockHeight::from(end); + let cond = AstSelect::create_by(1, 1, vec![Box::new(cond_guard)]); + let br_if = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(9), + ))]); + let br_else = AstSelect::create_list(vec![Box::new(HacToTrs::create_by( + recipient.clone(), + Amount::mei(2), + ))]); + let act = AstIf::create_by(cond, br_if, br_else); + + let tx = build_signed_type3(&main_acc, vec![Box::new(act)], 17); + let mut ctx = make_ctx(height, tx.as_read()); + seed_hac(&mut ctx, &main, 200); + tx.execute(&mut ctx).unwrap(); + prop_assert_eq!(hac_mei(&mut ctx, &recipient), 2); + } + + /// Random bytes on TEX wire must not panic (fuzz-adjacent). + #[test] + fn hip23_proptest_tex_wire_parse_never_panics( + data in prop::collection::vec(any::(), 0..512), + ) { + init_setup(); + let mut tex = TexCellAct::new(); + let _ = tex.parse(&data); + } } \ No newline at end of file diff --git a/tests/hip23_test_vectors.rs b/tests/hip23_test_vectors.rs new file mode 100644 index 0000000..1794175 --- /dev/null +++ b/tests/hip23_test_vectors.rs @@ -0,0 +1,22 @@ +//! Validates cross-implementation test vector registry. +//! Run: cargo test hip23_test_vectors_ -- --nocapture + +use std::path::PathBuf; + +#[test] +fn hip23_test_vectors_registry_loads() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/hip23_test_vectors.json"); + let raw = std::fs::read_to_string(&path).expect("hip23_test_vectors.json missing"); + let v: serde_json::Value = serde_json::from_str(&raw).expect("invalid JSON"); + assert_eq!(v["version"], "1.0"); + assert_eq!(v["spec"], "HIP-23"); + let vectors = v["vectors"].as_array().expect("vectors array"); + assert!(vectors.len() >= 12, "expected >= 12 vectors, got {}", vectors.len()); + for entry in vectors { + assert!(entry["id"].is_string()); + assert!(entry["pattern"].is_string()); + assert!(entry["test"].is_string()); + assert!(entry["expect"].is_string()); + } +} \ No newline at end of file diff --git a/tests/hip23_tex_replay.rs b/tests/hip23_tex_replay.rs index 13b1916..55a04f5 100644 --- a/tests/hip23_tex_replay.rs +++ b/tests/hip23_tex_replay.rs @@ -10,6 +10,8 @@ use field::*; use protocol::action::*; use protocol::tex::*; use sys::Account; +use basis::interface::StateOperat; +use testkit::sim::state::ForkableMemState; #[test] fn hip23_tex_replay_same_bundle_different_main_succeeds() { @@ -106,4 +108,53 @@ fn hip23_tex_replay_extra_unbalanced_party_fails_settlement() { let err = tx.execute(&mut ctx).unwrap_err(); assert_err_contains(&err, "settlement check failed"); +} + +#[test] +fn hip23_tex_replay_same_wire_twice_on_persisted_chain() { + init_setup(); + let main_a = Account::create_by("hip23-replay-ch-main-a").unwrap(); + let main_b = Account::create_by("hip23-replay-ch-main-b").unwrap(); + let pay_acc = Account::create_by("hip23-replay-ch-pay").unwrap(); + let get_acc = Account::create_by("hip23-replay-ch-get").unwrap(); + let pay = addr_of(&pay_acc); + let get = addr_of(&get_acc); + + let (pay_tex, get_tex) = build_balanced_tex_swap(&pay_acc, &get_acc, 100_000_000, 0, 0); + let pay_replay = clone_tex_wire(&pay_tex); + let get_replay = clone_tex_wire(&get_tex); + + let tx_a = build_signed_type3( + &main_a, + vec![Box::new(pay_tex), Box::new(get_tex)], + 0, + ); + let tx_b = build_signed_type3( + &main_b, + vec![Box::new(pay_replay), Box::new(get_replay)], + 0, + ); + + let mut state: Box = + Box::new(ForkableMemState::default()); + seed_hac_chain(&mut state, &addr_of(&main_a), 1_000_000); + seed_hac_chain(&mut state, &addr_of(&main_b), 1_000_000); + seed_hac_chain(&mut state, &pay, 5); + + try_execute_tx_fork(TEST_HEIGHT, false, tx_a.as_read(), &mut state).unwrap(); + try_execute_tx_fork(TEST_HEIGHT, false, tx_b.as_read(), &mut state).unwrap(); + assert_eq!(hac_mei_chain(&state, &get), 2); +} + +fn seed_hac_chain(state: &mut Box, addr: &Address, mei: u64) { + let taken = std::mem::replace(state, Box::new(ForkableMemState::default())); + *state = with_persisted_state(TEST_HEIGHT, taken, |ctx| seed_hac(ctx, addr, mei)); +} + +fn hac_mei_chain(state: &Box, addr: &Address) -> u64 { + let mut mei = 0u64; + let _ = with_persisted_state(TEST_HEIGHT, state.clone_state(), |ctx| { + mei = hac_mei(ctx, addr); + }); + mei } \ No newline at end of file From 16849ad37359b6b04ec12d379a7c20fa9a07cd28 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Mon, 22 Jun 2026 18:56:35 +0200 Subject: [PATCH 11/11] HIP-23: fix proptest wrong_protocol_cost false positive at block_reward=8 --- tests/hip23_proptest.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/hip23_proptest.rs b/tests/hip23_proptest.rs index baa3688..99fd781 100644 --- a/tests/hip23_proptest.rs +++ b/tests/hip23_proptest.rs @@ -8,6 +8,7 @@ use common::hip23::*; use field::*; use common::hip23_errors::{classify_error, Hip23ErrorCode}; use mint::action::AssetCreate; +use mint::genesis; use protocol::action::*; use protocol::tex::*; use proptest::prelude::*; @@ -217,6 +218,8 @@ proptest! { bad_mei in 1u64..100u64, serial in 9000u64..9999u64, ) { + let correct_mei = genesis::block_reward_number(PROP_BASE) as u64; + prop_assume!(bad_mei != correct_mei); init_setup(); let main_acc = Account::create_by(&format!("hip23-prop-p4-main-{serial}")).unwrap(); let issuer = addr_of(&Account::create_by(&format!("hip23-prop-p4-iss-{serial}")).unwrap());