Skip to content

iCKB Stack rewrite: one SDK, single-turn actors, one interface - #63

Open
phroi wants to merge 232 commits into
masterfrom
wip
Open

phroi wants to merge 232 commits into
masterfrom
wip

Conversation

@phroi

@phroi phroi commented Sep 20, 2026

Copy link
Copy Markdown
Member

This replaces the whole TypeScript stack: one published SDK, three single-turn actors, one app, and a test kit.

The previous stack grew faster than it settled: thirteen workspaces, a supervisor and a launcher around the bot, five published packages, and transaction logic spread so thin that following one conversion from the click to the signed bytes meant reading most of the repository. Reading it that way turned up a real bug: the order matcher inverted a conversion. It is fixed here and pinned by an oracle ported from the contracts, and the rest of that code is gone.

Users get an app that converts CKB to iCKB and back, shows them one date for everything they have converting, and collects what is ready with the next transaction they sign. Integrators get @ickb/sdk: one entry point, one dependency, plain sampled state and a transaction they can complete, sign and send. Operators get three processes that run one turn and exit. Every choice below, with the rejected alternatives and the accepted tradeoffs, is written down in docs/, which is the authority; this description is the map.

Shape

Thirteen workspaces become four: sdk (the published library), sdk/node (the bot, the testnet stimulus generator, the mainnet rate sampler), testkit (private test helpers), interface (the app). Gone: packages/core, dao, order, utils, node-utils, testkit, and apps/bot, tester, sampler, supervisor, interface.

@ickb/sdk is the only published package: one entry point, 25 exports, @ckb-ccc/core as its single dependency, browser safe. One file per on-chain script (udt.ts, logic.ts, owned_owner.ts, dao.ts, order/), the conversion workflow in conversion/, the send path in send/, and no barrels except the index.

What the SDK does

  • One read of the chain. getL1AccountState takes one tip and reads the book, the pool and every account lock with uncached exact-lock scans, sorting the cells here. It is complete and uncapped: a big book makes the read slower, never partial.
  • Plans that degrade instead of failing. Candidates are built most direct first, and completion takes the first one the wallet can actually fund. Short of CKB the plan turns into fewer direct withdrawals and a bigger standing order, not an error.
  • Completion never scans. It funds from the cells the state read already gave it, sweeps the rest of the account while the transaction stays under about 64 KiB, and puts change in a new plain cell.
  • The send owns its bytes. The local hash is the identity whatever the node replies, the signer cannot change ordered inputs, outputs or output data, the fee ceiling values inputs from the transaction before signing, and an ambiguous broadcast gets watched, never resent.
  • The wait ends. One bounded window, then the caller rebuilds from committed state.
  • Matching is checked against the contract. A TypeScript oracle ported from the contracts at ae8a11f, plus golden vectors generated by their Rust crate. If either port drifts, the suite fails.
  • DAO rules as the deployed script has them. Claim epochs from CCC, rechecked every run against a transcription of the deployed dao.c; the 64-output limit; the one-byte header index, with withdrawal deposit headers placed first so only they count against it.
  • Withdrawal timing is the caller's policy. A selection window and a broadcast reserve, both in epochs: tight for the bot, generous for a wallet where a human signs by hand. After signing, one fresh tip decides: a request that would commit after its claim is refused, not sent.

The maturity estimates explain how an order gets its date.

What the actors do

One turn is one process: read state, decide, send at most one transaction, exit 0 on a skip or a commit and 1 on any failure. The supervisor, the launcher, the tester and the generated config files are deleted. Restarting is systemd's job, the unit is the config, and the signing key stays in a 0600 file the unit names. It never reaches an environment value, an event or a log. The bot policy says what a turn does, in order.

What the app does

One preview per settled amount, built from exact wallet state and completed as a real transaction, then refreshed and rebuilt once more right before the wallet pops up. The balance row says what is in wallet, what is converting and what is collectable, and one "Ready:" line dates everything in flight. Every transaction the user signs also collects their converted funds, including orders the market will never fill and orders sitting for thirty days. The address in the header is also the destination: point it at another wallet and it sends the CKB and iCKB there, with no amount to type. That is the way out for a key-only wallet that has no xUDT transfer of its own.

Decisions

These are the ones that shape how the code reads. The rest, with the rejected alternatives, are in docs/.

For the user:

  • A conversion is two steps, not a position. One leg takes a DAO cycle, the other takes a fill. The app dates both and never asks the user to track them.
  • The default order fee is 0.01%. At 0.001% DAO growth put a CKB-to-iCKB order under par within four hours, so no bot would touch it. 0.01% is about two days of yield, long enough to notice a stalled bot.
  • An order thirty days on the book comes home. Dust, a remainder another matcher left, or an ask above the market can sit for a month. The next transaction the user signs melts it and returns the funds.
  • Nothing the user signs cancels a live conversion. The old model melted the standing order on every new one. That made the collect button destructive and it reset the signal that tells a seller nobody is filling.
  • CKB figures mean what the wallet holds. Capacity locked inside iCKB cells counts. Before, an address holding only iCKB read "0 CKB available" while its own Max button built a funded transaction from that same capacity.

For the integrator:

  • One package, one entry point, one dependency. Five published packages became one, and @ckb-ccc/core is the only thing it pulls in.
  • The SDK hands back state and a transaction, not a workflow. The caller completes it, signs it and sends it, and sets its own withdrawal timing policy.
  • Version 9000.0.0. The old stack left @ickb/sdk on npm at 1000.0.82, so a sane number could not publish. This one sorts above everything and takes the tag without a fight.
  • CCC stays at 1.20.0. Nothing in the releases up to 1.22.0 fixes a defect on a path this stack uses. The only correctness fix in the range is in UDT input selection, which it never calls.

For the operator:

  • One turn is one process. No supervisor, no launcher, no generated config. systemd restarts it, and a turn that hangs dies on the unit's own clock.
  • The journal is the alerting surface. Neither actor has a notification channel, because every condition worth waking someone for spans turns and only the journal sees across turns. The README shows the pipe.
  • The bot says why it did nothing. no_gain means the book offers nothing worth its fees at any size; unfunded_gain means it does but the balances cannot pay. A watcher can tell a dull market from an empty wallet.
  • The generator melts what the bot will not take. It judges its own orders with the bot's matcher, so the testnet book stays a book and not a graveyard.

For the repository:

  • One green gate per commit. pnpm check is the audit, the full lint and the interface build. Coverage is 100% on both source trees, and a dead-member lint sits beside knip because knip sees exports, not members.
  • pnpm 12, seven-day release age. Trusted publishing needs the 12 line. A freshly published package cannot be installed until it has had a week to be caught.

phroi added 30 commits August 9, 2026 17:24
Full working-tree state of the wip restructure before the planned
library rewrite. Review findings and the design blueprint that motivate
the rewrite are under docs/reviews/ (2026-08-09 snapshots).
Adds the 2026-08-09 review/design/decision snapshots and the
2026-08-10 final decisions record (the authoritative rewrite spec).
Removes the 34 July review-cycle artifacts after a salvage pass
folded their carry-overs into the final record; they remain at tag
pre-rewrite-baseline.
CCC family 1.17->1.18.2 (covers the integration-audit pin's runtime
code plus fixes #445/#448, #446, #451/#452, #453/#454; #444 remains
open upstream, mitigated by the HTTP-only fallbacks:[] client rule).
In-range refresh across tooling; react override follows the app to
19.2.8; brace-expansion override advanced for new advisories; tsgo
pinned (closes C9 with the already-removed stale exclude block);
gitignore log/ anchored to /log/. Typecheck clean, 1509 tests pass,
audit 12 vulns -> 2 (elliptic ignored-by-policy + nanoid clearing the
age gate).
…ct oracle

The matcher converted ckb_min_match into UDT units with inverted scales
(ceil(m*udtScale/ckbScale) instead of ceil(m*ckbScale/udtScale)), so at
asymmetric ratios it emitted partials the deployed limit_order script
rejects with InsufficientMatch (over-restrictive in the mainnet regime,
under-inclusive in the general one). Adds the entry.rs:116 plain-CKB-delta
post-guard as the authoritative check, a freestanding contract oracle in
testkit ported line-by-line from the deployed Rust (validate(),
deposit_to_ickb; 35 self-tests), oracle adjudication sweeps across four
ratio regimes in both directions (11 tests; 4 fail against the unfixed
matcher), and pinned contract fixtures: the four iCKB release ELFs
(contracts @ ae8a11f) plus the dao binary extracted from mainnet/testnet
genesis (byte-identical, provenance in fixtures README).
60 rows generated by the contracts repo's vector-gen crate (byte-exact
copies of deposit_to_ickb and validate with build-time drift assertions,
commit ae8a11f): 24 deposit-conversion rows incl. exact soft-cap
boundaries at three ARs, 36 limit-order rows across both directions,
dual-ratio orders, and check-ordering pins. The TS oracle agrees on all
60 (independent double-derivation). Adds node types to testkit for the
fixture loader.
Chart: live-tip marker dot, 2px non-scaling mark-spec stroke, tighter
mobile y-axis gutter (plot +15% at 390w). Form: brand casing iCKB (was
uppercased to ICKB by the grid's blanket uppercase), sentence-case
validation errors. Theme: color-scheme dark for UA scrollbars/controls.
Explainer: mobile heading scale. Removes an inert text-s utility.
Verified via preview snapshots at 1280w/390w, landing and connected
(testnet harness) states; a11y inventory and 167 interface tests intact.
250-run seeded properties (VITEST_FC_SEED replayable) across both scale
regimes: every emitted partial contract-valid via the oracle, allowance
respect, monotone consumption, full-fill shape, empty-below-minimum;
min-match boundary band probed explicitly. Core: ickbValue equals the
oracle's depositToIckb across both cap branches; convert round-trip
loss bounded exactly. Shared oracle helpers extracted for both suites.
fast-check ^4.9.0 as root devDependency. 357 tests green, no
counterexamples.
Resolves the Phase-2 blocked/unblocked contradiction, re-gates debugger
execution to the Phase-2 exit, stages the systemd cutover with a joint
rollback pair, derives the journal consumer inventory from the repo,
reorders gate/artifact dependencies, slices Phase 3, renames the
exported-allowed subtree to shared/ and adds B4 bases to the export
contract, records the B4 recipe durably with its re-probe obligation,
amends the withdrawal-selection product contract explicitly, and pins
the debugger release asset.
FakeClient extends the abstract ccc.Client: every member either consults
the in-memory ChainState (cells, headers, transactions, tip, fee stats,
send scripting) or throws FakeClientError synchronously — no network
path exists, unlike StubClient's live-client fallthrough. Overrides map
retained as the error-injection escape hatch; ClientCacheMemory keeps
caching paths real. StubClient stays untouched pending call-site
migration. One WIP fixture corrected for CCC 1.18's capacity raising
(#459): sub-occupancy outputs are illegal on-chain and now normalize at
construction.
…valence

Fee-drawing properties (0..10^7 + pinned constant) show contract
validity of emitted partials is fee-invariant, and a reachability suite
proves the entry.rs:116 post-guard is exactly equivalent to the
bMinMatch pre-gate for from()-constructed matchers (floor(u*b/a) >= m
iff u >= ceil(m*a/b); fee never enters either side): every sub-gate
would-be partial adjudicates InsufficientMatch, every admitted partial
clears the plain-CKB minimum. Guard retained as defense-in-depth with
its comment corrected from 'authoritative' to the proven equivalence.
Per review 003 residuals: section-5 title and open-items reflect the
delivered mapping; Phase-2 line carries the debugger exit gate instead
of the stale blocker; the sequencing pin replaces the four-consumer
coordinated change with the staged cutover over the grep-derived owner
inventory (now including tests/support and the supervisor README);
shared/ replaces internal/ with the B4 symbols in the root contract and
the dropped depcruise rule removed from the body.
No-subpaths confirmed safe (merged barrel adds zero retention); adopts
the entity-module-layout rule and the PURE-in-static-block ban; sets
the 2KB packed-probe budget for the 3a gate; queues two CCC upstream
reports (nested-manifest sideEffects omission, namespace monolith).
…oor gate

Replaces the evicted ephemeral B4 evidence (F-006) with a re-runnable
probe: tsgo emit + api-extractor over the amendment-7 two-module shape,
plus negative controls for ae-forgotten-export and for silent demotion
of that gate. The root-export-floor fixture typechecks all 31 contract
names against current barrels (type-aware: catches type-only impostors
of value names): 4 known-missing flagged for Phase-2 barrel work, 10
pending Phase-2 names tracked, and the contract adopts the existing
DAO_OUTPUT_LIMIT name. Re-points at the packed tarball as the 3a exit
gate. pnpm probes wires both.
typescript-eslint 8.65 flags vi.fn() spy references typed through
ccc.Signer as unbound methods (no this is captured); sonarjs 4.2 does
not track node:assert deepEqual through the smoke tests' loops. Five
reasoned per-line disables; behavior unchanged, both suites green.
ICKB-014 narrowed to post-deadline presentation in both body sites;
Phase-4 body points at the staged cutover amendments instead of the
'coordinated change' phrasing.
F-013: coverage gate green again — the entry.rs:116 mirror is exercised
via deliberate direct-constructor desync (the case it defends), and the
two FakeClient override/unscripted branches are covered.
F-014: root-export floor de-vacuated (DAO_OUTPUT_LIMIT owned by dao and
verified) and made nominally complete: result unions and per-entity
XBase consts tracked as named pending entries (38 names); provisional
status recorded as amendment 14.
F-015: committed treeshake reproducer (rollup+esbuild pinned devDeps,
pnpm probes): source-scan ban, runtime-breakage reproduction — refined
to the truth: rollup DEFAULTS keep the annotated call; the silent
class-kept/static-emptied breakage reproduces under
moduleSideEffects:false, a consumer config outside our control
(amendment 13) — and the 2KiB clean-layout budget.
F-016: body reconciled (entity check resolved, Phase-5 line, ICKB-025
supersession wording).
F-017/018/019 recorded as amendments 15-17: durable signed-bytes owner
keyed by chain/account/hash/inputs; full dependency-identity congruence
as fail-closed signing prerequisite; per-consumer confirmation-depth
table proposed for maintainer ratification.
…scribe

Keeps the dispatch describe under the 80-line function cap.
F-015: brace-aware static-block scanner with a nested-brace negative
control (the naive first-closing-brace regex provably misses deep
annotations); amendment-12 barrel claim narrowed to its exploratory
status with the packed 3a gate as the enforced form; probes wired into
the required gate (pnpm lint -> lint:probes).
F-016: resolved open-item removed; the treeshake gate names its real
command instead of the nonexistent check:deep.
F-017: the inviolable keep-list itself now requires the exact signed
bytes (amendment-15 lifecycle), not hash-only persistence.
…-019)

Supersedes the per-consumer depth table: depth 0 everywhere; automated
entities restart from clean state on revert (client reconstruction
replaces the ICKB-020 scoped cache rollback for stack consumers); the
interface communicates state transitions honestly and recovers via the
amendment-15 signed bytes; wait({depth}) stays published for off-chain-
dependent integrators; ICKB-008 recheck becomes the reorg monitor.
Merges the settling-copy residual into the ICKB-014 interface-
communication task.
…cation

Recovers e39f713 (cherry-picked: the chaining + clean-state-restart
ratification had been orphaned by a silently-failed push plus a
shared-checkout reset — push verification hardened hereafter).
F-005: amendment 11 now states the distinct-UID/LoadCredential rule it
was cited for; amendment 18's mainnet-rejection claim corrected to
harness protection (separate never-funded mainnet keys are the asset
boundary). F-014: IckbErrorCode ratified and added to the floor
manifest (39 names). F-015: the static-block scanner is TypeScript-AST
based with nested-brace AND lexical (string-brace) negative controls.
F-016: section-1 floor marked provisional-until-3a; section-2
congruence upgraded to the amendment-16 full identity binding.
F-017: appendix ICKB-007 constraint upgraded to exact signed bytes.
F-019: amendment 15 gains the chained-ancestor retention lifecycle
(parent bytes durable until all descendants terminal).
F-021: amendment 19 — every mandatory gate lands with its command and
CI membership in the slice that introduces it.
The wait only lets a turn journal its own commit; the next turn a minute
later rebuilds from committed state either way. A withdrawal request is
built inside the pool's fifteen-minute lock-up window, so a ten-minute
wait on a stuck one left five minutes to rebuild it before the deposit
locked for another thirty days. Seven days of testnet journals: 116
commits, 77 s at most, 54 s at p90. Decision 52(aj)(1).
…default exponent is 36

A fill was sized to the balances, so a fully fillable order met with a
balance a little short left a scrap under the ten-fee floor that no turn
took, and for a buyer DAO growth then buried it. `bestFill` now caps a
partial's payment at `bMaxMatch - bMinMatch`, skips the order when that
cap is under `bMinMatch`, and takes an affordable completion untouched
(a completion of any size is contract-valid). With it the SDK's default
minimum match goes from 2^33 to 2^36 shannons (about 687 CKB), so a
fresh remainder clears the floor 22 times over even at a 100,000 fee
rate; orders under about 1,375 CKB are taken whole or left, a band that
must stay under the bot's 2,000 iCKB refill line. Decision 52(aj)(2).
The order folder is one folder per on-chain script like the rest of the
SDK will be: `order/{cells,info,ratio,master,order_data,relative,
entity_validity,matcher,fill,fee,conversion,order}.ts`, no internal
barrel. Adopted from the two independent code reviews (2026-09-19):

- `OrderMatcher` is built only by `from()`: the public constructor, its
  parameter assert, the two "direct construction" guards in `match()`
  and their reachability suite are gone, with the dead real-ratio fields
  and `compareRealRatioDesc` (the bot ranks by `netOf`).
- `greatestBoundedFractionAtMost` is one loop of twenty lines; an
  exhaustive small-bound oracle (566,250 cases plus Uint64 boundaries)
  pins it, written against the old loop first.
- `OrderManager` owns its scan and transaction code as methods; the scan
  returns the group or `undefined` instead of six skip reasons nobody
  read, and `findOrders` returns an array (it always resolved every
  group before yielding).
- The five "already being spent" loops become one invariant in
  `completeTransaction`, which names an input spent twice; the order
  layer's two are gone here, the DAO layer's follow with its dissolution.
- Dead members: `Info.ckb2UdtCompare`/`udt2CkbCompare` (callers compare
  ratios), `Ratio.applyFee` (its fee scaling lives in `quoteConversion`),
  `OrderCell.isDualRatio`, `OrderCell.maturity` (the projection asks the
  estimate), `MasterCell.from`, `OrderManager.isMaster`,
  `Info.create`'s third argument, `cellInputLikeFrom` on this path.
- `minBigInt`, `maxBigInt`, `ceilDiv` once in `utils`.
…lans, one book

Adopted from the two independent code reviews and the user's decisions
of 2026-09-18/19 (decision 52(ak)):

- `IckbSdk` is one class in `sdk/src/sdk.ts` (four abstract layers, one
  leaf, no second implementor); `request`, `collect` and `estimate` are
  gone, their one caller each rewired.
- `conversion/` drops the `sdk_` prefix: types, plans, estimate,
  maturity, projection, withdrawal_ring, fundable_walk, error;
  `signerAccountLocks` lives with the signer code in `send/`.
- Direct first: iCKB-to-CKB plans are tried longest direct prefix first,
  the remainder as an order; the maturity-bucket ranking, its plan
  fields and surplus helpers are deleted (supersedes 46(a); the ranking
  also let an order leg of unknown maturity outrank a dated direct plan).
- One book, two filters: `system.orderPool` is every order past par,
  the wallet's own included; `user.orders` every order it owns.
- The withdrawal ring is one file over `IckbDepositCell`, selection is
  positional, the planner filters anchors first; `buildBaseTransaction`
  takes the context's collect steps and an optional withdrawal request.
- Records with one field or one implementor go: `PoolDepositState`,
  `IckbToCkbOrderEstimate` (the estimate carries its notice),
  `BuildBaseTransactionOptions`, `LockCells`, `FundableCompletion`, the
  two-record pool pipeline, `expectedChainIdentity`, `isIckbError`,
  `IckbError.retryable`.
- The barrel is the twenty names the interface reads plus the types
  their signatures need; the interface matches errors by class.
…ing fetch first

The SDK touches only iCKB deposits and iCKB withdrawals, never raw DAO
ones, and every `DaoManager` operation had exactly one caller in an iCKB
manager (user, 2026-09-19). Now `logic.ts` owns deposits and receipts,
`owned_owner.ts` withdrawal requests and withdrawals, both over the DAO
script deps they carry; `dao.ts` holds the plain shared rules (deposit
data, claim epoch, the lock-up window, the output and header-index
limits); `udt.ts` the token and the receipt payload it values.

Each batch reads the distinct transaction and block headers it needs
once, then decodes with pure functions, so the three promise caches,
their re-wraps and the batch cache options are gone; a deposit is valued
in one pass. Deleted with them: the out-point branches of the cell
converters, `DaoOutputLimitIndeterminateError` (an oversized transaction
with an unresolved input is refused as a DAO one), the entity classes
behind the owner and receipt payloads, `cellInputLikeFrom`, the brand on
`IckbDepositCell`, the per-layer spent-input loops. `getConfig` returns
the managers flat and the lock-up window is settled once in
`getL1AccountState`. The core and dao test suites are rewritten on the
new modules; vitest now includes every test directory. Decision 52(ak)(9).
…whole, and fold their folders

- The config reader parses the RPC URL once and returns its credential-free
  identity beside the URL; the control-character loop, the credential rule
  and `publicRpcEndpointIdentity` are gone (the URL string reaches only the
  client, the journal gets the identity fields).
- One catch in the bot entry point: `runBotTurn` propagates; the built and
  skipped events carry the decision only, whose `actions` the README's jq
  lines now read.
- The stimulus turn writes its log record directly instead of through a
  callback; `transactionShape` has one owner in `shared/format.ts`.
- Test seams out of production signatures: the emitter's `write` hook
  (tests read stdout), `matchTurn`'s partial cap, the sampler's samples
  per year, `readBotRuntimeConfig`.
- Layout: `bot/runtime/*` and `bot/policy/*` fold into `bot/`, the shared
  and stimulus barrels and `bot/failure.ts` go; tests mirror it.
Decision 52(ak).
…tion down to the form, parked queries

R17: the form draft is isCkb2Udt and text, two states in WalletGate and two
props down the same path; the C/I prefix, symbol2Direction and
direction2Symbol are gone. R18: actionMessage takes the preview and a
twelve-flag record; Action folds its four status helpers, timeUntilMaturity
moves into actionStatus.ts, the click handler and attempt ownership are a
hook and one function. R19: parseAmountInput is one anchored expression.
R22: L1StateType carries the SDK projection whole and the form reads it;
AssetDisplay nests the balance triple. R26: rateChartView returns what the
chart reads. N6: no test seams in production signatures (pending store
initial, RateChart now, cccConnection storage). N7: the submitting record
holds the promise. N11: RootConfig and QuoteState used directly. N16:
skipToken parks the quote and preview queries. Gate green; preview checked
at 1280 and 390 with the private-key harness.
…methods it found are gone

scripts/tooling/dead-members.ts loads the root and interface programs and reports a
public class member no production file references through the class, a type it
implements, or an interface of its name (const-class statics resolve through the
typed export variable); overrides exempt, testkit excluded. Wired into lint:knip.
First run: Ratio/Info/Relative/OrderData.isValid() and entity_validity.ts were
reachable only from tests; the tests assert validate() directly (52(ak)(10)).
…ates are the supply

A seller's date: one deposit's CKB now, the bot's working capital, unless a fillable
seller has sat on the book for over a turn (the origin's block number, read for free
from the master's transaction, dates the order), then each pool deposit at its real
claim date, less the deposits the same plan withdraws directly; demand is the order's
CKB plus every fillable seller priced better, at the DAO ratio; the first covering
date plus one turn, else waiting for liquidity. A buyer's date: one turn plus one per
cap of net demand ahead, the cap at the current ratio. Book orders count only when
fillsWhole would take them. BOT_TURN_MS names the ten minutes.

SystemState.ckbAvailable, ckbMaturing, CkbCumulative, poolCkb and binarySearch are
gone; poolDeposits is the supply. Contract: sdk/docs/pool_maturity_estimates.md;
decisions 52(ai)(15).
…e queries one, the page one

Chart: six rateChart* files become RateChart.tsx (the component and its unscaled labels)
and model.ts (samples, scale, ticks, view). Queries: the four query/ files become
app/queries.ts. The quote helper joins formState.ts, the button style, chain reader and
wallet label join shared/utils.ts (react-refresh keeps component files to components),
the header portal and section grid become one WalletPage the three wallet pages render
through, and chain.ts, walletGateState.ts and walletGateSupport.tsx fold into
WalletGate.tsx. interfaceLanding.tsx and staticWalletApp.tsx take their component's
name. Tests follow the layout; the element search reads named slot props.
StubClient is the one double: FakeClient and ChainState go (679 lines plus 447 of
self-tests), the legacy findCellsOnChain adapter with stub: cursors becomes pagedCells,
a page handler over a cell list sliced the way the SDK's findCells reads it, and
composedClient mirrors the client the connector hands the interface: a
ccc.Proxy.Base(ccc.Client) wrapper (ClientWithFeeRate, connector 2.0.0) that is a
ccc.Client but not a ClientJsonRpc, so the SDK's typed read paths are production paths
and the tests keep reaching them. Consult: T3 child astra-testkit-doubles-2026-09-19,
claims verified against the installed sources (52(ak)(11)).
sdk/src/index.ts is the only barrel: every importer of utils/index.ts names the owning
file (chain, codec, utils). The bot's readBotState, one importer, lives in turn.ts and
its tests in the turn's.
The thresholds give 2.2 (1.2 deposits in the inventory band, one deposit plus the
reserve to refill); the half deposit of CKB on top keeps serving sellers while a
withdrawal waits for its claim date, the steady state observed on testnet (52(aj)).
… bot refuses a credentialed RPC URL

The seller estimate subtracted the CKB an order had already received from what it
still needs, so a half-filled order read ten minutes with no CKB in sight (Opus 5,
Astra). The bot's RPC URL parser now rejects userinfo, whitespace and control
characters as the README promised; a credentialed URL reached the transport, whose
error text carries it (Astra, Sol). Docs corrected: the root README's obsolete
completion boundary and page-size sections and the src/core layout in both
READMEs, the policy note's runtime/ path, the interface README's quote module, the
generator's fee set, its ten-minute wait and its refused count (all five reviews).
Deleted: an inline copy of pushHeaderDep, the readyPoolDeposits sorting wrapper, the
generator's origin re-reads now that OrderGroup.blockNumber carries the block, a
stray doc comment and a doubled import (end-game consultation 2026-09-19).
…stor; the app watches an uncertain send

The connector hands the interface a composition proxy that fails instanceof
ccc.ClientJsonRpc, so the wait read transaction bodies, and CCC returns nothing for a
rejected transaction's null body: a rejection looked pending until the timeout and
the session stayed locked (Astra, Fable 5, Fable 5.1). jsonRpcRequestor finds the
requestor the proxy forwards; the wait and the header reader use it, so the interface
sees rejections and takes the status-only header reads the bot takes. After a send
whose answer was lost the app now enters the same confirmation window on the hash
it recorded, instead of reporting a failure and waiting for a click; a resend could
not tell what became of the first copy (Sol; Astra consult astra-send-wait-2026-09-19).
The stub client answers the raw status poll from the scripted body.
…ount as ahead; dual-ratio orders are dropped at the scan

End-game decisions 2, 6 and 7 (user, 2026-09-19/20; recorded as decisions amendment 52(al) in the closing commit).

Every iCKB is backed by a pool deposit that matures within one DAO cycle, so a sell order at par is always covered by the pool: the unknown-date path (`maturity-unavailable`, `maturityEstimateUnavailable`, "waiting for CKB liquidity", `shownMaturityText`, the projection filter and the plan branch) is deleted end to end; `maturity()` returns a date, and an order asking above par reads the pool's last claim date plus one turn. The notice keeps its one kind and loses the `kind` field.

A fillable order at the same price counts as ahead (the bot fills ties in an order of its own choosing), and an order already on the book is left out of its own queue by out point.

A dual-ratio order, valid on chain but placed by nothing in the stack, is dropped at the order scan, so nothing downstream knows it.

`CI=true pnpm check` exit 0 on this tree.
… and iCKB there, at amount zero

End-game decision 4 (user, 2026-09-20; Astra consult `astra-foreign-amount-2026-09-20`; recorded as decisions amendment 52(al) in the closing commit, superseding 52(af)'s amount clause).

A transaction to another address carries only native CKB and iCKB, never a converting position, since the new wallet may not read those, so it takes no amount: once the destination is another wallet's the amount reads "0" with "all CKB and iCKB go to the address above" under it, max and the direction switch are off, and the typed text comes back when the address is cleared (derived, no new state). The button reads "send all CKB and iCKB"; the status line says what is sent, that converting funds stay here, and "run it again until everything has been sent" when the size budget left cells behind. "Ready:" reads "now". The app's transaction boundary refuses a non-zero request to another address before reaching the SDK.

The SDK result carries `isSweepComplete`, whether every liquid cell became an input. A test pins the order the completer already had: iCKB cells first, plain last, the fee always from plain, so several transactions always fund each other.

While a transaction is prepared, signed or awaited the address reads as plain text with "cannot change during the transaction" under it. The word "move" is not used on screen (user): it named nothing the user knows; "send" is the wallet word.

Layout checked in the browser at 1280 and 390. `CI=true pnpm check` exit 0 on this tree.
…icy, checked again at send time

End-game decision 5 (user, 2026-09-19/20; Astra consult `astra-signing-deadline-2026-09-19`, two runs; recorded as decisions amendment 52(al) in the closing commit).

A withdrawal request committed after its claim locks the deposit for another cycle, and a wallet may hold the signature for a while. `LockUpPolicy` names a caller's rules in epochs, the chain's clock: a selection window (`minLockUp`, `maxLockUp`) and a `broadcastReserve`. `WALLET_LOCK_UP` (claims two hours to three days out, sent while ninety minutes remain) is the app's and the generator's; `BOT_LOCK_UP` (twenty minutes to an hour, fifteen) is the bot's and, since the bot makes the requests, the rule the estimate dates the pool's supply on. The SDK default window is gone: `getL1AccountState` takes the policy and carries it on `system.lockUp`.

Pool deposits carry their sampled `claimEpoch`; readers judge readiness with `depositMaturity` and the policy (`readyDeposits`), the ring reads the claim (one cycle round, so a roll changes no segment), and the ready guard in the base builder is gone with the sender's check subsuming it. Every builder that requests withdrawals puts `broadcastBefore`, the earliest selected claim less the reserve, on its result; `signAndSendTransaction` reads one fresh tip after the signature and the fee check and throws `TransactionExpiredError` without sending once the tip's epoch has reached it. The app releases the preview with "The withdrawal timing changed while you were signing. Nothing was sent. Review the refreshed preview and sign again."; the bot's turn fails and the next one rebuilds.

`CI=true pnpm check` exit 0 on this tree.
…dment 52(al) records the end game

End-game decision 8 (user, 2026-09-20): the sweep checks the size budget before each input, so the last one may overshoot it; accepted, and the policy and the constant's comment say so.

Amendment 52(al) records the end-game consultation (five reviewers, Grok unavailable), the eight decisions settled one at a time with their rejected alternatives and accepted tradeoffs, and the commits that built them.

`CI=true pnpm check` exit 0 on this tree.
…hatever the reason

Decision 52(am) (user, 2026-09-20). The generator's thirty-day rule moves into the SDK as `isStale`, beside `isRefused`; the app counts a stale order with the fulfilled and refused ones, so the user's next transaction melts it and returns the funds. Dust under a bot's fill cost, a remainder another matcher leaves, an ask above the market: a sell of real size is rightly never refused (52(q)), but these could sit for ever. The generator keeps drawing dust and now exercises the collection path.

Rejected: a horizon of DAO growth on the fill check (an assumption about the DAO rate, and it would melt a deliberate above-market ask); age past the order's own estimate (a bot outage would cancel every waiting order).

`CI=true pnpm check` exit 0 on this tree.
…l deposit headers go first

Decisions amendment 52(an) (user, 2026-09-20), from Grok 4.6's end-game review (`grok46-endgame-2026-09-20` over `7a08df98`: no confirmed defects), each suggestion verified against the code.

The order scan's cache-first origin read trusted a cached response without a block, so the app's long-lived client kept a lagging node's "pending" answer for the session: the order never counted as old and the estimate read it as uncommitted. As CCC's own readers do, the cache is trusted only once the entry carries a block number; no new traffic in the steady state.

Only phase 2 names a header by index, read as one byte by the deployed DAO script, so `withdraw` moves its distinct deposit headers to the front of `headerDeps`: the limit counts those alone, the projection's ready batch is the first 256 matured withdrawals whatever the receipts and requests, and the request-ahead-of-withdrawals residual is gone. Nothing else in the stack reads a header by position, each built transaction passes the step once and last, and every base transaction starts empty; `buildBaseTransaction` says so for integrators.

Two record corrections: the estimate doc's ten-minute lock-up floor is the bot's twenty since 52(al)(5); 52(al) deleted an inline copy of `pushHeaderDep`, not the function. Order expiration is settled as 52(am) after a Fable 5 consult.

`CI=true pnpm check` exit 0 on this tree.
… guards its cursor; one fit loop, one status parser

Decisions amendment 52(an), second round (user, 2026-09-20; fresh Opus 5 and Grok 4.6 audits over `f113ef91`, no confirmed defects, every suggestion verified).

`daoClaimEpoch` existed because CCC rolled a cycle on equal fractions; the pinned 1.20.0 `calcDaoClaimEpoch` is the same strict comparison, so the copy is gone and the dao.c model suite pins the installed CCC on every run.

A non-empty page always moves the indexer's cursor, so `findCells` fails with a named error when a full page brings it back unchanged, instead of looping for ever on a broken node in the app's public pool.

One withdrawal fit loop (`fitWithdrawalDeposits`, the caller sorts) replaces the bot's copy; one `rawTransactionStatus` serves the wait and the header read; the order scan's second cache write, `RING_EPOCHS` and the generator's second manager set are gone; the phase-2 `since` carries the packed epoch number rather than a hex that matched by codec coincidence. Stale comments and one README sentence corrected.

`CI=true pnpm check` exit 0 on this tree.
Clean-up settled one item at a time (user, 2026-09-20; recorded in decisions amendment 52(an)).

Deleted: the closed findings file, the rewrite overview (the root README points at the decision record), the CCC audit companion (its rows built, superseded or contradicted; its six standing CCC and node facts went to the maintainer's cross-project notes for re-verification), PRODUCT.md, the SDK's .npmignore, the two package-level .gitignore files, the private packages' licence sections, ten uncalled package scripts and four root ones, the sampler's tee into a file, a dead eslint override for a folder that no longer exists, seven stale ignore lines. Three small source files are folded into their neighbours: IckbError and the fundable walk into sdk.ts, signerAccountLocks into the sender.

Every folder that is not source or tests has a README (scripts, patches, the two docs folders, the vectors' provenance note renamed); the interface's public folder is described in its README since Vite would publish one there. The runtime-config test makes its temporary directory under the OS temp dir; the manual-run examples name the key directory the units use.

`CI=true pnpm check` exit 0 on this tree.
The three-day window of 52(v) held only while the SDK was unpublished; seven days is the standing policy before the fork (user, 2026-09-14 and 2026-09-20). A frozen install verifies every lockfile entry against the policy and passes: nothing installed is younger than a week.

`CI=true pnpm check` exit 0 on this tree.
@phroi phroi closed this Sep 20, 2026
@phroi
phroi deleted the wip branch September 20, 2026 15:39
The pre-rewrite stack left @ickb/sdk on npm at 1000.0.82, so the 0.1.0
this branch declared could not publish. 9000.0.0 sorts above everything
published, so the first release takes the latest tag with no dist-tag
surgery, and one number across the repo drops the 1001.0.0 placeholder
that marked a package unpublished. Recorded as decisions amendment
52(ao); it supersedes 52(ad)'s first-version clause.
@phroi phroi reopened this Sep 21, 2026
Read the changelogs at ckb-devrel/ccc 3e9087267bd8. Nothing in 1.20.1,
1.21.0 or 1.22.0 fixes a defect on a path we use: the one correctness
fix in the range is 1.20.1's UDT input selection, and the stack never
calls it, funding completion from the cells the state read returned and
using CCC only for completeFeeChangeToLock. Recorded as decisions
amendment 52(ap), which closes the bump slice 52(v) left open.

The minimumReleaseAge comment still described the three-day window that
was restored to seven days before it; it now says what the value is.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant