Skip to content

feat(cluster-tool): import the LCO exports at bootstrap - #151

Merged
heifner merged 16 commits into
masterfrom
feature/lco-import
Oct 26, 2026
Merged

heifner merged 16 commits into
masterfrom
feature/lco-import

Conversation

@heifner

@heifner heifner commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

--ethereum-bootstrap-json-file / --solana-bootstrap-json-file replay each outpost's frozen LCO export during epoch zero, in a new LcoImport phase group after SyndicationConfig:

  • Staker principal is credited through sysio.synd::importsynd: minted to the account the staker's public key is linked to, else parked under the key until it links.
  • Pretokens (purchases and stakers' pretoken yield) become WIRE claims through sysio.dclaim::importseed, one WIRE per pretoken, keyed by native address.
  • The LCO's protocol-owned liq (the booked custody less every staker's principal) seeds each chain's yield pool through sysio.liq::regliqpool. Its WIRE side is LIQ × the chain's USD price ÷ the highest tranche price.
  • The claims are funded. reconcile-lco-claims checks that sysio.dclaim::unmapped holds exactly the planned claims, and reconcile-lco-syndication that sysio.synd::parked holds exactly the positions whose key has no authex link, each linked account holds exactly the positions linked to it, and each shadow's outstanding equals its booked custody. A missing, extra, wrong or doubled credit stops the import before importdone makes it final. Both imports are then sealed, and verify-lco-import checks each shadow's outstanding supply, its pool, the claim funding, the seals and custody mismatches. Table reads page on the node's next_key, so an export of any size reconciles.

The plan is checked against the BootstrapPlatformConfig invariants and written into the cluster directory as lco-import-bootstrap-platform-config.json. Each export is checked before anything runs: a Solana export must hash to its allocationHash and, against external outposts, come from the targeted program; an Ethereum staker's pubkey must be its address's key; no export list may name an address twice; every row and declared total must add up; and each pool must meet sysio.swap's own limits, seeds below 10^15 included. The claims, the T5 allotment and the node-owner commitments must fit the 1,000,000,000 WIRE supply, or the bootstrap is refused before it starts. Before the first import write, the funding account must hold every claim, sysio must keep its T5 allotment and node-owner commitments, and no imported shadow may have supply yet. The emission config's t5_distributable excludes every seeded pool's WIRE. Launch values not yet confirmed (prices, tranche price, pool parameters, the claim funding account and the Ethereum booked custody) are named placeholders. The bootstrap logs each one and refuses to run against external outposts while any remain. Mock LIQ pools skip the shadows the import seeds, and local outposts get custody backing for the imported shadow.

flow-lco-import imports committed fixture exports and proves each holder links, claims its WIRE and trades through the LIQSOL pool. sysio.dclaim importseed / importdone / claim and a seeded Solana keypair join the Steps palette (flow-emissions-soak moves onto them), token transfer steps now sign as their sender, and a push that may have reached the node is never re-sent.

No wire-sysio change is needed: importsynd, regliqpool, importseed and BootstrapPlatformConfig are on master.

e2e 39682491810 ran on 95a73c30 with every other repo at its manifest default. flow-lco-import passed. flow-syndication-plugin-underwriting and flow-syndication-rate-limit failed because wire-solana next moved to 74f75483 (#583, #584) without a matching change to wire-sysio's Solana plugin:

  • The plugin read #583's 123-byte GlobalState as the old layout and never sent report_liq_yield; fixed by wire-sysio#691 (merged 10-22).
  • The DESYNDICATE_LIQ dispatch exceeds the default 200k compute budget; HM's draft wire-sysio#705 sets the limit.

Both flows passed on this branch's previous run, 39665776949, against wire-solana d4b1d64a. e2e 39700611924 then ran flow-lco-import alone on 7e294dbe and passed. e2e 39959779456 ran flow-lco-import alone on 74436eb2 and passed; reconcile-lco-claims reconciled all 4 planned claims before the seal. e2e 40341360903 ran flow-lco-import alone on fccb1cf2 and passed. e2e 40355518998 ran flow-lco-import alone on 4012b42e and passed. e2e 40366917246 ran flow-lco-import alone on 8cb38ca2 and passed. e2e 40373363113 ran it on fa5374c7 and failed at reconcile-lco-syndication, which could not read the uncompressed EM keys nodeop returns for sysio.authex::links; 1cc8c074 fixes it. e2e 40379661332 ran flow-lco-import alone on 1cc8c074 and passed.

Verified locally: build, lint and every unit suite, except one ApiNodeStartScriptRenderer test that fails only under this host's bash 5.3.

Related: wire-tools-ts#126 (Chuy's draft Ethereum import).

`--ethereum-bootstrap-json-file` / `--solana-bootstrap-json-file` replay each outpost's frozen pre-launch export during epoch zero, in the `LcoImport` phase group: staker principal through `sysio.synd::importsynd`, pretoken claims through `sysio.dclaim::importseed`, the protocol-owned liq as the LCO pool through `sysio.liq::regliqpool` (its WIRE side at the highest tranche price), then the claims funded, both imports sealed and the result verified. The plan is checked against the `BootstrapPlatformConfig` invariants and written as that record. Unconfirmed prices, pool tuning, the Ethereum booked custody and the funding account are named placeholders; external outposts refuse them.

`flow-lco-import` imports committed fixture exports and proves each holder links, claims and trades against the pool. `dclaim` importseed / importdone / claim and a seeded Solana keypair join the Steps palette (flow-emissions-soak moves onto them), and token transfers now sign as their sender.
Both debugging fixtures build a whole `ClusterConfig`, which gained `lcoImport` in 4f6cbd3; a clean build refuses them without it.
The chain's JSON spells an ABI `bool` as `0`/`1`, so `verify-lco-import` failed on `1 === true` after both seals had landed (e2e 39665776949). The verify now reads the seals by truthiness, and its tests feed the chain's own encoding through the typed accessors.

`flow-lco-import`'s trade check now lets the pool's WIRE side exceed seed plus trade: a trade first settles the pool's owed yield into that side.
…incipal

capital-index #10 replaced the Solana export's `maxInitialChainAmount` with `initialChainAmount`, the frozen purchased principal. The PostLaunch flip books exactly staked plus purchased principal, so sizing the LIQSOL pool from `backingAvailable` minted custody slack (converted yield, donations) that the settlement ledger cannot pay out. The pool now takes exactly `initialChainAmount`, the import refuses booked principal above `backingAvailable`, and the schema matches the merged export.
`importdone` makes `sysio.dclaim`'s import final. `reconcile-lco-claims` now reads `sysio.dclaim::unmapped` after the claim batches and funding, and stops the import before the seal when a planned claim is missing, carries another amount, or a row the plan does not hold exists. Raised in review of wire-sysio#695 (CertiK WNS-63).
@heifner
heifner requested a review from valthon October 23, 2026 19:16

@valthon valthon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at 74436eb2: every changed line of the main source files (WireLcoImportTool, LcoImportSteps, the ClusterBuildDefaults / ClusterConfigProvider / CLI hunks, DclaimContractSteps, TokenContractSteps, RegistrySteps, SolanaFundingTool, LcoImportConfig, both snapshot schemas, Constants, and the emissions-soak refactor), with importsynd / importseed / importdone checked against the wire-sysio contract source. Not read: the test files, the fixture JSON, docs/production-bootstrap.md and the READMEs. Nothing was run locally. Each finding below was confirmed by reading the PR-head source.

Three are blocking for a real launch export: the 1,000-row ceiling on the claim reconcile, the sysio.synd seal that runs before any check, and the exact-equality funding check.

One finding sits outside the diff:

  • packages/cluster-tool/src/tools/wire/WireCollateralTool.ts:57: the FundingAccount JSDoc still says it is "Coupled to TokenContractSteps.runTransfer, which always signs as sysio@active". This PR changes runTransfer to sign as from@active, so the comment now describes a coupling that no longer exists. It should be updated in this PR.

)
const { rows, more } = await ctx.wire
.getSysioContract(SysioContractName.dclaim)
.tables.unmapped.query({ limit: WireSyndicationTool.TableRowLimit })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: the import can never be sealed above 1,000 claims. reconcile-lco-claims reads sysio.dclaim::unmapped once with limit: WireSyndicationTool.TableRowLimit (1,000) and then calls assertCompleteRead. A launch export with more than 1,000 claims across both chains (ClaimBatchSize = 500 implies thousands are expected) returns more = true, the assert throws, and the bootstrap stops before seal-lco-claims every time. The 4-claim fixture flow never reaches this. Read the table in pages (lower_bound / more) until the read is complete.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: WireClient table queries gain queryAll, which pages on the node's next_key, and the reconcile reads unmapped through it.

),
...(sealSyndication
? [
SyndContractSteps.planImportdone<C>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: the syndication import is sealed before anything checks it. seal-lco-syndication (sysio.synd::importdone) runs right after the claim seal. The check that outstanding supply equals custody only runs later, in verify-lco-import. importsynd adds to what is already parked, so a retried or re-pushed batch (for example after a finality timeout) credits a 50-position batch twice. The claim side would catch that in reconcile-lco-claims before its seal; here the doubled positions become final first, and the later verify fails when the import can no longer be corrected. The PR body's "a missing, extra or wrong row stops the import before importdone makes it final" covers claims only. Suggest a matching reconcile of sysio.synd's parked positions against chainPlan.syndications before this seal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added reconcile-lco-syndication before both seals. Every parked row must be a planned position at its planned amount, and each shadow's outstanding must equal its booked custody. parked alone can't be compared exactly: importsynd mints straight to the account when the pubkey already has an authex link, so that position never parks. The outstanding check covers it. A batch pushed twice fails one check or the other.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The aggregate outstanding check does not reconcile linked positions exactly. sysio.synd::importsynd mints directly to an already-linked account and creates no parked row (wire-sysio/contracts/sysio.synd/src/sysio.synd.cpp:1636-1647). Consequently, omitting linked allocation A and importing an equal-sized linked allocation B twice leaves the parked-row validation with nothing to reject and preserves aggregate outstanding, so this gate passes before irreversible importdone. Please resolve AuthX links and compare per-destination imported balance deltas, aggregating multiple planned keys that link to the same account, or retain exact import receipts that can be reconciled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: reconcile-lco-syndication now resolves each planned position as importsynd does. It reads sysio.authex::links and, for a key linked more than once, takes the lowest-keyed row, which is the one the bypubkey lookup finds. parked must hold exactly the unlinked positions, and each linked account must hold exactly the sum of the positions linked to it, including several keys linked to one account. A new verify-lco-shadows-unminted step checks before the first import write that no imported shadow has supply, so each balance the reconcile reads is the import's delta. Your example (A skipped, B imported twice) now fails on A's account.

.getSysioContract(SysioContractName.synd)
.tables.syndstate.query()
Assert.strictEqual(
funded,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: the funding check rejects any extra WIRE. This asserts that sysio.dclaim's whole WIRE balance equals the imported claim total. Any WIRE already on sysio.dclaim (a genesis allocation, an emission, any pre-funding) makes funded > claims, and the bootstrap fails even though every claim is covered. Use funded >= claims, or assert on the change across the funding transfer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: funded >= claims.

),
{ lastPoolLiqBalance, totalLiqEthPurchased, highestTranchePriceUsd } =
snapshot.metadata,
bookedPlaceholder =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Placeholder path only (when metadata.lastPoolLiqBalance is absent). bookedPlaceholder sums every staker's raw netPrincipal plus totalLiqEthPurchased in wei and floors once to 9 decimals. The syndicated total subtracted in createPool sums each position's own floor, and positions under 1e9 wei are dropped. With totalLiqEthPurchased = "0", initialChainAmount is therefore only the flooring remainders: createPool throws "no LCO liq is left for the LIQETH pool" when that is 0, and otherwise seeds the pool with a few subunits. External outposts are already blocked while placeholders remain, so this only affects local runs, but the placeholder should be derived as the syndicated total plus the scaled purchases, not floored separately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the placeholder is now the syndicated total, scaled back to wei, plus totalLiqEthPurchased, so the pool gets exactly the purchases. With no purchases the import still refuses, since no LCO liq is left for the pool.

.filter(({ staker }) => staker.pubkey == null)
.map(({ staker }) => staker.address)
Assert.ok(
unkeyed.length === 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One staker with a non-zero position and a null pubkey makes the whole Ethereum plan throw while ClusterBuildDefaults is being composed. The schema documents null as expected ("a contract wallet, or an address that never signed one itself"), so one Safe or multisig staker in the frozen export blocks the launch bootstrap until the exporter changes. Is there a decided route for that principal (an override map from address to pubkey, a claim keyed by address, or folding it into the pool)? Whichever it is, it needs to exist before the real export is run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No route is decided yet; the policy is with Jack and Chuy. Until it is, the import refuses the export and names every such staker, so no principal is dropped or credited elsewhere silently. Chuy's mainnet export run on 2026-10-13 found a recovered pubkey for all 20 stakers.

Comment thread packages/cluster-tool/src/Constants.ts Outdated
}

/** The depot chain code of the Ethereum outpost (`sysio.chains` row). */
export const ETHEREUM_CHAIN_CODE = "ETHEREUM"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ETHEREUM_CHAIN_CODE / SOLANA_CHAIN_CODE re-declare codenames the repo already declares: OperatorDaemonTool.EthereumChainCodename / SolanaChainCodename (tools/wire/OperatorDaemonTool.ts:145,147) and SolanaOutpostBootstrapper.SolanaChainCodename (orchestration/solana/SolanaOutpostBootstrapper.ts:576), while RegistrySteps still spells "ETHEREUM" / "SOLANA" as literals (RegistrySteps.ts:133,140,836,846). Per reuse-shared-symbols-never-redeclare.md, keep one declaration and reference it everywhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: Constants.ETHEREUM_CHAIN_CODENAME / SOLANA_CHAIN_CODENAME are the one declaration. OperatorDaemonTool, SolanaOutpostBootstrapper, RegistrySteps, SyndicationScenario and the flows reference them.

type LiqPoolSpecJson = Parameters<typeof LiqPoolSpec.fromJson>[0]

/** True when `json` decodes as a `LiqPoolSpec` (`fromJson` does not throw). */
function isLiqPoolSpecJson(json: unknown): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A hand-written try { …; return true } catch { return false } where getValue from @wireio/shared is the prescribed primitive (either-try-vs-guard.md): getValue(() => (LiqPoolSpec.fromJson(json as LiqPoolSpecJson), true), false).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: getValue(() => LiqPoolSpec.fromJson(...) != null, false).

): Promise<bigint> {
const { rows, more } = await ctx.wire
.getSysioContract(SysioContractName.dclaim)
.tables.unmapped.query({ limit: WireSyndicationTool.TableRowLimit })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

readUnmappedClaim / readPendingClaim / readParkedPosition each re-query the whole table and are called once per claim or holder inside eachSeries (lines 219–258), so the verify makes one full-table RPC per planned row (N²). Each read also inherits the 1,000-row hard assert, so this flow can't be pointed at a realistic export. Reading each table once (in pages) into a map keyed by address or pubkey would make the same checks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: each table is read once through queryAll into a map keyed by chain and native key or address.

options,
chainCode,
batchIndex,
WireLcoImportTool.syndicationBatch(chainPlan, batchIndex).credits

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WireLcoImportTool.syndicationBatch(...) / claimBatch(...) build each batch's full action data (base58-decoding and hex-encoding every pubkey and address) at compose time, only for .credits.length; the runner then builds them again. The count can be derived as Math.min(batchSize, length - batchIndex * batchSize) without building the batch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: WireLcoImportTool.batchCreditCount.

…table reads

Addresses valthon's review of #151.

- `reconcile-lco-syndication` runs before both seals. Every `sysio.synd::parked` row must be a planned position at its planned amount, and each shadow's outstanding must equal its booked custody: `importsynd` mints a linked pubkey's position instead of parking it.
- `WireClient` table queries gain `queryAll`, which pages on the node's `next_key`. The claim reconcile and the flow read through it, and the flow reads each table once.
- The claim funding check accepts WIRE `sysio.dclaim` already held.
- The Ethereum placeholder custody is the imported positions plus the purchases, so per-position flooring no longer reaches the pool.
- Batch credit counts no longer build the batches.
- `Constants.ETHEREUM_CHAIN_CODENAME` / `SOLANA_CHAIN_CODENAME` replace the other declarations and literals of the outpost codenames.
@heifner

heifner commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Updated the WireCollateralTool.FundingAccount JSDoc: runTransfer signs as the transfer's from.

plan != null,
"LcoImportSteps: the cluster config carries no launch-data import"
)
Assert.deepStrictEqual(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — this comparison does not prove that the imported plan is unchanged. summarize(plan) contains only counts and aggregate totals, so the export can redistribute the same total among the same number of addresses or pubkeys after composition and still pass. The newly read recipients and amounts are then published, imported, and sealed. Please persist and execute the originally validated plan, or carry and compare a canonical digest that covers every allocation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the summary now carries planDigest, a SHA-256 over every allocation (each chain's program, positions, claims and pool, and the funding account), so publishing refuses a moved allocation even with the same counts and totals.

snapshot = readExport(EthereumGenesisSnapshotSchemaCodec, file),
chainCode = Constants.ETHEREUM_CHAIN_CODENAME,
tokenCode = Constants.liqTokenCode(chainCode),
positions = snapshot.stakers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — verify that each recovered Ethereum pubkey belongs to the staker address. A positive position currently requires only a non-null, syntactically valid compressed key. A valid but unrelated key therefore redirects the liqETH position to another depot identity; the test fixture already pairs 0x1111… with the generator key, which derives to 0x7e5f…. The source Ethereum contract enforces this relationship during syndication. Please derive and normalize the address from every non-null key and require equality with staker.address before creating the credit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: every non-null staker pubkey must derive to the staker's address, or the plan is refused, naming the addresses. The unit fixture now uses the generator keys' real addresses.

function createSolanaPlan(chainConfig: LcoImportChainConfig): ChainDerivation {
const file = chainConfig.bootstrapJsonFile,
snapshot = readExport(SolanaGenesisSnapshotSchemaCodec, file),
{ accounting } = snapshot,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — bind this export to the configured Solana deployment. snapshot.programId is never consumed. In external-outpost mode, a valid export from a different liqsol_core program can therefore seed depot liabilities while the daemons target externalOutposts.solana.programId. Please require those program IDs to match before constructing the plan. A valid allocation hash for a different program would not provide this binding.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: against external outposts, the Solana export's programId must equal externalOutposts.solana.programId, checked when the bootstrap is composed.

),
// The PostLaunch flip books exactly staked plus purchased principal; minting the
// custody slack too would create LIQSOL the settlement ledger cannot pay out.
custodyTotal =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — reconcile the pool principal with the wallet allocations. accounting.initialChainAmount is trusted and only checked against available backing, but the export defines it as frozen purchased principal. It should exactly equal sum(balances[].purchasedLiqsol) in the same 9-decimal units. If the accounting value is edited upward while remaining below backingAvailable, this code mints LIQSOL pool principal that the per-wallet settlement ledger never booked. Please enforce the equality before pool construction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: initialChainAmount must equal the sum of balances[].purchasedLiqsol, both in the export's liqSOL units, as capital-index's own genesis reconciliation requires.

[
...(claimTotal > 0n
? [
TokenContractSteps.planTransfer<C>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — this full-balance transfer is unsafe under the existing ambiguous transport retry. ClioRunner retries Connection reset / Failed http request failures even though clio push action re-signs each run with fresh TAPOS. If the first transfer lands and its response resets, another complete claimTotal transfer can land. The later funded >= claims check then allows sealing with surplus WIRE stranded in sysio.dclaim, which has no surplus-withdraw action. Please distinguish ambiguous submission from proven pre-submission failure and do not re-send it, or sign once and rebroadcast the identical transaction. The additive import batches use the same path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in WireClient.withFinality, where the re-send happened: its pre-inclusion retry re-ran send after any error. It now never re-sends a push that may have reached the node, meaning clio's http_exception (Error 3200000–3200006, an HTTP failure after the connection opened) or a clio killed by its timeout. The step fails and says the push may have landed. ClioRunner's own retry is narrowed to a failed connection: with clio's libcurl transport, Failed http request to nodeop comes only from CURLE_COULDNT_CONNECT / CURLE_URL_MALFORMAT, and Connection reset is never printed, so it is no longer treated as one.

/** Derive the Solana outpost's plan from its genesis export. */
function createSolanaPlan(chainConfig: LcoImportChainConfig): ChainDerivation {
const file = chainConfig.bootstrapJsonFile,
snapshot = readExport(SolanaGenesisSnapshotSchemaCodec, file),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — validate the declared Solana allocation hash. allocationHash is part of the snapshot schema but is never read here. The checked-in flow fixture declares 9b8e…5f60; SHA-256 of its canonical compact {schemaVersion, programId, balances} JSON is ff3eafad9ecfa423ba4db83a7b9185c96b74b84434a5b4736959774de9d5298e, yet the fixture is accepted. Please recompute the exporter-defined digest and reject a mismatch before deriving any credits.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the import recomputes capital-index's digest (SHA-256 of the compact {schemaVersion, programId, balances} JSON, in the export's own key order) and refuses a mismatch. The flow fixture's hash is now ff3eafad…, matching your computation.

`${label}: its WIRE side rounds to nothing`
)
Assert.ok(
pool.initialChainAmount <= AssetMaxAmount &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — validate the aggregate assets, not only each component. This bounds each pool seed, and mergeSyndications bounds each position, but nothing requires pool.custodyTotal <= AssetMaxAmount. Claims have the same gap: each claim is bounded, while planClaimTotal can exceed the maximum balance accepted by the funding transfer. Individually valid rows can therefore compose successfully and fail during supply growth or funding after earlier batches have committed. Please reject over-range aggregate custody per shadow and aggregate claim funding while building the plan.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: each pool's custodyTotal and the total claim funding must fit asset::max_amount.

* flow's scenario defaults sets them). The export files themselves are the
* `--ethereum-bootstrap-json-file` / `--solana-bootstrap-json-file` flags.
*/
export const LcoImportOptionsSchema = z.object({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — the caller-facing schema silently strips misspelled options. The options-file loader passes the whole lcoImport object through this codec, and non-strict z.object removes unknown keys. A typo such as nativePriceUSD therefore disappears and can select placeholder economics, despite the options-file contract that unknown keys are hard errors. Please make LcoImportOptionsSchema and its nested chain-options schema strict and add top-level and nested typo tests. The persisted config schema can remain loose if forward compatibility is intentional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: LcoImportOptionsSchema and the chain options are strict. Tests cover a top-level and a nested typo, and one through the options-file loader.

@valthon valthon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at fccb1cf. The three previously reported blockers are addressed: claim reconciliation pages through all rows, syndication reconciliation runs before both seals, and the funding check accepts surplus WIRE. No new blocking findings.

Coverage: read every changed line in the 24-file follow-up commit, including the regression tests. This supplements the earlier source review at 74436eb; it is not a fresh full-PR review. Six targeted regression suites passed locally (178 tests), and the PR build-and-test check is green.

The policy for Ethereum stakers without recovered pubkeys remains open; the importer explicitly rejects those exports.

Approval posted at David's explicit request following the disclosed review coverage.

…ous push

Addresses huangminghuang's review of #151.

- The plan summary carries a digest of every allocation, so an export changed after composition is refused even with the same counts and totals.
- Each Ethereum staker's pubkey must be its address's key.
- A Solana export must hash to its `allocationHash`, its `initialChainAmount` must equal its wallets' purchased liqSOL, and against external outposts its `programId` must be the targeted program.
- Each pool's booked custody and the claim funding total must fit an asset.
- `WireClient.withFinality` no longer re-sends a push that may have reached the node (clio's `http_exception`, or a clio killed by its timeout), and `ClioRunner` retries only a failed connection.
- The `lcoImport` options are strict, so a misspelled member is an error.
- The flow fixture's `allocationHash` is the exporter's.
// custody slack too would create LIQSOL the settlement ledger cannot pay out.
custodyTotal =
toDepot(accounting.initialChainAmount) + syndicatedTotal(syndications),
backing = toDepot(accounting.backingAvailable)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — derive the backing bound instead of trusting another exported total. The schema defines backingAvailable as poolBalance - unreportedLiqsolYield, but this code never checks that relation, and allocationHash covers only balances. I reproduced a schema-valid snapshot with poolBalance = "1" and a stale backingAvailable that still passes this check and creates 6,000,000,000 units of depot custody. That can seal LIQSOL liabilities the outpost pool cannot cover. Please require poolBalance >= unreportedLiqsolYield and derive/compare backingAvailable exactly before using it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: poolBalance must cover unreportedLiqsolYield, and backingAvailable must equal their difference. More generally, allocationHash only detects a corrupted file: anyone who edits an export can recompute it. So these checks catch exporter bugs and partial edits, not deliberate tampering. The export's integrity rests on where it came from: a capital-index run and its stored artifact.

},
claims = planClaimTotal(plan)
// One transfer funds every claim, so the total must fit one asset.
Assert.ok(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — the new aggregate check is still much larger than the WIRE that can exist. AssetMaxAmount is 2^62−1, but bootstrap issues only 1,000,000,000 WIRE (1e18 atomic units). A plan with exactly 1e18 claims plus any positive pool passes here; regliqpool drains the pool WIRE first, then fund-lco-claims overdraws after the position, claim, and pool writes have committed. It also ignores the 341.5m node-owner and 375m T5 commitments. Please perform a composition-level supply budget over claims, adjusted T5, node-owner commitments, imported pools, and surviving mock pools before any step runs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed with a live check rather than one at composition: verify-lco-funding runs right after the plan is published, before the import's first write. The funding account must hold every claim, and sysio, after paying any claims it funds, must still hold the T5 allotment and the node-owner tier commitments (341.5M WIRE at the defaults). The pools sit inside the T5 allotment through the earmark below. A live read also sees whatever else the bootstrap spent, which constants could not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This remains unresolved. The new verify-lco-funding check runs during execution after the bootstrap has already deployed and configured contracts and issued WIRE; it only precedes the LCO import writes. A plan with 283,500,000.000000001 WIRE of claims still passes createPlan and composition even though it can never fit beside the 375m T5 allotment and 341.5m maximum node-owner commitment, then fails one atomic unit short after partial bootstrap state exists. Please add the original composition-time invariant where lcoImportPlan and dexEarmark are known: claims + adjusted T5 distributable + every seeded pool WIRE amount + the node-owner commitment must fit the 1bn supply. The live balance read should remain as the additional availability check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the bootstrap now checks the budget at composition, before any step runs. The claims, the T5 distributable after the earmark, every seeded pool's WIRE and the node-owner commitment must fit the 1,000,000,000 WIRE supply. Your 283,500,000.000000001 WIRE example is refused there; exactly 283,500,000 WIRE passes. verify-lco-funding stays as the live availability check.

return BootstrapPlatformConfig.create({
schemaVersion: RecordSchemaVersion,
network,
t5DexAllocation: liqPools.reduce(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — this records the DEX earmark but never applies it to live emissions. ClusterBuildDefaults still passes Constants.EMISSION_CONFIG_DEFAULTS unchanged to setemitcfg, so regliqpool removes this WIRE from sysio while t5_distributable still promises the full 375m WIRE. The wire-sysio bootstrap accounting requires t5_distributable = A - D with t5_floor unchanged; otherwise the real-balance readiness gate eventually sees a pool-sized shortfall. Please compute D from every imported and enabled surviving mock pool and use the adjusted config in the live setemitcfg action, not only in this record.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the live setemitcfg now gets t5_distributable = A − D, with D the WIRE side of every pool the bootstrap seeds (the imported ones and any mock pools that survive) and t5_floor unchanged.

`${label}: its WIRE side rounds to nothing`
)
Assert.ok(
pool.initialChainAmount <= AssetMaxAmount &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — the preflight still accepts values the downstream pool actions reject after earlier import batches commit. sysio.swap::inittoken requires both seeds < 10^15 (not merely within the asset range), and lockedShares < floor(sqrt(x*y)); the current square test accepts x=2, y=3, lockedShares=2. setyield also requires clipFloor <= asset::max_amount. Please mirror those exact constraints here; otherwise validation succeeds, positions and claims are imported, and the later pool action aborts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: seeds must be below 10^15 (INIT_MAX), lockedShares below isqrt(liq × WIRE), and clipFloor within the asset range. The 10^15 limit means a pool's WIRE side must stay under 1,000,000 WIRE; a larger launch pool is refused before the import writes anything.

delayMs: WireClient.FinalityRetryDelayMs,
label
label,
checkResult: ClioRunner.isAmbiguousSubmission

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — the follow-up remains fail-open for unrecognized post-submission failures. retry treats checkResult=true as stop, so every error not matched by isAmbiguousSubmission is re-sent. Error 3040008: Duplicate transaction is one concrete case: it proves the signed transaction already exists, but the classifier matches only killed/signal/3200xxx; this retry can re-sign with fresh TAPOS and apply a transfer or deposit again. ERR_CHILD_PROCESS_STDIO_MAXBUFFER has the same unrecognized post-submit shape. Please allowlist only errors that prove no submission or a definitive rejection, and stop on unknowns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the retry is now an allowlist. A push is re-sent only when it is proven not applied: its connection failed, or clio printed a chain error code for a rejection other than the HTTP family and 3040008 Duplicate transaction. Anything else, ERR_CHILD_PROCESS_STDIO_MAXBUFFER and a killed clio included, fails the step without re-sending.

[
...snapshot.purchasers.map(purchaser => ({
nativeAddress: nativeBytesHex(ChainKind.EVM, purchaser.address),
amount: BigInt(purchaser.totalPretokens)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — reconcile the value used for claims with its components. The snapshot schema defines totalPretokens = pretokens + pretokenYield, but this path trusts totalPretokens directly. I changed a purchaser with a 3-WIRE component total to totalPretokens = 999 WIRE; the schema and plan accepted it and produced the 999-WIRE claim. Please derive the claim from the components (or require exact equality), and apply the same consistency check to the aggregate metadata totals before funding irreversible claims.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: each row's totalPretokens must equal its pretokens plus pretokenYield, on both chains. The declared totals must equal their rows too: Solana's directlyPurchasedPretokens, allocatedPretokenYield and totalUserPretokens, and Ethereum's totalPrincipal, totalLiqEthStaked and totalLiqEthPurchased. Ethereum's metadata has no pretoken total. Each relation is exact in its exporter (capital-index, and opp-snapshot in wire-libraries-ts#109), so a genuine export always passes. The same trust boundary as above applies.

function isKeyOf(pubkey: string, address: string): boolean {
return getValue(
() =>
ethers.computeAddress(pubkey).toLowerCase() === address.toLowerCase(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — this rejects a key format the schema explicitly accepts. EvmCompressedPublicKeyPattern permits an optional 0x prefix, but ethers.computeAddress rejects the 66-character unprefixed form as an invalid BytesLike; getValue then turns that into a misleading mis-keyed failure. Normalize with evmDisplayHex(pubkey) before computeAddress, or make the prefix mandatory in the schema, and cover both forms in the test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the key goes through evmDisplayHex before computeAddress, and the tests cover both forms.

…t total

Addresses huangminghuang's second review of #151.

- The live `setemitcfg` carves every seeded pool's WIRE (the LCO pools and any mock pools) out of `t5_distributable`, as wire-sysio's T5 DEX earmark rule requires.
- `verify-lco-funding` runs before the import's first write: the funding account must hold every claim, and `sysio` must keep its T5 allotment and node-owner commitments.
- Pools meet `sysio.swap`'s own limits: seeds below 10^15, `lockedShares` below `isqrt(liq × WIRE)`, `clipFloor` within the asset range.
- Each row's `totalPretokens` must be its parts, and every declared total in both exports must equal what its rows add up to, including the Solana `backingAvailable`.
- `withFinality` re-sends a push only when its connection failed or the node rejected it; anything else, `Duplicate transaction` included, is never re-sent.
- An unprefixed staker pubkey is accepted, as the schema allows.

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fresh review at 8cb38ca2. Two new findings are inline; I also replied to the existing supply-budget and syndication-reconciliation threads because those concerns remain only partially addressed.

snapshot.allocationHash,
`WireLcoImportTool: ${file}: the balances hash to ${allocationHash}, not the declared allocationHash ${snapshot.allocationHash}`
)
assertRowTotals(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — reject duplicate source identities before merging them. assertRowTotals validates rows independently, but mergeSyndications and createClaims later sum repeated identities, so a duplicated export row is credited twice. I reproduced this on HEAD: duplicating the Ethereum fixture purchaser whose liqPrincipalIn is zero preserved every checked metadata total but raised that address claim from 2,000,000,000 to 3,500,000,000 atomic WIRE. A Solana syndication-only duplicate likewise doubled its position after recomputing the self-hash, while all current accounting checks passed. The exporters construct one row per address, so require unique balances[].address, and separately unique normalized Ethereum staker and purchaser addresses; an address may still legitimately occur once in each Ethereum array.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: an export list naming an address twice is refused before anything is derived: the Solana balances, and the Ethereum stakers and purchasers each, compared in lowercase. An address may still appear once in each Ethereum list. With unique addresses no two positions share a pubkey, so positions are no longer merged, only sorted and range-checked.

poolBalance >= unreported,
`WireLcoImportTool: ${file}: the pool's ${poolBalance} liqSOL is less than its ${unreported} unreported yield`
)
assertTotals(file, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — two exporter-defined accounting identities are still unchecked. Please also require poolPretokens == allocatedPretokenYield + unallocatedPoolPretokens and totalPretokensSold == directlyPurchasedPretokens + poolPretokens. unallocatedPoolPretokens, poolPretokens, and totalPretokensSold are outside allocationHash, so stale or partially edited values currently pass despite the importer claiming to validate every declared total. unallocatedPoolPretokens may legitimately be nonzero, so totalPretokensSold should not be compared directly with totalUserPretokens.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: a Solana export's poolPretokens must equal allocatedPretokenYield + unallocatedPoolPretokens, and its totalPretokensSold must equal directlyPurchasedPretokens + poolPretokens. A nonzero unallocatedPoolPretokens still passes.

…repeated export rows

Addresses huangminghuang's third review of #151.

- The bootstrap is refused at composition when the claims, the T5 allotment (every seeded pool's WIRE included) and the node-owner commitments exceed the 1,000,000,000 WIRE supply. `verify-lco-funding` stays as the live check.
- `reconcile-lco-syndication` resolves each position through `sysio.authex::links` as `importsynd` does: `parked` must hold exactly the unlinked positions, and each linked account exactly the positions linked to it. The new `verify-lco-shadows-unminted` checks before the first write that the import is each shadow's first mint, so each balance is what the import credited.
- An export list naming an address twice is refused: the Solana `balances`, and the Ethereum `stakers` and `purchasers` each (an address may be in both).
- A Solana export's `poolPretokens` must be its allocated plus unallocated yield, and its `totalPretokensSold` its direct purchases plus `poolPretokens`.
return new Map(
sortBy(links, link => BigInt(link.key))
.reverse()
.map(link => [PublicKey.from(link.pub_key).toString(), link.username])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — live EM link rows make this reconciliation throw before either import seal. get_table_rows renders an EM public_key through nodeop as an uncompressed PUB_EM_04… value (65 bytes). PublicKey.from(...).toString() then throws Expected 33-byte compressed key for EM, got 65; I reproduced that with the uncompressed fixture already used by sdk-core AuthEx client tests. This is reached in every normal bootstrap because nodeownreg records the bootstrap node owner Ethereum link before the LCO phase, and this loop parses every link, including unrelated ones. Normalize EM rows to compressed form before stringifying (matching sdk-core AuthexClient canonicalization) and add a live-style PUB_EM_04… row test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1cc8c07, which e2e 40373363113 caught on fa5374c: WireLcoImportTool.linkRowKey compresses each EM row the same way as sdk-core's AuthexClient before matching, and the tests build link rows as live PUB_EM_04… values. e2e 40379661332 then passed on 1cc8c07.

`fund sysio.dclaim with the ${formatWireAsset(claimTotal)} of imported claims`,
options,
{
from: plan.dclaimFundingAccount,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — this can schedule a late self-transfer after all import writes. The config accepts sysio.dclaim as dclaimFundingAccount, and runVerifyFunding accepts its existing balance. For nonzero claims, this still plans sysio.dclaim → sysio.dclaim; sysio.token::transfer rejects that with cannot transfer to self. Because the transfer is in LcoImportSeal, after every chain import phase, this schema-valid pre-funded configuration leaves the state partially imported and unsealed. Either omit funding when source is already the destination (while retaining the balance preflight), or reject this setting during plan construction before any write.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the plan now refuses sysio.dclaim as dclaimFundingAccount while it is built, before any write.

…tching positions

e2e 40373363113 failed at `reconcile-lco-syndication` on fa5374c ("Expected 33-byte compressed key for EM, got 65"). nodeop renders an EM `public_key` as unprefixed uncompressed hex (`PUB_EM_04…`), but `importsynd` hashes the compressed key into the `links` `bypubkey` index. Each row's key is now compressed before it is matched, and the tests build link rows in nodeop's own spelling.
`fund-lco-claims` transfers the claims into `sysio.dclaim`, and `sysio.token::transfer` refuses a transfer to self, so that setting would fail after every import write. The plan now refuses it before anything runs.

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed current head 0b40b3e. No remaining actionable findings. The prior EM link-key canonicalization and sysio.dclaim self-funding issues are addressed; local build, lint, focused tests, and current-head CI pass.

@heifner
heifner merged commit c7eada6 into master Oct 26, 2026
2 checks passed
@heifner
heifner deleted the feature/lco-import branch October 26, 2026 19:52
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.

3 participants