This post mirrors the original security review report published in ickb/contracts: any question or feedback is more than welcomed ![]()
- Review completion date: 2026-05-01.
- Reviewed contracts commit:
454cfa9. This is the last commit that changedscripts/contracts/**orscripts/Cargo.toml.- Executable test evidence: current
scripts/tests/**suite in this repository state.- Scope:
iCKB Logic,Owned Owner,Limit Order, and the sharedutilscrate.- Cross-references: the iCKB whitepaper and Nervos L1 reference implementations.
- Prior external audit: Scalebit (2024-09-10). That audit reported three issues: two informational and one minor.
Executive Summary
Under the current deployment assumptions, the review confirmed one live issue: the known Limit Order confusion attack.
iCKB Logichas a provenance-blind path: receiptless DAO-shaped outputs can later be treated as deposits, and a separately funded aggregate-deposit path can realize the split-vs-aggregate soft-cap spread.- Even so, the current
iCKB Logictests do not show theft, duplicated principal, or a standalone profit path beyond assets the caller already controls. Limit Orderremains vulnerable to phantom-order continuation, real-order stranding through fake match-state cells, and master rebinding when cloned or otherwise indistinguishable orders are cross-wired at mint or during match.Owned Ownerpreserves its pairing rules under the current whole-transaction-binding lock model; the remaining weak-lock claim-reassignment cases stay at the integration boundary.- All other candidate issues are blocked paths, generic CKB model constraints, or boundary cases relevant only to future integrations.
Findings Summary
One known live issue remains in scope.
| ID | Status | Component | Finding |
|---|---|---|---|
| LO-01 | Known | Limit Order |
CKB does not execute output locks at creation time, and limit_order accepts swapped mint pairings, so phantom and cross-wired order/master lineages can survive into later match or melt flows. |
Protocol Model and Assumptions
The component analyses below rely on the following protocol model and deployment assumptions.
iCKB is an inflation-protected CKB token (xUDT) that tokenizes NervosDAO deposits into a liquid, fungible asset. The protocol owns all CKB deposits in a shared pool. Users deposit CKB and receive iCKB; later, they can burn iCKB to withdraw from any mature deposit in that pool.
Three on-chain scripts govern that flow:
- iCKB Logic (dual-role): lock script on deposit cells, type script on receipt cells. Enforces the core balance equation and deposit-receipt accounting.
- Owned Owner: pairs withdrawal request cells with owner cells so users can claim NervosDAO phase 2 withdrawals.
- Limit Order: enables order-book-style exchange between CKB and UDTs, abstracting over NervosDAO and iCKB protocol constraints.
Key Flows
Deposit (two phases):
- Phase 1: CKB locked into NervosDAO deposit cells (lock = iCKB Logic, type = DAO). Receipts (type = iCKB Logic) track the deposits.
- Phase 2: Receipts converted to iCKB
xUDTtokens using the deposit block’s accumulated rate (AR).
Withdrawal:
- Phase 1: iCKB burned to release deposits from the pool into NervosDAO withdrawal requests.
- Phase 2: Standard NervosDAO withdrawal (outside iCKB scope).
Mixed transactions: A single transaction can combine deposit phase 1, phase 2, and withdrawal operations.
The next four execution rules explain why later findings hold or fail.
Script Grouping
Mixed transactions execute iCKB Logic twice, but both runs see the same global cells and apply the same checks, so the dual execution does not create a desync path. CKB groups scripts by both hash and role. From script/src/types.rs:179-180:
A cell can have a lock script and an optional type script. Even they reference the same script, lock script and type script will not be grouped together.
Lock groups and type groups are stored in separate BTreeMaps (types.rs:657-659). iCKB Logic uses the same {code_hash, Data1, empty_args} for both roles, so a mixed transaction produces one lock group for deposit cells and one type group for receipt cells.
Both groups still see the same global cell set (Source::Input and Source::Output, not group-local sources) and apply the same balance checks, so duplicate execution does not create a path where one run succeeds and the other fails.
Script group construction at types.rs:716-740: lock groups are built from input locks only; type groups are built from both input and output types.
xUDT Owner Mode
xUDT owner mode stays within iCKB accounting because, in the deployed configuration, only two owner-mode routes are live and both co-execute iCKB Logic.
iCKB xUDT args are built as [ickb_logic_hash, XUDT_ARGS_FLAGS] with XUDT_ARGS_FLAGS = 0x80000000.
RFC 0052’s owner-mode update has four triggers: matching input lock, matching input type, matching output type, and a witness owner_script whose hash matches the owner hash in args.
In the deployed configuration, the upstream xudt_rce.c flag parsing enables input lock by default, enables input type when flags & 0x80000000 is non-zero, and enables output type only when flags & 0x40000000 is non-zero. It also falls back to the witness owner_script path.
The witness owner_script path requires an exported validate symbol loaded via ckb_dlsym, while iCKB Logic is built as a CKB entrypoint (program_entry via ckb_std::entry!), not an xUDT validate extension. Under iCKB’s deployed args [ickb_logic_hash, 0x80000000], the live owner-mode paths are:
- A receipt (input type = iCKB Logic): needed for phase 2 minting
- A deposit (input lock = iCKB Logic): fires during withdrawal
In both cases iCKB Logic co-executes, as a type script for receipts or a lock script for deposits. Under this design, xUDT cannot enter owner mode without iCKB Logic also running.
NervosDAO Interaction
The deposit phase 1 section of the iCKB whitepaper pins NervosDAO to the historical 814eb82 dao.c. That version enforces the known 64-output-cell limit with an output_withdrawing_mask bitset. iCKB inherits that as a platform constraint, but its own correctness does not depend on the limit.
Key NervosDAO constraints confirmed against the whitepaper-pinned dao.c:
- Withdrawal request must be at the same output index as the consumed deposit.
- Withdrawal request capacity must equal the deposit capacity.
- Withdrawal request lock is not checked by
validate_withdrawing_cell, but current CKB nodes still apply the DaoScriptSizeVerifier, so the withdrawing lock must at least match the consumed deposit lock’s serialized size. - AR is read from
deposit_data.dao[8], matching iCKB’sAR_OFFSET = 160 + 8.
Header Access
Header access explains why iCKB uses a two-phase deposit flow. From script/src/syscalls/load_header.rs:
- For
Source::Input: returns the header of the block containing the cell’s creation transaction. The block hash must appear inheader_deps. - For
Source::Output: always returnsINDEX_OUT_OF_BOUND(load_header.rs:80). Deposits require two phases.
Two shared boundaries matter in the later sections: authorization and accounting.
Authorization Boundary
Several candidate issues turn on the boundary between lock-script authorization and type-script accounting. The lock script / type script split means ownership is enforced by the user lock, while ickb_logic enforces value conservation. Its balance equation and cell classification never inspect who receives the output iCKB or the phase-1 DAO claim.
Under the current whole-transaction-binding user-lock assumption, that division is acceptable because the user’s signature commits to the full transaction. The deployment model used for this review assumes current user-facing iCKB flows use strong transaction-binding locks and does not treat delegated or adopted OTX flows as in-scope present-day integrations.
The weak-lock tests in this report do not replay a current wallet path. They model future or custom delegated, OTX, or other non-output-binding integrations, where recipient binding is an integration invariant rather than a guarantee provided by the iCKB contracts.
Executed regressions show why that stays a boundary case rather than a current finding:
- Weak-lock behavior: Recipient reassignment is possible in both
iCKB LogicandOwned Owner. The phase-2 weak-lock redirect test and the withdrawal weak-lock redirect test demonstrate that weak-lock path. - Signed phase-2 minting: Once
sighashbinds the full transaction, phase-2 redirects stop working. The phase-2 sighash binding test and the mixed phase-2 sighash binding test show that binding. - Signed withdrawals: The same redirect pattern fails for withdrawal outputs once
sighashbinds the transaction. The withdrawal sighash binding test and the mixed withdrawal sighash binding test show the same result. - Witness binding: The input-group signing test and the full-witness signing test confirm the witness-binding model used by the signed path.
The same ancillary scripts section already warns that delegated and OTX ownership patterns have their own pitfalls.
Accounting Basis and Build Setting
The main accounting claims in this report rest on three properties:
- Balance equation:
entry.rs:32enforcesin_udt + in_receipts == out_udt + in_deposits. - Deposit-receipt accounting:
entry.rs:132-137requiresdeposited == receiptedper amount bucket. - Overflow checks:
overflow-checks = truekeeps the release-build accumulators from silently wrapping.
Together, these preserve protocol accounting across deposit, conversion, withdrawal, and mixed transactions.
Scope and Methodology
This section pins the reviewed sources and the checks behind the conclusions.
Reference Repositories
Methodology
The review prioritized executable behavior over static plausibility and classified issues by their concrete impact.
- Reviewed the deployed release binaries and the transaction semantics they actually enforce, not just the latest source-level intent.
- Reproduced script behavior locally with
ckb-testtool, using executable transaction tests rather than relying on inspection alone. - Reused and extended the existing test suite, including replayed transaction shapes from observed protocol flows.
- Computed NervosDAO accounting with the exact DAO withdrawal math before classifying any claim-path discrepancy as a finding.
- Separated findings that affect the current deployment from weak-lock assumptions, operator mistakes, or broader integration-only scenarios.
- Removed or downgraded candidate issues that did not yield a harmful state transition, exploitable profit path, or realistic user loss.
- Explicitly tested mixed transactions and script-group interactions because iCKB safety depends on whole-transaction execution rather than isolated cell intent.
Test Coverage
A module wiring overview plus the test layout note show three layers:
- Test harness and utilities: root harness, fixtures, encoders, signing, and replay_helpers.
- Scenario suite roots: ickb_logic, owned_owner, limit_order, and replay, each wiring topic-focused files under the matching subdirectory.
- Helper-focused unit coverage: helpers, which checks the shared encoders and witness/data builders used by the larger suites.
A fresh cargo test -p tests run passed 214 tests with no failures. Those tests cover deployment-hash sanity checks, helper encodings, core flows, blocked-path regressions, and replayed transaction shapes across iCKB Logic, Owned Owner, and Limit Order.
The strong-lock regressions in ickb_logic, owned_owner, and signing replay secp256k1_blake160_sighash_all 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 the stated deployment assumptions rather than a separate in-repo replay.
Limit Order
The deployed Limit Order binary still carries one live issue: the known confusion attack. No other candidate path in this component validates as a live issue.
Design
Limit Order supports three operations: Mint (create order plus master), Match (partial or full fill), and Melt (destroy order plus master). Orders store exchange ratios (ckb_to_udt, udt_to_ckb) and a minimum match size.
Key Validations
Value conservation (entry.rs:103-105): uses C256 (checked U256) to prevent overflow.
if i.ckb * ckb_mul + i.udt * udt_mul > o.ckb * ckb_mul + o.udt * udt_mul
Concave ratio check (entry.rs:233-235): ensures round-trip conversion doesn’t lose value for the maker:
if c2u.ckb_mul * u2c.udt_mul < c2u.udt_mul * u2c.ckb_mul
Minimum match enforcement (entry.rs:108-129): prevents dust-level partial matches.
Strict data length on execution (entry.rs:166): when limit_order executes, order data must be exactly UDT_SIZE + ORDER_SIZE bytes, so trailing data fails validation.
Assessment
Executed tests confirm several live confusion manifestations under the current deployment assumptions, and separate tests bound the rebinding path.
| ID | Status | Finding |
|---|---|---|
| LO-01 | Known | CKB does not execute output locks at creation time, and limit_order’s mint branch accepts swapped mint pairings, so mint-time output creation can seed phantom or cross-wired order/master lineages that later pass match or melt validation. |
Confirmed live manifestations:
- Phantom orders can be created without master validation: the phantom mint creation test.
- That phantom lineage can then enter and keep advancing through fake match state without a real master: the phantom mint continuation test and the fake match lineage continuation test.
- A fake match-shaped order can melt against a real master and strand the real order: the real-order stranding test.
- Cross-wired or cloned orders can rebind masters at mint or during match: the mint crosswire test and the cloned-order master-swap test.
- The cloned-order continuation is reproduced with
secp256k1_blake160_sighash_all-protected masters, so the live path is not a weak-lock-only artifact: the cloned-order master-swap test.
Bounding evidence: separate tests show that the rebinding path is narrower than arbitrary master rewriting:
- Differing mint capacities and differing match progress both block cross-wiring: the distinct mint-capacity crosswire block test and the distinct match-progress crosswire block test.
- Even real orders fail to cross-wire arbitrarily, whether the checked info matches or differs: the same-info real-order crosswire block test and the different-info mainnet crosswire block test.
The UDT → CKB zero-UDT fulfilled-order shape still fails at the outer InvalidMatch check rather than surfacing a dedicated fulfilled-order guard.
iCKB Logic
The deployed iCKB Logic binary is provenance-blind at cell classification, but once a cell is inside the scripted flow it still preserves receipt and xUDT accounting. The subsections below explain both properties and why the currently executed provenance-blind paths still stop short of a confirmed theft or profit finding.
Core Invariant
if in_udt_ickb + in_receipts_ickb != out_udt_ickb + in_deposits_ickb {
return Err(Error::AmountMismatch);
}
- Deposit phase 1:
0 + 0 == 0 + 0(deposits and receipts handled by accounting check only) - Deposit phase 2:
0 + receipt_value == out_udt + 0 - Withdrawal:
in_udt + 0 == out_udt + deposit_value - Mixed: any combination
Cell Classification
celltype.rs:60-82 classifies every cell in the transaction by examining lock and type script hashes, plus the DAO data shape when the DAO hash is present:
| Lock | Type | Classification | Line |
|---|---|---|---|
| iCKB Logic | DAO deposit (8 zero bytes) | Deposit |
L69 |
| iCKB Logic | anything else | ScriptMisuse (error) |
L72 |
| other | iCKB Logic | Receipt |
L75 |
| other | iCKB xUDT | Udt |
L78 |
| DAO deposit (8 zero bytes) as lock | any | ScriptMisuse (error) |
L62 |
| iCKB xUDT as lock | any | ScriptMisuse (error) |
L63 |
| other | other | Unknown (ignored) |
L81 |
ScriptType::None is only synthesized for missing type scripts; script_type() never returns None for locks, so the (ScriptType::None, _) arm is unreachable. NervosDAO withdrawal requests, which have non-zero data, correctly classify as Unknown via is_deposit_data.
Deposit-Receipt Accounting (check_output)
entry.rs:86-140 uses a BTreeMap<u64, Accounting> to ensure every group of same-sized output deposits is matched by equal receipt counts.
- Output deposit:
extract_unused_capacity→ keyed by amount,deposited += 1 - Output receipt:
extract_receipt_data→ keyed bydeposit_amount,receipted += quantity - Final check at
entry.rs:132-137: all entries must havedeposited == receipted
It also validates:
- Deposits:
1000 CKB <= unoccupied_capacity <= 1M CKB(entry.rs:101-106, bounds atconstants.rs:5-6) - Receipts:
deposit_quantity > 0(entry.rs:114-116) - UDT:
amount <= u64::MAX(entry.rs:123-125)
iCKB Conversion (deposit_to_ickb)
entry.rs:71-84 uses the same conversion for minting and withdrawal:
let ickb_amount = amount * ar_0 / ar_m;
if ickb_amount > ICKB_SOFT_CAP_PER_DEPOSIT {
return Ok(ickb_amount - (ickb_amount - ICKB_SOFT_CAP_PER_DEPOSIT) / 10);
}
- Division by zero: impossible (the RFC 0023 accumulated-rate rule sets
AR_0 = 10 ^ 16andAR_i = AR_{i-1} + floor(AR_{i-1} * s_i / C_{i-1}), soAR_mis non-zero). - Overflow:
u64 * u128fits in u128 (~1.8e19 * 1e16 = ~1.8e35 < 3.4e38). - Precision: integer division loses at most 1 shannon per operation.
- Fee/discount symmetry: the same function is used for both input receipts (fee,
entry.rs:58-59) and input deposits (discount,entry.rs:52). The protocol breaks even: minted amount = burned amount.
has_empty_args Validation
Cell identification relies on the exact deployed script hash. utils/utils.rs:14-36 enforces empty args on:
- The current script instance (covers input lock, input type, output type via CKB’s execution model)
- All output locks matching the same code_hash + hash_type (
utils.rs:29-31)
Output types don’t need explicit checking because they trigger execution and self-validate.
Assessment
iCKB Logic has one state-admission blind spot plus several boundary points:
- Provenance blind spot: Receiptless DAO-shaped outputs can be admitted at output-lock creation time because CKB does not execute output locks, and later treated as pool deposits because
cell_type_iterclassifies anyickb_logic-locked, DAO-typed, deposit-data input asDepositwith no receipt-provenance check. The receiptless deposit-admission test confirms that admission-plus-later-classification path. - Accepted spread path is still self-funded: When a separately provided split receipt is paired against a self-funded receiptless aggregate deposit, the contract accepts minting only the soft-cap valuation delta rather than the aggregate principal. The delta-only spread test and the oversized spread test show the accepted spread, while the deposit-alone spread block test blocks the deposit-alone variant.
- Ordinary mixed flows still apply the soft cap per receipt: The mixed phase1-phase2 soft-cap test shows that adding fresh phase-1 deposits in the same transaction does not turn this into a general aggregate soft-cap bypass.
- Executed path limit: The narrowing comment in the phase-2 claim test and the self-funded principal claim test make the current limit explicit: the self-funded aggregate principal remains claimable in DAO phase 2, so the executed path does not show third-party principal theft or duplicated recovery.
- Prefix behavior: Trailing bytes in receipt and
xUDTdata reflect prefix-based parsing, while truncated encodings are still rejected. The receipt trailing-bytes tests, truncated receipt tests, and trailing/shortxUDToutput-data tests cover that behavior. - Weak-lock boundary: Recipient-redirection scenarios remain boundary cases and do not apply under the current whole-transaction-binding user-lock assumption.
- Rounding: Whole-CKB rounding claims remain false leads. The rounding mint test and the rounding withdrawal test fail unless exact shannon precision is used.
On current executable evidence, that provenance-blind path is real, but it still falls short of a confirmed theft or standalone profit finding.
Owned Owner
The deployed Owned Owner binary preserves its pairing invariant under the current assumptions. The remaining questions concern which cells can be paired and who controls the pair at creation time.
Design
Owned Owner pairs owned cells, namely NervosDAO withdrawal requests, with owner cells through a MetaPoint derived from the owner cell’s owned_distance field:
- Mint: the owner cell stores a signed distance to the owned cell, so
owned_index == owner_index + owned_distance. - Melt: both cells must be consumed together.
Validation
For inputs and outputs separately, the script enforces the same pairing rules:
- Each metapoint must have exactly
owned == 1andowner == 1(entry.rs:57-60). - Owned cells must be NervosDAO withdrawal requests: DAO type + non-zero data (
entry.rs:45-47). - A cell using the script as both lock and type is rejected (
entry.rs:53).
Assessment
Owned Owner leaves four boundary points. The live claim-rotation path stays blocked in the currently modeled flows:
- Live claim rotation remains blocked: Attempts to roll a live claim into fresh
Owned Ownerpairs or to crosswire a fully DAO-constrained batch are rejected before a new live pairing survives. The fresh-pair rotation block test, new-pair rotation block test, and the DAO index-rule crosswire block test show that block. - Crosswired later-claim ownership still depends on weak phase-1 authorization: When phase-1 owner outputs use weak or otherwise non-output-binding locks, later DAO claims can be reassigned across mixed foreign-plus-iCKB batches or fully iCKB batches. The mixed foreign-plus-iCKB weak-lock crosswire test, two-way weak-lock crosswire claim test, and three-way weak-lock crosswire claim test show that boundary case. Under the current strong-lock deployment assumption, this is not a present finding.
- Foreign DAO withdrawal wrapping is allowed: The foreign DAO wrapping test shows that any DAO withdrawal request can be wrapped and later claimed, because the script checks only DAO type plus withdrawal-shaped data on the owned cell.
- Creator-side dead states are narrower than arbitrary malformed pairs: When
Owned Owneractually executes as a type script, it rejects orphan and count-mismatch shapes, as shown by the orphan-owner rejection test and the two-owner mismatch test. But creation still accepts pairs whose owner lock never validated, as shown by the unspendable foreign-owner-lock test and the limit-order owner-lock stranding test. It also allows lock-onlyOwned Ownerlook-alikes that later fail on spend, as shown by the lock-only non-DAO look-alike test and the lock-only DAO-deposit look-alike test.
Deployment Context and Documented Risks
Witness Malleability (Documented)
Witness malleability is a documented property, not a new finding. All three scripts use the script-as-lock (unsigned) plus script-as-type (controller) pattern, and none of them reads witnesses.
The whitepaper states the consequence directly: “if a script in a transaction needs to store data in the witness and this data can be tampered without the transaction becoming invalid, then this transaction must not employ the scripts presented in the current whitepaper.”
Non-Upgradable Deployment
The reviewed deployment is non-upgradable under the observed script references.
Under RFC 0022 and RFC 0032, the deployed scripts in transactions use hash_type = data1. Referencing scripts locate code from cell_deps by cell data hash and run it on CKB VM v1. That reference mode pins validation to the exact deployed binary bytes.
This pinning is not a property stored in the binary cell itself. It is a property of how scripts in transactions refer to that cell.
By contrast, hash_type = type would locate code by type-script hash, allowing a replacement code cell with the same type script and different contents, with upgrade policy then governed by that code cell’s lock script. Separately, the published binary cells themselves are locked with a secp256k1_blake160 zero lock, an unspendable lock, so no trusted operator key remains as an owner of the binaries.
Under the hash_type = data1 reference mode, fixing a post-deployment bug would require migration to new script deployments and a new dep group.
Conclusion
Under the current deployment assumptions, only the known Limit Order confusion attack remains live.
The iCKB Logic provenance-blind path exists, but the executed tests still stop at a self-funded edge case.
The remaining Owned Owner and cross-script cases are blocked or confined to integration/deployment boundaries.