Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: tests

on:
push:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
tests:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: scripts
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# rustup reads scripts/rust-toolchain, so both steps use the pinned Rust.
- name: Test the release binaries
run: cargo test --locked -p tests
# The suite compiles only vector-gen/src/vectors.rs; build the JSON writer too.
- name: Build the vector generator
working-directory: scripts/vector-gen
run: cargo build --locked --release
173 changes: 95 additions & 78 deletions 20260501-ICKB-Audit-Report.md → ICKB-Audit-Report.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ These L1 Scripts have been both internally reviewed by individuals with deep exp

For convenience, a local copy of the Scalebit report is also available in this repository at [`20240911-ICKB-Final-Audit-Report.pdf`](./20240911-ICKB-Final-Audit-Report.pdf).

This repository also includes a later local executable review of the deployed release binaries in [`20260501-ICKB-Audit-Report.md`](./20260501-ICKB-Audit-Report.md).
This repository also includes a later local executable review of the deployed release binaries in [`ICKB-Audit-Report.md`](./ICKB-Audit-Report.md).

## Licensing

Expand Down
1 change: 1 addition & 0 deletions scripts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

These L1 Scripts have been both internally reviewed by individuals with deep experience in Nervos L1 and [externally audited by the Scalebit team](https://scalebit.xyz/reports/20240911-ICKB-Final-Audit-Report.pdf), an internationally recognized blockchain security team.

This repository also includes a later local executable review of the deployed release binaries in [`../20260501-ICKB-Audit-Report.md`](../20260501-ICKB-Audit-Report.md).
This repository also includes a later local executable review of the deployed release binaries in [`../ICKB-Audit-Report.md`](../ICKB-Audit-Report.md).

## Building the Scripts

Expand Down
1 change: 1 addition & 0 deletions scripts/tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ ckb-testtool = "0.11"
ckb-dao-utils = "0.114"
ckb-system-scripts = "0.5.4"
hex = "0.4"
primitive-types = { version = "0.12.2", default-features = false }
3 changes: 3 additions & 0 deletions scripts/tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
- `src/tests/signing.rs`: sighash signing helpers for whole-transaction-binding tests.
- `src/tests/replay_helpers.rs`: live-shape parsers and batch builders used by replay-heavy cases.
- `src/tests/helpers.rs`: focused unit tests for shared encoders.
- `src/tests/protocol_vectors.rs`: replays the golden vectors of `../vector-gen` (the values the contracts' own Rust logic gives each case) against the release binaries, except two named vectors that describe cells consensus cannot create.
- `src/tests/ickb_logic.rs`, `owned_owner.rs`, `limit_order.rs`, `replay.rs`: thin suite roots that wire topic-focused files under `src/tests/ickb_logic/`, `owned_owner/`, `limit_order/`, and `replay/`.

Some replay tests intentionally rebuild live transaction shapes instead of minimizing the fixture. Those cases keep the audit claims tied to realistic batching, witness layout, header dependencies, and cross-script interaction patterns.

Current strong-lock coverage is direct for `secp256k1_blake160_sighash_all`, which the signed-flow tests use as the representative whole-transaction-binding lock. This repo does not include a `QRL` fixture or harness, so conclusions for other strong-lock deployments rely on the same binding property and deployment assumptions rather than a separate replay in this suite.

`ckb-testtool`'s `Context::verify_tx` executes `OutputsDataVerifier`, which checks that the output and output-data counts match, and then executes the transaction scripts through `TransactionScriptsVerifier`. It does not run the full node non-contextual or contextual transaction verifiers, so it does not establish checks such as occupied capacity, `since`, or cellbase maturity. Tests therefore verify through `VerifyRealistic::verify` in `src/tests.rs`, which first holds every input and output to its occupied capacity, then holds every transaction the scripts accept to the node's capacity balance (outputs may not exceed inputs unless a DAO input is present) and to its DAO lock-size rule (for deposits committed since block 10,000,000). So no fixture builds a cell or transaction those node checks would reject. Other consensus-level conclusions rely on CKB source evidence rather than `Context::verify_tx` alone.
205 changes: 174 additions & 31 deletions scripts/tests/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod helpers;
mod ickb_logic;
mod limit_order;
mod owned_owner;
mod protocol_vectors;
mod replay;
mod replay_helpers;
mod signing;
Expand All @@ -26,9 +27,127 @@ use fixtures::*;
use replay_helpers::*;
use signing::*;

// ckb-testtool runs scripts only, so a fixture could build a cell consensus rejects and still pass.
// Tests verify through this check, which first holds every input and output to its occupied capacity.
trait VerifyRealistic {
fn verify(&self, tx: &TransactionView, max_cycles: u64) -> Result<u64, Error>;
}

impl VerifyRealistic for Context {
fn verify(&self, tx: &TransactionView, max_cycles: u64) -> Result<u64, Error> {
let check = |side: &str, index: usize, cell: &CellOutput, data_len: usize| {
let occupied = cell
.occupied_capacity(ckb_testtool::ckb_types::core::Capacity::bytes(data_len).expect("data length"))
.expect("occupied capacity")
.as_u64();
let capacity: u64 = cell.capacity().unpack();
assert!(
capacity >= occupied,
"{side} {index} holds {capacity} shannons, below its occupied capacity {occupied}"
);
};
for (index, input) in tx.inputs().into_iter().enumerate() {
if let Some((cell, data)) = self.get_cell(&input.previous_output()) {
check("input", index, &cell, data.len());
}
}
for (index, (cell, data)) in tx.outputs().into_iter().zip(tx.outputs_data()).enumerate() {
check("output", index, &cell, data.raw_data().len());
}
let cycles = self.verify_tx(tx, max_cycles)?;
// A transaction the scripts accept must also pass the node's transaction checks that
// ckb-testtool skips: CapacityVerifier and DaoScriptSizeVerifier.
let dao_code_hash = build_genesis_type_id_script(OUTPUT_INDEX_DAO).calc_script_hash();
let is_dao = |cell: &CellOutput| {
cell.type_().to_opt().map_or(false, |script| {
script.code_hash() == dao_code_hash && script.hash_type() == ScriptHashType::Type.into()
})
};
let inputs: Vec<_> = tx.inputs().into_iter().map(|input| (input.previous_output(), self.get_cell(&input.previous_output()))).collect();
// Outputs may not exceed inputs, except in a transaction with a DAO input, whose
// compensation the DAO script checks instead.
if !inputs.iter().any(|(_, cell)| cell.as_ref().map_or(false, |(cell, _)| is_dao(cell))) {
let inputs_sum: u64 = inputs.iter().filter_map(|(_, cell)| cell.as_ref()).map(|(cell, _)| Unpack::<u64>::unpack(&cell.capacity())).sum();
let outputs_sum: u64 = tx.outputs().into_iter().map(|cell| Unpack::<u64>::unpack(&cell.capacity())).sum();
assert!(outputs_sum <= inputs_sum, "outputs hold {outputs_sum} shannons, more than the inputs' {inputs_sum}");
}
// A deposit committed since the node's starting block and the withdrawal request at its
// index use locks of the same serialized size; older deposits are exempt.
for (index, ((out_point, deposit), output)) in inputs.iter().zip(tx.outputs()).enumerate() {
let Some((deposit, data)) = deposit else {
continue;
};
let exempt = self
.transaction_infos
.get(out_point)
.map_or(false, |info| info.block_number < DAO_LOCK_SIZE_RULE_START_BLOCK);
if !exempt && is_dao(deposit) && is_dao(&output) && data.iter().all(|byte| *byte == 0) {
assert_eq!(
deposit.lock().total_size(),
output.lock().total_size(),
"DAO output {index} changes the lock size of the deposit it withdraws"
);
}
}
Ok(cycles)
}
}

#[test]
#[should_panic(expected = "below its occupied capacity")]
fn verify_rejects_a_cell_below_its_occupied_capacity() {
let mut context = Context::default();
let lock = always_success_lock(&mut context);
let funding = context.create_cell(
cell(1_000 * CKB, &lock, None),
Bytes::new(),
);
let tx = TransactionBuilder::default()
.input(input(funding))
.output(cell(40 * CKB, &lock, None))
.output_data(Bytes::new().pack())
.build();
let tx = context.complete_tx(tx);
let _ = context.verify(&tx, MAX_CYCLES);
}

/// A phase 1 withdrawal whose request lock is smaller than its deposit's, for a deposit
/// committed at `block_number`.
fn verify_withdrawal_with_smaller_request_lock(block_number: u64) {
let mut context = Context::default();
let deposit_lock = named_always_success_lock(&mut context, b"depositor");
let request_lock = always_success_lock(&mut context);
let dao = dao_script(&mut context);
let capacity = 1_000 * CKB;
let deposit = create_deposit(&mut context, capacity, &deposit_lock, &dao);
let header = gen_header(block_number, GENESIS_AR, 35, block_number - 10, 1000);
link_cell_to_header(&mut context, &deposit, &header);
let tx = TransactionBuilder::default()
.input(input(deposit))
.output(cell(capacity, &request_lock, Some(&dao)))
.output_data(withdrawal_request_data(block_number).pack())
.header_dep(header.hash())
.build();
let tx = context.complete_tx(tx);
context.verify(&tx, MAX_CYCLES).expect("the DAO script accepts the withdrawal");
}

#[test]
#[should_panic(expected = "changes the lock size of the deposit it withdraws")]
fn verify_rejects_a_withdrawal_request_whose_lock_size_differs() {
verify_withdrawal_with_smaller_request_lock(DAO_LOCK_SIZE_RULE_START_BLOCK);
}

#[test]
fn verify_exempts_deposits_older_than_the_lock_size_rule() {
verify_withdrawal_with_smaller_request_lock(DAO_LOCK_SIZE_RULE_START_BLOCK - 1);
}

// Shared test constants and on-chain error codes.
const MAX_CYCLES: u64 = 10_000_000;
const SHANNONS: u64 = 100_000_000;
// The node's starting_block_limiting_dao_withdrawing_lock (ckb spec/src/consensus.rs).
const DAO_LOCK_SIZE_RULE_START_BLOCK: u64 = 10_000_000;
const CKB: u64 = 100_000_000;
const GENESIS_AR: u64 = 10_000_000_000_000_000;
const SIGNATURE_SIZE: usize = 65;
const SYNTHETIC_DEPOSIT_AR: u64 = GENESIS_AR;
Expand All @@ -46,6 +165,7 @@ const ERROR_LENGTH_NOT_ENOUGH: i8 = 3;
const ERROR_AMOUNT_MISMATCH: i8 = 11;
const ERROR_AMOUNT_UNREASONABLY_BIG: i8 = 12;
const ERROR_DAO_INVALID_WITHDRAW_BLOCK: i8 = -14;
const ERROR_DAO_INVALID_WITHDRAWING_CELL: i8 = -20;
const ERROR_DAO_INCORRECT_CAPACITY: i8 = -15;
const ERROR_LIMIT_ORDER_INVALID_CONFIGURATION: i8 = 21;
const ERROR_LIMIT_ORDER_DIFFERENT_INFO: i8 = 16;
Expand All @@ -59,8 +179,10 @@ const ERROR_LIMIT_ORDER_INVALID_RATIO: i8 = 9;
const ERROR_LIMIT_ORDER_INVALID_CKB_MIN_MATCH_LOG: i8 = 10;
const ERROR_LIMIT_ORDER_CONCAVE_RATIO: i8 = 11;
const ERROR_LIMIT_ORDER_BOTH_RATIOS_NULL: i8 = 12;
const ERROR_LIMIT_ORDER_MISSING_UDT_TYPE: i8 = 13;
const ERROR_LIMIT_ORDER_INVALID_MATCH: i8 = 17;
const ERROR_LIMIT_ORDER_DECREASING_VALUE: i8 = 18;
const ERROR_LIMIT_ORDER_ATTEMPT_TO_CHANGE_FULFILLED: i8 = 19;
const ERROR_LIMIT_ORDER_INSUFFICIENT_MATCH: i8 = 20;
const ERROR_SCRIPT_PANIC: i8 = -1;
const ERROR_OWNED_OWNER_NOT_WITHDRAW_REQUEST: i8 = 6;
Expand All @@ -77,6 +199,10 @@ fn assert_script_error(err: Error, err_code: i8) {
);
}

fn fail(context: &Context, tx: &TransactionView, err_code: i8) {
assert_script_error(context.verify(tx, MAX_CYCLES).unwrap_err(), err_code);
}

fn assert_script_error_in(err: Error, err_codes: &[i8]) {
let error_string = err.to_string();
assert!(
Expand All @@ -91,6 +217,43 @@ fn load_binary(name: &str) -> Bytes {
Loader::default().load_binary(name)
}

#[test]
fn type_role_scan_with_512_unrelated_outputs_stays_within_test_cycle_budget() {
const PADDING_OUTPUTS: usize = 512;

let mut context = Context::default();
let user_lock = always_success_lock(&mut context);
let (ickb_logic, xudt) = ickb_logic_and_xudt_scripts(&mut context);
let deposit_amount = 1_000 * CKB;
let deposit_header = gen_header(1554, GENESIS_AR, 35, 1000, 1000);
let receipt_input = create_receipt(&mut context, &user_lock, &ickb_logic, 1, deposit_amount);
link_cell_to_header(&mut context, &receipt_input, &deposit_header);
let funding_input = context.create_cell(
cell((PADDING_OUTPUTS as u64 + 1) * 100 * CKB, &user_lock, None),
Bytes::new(),
);

let mut outputs = vec![cell(occupied_capacity(&user_lock, &xudt, 16), &user_lock, Some(&xudt))];
let mut outputs_data = vec![udt_data(u128::from(deposit_amount))];
for _ in 0..PADDING_OUTPUTS {
outputs.push(cell(100 * CKB, &user_lock, None));
outputs_data.push(Bytes::new());
}

let tx = TransactionBuilder::default()
.input(input(receipt_input))
.input(input(funding_input))
.outputs(outputs)
.outputs_data(outputs_data.pack())
.header_dep(deposit_header.hash())
.build();
let tx = context.complete_tx(tx);
// verify fails with ExceededMaximumCycles above the budget, so passing is the bound.
context
.verify(&tx, MAX_CYCLES)
.expect("type-role scan with 512 unrelated outputs should stay within the test cycle budget");
}

// Harness sanity checks.
#[test]
fn release_binary_hashes_match_deployment_references() {
Expand Down Expand Up @@ -129,54 +292,34 @@ fn scaffolding_tests_fail_for_the_reasons_reported() {
let mut context = Context::default();
let ickb_logic = data1_script(&mut context, "ickb_logic", Bytes::from(vec![42]));
let input_out_point = context.create_cell(
CellOutput::new_builder()
.capacity(1000u64.pack())
.lock(ickb_logic.clone())
.build(),
cell(1000 * CKB, &ickb_logic, None),
Bytes::new(),
);
let tx = TransactionBuilder::default()
.input(CellInput::new_builder().previous_output(input_out_point).build())
.input(input(input_out_point))
.outputs(vec![
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(ickb_logic.clone())
.build(),
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(ickb_logic)
.build(),
cell(500 * CKB, &ickb_logic, None),
cell(500 * CKB, &ickb_logic, None),
])
.outputs_data(vec![Bytes::new(), Bytes::new()].pack())
.build();
let tx = context.complete_tx(tx);
let err = context.verify_tx(&tx, MAX_CYCLES).unwrap_err();
assert_script_error(err, ERROR_NOT_EMPTY_ARGS);
fail(&context, &tx, ERROR_NOT_EMPTY_ARGS);

let mut context = Context::default();
let ickb_logic = ickb_logic_script(&mut context);
let input_out_point = context.create_cell(
CellOutput::new_builder()
.capacity(1000u64.pack())
.lock(ickb_logic.clone())
.build(),
cell(1000 * CKB, &ickb_logic, None),
Bytes::new(),
);
let tx = TransactionBuilder::default()
.input(CellInput::new_builder().previous_output(input_out_point).build())
.input(input(input_out_point))
.outputs(vec![
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(ickb_logic.clone())
.build(),
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(ickb_logic)
.build(),
cell(500 * CKB, &ickb_logic, None),
cell(500 * CKB, &ickb_logic, None),
])
.outputs_data(vec![Bytes::new(), Bytes::new()].pack())
.build();
let tx = context.complete_tx(tx);
let err = context.verify_tx(&tx, MAX_CYCLES).unwrap_err();
assert_script_error(err, ERROR_SCRIPT_MISUSE);
fail(&context, &tx, ERROR_SCRIPT_MISUSE);
}
Loading