Repository navigation
feat(cluster-tool): import the LCO exports at bootstrap - #151
Conversation
`--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).
valthon
left a comment
There was a problem hiding this comment.
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: theFundingAccountJSDoc still says it is "Coupled toTokenContractSteps.runTransfer, which always signs assysio@active". This PR changesrunTransferto sign asfrom@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 }) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed: funded >= claims.
| ), | ||
| { lastPoolLiqBalance, totalLiqEthPurchased, highestTranchePriceUsd } = | ||
| snapshot.metadata, | ||
| bookedPlaceholder = |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| /** The depot chain code of the Ethereum outpost (`sysio.chains` row). */ | ||
| export const ETHEREUM_CHAIN_CODE = "ETHEREUM" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Fixed: getValue(() => LiqPoolSpec.fromJson(...) != null, false).
| ): Promise<bigint> { | ||
| const { rows, more } = await ctx.wire | ||
| .getSysioContract(SysioContractName.dclaim) | ||
| .tables.unmapped.query({ limit: WireSyndicationTool.TableRowLimit }) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Updated the |
| plan != null, | ||
| "LcoImportSteps: the cluster config carries no launch-data import" | ||
| ) | ||
| Assert.deepStrictEqual( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 && |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 && |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
| `fund sysio.dclaim with the ${formatWireAsset(claimTotal)} of imported claims`, | ||
| options, | ||
| { | ||
| from: plan.dclaimFundingAccount, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
--ethereum-bootstrap-json-file/--solana-bootstrap-json-filereplay each outpost's frozen LCO export during epoch zero, in a newLcoImportphase group afterSyndicationConfig:sysio.synd::importsynd: minted to the account the staker's public key is linked to, else parked under the key until it links.sysio.dclaim::importseed, one WIRE per pretoken, keyed by native address.sysio.liq::regliqpool. Its WIRE side is LIQ × the chain's USD price ÷ the highest tranche price.reconcile-lco-claimschecks thatsysio.dclaim::unmappedholds exactly the planned claims, andreconcile-lco-syndicationthatsysio.synd::parkedholds 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 beforeimportdonemakes it final. Both imports are then sealed, andverify-lco-importchecks each shadow's outstanding supply, its pool, the claim funding, the seals and custody mismatches. Table reads page on the node'snext_key, so an export of any size reconciles.The plan is checked against the
BootstrapPlatformConfiginvariants and written into the cluster directory aslco-import-bootstrap-platform-config.json. Each export is checked before anything runs: a Solana export must hash to itsallocationHashand, 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 meetsysio.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,sysiomust keep its T5 allotment and node-owner commitments, and no imported shadow may have supply yet. The emission config'st5_distributableexcludes 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-importimports committed fixture exports and proves each holder links, claims its WIRE and trades through the LIQSOL pool.sysio.dclaimimportseed / importdone / claim and a seeded Solana keypair join the Steps palette (flow-emissions-soakmoves 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,importseedandBootstrapPlatformConfigare on master.e2e 39682491810 ran on
95a73c30with every other repo at its manifest default.flow-lco-importpassed.flow-syndication-plugin-underwritingandflow-syndication-rate-limitfailed because wire-solananextmoved to74f75483(#583, #584) without a matching change to wire-sysio's Solana plugin:GlobalStateas the old layout and never sentreport_liq_yield; fixed by wire-sysio#691 (merged 10-22).DESYNDICATE_LIQdispatch 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 ranflow-lco-importalone on7e294dbeand passed. e2e 39959779456 ranflow-lco-importalone on74436eb2and passed;reconcile-lco-claimsreconciled all 4 planned claims before the seal. e2e 40341360903 ranflow-lco-importalone onfccb1cf2and passed. e2e 40355518998 ranflow-lco-importalone on4012b42eand passed. e2e 40366917246 ranflow-lco-importalone on8cb38ca2and passed. e2e 40373363113 ran it onfa5374c7and failed atreconcile-lco-syndication, which could not read the uncompressed EM keys nodeop returns forsysio.authex::links;1cc8c074fixes it. e2e 40379661332 ranflow-lco-importalone on1cc8c074and passed.Verified locally: build, lint and every unit suite, except one
ApiNodeStartScriptRenderertest that fails only under this host's bash 5.3.Related: wire-tools-ts#126 (Chuy's draft Ethereum import).