From f2984999cfdd1de3c8dc3aab2f26ed557a4f1b74 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 22 Sep 2026 13:59:32 -0500 Subject: [PATCH 1/2] cluster-tool + flow-liq-yield: bootstrap sysio.swap/sysio.liq and drive the LIQ reward flow end to end The bootstrap configures the swap, opens one shadow symbol per registered liq token and sets the kicker; the mock yield pools are opt-in (--enable-mock-liq-pools), seeded inside the epoch-0 window like the mock reserves. Steps.contracts.sysio.{liq,swap} are the per-action factories; Steps.user.planProvisionWire is the shared WIRE-user provisioning flow-swap-from-wire carried locally. flow-liq-yield: synd -> parked -> createlink sweeps it -> report_liq_yield -> liqpending -> the batch operators' queueyield and tickyield -> claim pays exactly owed(row, index) -> desyndicate -> DESYNDICATE_LIQ pays the liqSOL ATA. Two harness fixes the run surfaced: confirmSignature checked a tx's err only after returning on a confirmed status, so an on-chain failure passed as success; WireClient.symbolCodeKeyRange reads a KV row keyed by one symbol_code exactly (a bare code as a bound is a nodeop parse error). .pnpmfile.cjs takes WIRE_LIBRARIES_TS_PATH / WIRE_SYSIO_PATH so a worktree links its sibling worktrees. Pairs with wire-sysio#634 and wire-libraries-ts#84. Change-Id: I67ceaa5927bb8deb4a71ed70426e223e41a44b22 --- .pnpmfile.cjs | 23 +- CLAUDE.md | 28 +- README.md | 2 + .../src/config/ClusterConfig.ts | 7 + .../tests/config/ClusterConfig.test.ts | 5 +- packages/cluster-tool/src/Constants.ts | 10 +- .../src/cli/ClusterBuildOptionsArgs.ts | 4 + .../clients/solana/utils/signatureUtils.ts | 14 +- .../src/clients/wire/WireClient.ts | 16 + .../src/config/ClusterBuildOptions.ts | 8 + .../src/config/ClusterConfigProvider.ts | 1 + .../cluster-tool/src/flow/oppEnvelopeScan.ts | 21 + .../src/orchestration/ClusterBuildDefaults.ts | 67 +- .../src/orchestration/steps/RegistrySteps.ts | 146 +++- .../src/orchestration/steps/UserSteps.ts} | 38 +- .../steps/contracts/sysio/LiqContractSteps.ts | 274 +++++++ .../contracts/sysio/SwapContractSteps.ts | 56 ++ .../steps/contracts/sysio/index.ts | 4 + .../src/orchestration/steps/index.ts | 3 + .../tools/solana/SolanaLiqSyndicationTool.ts | 35 + .../tests/cli/ClusterBuildOptionsArgs.test.ts | 12 +- .../clients/solana/signatureUtils.test.ts | 18 + .../tests/clients/wire/WireClient.test.ts | 28 + .../tests/config/clusterConfigFixture.ts | 1 + .../tests/flow/oppEnvelopeScan.test.ts | 29 + .../ClusterBuildDefaultsMockLiqPools.test.ts | 68 ++ .../orchestration/steps/RegistrySteps.test.ts | 84 ++ .../orchestration/steps/UserSteps.test.ts | 37 + .../contracts/sysio/LiqContractSteps.test.ts | 133 ++++ .../contracts/sysio/SwapContractSteps.test.ts | 29 + .../tests/local/fixtureCluster.ts | 1 + .../tests/store/cluster/ClusterSlice.test.ts | 1 + packages/flow-liq-syndication/README.md | 16 +- .../src/LIQSyndicationScenario.ts | 20 +- packages/flow-liq-yield/README.md | 92 +++ packages/flow-liq-yield/package.json | 21 + .../flow-liq-yield/src/LIQYieldScenario.ts | 746 ++++++++++++++++++ .../src/LIQYieldScenarioConstants.ts | 117 +++ packages/flow-liq-yield/src/index.ts | 10 + .../src/steps/LIQYieldScenarioUserSteps.ts | 88 +++ packages/flow-liq-yield/src/steps/index.ts | 1 + packages/flow-liq-yield/tsconfig.json | 11 + packages/flow-liq-yield/tsconfig.src.json | 14 + .../src/SwapFromWireScenario.ts | 8 +- .../flow-swap-from-wire/src/steps/index.ts | 1 - tsconfig.json | 1 + 46 files changed, 2280 insertions(+), 69 deletions(-) rename packages/{flow-swap-from-wire/src/steps/SwapFromWireScenarioUserSteps.ts => cluster-tool/src/orchestration/steps/UserSteps.ts} (60%) create mode 100644 packages/cluster-tool/src/orchestration/steps/contracts/sysio/LiqContractSteps.ts create mode 100644 packages/cluster-tool/src/orchestration/steps/contracts/sysio/SwapContractSteps.ts create mode 100644 packages/cluster-tool/tests/orchestration/ClusterBuildDefaultsMockLiqPools.test.ts create mode 100644 packages/cluster-tool/tests/orchestration/steps/UserSteps.test.ts create mode 100644 packages/cluster-tool/tests/orchestration/steps/contracts/sysio/LiqContractSteps.test.ts create mode 100644 packages/cluster-tool/tests/orchestration/steps/contracts/sysio/SwapContractSteps.test.ts create mode 100644 packages/flow-liq-yield/README.md create mode 100644 packages/flow-liq-yield/package.json create mode 100644 packages/flow-liq-yield/src/LIQYieldScenario.ts create mode 100644 packages/flow-liq-yield/src/LIQYieldScenarioConstants.ts create mode 100644 packages/flow-liq-yield/src/index.ts create mode 100644 packages/flow-liq-yield/src/steps/LIQYieldScenarioUserSteps.ts create mode 100644 packages/flow-liq-yield/src/steps/index.ts create mode 100644 packages/flow-liq-yield/tsconfig.json create mode 100644 packages/flow-liq-yield/tsconfig.src.json diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index d82737a17..a3ee34abd 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -14,17 +14,26 @@ const Path = require("path") const Fs = require("node:fs") -// Resolve relative to this file's location -const wireLibPackagesPath = Path.resolve( - __dirname, - "..", - "wire-libraries-ts", - "packages" +/** + * Sibling repo roots. They default to the platform layout — `../wire-libraries-ts` + * and `../wire-sysio` beside this repo — and a feature worktree whose siblings are + * ALSO worktrees points them at those with `WIRE_LIBRARIES_TS_PATH` and + * `WIRE_SYSIO_PATH`, so its `@wireio/*` links resolve to the sibling branches it + * is developed against instead of the shared checkouts. The `Linked …` lines + * `pnpm install` prints are the record of what resolved. + */ +const wireLibrariesPath = Path.resolve( + process.env.WIRE_LIBRARIES_TS_PATH ?? Path.resolve(__dirname, "..", "wire-libraries-ts") ) +const wireSysioPath = Path.resolve( + process.env.WIRE_SYSIO_PATH ?? Path.resolve(__dirname, "..", "wire-sysio") +) + +const wireLibPackagesPath = Path.join(wireLibrariesPath, "packages") const wireOPPPkgPaths = ["typescript"].map(target => [ `@wireio/opp-${target}-models`, - Path.resolve(__dirname, "..", "wire-sysio", "build", "opp", target) + Path.join(wireSysioPath, "build", "opp", target) ]) /** diff --git a/CLAUDE.md b/CLAUDE.md index 9ba2815c0..8fd72ba4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ pnpm workspaces (no nx/turbo/lerna). All packages under `packages/`: |---------|---------| | `cluster-tool` (`@wireio/cluster-tool`) | THE core library: orchestration engine (PhaseGroup → Phase → Step → Report), process managers, chain clients, config/bind resolution, Steps palette, flow substrate (`FlowCLI`/`FlowScenario`), CLI | | `cluster-tool-shared` (`@wireio/cluster-tool-shared`) | Zod schema-first persisted shapes (`ClusterConfig`, `BindConfig`, `ClusterState`, `SignatureProviderConfig`, `ExternalOutpostConfig`, `ExternalClusterConfig`, `ChainTokenAmount`, `QueryEngineConfig`) behind the generic `SchemaCodec` (validate-both-ends serialize/deserialize) | -| `flow-*` (16 packages) | One scenario each — standalone executables built on `FlowCLI.create(Scenario).run()`; batch-operator lifecycle (slashing/termination), collateral, reserves, emissions soak, node-owner NFT, yield distribution, liq syndication, and the six swap variants | +| `flow-*` (17 packages) | One scenario each — standalone executables built on `FlowCLI.create(Scenario).run()`; batch-operator lifecycle (slashing/termination), collateral, reserves, emissions soak, node-owner NFT, yield distribution, liq syndication, liq yield, and the six swap variants | | `debugging-shared` / `debugging-server` / `debugging-client-shared` / `debugging-client-tool` / `debugging-client-tool-tui` | OPP debugging surface: shared types + storage paths, ingest server, RPC client, CLI, TUI | | `test-app-server` | Fixture app server used by debugging tests | @@ -294,7 +294,9 @@ against already-deployed REMOTE ETH+SOL outposts — no local anvil/validator), override merged over the resolved defaults; a remote anvil/solana address requires `--external-outpost-config`), `--enable-mock-reserves` (default off — seed the 8 mock (chain, token) PRIMARY reserves at bootstrap; a real / external -depot leaves these unseeded), `--api-count ` (default 0 — plan N API nodes: +depot leaves these unseeded), `--enable-mock-liq-pools` (default off — seed the +2 mock shadow-liq yield pools on `sysio.swap`, minting the pool shadow from +nothing; a real / external depot never does), `--api-count ` (default 0 — plan N API nodes: non-producing nodeops in the p2p mesh, port pairs under `bind.nodeop.ports.api`, loading `sysio::query_engine_plugin`, `trace_api_plugin` in every deployment kind, and never `producer_api_plugin`), and @@ -342,12 +344,17 @@ inline signing key (the GHA workflow is SSM-only, so published archives do not). the SAME `applyClusterBuildOptionsArgs` surface every flow uses (env vars `WIRE_*` seed the path flags). Exit code mirrors the bootstrap Report. -**Flow authoring — mock reserves.** A flow that reads the mock (chain, token) -PRIMARY reserves sets `enableMockReserves: true` in its `Scenario.defaults` (the -same mechanism it uses for `operatorsPerEpoch` / collateral) — never in `plan()`. -The bootstrap seeds them during epoch 0; a flow's `plan()` phases always run AFTER -`EpochBootstrap` advances epoch 0→1, and the depot gates `regreserve` to epoch 0, -so `regreserve` can never be called from a flow phase. +**Flow authoring — mock reserves and liq pools.** A flow that reads the mock +(chain, token) PRIMARY reserves sets `enableMockReserves: true` in its +`Scenario.defaults` (the same mechanism it uses for `operatorsPerEpoch` / +collateral) — never in `plan()`. The bootstrap seeds them during epoch 0; a +flow's `plan()` phases always run AFTER `EpochBootstrap` advances epoch 0→1, and +the depot gates `regreserve` to epoch 0, so `regreserve` can never be called from +a flow phase. The mock shadow-liq yield pools (`enableMockLiqPools: true`, +`sysio.liq::regliqpool`) follow the same rule for the same reason. The shadow +symbols themselves (`sysio.liq::create`, one per registered liq token), the swap's +`setconfig` and the kicker are registry setup a real depot performs too, so the +bootstrap does those unconditionally. **Flow authoring — API nodes.** A flow that needs an API node sets `apiCount` (and, if needed, `queryEngine`) in its `Scenario.defaults`; the nodes start in @@ -391,7 +398,10 @@ the `ApiNodes` group right before `OperatorNodes`. `.pnpmfile.cjs` hooks resolve `@wireio/*` packages from sibling repos (`../wire-libraries-ts/packages/` → `sdk-core`/`shared`/`shared-node`; `wire-sysio/build/opp/typescript` → `@wireio/opp-typescript-models`). They -link automatically on `pnpm install` when the siblings exist. +link automatically on `pnpm install` when the siblings exist. A feature +worktree developed against sibling WORKTREES points the hook at them — +`WIRE_LIBRARIES_TS_PATH= WIRE_SYSIO_PATH= pnpm install` +— and the `Linked …` lines it prints are the record of what resolved. > **Never depend on `@wireio/opp-solidity-models` here** — it is > `wire-ethereum`-only (`opp-models-packages.md`). diff --git a/README.md b/README.md index dbcba0968..9bf2ab750 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ pnpm workspace (no nx/turbo/lerna); everything lives under `packages/`. | `flow-batch-operator-termination` | `@wireio/test-flow-batch-operator-termination` | Batch-operator termination via delivery underperformance | | `flow-yield-distribution` | `@wireio/test-flow-yield-distribution` | `STAKING_REWARD` (Ethereum) → `sysio.dclaim::onreward` → `fundclaim` | | `flow-liq-syndication` | `@wireio/test-flow-liq-syndication` | Real `synd` / `report_liq_yield` emit `SYNDICATE_LIQ` / `LIQ_YIELD`; depot keeps advancing | +| `flow-liq-yield` | `@wireio/test-flow-liq-yield` | Syndicated liqSOL parked → linked → credited; reported yield minted, sold through `sysio.swap`, claimed as WIRE; `DESYNDICATE_LIQ` paid on the outpost | | `flow-emissions-soak` | `@wireio/test-flow-emissions-soak` | Multi-hour emissions + `sysio.dclaim` payout soak | | `debugging-*` / `test-app-server` | `@wireio/debugging-*` | OPP debugging server, client tooling, TUI, shared types | @@ -353,6 +354,7 @@ command comes first). | `--terminate-window-ms` | | — | termination evaluation window in ms | | `--bind-all` | | `false` | bind every daemon to `0.0.0.0` instead of loopback | | `--enable-mock-reserves` | | `false` | seed the 8 mock (chain, token) PRIMARY reserves at bootstrap | +| `--enable-mock-liq-pools` | | `false` | seed the 2 mock shadow-liq yield pools (LIQETH, LIQSOL) on `sysio.swap` at bootstrap — mints the pool shadow from nothing, so a real / external depot never sets it | | `--api-count` | | `0` | API nodes — non-producing nodeops meshed with bios + producers, serving `/v1/chain/*` and the query engine's `POST /v1/query/execute`; never `producer_api_plugin` | | `--query-engine-read-mode` | | nodeop's own (`head`) | read mode of the API nodes' query engine (`head` or `irreversible`); renders `read-mode` only when set | | `--query-engine-` | | plugin default | one per `query-*` limit (`worker-threads`, `max-in-flight`, `max-query-bytes`, `timeout-ms`, `max-capture-ms`, `max-abi-bytes`, `max-scan-rows`, `max-raw-bytes`, `max-memory-bytes`, `max-groups`, `max-result-rows`, `max-response-bytes`), rendered as `query-` into the API nodes' config.ini only when set; any of the thirteen requires `--api-count` ≥ 1 | diff --git a/packages/cluster-tool-shared/src/config/ClusterConfig.ts b/packages/cluster-tool-shared/src/config/ClusterConfig.ts index 31f049dcb..9700ab054 100644 --- a/packages/cluster-tool-shared/src/config/ClusterConfig.ts +++ b/packages/cluster-tool-shared/src/config/ClusterConfig.ts @@ -285,6 +285,13 @@ export const ClusterConfigSchema = z.object({ * unless a caller (or a flow's scenario defaults) opts in. */ enableMockReserves: z.boolean().default(false), + /** + * Whether the bootstrap seeds the 2 mock shadow-liq yield pools on + * `sysio.swap` (the `--enable-mock-liq-pools` create flag). Schema-defaulted + * `false` so pre-existing configs — and every real/external depot — mint no + * unbacked shadow unless a caller (or a flow's scenario defaults) opts in. + */ + enableMockLiqPools: z.boolean().default(false), /** * Which command produced this tree (SHARED-25 AC#4). Schema-defaulted * {@link ClusterDeploymentKind.local} so pre-existing configs — and every diff --git a/packages/cluster-tool-shared/tests/config/ClusterConfig.test.ts b/packages/cluster-tool-shared/tests/config/ClusterConfig.test.ts index bf2607c48..25d2e77b8 100644 --- a/packages/cluster-tool-shared/tests/config/ClusterConfig.test.ts +++ b/packages/cluster-tool-shared/tests/config/ClusterConfig.test.ts @@ -91,6 +91,7 @@ describe("ClusterConfig shape", () => { externalOutposts: null, debuggingServerEnabled: true, enableMockReserves: false, + enableMockLiqPools: false, deploymentKind: ClusterDeploymentKind.local, chainStateDbSizeMb: DefaultChainStateDbSizeMb, queryEngine: createUnsetQueryEngineConfig(), @@ -121,13 +122,14 @@ describe("ClusterConfig shape", () => { expect(rehydrated).toEqual(config) }) - it("loads a legacy config (no signatureProvider/awsClusterNodeConfig/externalOutposts/debuggingServerEnabled/enableMockReserves/deploymentKind/chainStateDbSizeMb) via schema defaults", () => { + it("loads a legacy config (no signatureProvider/awsClusterNodeConfig/externalOutposts/debuggingServerEnabled/enableMockReserves/enableMockLiqPools/deploymentKind/chainStateDbSizeMb) via schema defaults", () => { const parsed = JSON.parse(ClusterConfigSchemaCodec.serialize(config)) delete parsed.signatureProvider delete parsed.awsClusterNodeConfig delete parsed.externalOutposts delete parsed.debuggingServerEnabled delete parsed.enableMockReserves + delete parsed.enableMockLiqPools delete parsed.deploymentKind delete parsed.chainStateDbSizeMb const rehydrated = ClusterConfigSchemaCodec.deserialize( @@ -141,6 +143,7 @@ describe("ClusterConfig shape", () => { expect(rehydrated.externalOutposts).toBeNull() expect(rehydrated.debuggingServerEnabled).toBe(true) expect(rehydrated.enableMockReserves).toBe(false) + expect(rehydrated.enableMockLiqPools).toBe(false) // A config predating either field loads as the CREATE shape: trace_api on // every role, and nodeop's own stock chain-state DB size. expect(rehydrated.deploymentKind).toBe(ClusterDeploymentKind.local) diff --git a/packages/cluster-tool/src/Constants.ts b/packages/cluster-tool/src/Constants.ts index 3144e8a31..a18976087 100644 --- a/packages/cluster-tool/src/Constants.ts +++ b/packages/cluster-tool/src/Constants.ts @@ -117,6 +117,8 @@ export namespace Constants { "sysio.reserv", "sysio.chalg", "sysio.dclaim", + "sysio.swap", + "sysio.liq", "sysio.gov", "sysio.ops", "dev.owner1" @@ -320,7 +322,9 @@ export namespace Constants { "sysio.uwrit": "contracts/sysio.uwrit", "sysio.reserv": "contracts/sysio.reserv", "sysio.chalg": "contracts/sysio.chalg", - "sysio.dclaim": "contracts/sysio.dclaim" + "sysio.dclaim": "contracts/sysio.dclaim", + "sysio.swap": "contracts/sysio.swap", + "sysio.liq": "contracts/sysio.liq" } as const export type OppContractName = keyof typeof OPP_CONTRACT_PATHS @@ -335,7 +339,9 @@ export namespace Constants { "sysio.uwrit", "sysio.reserv", "sysio.chalg", - "sysio.dclaim" + "sysio.dclaim", + "sysio.swap", + "sysio.liq" ] as const /** Plugins loaded on a batch-operator node. */ diff --git a/packages/cluster-tool/src/cli/ClusterBuildOptionsArgs.ts b/packages/cluster-tool/src/cli/ClusterBuildOptionsArgs.ts index b491a6ad5..fb797a747 100644 --- a/packages/cluster-tool/src/cli/ClusterBuildOptionsArgs.ts +++ b/packages/cluster-tool/src/cli/ClusterBuildOptionsArgs.ts @@ -551,6 +551,10 @@ export function buildOptionShape( false, "seed the 8 mock (chain, token) PRIMARY reserves at bootstrap" ), + enableMockLiqPools: leaf( + false, + "seed the 2 mock shadow-liq yield pools (LIQETH, LIQSOL) at bootstrap" + ), bind: buildBindShape(defaults), bindConfig: optionalLeaf( OptionLeafType.string, diff --git a/packages/cluster-tool/src/clients/solana/utils/signatureUtils.ts b/packages/cluster-tool/src/clients/solana/utils/signatureUtils.ts index c5e79ea34..e9a1ba032 100644 --- a/packages/cluster-tool/src/clients/solana/utils/signatureUtils.ts +++ b/packages/cluster-tool/src/clients/solana/utils/signatureUtils.ts @@ -48,7 +48,9 @@ export interface ConfirmSignatureOptions { /** * Poll `getSignatureStatus(signature)` until the transaction is - * confirmed/finalized, it reports an error, or `deadlineMs` elapses (then + * confirmed/finalized WITHOUT an error, it reports an error (thrown at once: a + * transaction the runtime processed with an error is still included in a + * block, so it reaches those statuses too), or `deadlineMs` elapses (then * throws). Each status RPC is bounded by `rpcTimeoutMs` via {@link withTimeout} * so a hung validator makes the individual poll reject rather than block * forever — the deadline budget is always honoured. @@ -103,13 +105,19 @@ export async function confirmSignature( ) pollCount++ + // The error is checked FIRST: a transaction the runtime processed with an + // error is still included in a block, so its status reaches `confirmed` + // and `finalized` too, and returning on the status would report an + // on-chain failure as success (flow-liq-yield run 4: a liqSOL deposit + // refused by `EpochRewardsActive` passed as OK, and the next step found + // the ATA it never created). + if (txError) + throw new Error(`${label} tx failed: ${JSON.stringify(txError)}`) if ( confirmationStatus === SolanaClient.ConfirmationStatus.confirmed || confirmationStatus === SolanaClient.ConfirmationStatus.finalized ) return - if (txError) - throw new Error(`${label} tx failed: ${JSON.stringify(txError)}`) if (options.rebroadcast && Date.now() - lastRebroadcast >= rebroadcastMs) { await withTimeout( diff --git a/packages/cluster-tool/src/clients/wire/WireClient.ts b/packages/cluster-tool/src/clients/wire/WireClient.ts index 837003e69..ce250a0b5 100644 --- a/packages/cluster-tool/src/clients/wire/WireClient.ts +++ b/packages/cluster-tool/src/clients/wire/WireClient.ts @@ -1000,6 +1000,22 @@ export namespace WireClient { return JSON.stringify({ [field]: Name.from(account).value.toString() }) } + /** + * The exact `get_table_rows` range of ONE row of a KV table keyed by a single `symbol_code` + * field: the node takes `lower_bound` inclusive and `upper_bound` EXCLUSIVE, so the range is + * `[code, code + 1)`. A row whose VALUE carries no identity (`sysio.liq::yieldidx`) can only be + * read this way — a lower bound alone hands back the NEXT symbol's row when the one asked for + * does not exist. Same JSON-object encoding as {@link nameKeyBound}; a bare code string fails + * at parse time exactly like a bare account name does. + */ + export function symbolCodeKeyRange(field: string, code: string): TableQueryArgs { + const { value } = Asset.SymbolCode.from(code) + return { + lowerBound: JSON.stringify({ [field]: value.toString() }), + upperBound: JSON.stringify({ [field]: value.adding(1).toString() }) + } + } + /** * The single field a claimable-balance read consumes, shared by `sysio.reserv::wireclaims` and * `sysio.system::payclaims` — both rows carry `balance` in atomic units, serialized as a string diff --git a/packages/cluster-tool/src/config/ClusterBuildOptions.ts b/packages/cluster-tool/src/config/ClusterBuildOptions.ts index 87ae3cb54..fcde4aaa9 100644 --- a/packages/cluster-tool/src/config/ClusterBuildOptions.ts +++ b/packages/cluster-tool/src/config/ClusterBuildOptions.ts @@ -68,6 +68,14 @@ export interface ClusterBuildOptions { * to the bootstrap window (epoch 0), so this only ever seeds pre-EpochBootstrap. */ enableMockReserves?: boolean + /** + * Seed the 2 mock shadow-liq yield pools (LIQETH, LIQSOL) on `sysio.swap` at + * bootstrap (`--enable-mock-liq-pools`). Default `false` at every layer — an + * external / real-world depot mints NO unbacked shadow unless a caller (or a + * flow's scenario `defaults`) opts in. The depot contract gates `regliqpool` + * to the bootstrap window (epoch 0), so this only ever seeds pre-EpochBootstrap. + */ + enableMockLiqPools?: boolean // nodeop tuning /** * Uniform nodeop chain-state DB size in MiB for every node (SHARED-31). Omit diff --git a/packages/cluster-tool/src/config/ClusterConfigProvider.ts b/packages/cluster-tool/src/config/ClusterConfigProvider.ts index 1c85c0cbc..2db3b1b32 100644 --- a/packages/cluster-tool/src/config/ClusterConfigProvider.ts +++ b/packages/cluster-tool/src/config/ClusterConfigProvider.ts @@ -248,6 +248,7 @@ export namespace ClusterConfigProvider { externalOutposts, debuggingServerEnabled: true, enableMockReserves: options.enableMockReserves ?? false, + enableMockLiqPools: options.enableMockLiqPools ?? false, // `resolve` is the CREATE path, so the tree it describes is always local; // `create-external-config`'s Rebind re-stamps its merged config `external`. deploymentKind: ClusterDeploymentKind.local, diff --git a/packages/cluster-tool/src/flow/oppEnvelopeScan.ts b/packages/cluster-tool/src/flow/oppEnvelopeScan.ts index 496f991b0..ff19fa85f 100644 --- a/packages/cluster-tool/src/flow/oppEnvelopeScan.ts +++ b/packages/cluster-tool/src/flow/oppEnvelopeScan.ts @@ -149,6 +149,27 @@ export function containsLIQYield( ) } +/** + * Whether a `DESYNDICATE_LIQ` attestation has circulated on `direction` — the + * depot → outpost proof that a holder's `sysio.liq::desyndicate` reached an + * envelope the outpost pays from (`flow-liq-yield`). + * + * @param oppDebuggingDirectory - The cluster's `data/opp-debugging/` path. + * @param direction - The cross-chain edge to scan (default: depot → Solana, + * the only outpost that redeems syndicated liq today). + * @returns Whether any matching envelope carries a DESYNDICATE_LIQ attestation. + */ +export function containsDesyndicateLIQ( + oppDebuggingDirectory: string, + direction: DebugOutpostEndpointsType = DebugOutpostEndpointsType.DEPOT_OUTPOST_SOLANA +): boolean { + return envelopeDataContains( + oppDebuggingDirectory, + direction, + attestationEntryTag(AttestationType.DESYNDICATE_LIQ) + ) +} + /** * The raw attestation payloads of every `type` attestation carried by any * `.data` envelope artifact for `direction`. diff --git a/packages/cluster-tool/src/orchestration/ClusterBuildDefaults.ts b/packages/cluster-tool/src/orchestration/ClusterBuildDefaults.ts index cd6a1ce55..621b44302 100644 --- a/packages/cluster-tool/src/orchestration/ClusterBuildDefaults.ts +++ b/packages/cluster-tool/src/orchestration/ClusterBuildDefaults.ts @@ -30,7 +30,7 @@ import { ContractSteps } from "./steps/ContractSteps.js" const log = getLogger(__filename) -const { SysioContractName } = SysioContracts +const { SysioContractName, SysioContractAccount } = SysioContracts const { DeployMode } = ContractSteps const { Actor } = Report @@ -47,6 +47,14 @@ const InitialSysSupply = "1000000000.0000 SYS" const ProducerSysGrant = "1000000.0000 SYS" /** The WIRE emissions token supply (9-decimal). */ const WireSupply = "1000000000.000000000 WIRE" +/** WIRE's ABI symbol — the system token every swap pair quotes its second leg in. */ +const WireSymbol = "9,WIRE" +/** + * `sysio.liq`'s T5 kicker: the share of every yield intake requested from T5 + * and folded into the same index bump, in basis points. Mirrors the contract + * default, so a cluster pays yield the way the network will. + */ +const LiqKickerBps = 200 /** WIRE-leg swap fee (bps) + collateral-lock challenge window (dev). */ const SwapFeeBps = 30 const CollateralLockDurationMs = 600_000 @@ -551,7 +559,9 @@ export namespace ClusterBuildDefaults { SysioContractName.uwrit, SysioContractName.reserv, SysioContractName.chalg, - SysioContractName.dclaim + SysioContractName.dclaim, + SysioContractName.swap, + SysioContractName.liq ] ClusterBuildPhase.create( prerequisites, @@ -900,6 +910,59 @@ export namespace ClusterBuildDefaults { {} ) } + // The shadow-liq system, in the order sysio.liq's README gives a deployment: + // the swap's governance + system token, one shadow symbol per registered liq + // token, the T5 kicker. Registry setup a real depot performs too — unconditional. + ClusterBuildPhase.create( + prerequisites, + "SwapConfig", + "Configure sysio.swap" + ).push( + Steps.contracts.sysio.swap.planSetconfig( + Actor.Sysio, + "configure-swap", + "set the swap's fee authority and system token", + {}, + { + fee_authority: SysioContractAccount[SysioContractName.system], + system_token: { + sym: WireSymbol, + contract: SysioContractAccount[SysioContractName.token] + } + } + ) + ) + Steps.registry.planShadowLiqTokens( + prerequisites, + "ShadowLiqTokens", + "Open the shadow liq symbols on sysio.liq", + {} + ) + ClusterBuildPhase.create( + prerequisites, + "LiqConfig", + "Configure sysio.liq" + ).push( + Steps.contracts.sysio.liq.planSetkicker( + Actor.Sysio, + "configure-liq-kicker", + "set the T5 yield kicker", + {}, + { bps: LiqKickerBps } + ) + ) + // Mock shadow-liq yield pools — opt-in via `--enable-mock-liq-pools` (default + // off, so a real / external depot never mints unbacked shadow). Seeded HERE, + // pre-EpochBootstrap, because the contract gates `regliqpool` to the epoch-0 + // bootstrap window, exactly like `regreserve`. + if (config.enableMockLiqPools) { + Steps.registry.planMockLiqPools( + prerequisites, + "MockLiqPools", + "Seed the 2 mock shadow-liq yield pools on sysio.swap", + {} + ) + } ClusterBuildPhase.create( prerequisites, "UnderwriterConfig", diff --git a/packages/cluster-tool/src/orchestration/steps/RegistrySteps.ts b/packages/cluster-tool/src/orchestration/steps/RegistrySteps.ts index abad776ee..79dafa1cc 100644 --- a/packages/cluster-tool/src/orchestration/steps/RegistrySteps.ts +++ b/packages/cluster-tool/src/orchestration/steps/RegistrySteps.ts @@ -14,6 +14,7 @@ import { } from "../ClusterBuildStep.js" import { ClusterConfigProvider } from "../../config/ClusterConfigProvider.js" import type { SolanaFundingTool } from "../../tools/solana/SolanaFundingTool.js" +import { LiqContractSteps } from "./contracts/sysio/LiqContractSteps.js" import { ReservContractSteps } from "./contracts/sysio/ReservContractSteps.js" import { OperatorDaemonArtifactsKey } from "../outputs/OperatorDaemonArtifacts.js" @@ -82,6 +83,78 @@ export namespace RegistrySteps { toReserveRegistration(chainCodename, tokenCodename, label) ) + /** + * The shadow liq symbols the bootstrap opens on `sysio.liq` — `[chainCodename, + * tokenCodename]`, one per liq token {@link runSeedRegistry} registers. Private + * source both {@link ShadowLiqTokenRegistrations} and + * {@link MockLiqPoolRegistrations} derive from, in this order. + */ + const ShadowLiqTokenPairs = [ + ["ETHEREUM", "LIQETH"], + ["SOLANA", "LIQSOL"] + ] as const + /** Depot-frame precision every liq token is registered at, which its shadow symbol carries. */ + const LiqTokenPrecision = 9 + /** Suffix on a shadow's token codename naming its yield pool's pair token (`LIQSOL` → `LIQSOLP`). */ + const LiqPoolPairSuffix = "P" + /** + * Mock yield-pool parameters, per pool. The two seeds and the fee mirror the + * dev bootstrap config (`etc/config/dex/dex-config.dev.json`); the tick pacing + * is the dev cluster's own — a 30 s horizon and a 30 % depth cap sell a flow's + * reported yield in ONE tick, so a scenario observes one index bump and then a + * stable ledger instead of a day-long drip. + */ + const MockLiqPoolShadowSeed = 10_000_000_000 + /** WIRE (9-dec base units) the system account seeds each mock pool with. */ + const MockLiqPoolWireSeed = 10_000_000_000 + /** Swap fee of each mock pool, per ten-thousand (0.30 %). */ + const MockLiqPoolFee = 30 + /** Pair-token shares locked at each mock pool's creation. */ + const MockLiqPoolLockedShares = 0 + /** Seconds over which a mock pool's reservoir is time-shared into clips. */ + const MockLiqPoolConversionHorizonSec = 30 + /** Ceiling of one clip as bps of the pool's shadow depth. */ + const MockLiqPoolDepthCapBps = 3000 + /** Smallest clip (shadow base units) a mock pool sells. */ + const MockLiqPoolClipFloor = 1000 + + /** A symbol in ABI form (`9,LIQSOL`) from a codename, at the liq precision. */ + function liqSymbol(codename: string): string { + return `${LiqTokenPrecision},${codename}` + } + + /** + * The 2 `sysio.liq::create` rows — one shadow symbol per registered liq + * token, bound to that token's chain. Shared by {@link planShadowLiqTokens} + * (one Report step per row) and its unit test. + */ + export const ShadowLiqTokenRegistrations: SysioContracts.SysioLiqCreateAction[] = + ShadowLiqTokenPairs.map(([chainCodename, tokenCodename]) => ({ + sym: liqSymbol(tokenCodename), + chain_code: { value: SlugName.from(chainCodename) }, + token_code: { value: SlugName.from(tokenCodename) } + })) + + /** + * The 2 mock `sysio.liq::regliqpool` rows — one yield pool per shadow, seeded + * from the system account. Shared by {@link planMockLiqPools} (one Report step + * per row) and its unit test. The contract gates `regliqpool` to the bootstrap + * window (epoch 0), so these seed ONLY during bootstrap — never from a flow phase. + */ + export const MockLiqPoolRegistrations: SysioContracts.SysioLiqRegliqpoolAction[] = + ShadowLiqTokenPairs.map(([chainCodename, tokenCodename]) => ({ + chain_code: { value: SlugName.from(chainCodename) }, + token_code: { value: SlugName.from(tokenCodename) }, + pair_symbol: liqSymbol(`${tokenCodename}${LiqPoolPairSuffix}`), + initial_chain_amount: MockLiqPoolShadowSeed, + initial_wire_amount: MockLiqPoolWireSeed, + fee: MockLiqPoolFee, + locked_shares: MockLiqPoolLockedShares, + conversion_horizon_sec: MockLiqPoolConversionHorizonSec, + depth_cap_bps: MockLiqPoolDepthCapBps, + clip_floor: MockLiqPoolClipFloor + })) + /** Seed chains + tokens + chain-token bindings. */ export function planSeedRegistry< C extends ClusterBuildContext = ClusterBuildContext @@ -208,6 +281,77 @@ export namespace RegistrySteps { return ClusterBuildPhase.create(parent, name, description, steps) } + /** + * ONE phase of per-shadow `sysio.liq::create` steps — the shadow symbols the + * depot mints syndicated liq into, one per registered liq token. Registry + * setup, not mock data: a real depot opens the same symbols, so this phase is + * unconditional. It runs after {@link planSeedRegistry} (each shadow binds to + * a registered, active liq token) and after `sysio.swap` is configured (the + * pools below trade the shadows against its system token). Self-registers + * on `parent`. + * + * @param parent - The build root or enclosing PhaseGroup. + * @param name - Short phase name. + * @param description - Human-readable phase description. + * @param options - Step option overrides threaded to every create step. + * @returns The self-registered shadow-token phase. + */ + export function planShadowLiqTokens< + C extends ClusterBuildContext = ClusterBuildContext + >( + parent: ClusterBuildParent, + name: string, + description: string, + options: ClusterBuildStepOptions + ): ClusterBuildPhase { + const steps: ClusterBuildStep.Any[] = ShadowLiqTokenPairs.map( + ([, tokenCodename], index) => + LiqContractSteps.planCreate( + Report.Actor.Sysio, + `create-shadow-${tokenCodename.toLowerCase()}`, + `open the ${tokenCodename} shadow symbol on sysio.liq`, + options, + ShadowLiqTokenRegistrations[index] + ) + ) + return ClusterBuildPhase.create(parent, name, description, steps) + } + + /** + * ONE phase of per-pool `sysio.liq::regliqpool` steps — the mock shadow-liq + * yield pools on `sysio.swap`, gated behind `--enable-mock-liq-pools` (default + * off, so a real / external depot never mints unbacked shadow). The contract + * gates `regliqpool` to the bootstrap window (epoch 0), so this phase only + * ever runs pre-EpochBootstrap and can never be reached from a flow phase. + * Runs after {@link planShadowLiqTokens}. Self-registers on `parent`. + * + * @param parent - The build root or enclosing PhaseGroup. + * @param name - Short phase name. + * @param description - Human-readable phase description. + * @param options - Step option overrides threaded to every pool step. + * @returns The self-registered pool-seeding phase. + */ + export function planMockLiqPools< + C extends ClusterBuildContext = ClusterBuildContext + >( + parent: ClusterBuildParent, + name: string, + description: string, + options: ClusterBuildStepOptions + ): ClusterBuildPhase { + const steps: ClusterBuildStep.Any[] = ShadowLiqTokenPairs.map( + ([chainCodename, tokenCodename], index) => + LiqContractSteps.planRegliqpool( + Report.Actor.Sysio, + `seed-liq-pool-${chainCodename.toLowerCase()}-${tokenCodename.toLowerCase()}`, + `seed the ${tokenCodename}/WIRE yield pool on sysio.swap`, + options, + MockLiqPoolRegistrations[index] + ) + ) + return ClusterBuildPhase.create(parent, name, description, steps) + } + /** Named runner — port of the old `ClusterManager` Phase 16 / 16a / 16b (chains, tokens, chain-token bindings). */ export async function runSeedRegistry( ctx: C, @@ -441,7 +585,7 @@ export namespace RegistrySteps { code: { value: SlugName.from(codename) }, symbol_name: symbolName, description, - precision: 9, + precision: LiqTokenPrecision, address } } diff --git a/packages/flow-swap-from-wire/src/steps/SwapFromWireScenarioUserSteps.ts b/packages/cluster-tool/src/orchestration/steps/UserSteps.ts similarity index 60% rename from packages/flow-swap-from-wire/src/steps/SwapFromWireScenarioUserSteps.ts rename to packages/cluster-tool/src/orchestration/steps/UserSteps.ts index d9d1a4ace..7d2beb9dc 100644 --- a/packages/flow-swap-from-wire/src/steps/SwapFromWireScenarioUserSteps.ts +++ b/packages/cluster-tool/src/orchestration/steps/UserSteps.ts @@ -1,31 +1,33 @@ +import { Report } from "../../report/Report.js" +import { provisionWireUser } from "../../tools/wire/WireUserTool.js" +import { ClusterBuildContext } from "../ClusterBuildContext.js" import { ClusterBuildStep, - provisionWireUser, - type ClusterBuildContext, - type ClusterBuildStepOptions, - type Report, - type StepInput -} from "@wireio/cluster-tool" + type ClusterBuildStepOptions +} from "../ClusterBuildStep.js" +import type { StepInput } from "../StepRunner.js" /** - * Flow-local user Steps — WIRE-side depositor provisioning. The harness ships - * {@link provisionWireUser} as flow-layer plumbing (create account + resource - * policy + optional treasury funding); this factory lifts it into ONE - * Report-validated `ClusterBuildStep` per the plan's `Steps.user.provisionWire` - * shape. + * Steps that provision the WIRE-side user identities a flow scenario acts + * as: a swap depositor, a swap-to-WIRE recipient, a reserve owner, a liq + * holder. The harness ships {@link provisionWireUser} as the flow-layer + * plumbing (create the account under the dev key, attach the resource + * policy, optionally fund it from the treasury); these factories lift it + * into ONE Report-validated step per user. */ -export namespace SwapFromWireScenarioUserSteps { +export namespace UserSteps { /** Input for {@link planProvisionWire}. */ export interface ProvisionWireInput extends StepInput { - readonly kind: "SwapFromWireScenarioUserSteps.ProvisionWireInput" + readonly kind: "UserSteps.ProvisionWireInput" /** WIRE account name to provision. */ readonly account: string - /** Raw 9-dec WIRE base units funded from the `sysio` treasury. */ + /** Raw 9-dec WIRE base units funded from the `sysio` treasury; `0n` creates the account unfunded. */ readonly fundWireAmount: bigint } /** - * Provision a WIRE user account and fund it from the treasury. + * Provision a WIRE user account, funded from the treasury when + * `fundWireAmount` is positive. * * @param actor - The narrative subject. * @param name - Step name (report row). @@ -50,11 +52,7 @@ export namespace SwapFromWireScenarioUserSteps { name, description, options, - { - kind: "SwapFromWireScenarioUserSteps.ProvisionWireInput", - account, - fundWireAmount - }, + { kind: "UserSteps.ProvisionWireInput", account, fundWireAmount }, runProvisionWire ) } diff --git a/packages/cluster-tool/src/orchestration/steps/contracts/sysio/LiqContractSteps.ts b/packages/cluster-tool/src/orchestration/steps/contracts/sysio/LiqContractSteps.ts new file mode 100644 index 000000000..4650e6f53 --- /dev/null +++ b/packages/cluster-tool/src/orchestration/steps/contracts/sysio/LiqContractSteps.ts @@ -0,0 +1,274 @@ +import { SysioContracts } from "@wireio/sdk-core" +import { WireClient } from "../../../../clients/wire/WireClient.js" +import { Report } from "../../../../report/Report.js" +import { ClusterBuildContext } from "../../../ClusterBuildContext.js" +import { + ClusterBuildStep, + type ClusterBuildStepOptions +} from "../../../ClusterBuildStep.js" +import type { StepInput } from "../../../StepRunner.js" + +const { SysioContractName, SysioContractAccount } = SysioContracts + +/** + * Steps for `sysio.liq` actions — the shadow liq token. + * + * The contract-signed actions (`create`, `regliqpool`) ride the client's default + * authorization, the contract account. `setkicker` is governance's: the contract + * requires the system account. The holder actions (`claim`, `desyndicate`) are + * signed by the holder named in their data, and the permissionless `sweep` by + * whichever account foots the CPU, which rides the step input. + */ +export namespace LiqContractSteps { + /** Input for {@link planCreate} — the generated `liq::create` data. */ + export interface CreateInput extends StepInput { + readonly kind: "LiqContractSteps.CreateInput" + readonly data: SysioContracts.SysioLiqCreateAction + } + + /** + * `sysio.liq::create` — open one shadow symbol, bound to an active liq token + * of the chain and token registries. Signed by the contract. + */ + export function planCreate( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + data: SysioContracts.SysioLiqCreateAction + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "LiqContractSteps.CreateInput", data }, + runCreate + ) + } + + /** Named runner — `sysio.liq::create`. */ + export async function runCreate( + ctx: C, + input: CreateInput, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + await ctx.wire + .getSysioContract(SysioContractName.liq) + .actions.create.invoke(input.data) + } + + /** Input for {@link planSetkicker} — the generated `liq::setkicker` data. */ + export interface SetkickerInput extends StepInput { + readonly kind: "LiqContractSteps.SetkickerInput" + readonly data: SysioContracts.SysioLiqSetkickerAction + } + + /** + * `sysio.liq::setkicker` — set the T5 kicker folded into every yield intake, + * in basis points of the intake. Governance's action: signed by the system + * account, the authority council proposals execute as. + */ + export function planSetkicker( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + data: SysioContracts.SysioLiqSetkickerAction + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "LiqContractSteps.SetkickerInput", data }, + runSetkicker + ) + } + + /** Named runner — `sysio.liq::setkicker`, signed by the system account. */ + export async function runSetkicker( + ctx: C, + input: SetkickerInput, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + await ctx.wire + .getSysioContract(SysioContractName.liq) + .actions.setkicker.invoke(input.data, { + authorization: WireClient.activeAuthorization( + SysioContractAccount[SysioContractName.system] + ) + }) + } + + /** Input for {@link planRegliqpool} — the generated `liq::regliqpool` data. */ + export interface RegliqpoolInput extends StepInput { + readonly kind: "LiqContractSteps.RegliqpoolInput" + readonly data: SysioContracts.SysioLiqRegliqpoolAction + } + + /** + * `sysio.liq::regliqpool` — seed one shadow's yield pool on `sysio.swap`: mint + * the pool's shadow to the system account, deposit both seeds, create the + * pair with the shadow as its yield leg, and set its tick pacing. The contract + * gates it to the epoch-0 bootstrap window, so it only ever runs + * pre-EpochBootstrap. Signed by the contract. + */ + export function planRegliqpool( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + data: SysioContracts.SysioLiqRegliqpoolAction + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "LiqContractSteps.RegliqpoolInput", data }, + runRegliqpool + ) + } + + /** Named runner — `sysio.liq::regliqpool`. */ + export async function runRegliqpool( + ctx: C, + input: RegliqpoolInput, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + await ctx.wire + .getSysioContract(SysioContractName.liq) + .actions.regliqpool.invoke(input.data) + } + + /** Input for {@link planSweep} — the generated `liq::sweep` data plus the CPU payer. */ + export interface SweepInput extends StepInput { + readonly kind: "LiqContractSteps.SweepInput" + readonly data: SysioContracts.SysioLiqSweepAction + /** The account whose signature carries the permissionless push. */ + readonly signer: string + } + + /** + * `sysio.liq::sweep` — deliver the shadow parked against an account's link + * for a chain family. Permissionless: `signer` only foots the CPU. + */ + export function planSweep( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + data: SysioContracts.SysioLiqSweepAction, + signer: string + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "LiqContractSteps.SweepInput", data, signer }, + runSweep + ) + } + + /** Named runner — `sysio.liq::sweep`, signed by the CPU payer. */ + export async function runSweep( + ctx: C, + input: SweepInput, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + await ctx.wire + .getSysioContract(SysioContractName.liq) + .actions.sweep.invoke(input.data, { + authorization: WireClient.activeAuthorization(input.signer) + }) + } + + /** Input for {@link planClaim} — the generated `liq::claim` data. */ + export interface ClaimInput extends StepInput { + readonly kind: "LiqContractSteps.ClaimInput" + readonly data: SysioContracts.SysioLiqClaimAction + } + + /** + * `sysio.liq::claim` — pay a holder every subunit of WIRE owed to their + * shadow row and settle it at the current index. Signed by the holder. + */ + export function planClaim( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + data: SysioContracts.SysioLiqClaimAction + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "LiqContractSteps.ClaimInput", data }, + runClaim + ) + } + + /** Named runner — `sysio.liq::claim`, signed by the holder. */ + export async function runClaim( + ctx: C, + input: ClaimInput, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + await ctx.wire + .getSysioContract(SysioContractName.liq) + .actions.claim.invoke(input.data, { + authorization: WireClient.activeAuthorization(input.data.holder) + }) + } + + /** Input for {@link planDesyndicate} — the generated `liq::desyndicate` data. */ + export interface DesyndicateInput extends StepInput { + readonly kind: "LiqContractSteps.DesyndicateInput" + readonly data: SysioContracts.SysioLiqDesyndicateAction + } + + /** + * `sysio.liq::desyndicate` — settle, burn, and queue `DESYNDICATE_LIQ` for + * the outpost to pay the holder's linked key inline. Signed by the holder, + * who must be AuthX-linked for the token's chain. + */ + export function planDesyndicate( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + data: SysioContracts.SysioLiqDesyndicateAction + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "LiqContractSteps.DesyndicateInput", data }, + runDesyndicate + ) + } + + /** Named runner — `sysio.liq::desyndicate`, signed by the holder. */ + export async function runDesyndicate( + ctx: C, + input: DesyndicateInput, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + await ctx.wire + .getSysioContract(SysioContractName.liq) + .actions.desyndicate.invoke(input.data, { + authorization: WireClient.activeAuthorization(input.data.holder) + }) + } +} diff --git a/packages/cluster-tool/src/orchestration/steps/contracts/sysio/SwapContractSteps.ts b/packages/cluster-tool/src/orchestration/steps/contracts/sysio/SwapContractSteps.ts new file mode 100644 index 000000000..9e4339d85 --- /dev/null +++ b/packages/cluster-tool/src/orchestration/steps/contracts/sysio/SwapContractSteps.ts @@ -0,0 +1,56 @@ +import { SysioContracts } from "@wireio/sdk-core" +import { Report } from "../../../../report/Report.js" +import { ClusterBuildContext } from "../../../ClusterBuildContext.js" +import { + ClusterBuildStep, + type ClusterBuildStepOptions +} from "../../../ClusterBuildStep.js" +import type { StepInput } from "../../../StepRunner.js" + +const { SysioContractName } = SysioContracts + +/** + * Steps for `sysio.swap` actions — the depot swap the shadow liq yield pools + * live on. Signed by the contract, the client's default authorization. + */ +export namespace SwapContractSteps { + /** Input for {@link planSetconfig} — the generated `swap::setconfig` data. */ + export interface SetconfigInput extends StepInput { + readonly kind: "SwapContractSteps.SetconfigInput" + readonly data: SysioContracts.SysioSwapSetconfigAction + } + + /** + * `sysio.swap::setconfig` — the deployment's configuration step: the + * contract-wide fee authority (governance, which executes as the system + * account) and the system token every pair's second leg is quoted in. + */ + export function planSetconfig( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + data: SysioContracts.SysioSwapSetconfigAction + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "SwapContractSteps.SetconfigInput", data }, + runSetconfig + ) + } + + /** Named runner — `sysio.swap::setconfig`. */ + export async function runSetconfig( + ctx: C, + input: SetconfigInput, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + await ctx.wire + .getSysioContract(SysioContractName.swap) + .actions.setconfig.invoke(input.data) + } +} diff --git a/packages/cluster-tool/src/orchestration/steps/contracts/sysio/index.ts b/packages/cluster-tool/src/orchestration/steps/contracts/sysio/index.ts index 47fc72331..ef78c4abd 100644 --- a/packages/cluster-tool/src/orchestration/steps/contracts/sysio/index.ts +++ b/packages/cluster-tool/src/orchestration/steps/contracts/sysio/index.ts @@ -2,10 +2,12 @@ import { BiosContractSteps } from "./BiosContractSteps.js" import { ChainsContractSteps } from "./ChainsContractSteps.js" import { DclaimContractSteps } from "./DclaimContractSteps.js" import { EpochContractSteps } from "./EpochContractSteps.js" +import { LiqContractSteps } from "./LiqContractSteps.js" import { MsgchContractSteps } from "./MsgchContractSteps.js" import { OpregContractSteps } from "./OpregContractSteps.js" import { ReservContractSteps } from "./ReservContractSteps.js" import { RoaContractSteps } from "./RoaContractSteps.js" +import { SwapContractSteps } from "./SwapContractSteps.js" import { SystemContractSteps } from "./SystemContractSteps.js" import { TokenContractSteps } from "./TokenContractSteps.js" import { TokensContractSteps } from "./TokensContractSteps.js" @@ -22,10 +24,12 @@ export namespace SysioContractSteps { export import chains = ChainsContractSteps export import dclaim = DclaimContractSteps export import epoch = EpochContractSteps + export import liq = LiqContractSteps export import msgch = MsgchContractSteps export import opreg = OpregContractSteps export import reserv = ReservContractSteps export import roa = RoaContractSteps + export import swap = SwapContractSteps export import system = SystemContractSteps export import token = TokenContractSteps export import tokens = TokensContractSteps diff --git a/packages/cluster-tool/src/orchestration/steps/index.ts b/packages/cluster-tool/src/orchestration/steps/index.ts index 8166ee83e..a6926c55a 100644 --- a/packages/cluster-tool/src/orchestration/steps/index.ts +++ b/packages/cluster-tool/src/orchestration/steps/index.ts @@ -11,6 +11,7 @@ import { OperatorSteps } from "./OperatorSteps.js" import { ProtocolSteps } from "./ProtocolSteps.js" import { RegistrySteps } from "./RegistrySteps.js" import { StartScriptSteps } from "./StartScriptSteps.js" +import { UserSteps } from "./UserSteps.js" import { EthereumOutpostSteps } from "../ethereum/EthereumOutpostSteps.js" import { SolanaLiqsolSurfaceSteps } from "../solana/SolanaLiqsolSurfaceSteps.js" import { SolanaOutpostSteps } from "../solana/SolanaOutpostSteps.js" @@ -57,4 +58,6 @@ export namespace Steps { export import debuggingServerBundle = DebuggingServerBundleSteps /** Per-daemon `start.sh` emission (create + create-external-config rebind). */ export import startScript = StartScriptSteps + /** WIRE-side user identities a flow scenario acts as. */ + export import user = UserSteps } diff --git a/packages/cluster-tool/src/tools/solana/SolanaLiqSyndicationTool.ts b/packages/cluster-tool/src/tools/solana/SolanaLiqSyndicationTool.ts index 3ce3a24cb..327434b86 100644 --- a/packages/cluster-tool/src/tools/solana/SolanaLiqSyndicationTool.ts +++ b/packages/cluster-tool/src/tools/solana/SolanaLiqSyndicationTool.ts @@ -39,6 +39,7 @@ import { import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, + getAccount, getAssociatedTokenAddressSync } from "@solana/spl-token" @@ -427,6 +428,40 @@ export namespace SolanaLiqSyndicationTool { } } + /** + * A user's liqSOL balance — the Token-2022 amount on the ATA the liqsol mint + * derives for their wallet. A READ; `0n` before the ATA exists (a user who + * has never held liqSOL). The redemption case of a flow reads it before and + * after `DESYNDICATE_LIQ` lands to prove the outpost paid. + * + * @param ctx - The build context (RPC connection + `solanaPath`). + * @param userName - Durable handle of the user's persisted keypair. + * @returns The ATA's raw liqSOL amount (9-decimal base units). + */ + export async function readLiqsolBalance( + ctx: C, + userName: string + ): Promise { + const user = SolanaFundingTool.loadKeypair(ctx.config.dataPath, userName), + { userAta } = deriveUserPdas(ctx.config.solanaPath, user.publicKey) + try { + return ( + await getAccount( + ctx.solana.connection, + userAta, + undefined, + TOKEN_2022_PROGRAM_ID + ) + ).amount + } catch (error) { + // A missing ATA is the expected never-held case — breadcrumb, not a failure. + log.debug( + `readLiqsolBalance(${userName} → ${userAta.toBase58()}): ${error instanceof Error ? error.message : String(error)}` + ) + return 0n + } + } + /** * The outpost's current launch state (`GlobalState.wire_state`), decoded from * the Anchor coder's tagged-union representation back onto {@link WireState}. diff --git a/packages/cluster-tool/tests/cli/ClusterBuildOptionsArgs.test.ts b/packages/cluster-tool/tests/cli/ClusterBuildOptionsArgs.test.ts index 87511501f..c7eba15ba 100644 --- a/packages/cluster-tool/tests/cli/ClusterBuildOptionsArgs.test.ts +++ b/packages/cluster-tool/tests/cli/ClusterBuildOptionsArgs.test.ts @@ -538,6 +538,12 @@ describe("toClusterBuildOptions reverse parse", () => { expect( toClusterBuildOptions({ "enable-mock-reserves": false }).enableMockReserves ).toBe(false) + expect( + toClusterBuildOptions({ "enable-mock-liq-pools": true }).enableMockLiqPools + ).toBe(true) + expect( + toClusterBuildOptions({ "enable-mock-liq-pools": false }).enableMockLiqPools + ).toBe(false) }) it("reads --chain-state-db-size-mb, and leaves it ABSENT when omitted", () => { @@ -588,8 +594,9 @@ describe("register → parse round-trip", () => { expect(options.epochDurationSec).toBe(60) expect(options.nodeCount).toBe(1) expect(options.bindAll).toBe(false) - // no opt-in ⇒ the default-false mock-reserves flag survives as false + // no opt-in ⇒ the default-false mock-data flags survive as false expect(options.enableMockReserves).toBe(false) + expect(options.enableMockLiqPools).toBe(false) // unseeded (null-default) bind ports never materialize expect(options.bind?.kiod?.port).toBeUndefined() // …and neither does the unseeded chain-state DB size (SHARED-31) — the @@ -615,6 +622,7 @@ describe("register → parse round-trip", () => { terminateMaxPercentMisses24h: 99, terminateWindowMs: 3_600_000, enableMockReserves: true, + enableMockLiqPools: true, chainStateDbSizeMb: 8_192, apiCount: 2, queryEngine: { readMode: NodeopReadMode.irreversible, maxInFlight: 8 } @@ -633,6 +641,8 @@ describe("register → parse round-trip", () => { expect(options.terminateWindowMs).toBe(3_600_000) // the scenario-defaults opt-in path the 6 reserve-needing flows rely on expect(options.enableMockReserves).toBe(true) + // …and the one the liq-yield flow's pools ride + expect(options.enableMockLiqPools).toBe(true) // the same scenario-defaults path carries the SHARED-31 override expect(options.chainStateDbSizeMb).toBe(8_192) // …and a flow's API nodes + their query engine diff --git a/packages/cluster-tool/tests/clients/solana/signatureUtils.test.ts b/packages/cluster-tool/tests/clients/solana/signatureUtils.test.ts index 8ff1a38a3..0aa3fa70b 100644 --- a/packages/cluster-tool/tests/clients/solana/signatureUtils.test.ts +++ b/packages/cluster-tool/tests/clients/solana/signatureUtils.test.ts @@ -47,6 +47,24 @@ describe("confirmSignature", () => { ).rejects.toThrow(/tx failed/) }) + it("throws when a CONFIRMED tx carries an error — inclusion is not success", async () => { + // A transaction the runtime processed with an error is still included in a + // block, so its status reaches `confirmed` too. Returning on the status + // reported an on-chain `EpochRewardsActive` deposit refusal as OK, and the + // step after it found the token account that deposit never created. + mockStatus("confirmed", "tx-error") + await expect( + confirmSignature(connection, "sig", "test", { intervalMs: 1 }) + ).rejects.toThrow(/tx failed/) + }) + + it("throws when a FINALIZED tx carries an error", async () => { + mockStatus("finalized", "tx-error") + await expect( + confirmSignature(connection, "sig", "test", { intervalMs: 1 }) + ).rejects.toThrow(/tx failed/) + }) + it("throws on the deadline when never confirmed", async () => { mockStatus("processed") await expect( diff --git a/packages/cluster-tool/tests/clients/wire/WireClient.test.ts b/packages/cluster-tool/tests/clients/wire/WireClient.test.ts index c4021edcf..7c710cca8 100644 --- a/packages/cluster-tool/tests/clients/wire/WireClient.test.ts +++ b/packages/cluster-tool/tests/clients/wire/WireClient.test.ts @@ -281,6 +281,34 @@ describe("WireClient", () => { }) }) + describe("symbolCodeKeyRange", () => { + // sysio.liq's symbol-keyed KV tables (stat, liqpending, yieldidx) key on ONE uint64 + // `symbol_code`. The node takes `lower_bound` inclusive and `upper_bound` EXCLUSIVE, and a + // bare code string fails at parse time (`Unexpected char '76' in "LIQSOL"`) exactly like a + // bare account name does — which is what took flow-liq-yield's first yield read down. + it("spans exactly [code, code + 1) as JSON objects keyed by the ABI key field", () => { + const { lowerBound, upperBound } = WireClient.symbolCodeKeyRange("symbol_code", "LIQSOL"), + lower = JSON.parse(lowerBound), + upper = JSON.parse(upperBound) + expect(Object.keys(lower)).toEqual(["symbol_code"]) + expect(Object.keys(upper)).toEqual(["symbol_code"]) + expect(typeof lower.symbol_code).toBe("string") + expect(BigInt(upper.symbol_code)).toBe(BigInt(lower.symbol_code) + 1n) + }) + + it("carries the code's raw uint64, so two codes' ranges never overlap", () => { + const at = (bound: string): bigint => BigInt(JSON.parse(bound).symbol_code), + sol = WireClient.symbolCodeKeyRange("symbol_code", "LIQSOL"), + eth = WireClient.symbolCodeKeyRange("symbol_code", "LIQETH") + expect(at(sol.lowerBound)).not.toBe(at(eth.lowerBound)) + expect(at(sol.lowerBound) >= at(eth.upperBound) || at(sol.upperBound) <= at(eth.lowerBound)).toBe(true) + }) + + it("refuses a code that is not a symbol code", () => { + expect(() => WireClient.symbolCodeKeyRange("symbol_code", "liqsol")).toThrow() + }) + }) + describe("transaction expiration", () => { it("pins an expiration well above clio's 30s default", () => { // clio's default is the SIGN->INCLUSION window, not execution. On a large diff --git a/packages/cluster-tool/tests/config/clusterConfigFixture.ts b/packages/cluster-tool/tests/config/clusterConfigFixture.ts index 65464ac18..3435c837f 100644 --- a/packages/cluster-tool/tests/config/clusterConfigFixture.ts +++ b/packages/cluster-tool/tests/config/clusterConfigFixture.ts @@ -119,6 +119,7 @@ export const PersistedFixture: ClusterConfig = { externalOutposts: null, debuggingServerEnabled: true, enableMockReserves: false, + enableMockLiqPools: false, deploymentKind: ClusterDeploymentKind.local, chainStateDbSizeMb: DefaultChainStateDbSizeMb, // Nothing set: nodeop's read mode, the plugin's limits. diff --git a/packages/cluster-tool/tests/flow/oppEnvelopeScan.test.ts b/packages/cluster-tool/tests/flow/oppEnvelopeScan.test.ts index 9c1eed06c..1cba14cd9 100644 --- a/packages/cluster-tool/tests/flow/oppEnvelopeScan.test.ts +++ b/packages/cluster-tool/tests/flow/oppEnvelopeScan.test.ts @@ -11,6 +11,7 @@ import { } from "@wireio/opp-typescript-models" import { attestationEntryTag, + containsDesyndicateLIQ, containsLIQYield, containsSwapRevert, containsSyndicateLIQ, @@ -162,6 +163,34 @@ describe("oppEnvelopeScan", () => { }) }) + describe("containsDesyndicateLIQ", () => { + it("defaults to the depot → Solana direction the redemption travels", () => { + writeArtifact( + DebugOutpostEndpointsType.DEPOT_OUTPOST_SOLANA, + Buffer.from(DesyndicateLIQTagBytes) + ) + expect(containsDesyndicateLIQ(oppDirectory)).toBe(true) + expect( + containsDesyndicateLIQ( + oppDirectory, + DebugOutpostEndpointsType.OUTPOST_SOLANA_DEPOT + ) + ).toBe(false) + }) + + it("is false for a missing directory", () => { + expect(containsDesyndicateLIQ(Path.join(oppDirectory, "absent"))).toBe(false) + }) + + it("does not match the two inbound liq types", () => { + writeArtifact( + DebugOutpostEndpointsType.DEPOT_OUTPOST_SOLANA, + Buffer.from([...SyndicateLIQTagBytes, ...LIQYieldTagBytes]) + ) + expect(containsDesyndicateLIQ(oppDirectory)).toBe(false) + }) + }) + describe("containsSyndicateLIQ / containsLIQYield", () => { it("default to the direction each type actually travels", () => { writeArtifact( diff --git a/packages/cluster-tool/tests/orchestration/ClusterBuildDefaultsMockLiqPools.test.ts b/packages/cluster-tool/tests/orchestration/ClusterBuildDefaultsMockLiqPools.test.ts new file mode 100644 index 000000000..43b9d46f4 --- /dev/null +++ b/packages/cluster-tool/tests/orchestration/ClusterBuildDefaultsMockLiqPools.test.ts @@ -0,0 +1,68 @@ +import Path from "node:path" +import { ClusterBuildDefaults } from "@wireio/cluster-tool/orchestration" +import { + fixtureResolveEnvironment, + type ResolveEnvironment +} from "../config/resolveEnvironmentFixture.js" + +import { collectPhaseNames } from "./clusterBuildFixture.js" + +describe("ClusterBuildDefaults — mock-liq-pool gating", () => { + let environment: ResolveEnvironment + + beforeEach(() => { + environment = fixtureResolveEnvironment("mock-liq-pools-") + }) + + afterEach(() => { + environment.cleanup() + }) + + function baseOptions() { + return { + clusterPath: Path.join(environment.rootPath, "cluster"), + buildPath: environment.buildPath, + ethereumPath: "/fake/eth", + solanaPath: "/fake/sol" + } + } + + it("always configures the swap, opens the shadow symbols and sets the kicker, in that order", async () => { + const cluster = await ClusterBuildDefaults.create(baseOptions()) + const names = collectPhaseNames(cluster.children) + expect(names.indexOf("SwapConfig")).toBeGreaterThan(names.indexOf("Registry")) + expect(names.indexOf("ShadowLiqTokens")).toBe(names.indexOf("SwapConfig") + 1) + expect(names.indexOf("LiqConfig")).toBe(names.indexOf("ShadowLiqTokens") + 1) + expect(names.indexOf("UnderwriterConfig")).toBeGreaterThan(names.indexOf("LiqConfig")) + }) + + it("omits the MockLiqPools phase by default (no --enable-mock-liq-pools)", async () => { + const cluster = await ClusterBuildDefaults.create(baseOptions()) + const names = collectPhaseNames(cluster.children) + expect(names).toContain("ShadowLiqTokens") + expect(names).not.toContain("MockLiqPools") + }) + + it("adds MockLiqPools immediately after LiqConfig when enableMockLiqPools is set", async () => { + const cluster = await ClusterBuildDefaults.create({ + ...baseOptions(), + enableMockLiqPools: true + }) + const names = collectPhaseNames(cluster.children) + expect(names).toContain("MockLiqPools") + // gated phase is registered directly after the kicker config, pre-EpochBootstrap + expect(names.indexOf("MockLiqPools")).toBe(names.indexOf("LiqConfig") + 1) + expect(names.indexOf("MockLiqPools")).toBeLessThan(names.indexOf("EpochBootstrap")) + }) + + it("keeps MockReserves directly after Registry when both mock surfaces are enabled", async () => { + const cluster = await ClusterBuildDefaults.create({ + ...baseOptions(), + enableMockReserves: true, + enableMockLiqPools: true + }) + const names = collectPhaseNames(cluster.children) + expect(names.indexOf("MockReserves")).toBe(names.indexOf("Registry") + 1) + expect(names.indexOf("SwapConfig")).toBe(names.indexOf("MockReserves") + 1) + }) +}) diff --git a/packages/cluster-tool/tests/orchestration/steps/RegistrySteps.test.ts b/packages/cluster-tool/tests/orchestration/steps/RegistrySteps.test.ts index cc0eacfb4..a425b4adf 100644 --- a/packages/cluster-tool/tests/orchestration/steps/RegistrySteps.test.ts +++ b/packages/cluster-tool/tests/orchestration/steps/RegistrySteps.test.ts @@ -92,4 +92,88 @@ describe("Steps.registry", () => { expect(native?.initial_chain_amount).toBe(FullChainSeed) }) }) + + describe("planShadowLiqTokens", () => { + const LiqethCode = SlugName.from("LIQETH") + const SolanaCode = SlugName.from("SOLANA") + + it("returns a Phase of one Sysio create step per liq token", () => { + const phase = Steps.registry.planShadowLiqTokens( + newBuild(), + "ShadowLiqTokens", + "open the shadow symbols", + {} + ) + expect(phase).toBeInstanceOf(ClusterBuildPhase) + expect(phase.steps).toHaveLength(2) + expect( + phase.steps.every(step => step.actor === Report.Actor.Sysio) + ).toBe(true) + expect(phase.steps.map(step => step.name)).toEqual([ + "create-shadow-liqeth", + "create-shadow-liqsol" + ]) + phase.steps.forEach(step => { + expect(step.input.kind).toBe("LiqContractSteps.CreateInput") + }) + }) + + it("opens each shadow at the liq precision, bound to its own chain and token", () => { + const rows = Steps.registry.ShadowLiqTokenRegistrations + expect(rows).toHaveLength(2) + const liqeth = rows.find(row => row.token_code.value === LiqethCode) + expect(liqeth?.sym).toBe("9,LIQETH") + expect(liqeth?.chain_code.value).toBe(SlugName.from("ETHEREUM")) + const liqsol = rows.find(row => row.chain_code.value === SolanaCode) + expect(liqsol?.sym).toBe("9,LIQSOL") + expect(liqsol?.token_code.value).toBe(SlugName.from("LIQSOL")) + }) + }) + + describe("planMockLiqPools", () => { + const LiqsolCode = SlugName.from("LIQSOL") + // The dev-config seeds; the pacing is the dev cluster's own (one-tick sales). + const PoolSeed = 10_000_000_000 + const PoolFee = 30 + const HorizonSec = 30 + const DepthCapBps = 3000 + const ClipFloor = 1000 + + it("returns a Phase of one Sysio regliqpool step per shadow", () => { + const phase = Steps.registry.planMockLiqPools( + newBuild(), + "MockLiqPools", + "seed the mock yield pools", + {} + ) + expect(phase).toBeInstanceOf(ClusterBuildPhase) + expect(phase.steps).toHaveLength(2) + expect( + phase.steps.every(step => step.actor === Report.Actor.Sysio) + ).toBe(true) + expect(phase.steps.map(step => step.name)).toEqual([ + "seed-liq-pool-ethereum-liqeth", + "seed-liq-pool-solana-liqsol" + ]) + phase.steps.forEach(step => { + expect(step.input.kind).toBe("LiqContractSteps.RegliqpoolInput") + }) + }) + + it("names each pair token after its shadow, seeds both legs equally, and paces for one-tick sales", () => { + const rows = Steps.registry.MockLiqPoolRegistrations + expect(rows).toHaveLength(2) + const liqsol = rows.find(row => row.token_code.value === LiqsolCode) + expect(liqsol?.pair_symbol).toBe("9,LIQSOLP") + expect(liqsol?.chain_code.value).toBe(SlugName.from("SOLANA")) + expect(liqsol?.initial_chain_amount).toBe(PoolSeed) + expect(liqsol?.initial_wire_amount).toBe(PoolSeed) + expect(liqsol?.fee).toBe(PoolFee) + expect(liqsol?.locked_shares).toBe(0) + expect(liqsol?.conversion_horizon_sec).toBe(HorizonSec) + expect(liqsol?.depth_cap_bps).toBe(DepthCapBps) + expect(liqsol?.clip_floor).toBe(ClipFloor) + expect(rows.find(row => row.pair_symbol === "9,LIQETHP")).toBeDefined() + }) + }) }) diff --git a/packages/cluster-tool/tests/orchestration/steps/UserSteps.test.ts b/packages/cluster-tool/tests/orchestration/steps/UserSteps.test.ts new file mode 100644 index 000000000..643cc0ae5 --- /dev/null +++ b/packages/cluster-tool/tests/orchestration/steps/UserSteps.test.ts @@ -0,0 +1,37 @@ +import { Steps } from "@wireio/cluster-tool/orchestration" +import { Report } from "@wireio/cluster-tool/report" + +/** The user account every case provisions. */ +const UserAccount = "flow.user" +/** A treasury funding amount in raw 9-dec WIRE base units. */ +const FundWireAmount = 1_500_000_000n + +describe("Steps.user", () => { + it("provisionWire carries the account and its treasury funding", () => { + const step = Steps.user.planProvisionWire( + Report.Actor.User, + "provision-user", + `provision ${UserAccount} + fund it from the treasury`, + {}, + UserAccount, + FundWireAmount + ) + expect(step.actor).toBe(Report.Actor.User) + expect(step.input.kind).toBe("UserSteps.ProvisionWireInput") + expect(step.input.account).toBe(UserAccount) + expect(step.input.fundWireAmount).toBe(FundWireAmount) + expect(typeof step.runner).toBe("function") + }) + + it("provisionWire accepts an unfunded user (0n)", () => { + const step = Steps.user.planProvisionWire( + Report.Actor.User, + "provision-user", + `provision ${UserAccount}`, + {}, + UserAccount, + 0n + ) + expect(step.input.fundWireAmount).toBe(0n) + }) +}) diff --git a/packages/cluster-tool/tests/orchestration/steps/contracts/sysio/LiqContractSteps.test.ts b/packages/cluster-tool/tests/orchestration/steps/contracts/sysio/LiqContractSteps.test.ts new file mode 100644 index 000000000..1086cbf27 --- /dev/null +++ b/packages/cluster-tool/tests/orchestration/steps/contracts/sysio/LiqContractSteps.test.ts @@ -0,0 +1,133 @@ +import { Steps } from "@wireio/cluster-tool/orchestration" +import { Report } from "@wireio/cluster-tool/report" +import { SlugName, SysioContracts } from "@wireio/sdk-core" + +const { SysioLiqChainkind } = SysioContracts + +/** The shadow symbol every case below addresses (the depot's 9-decimal frame). */ +const ShadowSymbol = "9,LIQSOL" +/** The shadow symbol's code, as `claim` names it. */ +const ShadowSymbolCode = "LIQSOL" +/** The holder the user-signed cases act for. */ +const Holder = "liq.holder" + +describe("Steps.contracts.sysio.liq", () => { + it("create carries the liq::create data", () => { + const data: SysioContracts.SysioLiqCreateAction = { + sym: ShadowSymbol, + chain_code: { value: SlugName.from("SOLANA") }, + token_code: { value: SlugName.from("LIQSOL") } + } + const step = Steps.contracts.sysio.liq.planCreate( + Report.Actor.Sysio, + "create-shadow-liqsol", + "open the LIQSOL shadow symbol on sysio.liq", + {}, + data + ) + expect(step.actor).toBe(Report.Actor.Sysio) + expect(step.input.kind).toBe("LiqContractSteps.CreateInput") + expect(step.input.data).toBe(data) + expect(step.input.data.sym).toBe(ShadowSymbol) + expect(typeof step.runner).toBe("function") + }) + + it("setkicker carries the liq::setkicker data", () => { + const data: SysioContracts.SysioLiqSetkickerAction = { bps: 200 } + const step = Steps.contracts.sysio.liq.planSetkicker( + Report.Actor.Sysio, + "configure-liq-kicker", + "set the T5 yield kicker", + {}, + data + ) + expect(step.input.kind).toBe("LiqContractSteps.SetkickerInput") + expect(step.input.data).toBe(data) + expect(step.input.data.bps).toBe(200) + expect(typeof step.runner).toBe("function") + }) + + it("regliqpool carries the liq::regliqpool data", () => { + const data: SysioContracts.SysioLiqRegliqpoolAction = { + chain_code: { value: SlugName.from("SOLANA") }, + token_code: { value: SlugName.from("LIQSOL") }, + pair_symbol: "9,LIQSOLP", + initial_chain_amount: 10_000_000_000, + initial_wire_amount: 10_000_000_000, + fee: 30, + locked_shares: 0, + conversion_horizon_sec: 30, + depth_cap_bps: 3000, + clip_floor: 1000 + } + const step = Steps.contracts.sysio.liq.planRegliqpool( + Report.Actor.Sysio, + "seed-liq-pool-solana-liqsol", + "seed the LIQSOL/WIRE yield pool", + {}, + data + ) + expect(step.input.kind).toBe("LiqContractSteps.RegliqpoolInput") + expect(step.input.data).toBe(data) + expect(step.input.data.pair_symbol).toBe("9,LIQSOLP") + expect(typeof step.runner).toBe("function") + }) + + it("sweep carries the liq::sweep data AND the CPU payer, who is the signer", () => { + // `sweep` is permissionless — the account is not the signer, so the payer + // rides the step input for the authorization. + const data: SysioContracts.SysioLiqSweepAction = { + account: Holder, + chain_kind: SysioLiqChainkind.CHAIN_KIND_SVM + } + const step = Steps.contracts.sysio.liq.planSweep( + Report.Actor.User, + "sweep-parked-liqsol", + "deliver the shadow parked against the holder's link", + {}, + data, + Holder + ) + expect(step.actor).toBe(Report.Actor.User) + expect(step.input.kind).toBe("LiqContractSteps.SweepInput") + expect(step.input.data).toBe(data) + expect(step.input.signer).toBe(Holder) + expect(typeof step.runner).toBe("function") + }) + + it("claim carries the liq::claim data, whose holder is the signer", () => { + const data: SysioContracts.SysioLiqClaimAction = { + holder: Holder, + sym: ShadowSymbolCode + } + const step = Steps.contracts.sysio.liq.planClaim( + Report.Actor.User, + "claim-liqsol-yield", + "claim the WIRE owed to the holder's LIQSOL row", + {}, + data + ) + expect(step.input.kind).toBe("LiqContractSteps.ClaimInput") + expect(step.input.data).toBe(data) + expect(step.input.data.holder).toBe(Holder) + expect(typeof step.runner).toBe("function") + }) + + it("desyndicate carries the liq::desyndicate data, whose holder is the signer", () => { + const data: SysioContracts.SysioLiqDesyndicateAction = { + holder: Holder, + quantity: "2.000000000 LIQSOL" + } + const step = Steps.contracts.sysio.liq.planDesyndicate( + Report.Actor.User, + "desyndicate-liqsol", + "burn the holder's shadow and queue DESYNDICATE_LIQ", + {}, + data + ) + expect(step.input.kind).toBe("LiqContractSteps.DesyndicateInput") + expect(step.input.data).toBe(data) + expect(step.input.data.quantity).toBe("2.000000000 LIQSOL") + expect(typeof step.runner).toBe("function") + }) +}) diff --git a/packages/cluster-tool/tests/orchestration/steps/contracts/sysio/SwapContractSteps.test.ts b/packages/cluster-tool/tests/orchestration/steps/contracts/sysio/SwapContractSteps.test.ts new file mode 100644 index 000000000..5bc0b8b87 --- /dev/null +++ b/packages/cluster-tool/tests/orchestration/steps/contracts/sysio/SwapContractSteps.test.ts @@ -0,0 +1,29 @@ +import { Steps } from "@wireio/cluster-tool/orchestration" +import { Report } from "@wireio/cluster-tool/report" +import { SysioContracts } from "@wireio/sdk-core" + +const { SysioContractName, SysioContractAccount } = SysioContracts + +describe("Steps.contracts.sysio.swap", () => { + it("setconfig carries the swap::setconfig data", () => { + const data: SysioContracts.SysioSwapSetconfigAction = { + fee_authority: SysioContractAccount[SysioContractName.system], + system_token: { + sym: "9,WIRE", + contract: SysioContractAccount[SysioContractName.token] + } + } + const step = Steps.contracts.sysio.swap.planSetconfig( + Report.Actor.Sysio, + "configure-swap", + "set the swap's fee authority and system token", + {}, + data + ) + expect(step.actor).toBe(Report.Actor.Sysio) + expect(step.input.kind).toBe("SwapContractSteps.SetconfigInput") + expect(step.input.data).toBe(data) + expect(step.input.data.system_token.sym).toBe("9,WIRE") + expect(typeof step.runner).toBe("function") + }) +}) diff --git a/packages/debugging-client-shared/tests/local/fixtureCluster.ts b/packages/debugging-client-shared/tests/local/fixtureCluster.ts index 0e2088d23..d1f73cfff 100644 --- a/packages/debugging-client-shared/tests/local/fixtureCluster.ts +++ b/packages/debugging-client-shared/tests/local/fixtureCluster.ts @@ -158,6 +158,7 @@ export function makeFixtureCluster(): FixtureCluster { externalOutposts: null, debuggingServerEnabled: true, enableMockReserves: false, + enableMockLiqPools: false, deploymentKind: ClusterDeploymentKind.local, chainStateDbSizeMb: DefaultChainStateDbSizeMb, queryEngine: createUnsetQueryEngineConfig() diff --git a/packages/debugging-client-tool-tui/tests/store/cluster/ClusterSlice.test.ts b/packages/debugging-client-tool-tui/tests/store/cluster/ClusterSlice.test.ts index dc1202647..4545bb5ff 100644 --- a/packages/debugging-client-tool-tui/tests/store/cluster/ClusterSlice.test.ts +++ b/packages/debugging-client-tool-tui/tests/store/cluster/ClusterSlice.test.ts @@ -102,6 +102,7 @@ const stubConfig: ClusterConfig = { externalOutposts: null, debuggingServerEnabled: true, enableMockReserves: false, + enableMockLiqPools: false, deploymentKind: ClusterDeploymentKind.local, chainStateDbSizeMb: DefaultChainStateDbSizeMb, queryEngine: createUnsetQueryEngineConfig() diff --git a/packages/flow-liq-syndication/README.md b/packages/flow-liq-syndication/README.md index 288553f35..3020b2b97 100644 --- a/packages/flow-liq-syndication/README.md +++ b/packages/flow-liq-syndication/README.md @@ -12,9 +12,9 @@ liqsol_core::inject_bonus_synd_yield (permissionless — SOL-funded pool yield) liqsol_core::report_liq_yield (permissionless crank — non-admin signer) → LIQ_YIELD queued by the program → batch operator ferries both via an OPP envelope - → sysio.msgch consumes the envelope - (its dispatcher does NOT handle these types yet — they fall - through the unknown-type default, by design) + → sysio.msgch consumes the envelope and routes both to sysio.liq + (this user never links their key, so the syndication stays PARKED — + flow-liq-yield follows the credit through to WIRE and back) → sysio.epoch::advance keeps closing epochs ``` @@ -28,10 +28,10 @@ Asserts: consumes exactly one value of the shared liq sequence; the circulated attestation is DECODED and must match that delta, that sequence, the outpost's chain code and the depot's liqSOL token code. -3. **The depot accepts envelopes carrying unknown types.** +3. **The depot keeps advancing.** `sysio.epoch::epochstate.current_epoch_index` advances past the value - snapshotted before the syndication — a depot that choked on an unhandled - attestation type would stall the epoch instead. + snapshotted before the syndication — a depot that choked on either + attestation would stall the epoch instead. ## What the harness provides @@ -50,8 +50,8 @@ bootstrap binds that code to the mock SPL mint the swap flows use. The scenario's first step re-binds it to the REAL liqSOL mint, so only this flow's cluster sees the change. -`DESYNDICATE_LIQ` is depot-originated and has no depot emitter yet, so this flow -does not exercise it. +`DESYNDICATE_LIQ` is depot-originated (`sysio.liq::desyndicate`); `flow-liq-yield` +exercises it, together with the link, the yield sale and the claim. ## Single-shot per cluster diff --git a/packages/flow-liq-syndication/src/LIQSyndicationScenario.ts b/packages/flow-liq-syndication/src/LIQSyndicationScenario.ts index 1d218bdbd..894d4fa00 100644 --- a/packages/flow-liq-syndication/src/LIQSyndicationScenario.ts +++ b/packages/flow-liq-syndication/src/LIQSyndicationScenario.ts @@ -80,11 +80,13 @@ async function readCirculatedLIQYields( * permissionless `report_liq_yield` crank queues `LIQ_YIELD` for the delta * above its watermark. Both must reach an outpost → depot envelope. * - * The depot's `sysio.msgch` dispatcher does NOT handle either type yet (unknown - * types fall through its default), so the assertion is deliberately two-sided: - * the attestations must circulate, AND the depot must keep advancing its epoch - * while consuming envelopes that carry them. A depot that choked on an unknown - * type would stall the epoch — the last phase is what catches that. + * This flow proves the OUTPOST side: the two attestations circulate, and the + * depot keeps advancing its epoch while consuming envelopes that carry them — + * whatever its `sysio.msgch` dispatcher does with them (today it routes both + * to `sysio.liq`, where this user's unlinked key leaves the syndication parked; + * `flow-liq-yield` follows the credit through to WIRE and back to the outpost). + * A depot that choked on either type would stall the epoch — the last phase is + * what catches that. * * 1. **SnapshotDepotEpoch** — record `current_epoch_index` BEFORE any write. * 2. **Syndicate** — map the liq token, fund the user, deposit for liqSOL, flip @@ -93,7 +95,7 @@ async function readCirculatedLIQYields( * 3. **ReportLiqYield** — snapshot the on-chain liq-yield accounting, donate * bonus pool yield, crank `report_liq_yield` as a NON-admin, then prove the * circulated `LIQ_YIELD` decodes to exactly the reported delta. - * 4. **DepotAcceptsUnknownTypes** — `current_epoch_index` advances past the + * 4. **DepotKeepsAdvancing** — `current_epoch_index` advances past the * snapshot, so consensus kept closing epochs across both attestations. */ export class LIQSyndicationScenario extends FlowScenario { @@ -299,11 +301,11 @@ export class LIQSyndicationScenario extends FlowScenario { ) ) - // ── 4. The depot kept advancing while carrying the unknown types ── + // ── 4. The depot kept advancing while carrying both liq types ── ClusterBuildPhase.create( cluster, - "DepotAcceptsUnknownTypes", - "The depot advances its epoch past the snapshot — envelopes carrying types its dispatcher does not know are accepted, not fatal" + "DepotKeepsAdvancing", + "The depot advances its epoch past the snapshot — envelopes carrying the liq attestations are consumed, never fatal" ).push( verifyStep( Actor.Sysio, diff --git a/packages/flow-liq-yield/README.md b/packages/flow-liq-yield/README.md new file mode 100644 index 000000000..256571ef1 --- /dev/null +++ b/packages/flow-liq-yield/README.md @@ -0,0 +1,92 @@ +# flow-liq-yield + +The LIQ reward flow end to end, on the REAL `liqsol_core` paths and the real +depot contracts — nothing injected, nothing cranked by hand: + +``` +liqsol_core::sol_to_liqsol (SOL outpost — the user deposits SOL, gets liqSOL 1:1) +liqsol_core::set_wire_state (admin — Launching, then PostLaunch) +liqsol_core::synd (user — syndicates liqSOL into the pool) + → SYNDICATE_LIQ ferried to the depot + → sysio.msgch → sysio.liq::park (the key is not linked yet: PARKED) +sysio.authex::createlink (user — links the same Solana key) + → sysio.liq::linkswept (inline: the parked shadow becomes the user's row) +liqsol_core::inject_bonus_synd_yield (permissionless — SOL-funded pool yield) +liqsol_core::report_liq_yield (permissionless crank — non-admin signer) + → LIQ_YIELD ferried to the depot + → sysio.msgch → sysio.liq::mintyield (lands in liqpending, outside supply) +batch_operator_plugin → sysio.liq::queueyield (mints it, funds the swap's reservoir) +batch_operator_plugin → sysio.swap::tickyield (sells the reservoir through the pool) + → sysio.liq::addyield (the WIRE proceeds bump the cumulative index) +sysio.liq::claim (user — paid exactly owed(row, index) in WIRE) +sysio.liq::desyndicate (user — settles, burns, queues DESYNDICATE_LIQ) + → DESYNDICATE_LIQ ferried to the outpost + → liqsol_core handle_desyndicate_liq (pays the user's liqSOL ATA inline) +``` + +Asserts, in order: + +1. **The depot parks the syndication.** After `synd`, `SYNDICATE_LIQ` circulates + and `sysio.liq::parked` holds exactly the syndicated amount against the + user's (unlinked) Solana key; the user holds no shadow row. +2. **The link delivers it.** After `createlink`, the user's own row holds the + amount and nothing stays parked — the inline sweep, no permissionless `sweep`. +3. **Reported yield lands outside supply.** `report_liq_yield` advances the + watermark by at least the donation, `LIQ_YIELD` circulates, and `liqpending` + holds exactly the reported delta. +4. **The queue crank mints it.** `liqpending` empties and the shadow supply grows + by exactly the reported yield — the batch operators' `queueyield`. +5. **The tick crank sells it.** The cumulative index moves off zero and the + reservoir sells out — the batch operators' `tickyield` — so the ledger is + stable for an exact claim. +6. **The claim is exact.** The user's WIRE balance equals `owed(row, index)` + computed from the row and the index before the claim, the row is settled at + the index, and the pot fell by the payout. +7. **The redemption is paid on the outpost.** `DESYNDICATE_LIQ` reaches a + `DEPOT_OUTPOST_SOLANA` envelope carrying the user's key, the amount and a + non-zero request id, and the user's liqSOL ATA grows by the burned amount. +8. **The depot keeps advancing** across every attestation. + +## What the bootstrap provides + +Everything `flow-liq-syndication` relies on (the four wire-solana programs at +genesis, the liqsol surface), plus the depot's shadow-liq system: `sysio.swap` +and `sysio.liq` deployed, the swap configured, one shadow symbol per registered +liq token (`LIQETH`, `LIQSOL`), the kicker — and, because this scenario opts in +through `enableMockLiqPools`, the two mock yield pools `regliqpool` seeds inside +the epoch-0 window (the pool's shadow is minted from nothing, which is why the +seeding is opt-in and a real depot never sets it). The pools' tick pacing (a 30 s +horizon, a 30 % depth cap) is what makes assertion 5 converge in one tick. + +The batch operators' `queueyield` and `tickyield` cranks are `nodeop`'s own +(`batch_operator_plugin`, `--batch-yield-tick-interval-ms`); the flow only waits +for their effects. + +## Single-shot per cluster + +Like `flow-liq-syndication`, the scenario drives the outpost +`PreLaunch -> Launching -> PostLaunch`, which is TERMINAL; the FIRST step of the +Syndicate phase refuses a cluster that is not `PreLaunch`, before any write. +Give every run a fresh `--cluster-path` (what `run-flow.mjs` does by default). + +## Running + +Like every flow, run it with the canonical runner + heartbeat monitor pair +(see the repo README's "Running flows" / "Monitoring a live flow run" and +`wire-platform-manifest/.claude/rules/run-flows-via-canonical-scripts.md`): + +```bash +# From the wire-tools-ts root: +node scripts/run-flow.mjs flow-liq-yield \ + --cluster-path /tmp/wire-flow-liq-yield \ + --wire-build-path ../wire-sysio/build/release \ + --ethereum-path ../wire-ethereum \ + --solana-path ../wire-solana + +# In a second terminal — the mandatory six-probe heartbeat: +node scripts/flow-heartbeat-monitor.mjs --cluster-path /tmp/wire-flow-liq-yield +``` + +The `--wire-build-path` build must carry `sysio.swap` and `sysio.liq` (the +bootstrap deploys both) and a `nodeop` whose `batch_operator_plugin` cranks the +yield path; the `--solana-path` requirements are `flow-liq-syndication`'s. diff --git a/packages/flow-liq-yield/package.json b/packages/flow-liq-yield/package.json new file mode 100644 index 000000000..a36b047c1 --- /dev/null +++ b/packages/flow-liq-yield/package.json @@ -0,0 +1,21 @@ +{ + "name": "@wireio/test-flow-liq-yield", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "description": "Flow: syndicated liqSOL is credited on the depot, its reported yield is minted, sold through sysio.swap and claimed as WIRE, and the position is redeemed on the outpost", + "scripts": { + "build": "tsc -b tsconfig.json", + "test": "node lib/index.js", + "clean": "rm -rf lib" + }, + "dependencies": { + "@wireio/cluster-tool": "workspace:*", + "@wireio/debugging-shared": "workspace:*", + "@wireio/sdk-core": "^1.0.88", + "@wireio/opp-typescript-models": "^1.0.48" + }, + "devDependencies": { + "@types/node": "25.5.0" + } +} diff --git a/packages/flow-liq-yield/src/LIQYieldScenario.ts b/packages/flow-liq-yield/src/LIQYieldScenario.ts new file mode 100644 index 000000000..3b1fa7e6d --- /dev/null +++ b/packages/flow-liq-yield/src/LIQYieldScenario.ts @@ -0,0 +1,746 @@ +import Assert from "node:assert" + +import { oppDebuggingPath } from "@wireio/debugging-shared" +import { + AttestationType, + ChainKind, + DebugOutpostEndpointsType, + DesyndicateLIQ +} from "@wireio/opp-typescript-models" +import { Asset, SysioContracts } from "@wireio/sdk-core" +import { + ClusterBuildPhase, + FlowScenario, + ProtocolTiming, + Report, + SolanaFundingTool, + SolanaLiqSyndicationTool, + Steps, + WireClient, + WireState, + containsDesyndicateLIQ, + containsLIQYield, + containsSyndicateLIQ, + matchesProtoEnum, + outputKey, + pollUntil, + readEnvelopeAttestations, + verifyStep, + type ClusterBuild, + type ClusterBuildContext, + type ClusterBuildOptions +} from "@wireio/cluster-tool" +import { LIQYieldScenarioConstants as Constants } from "./LIQYieldScenarioConstants.js" +import { LIQYieldScenarioUserSteps } from "./steps/index.js" + +const { SysioContractName, SysioLiqChainkind } = SysioContracts +const { Actor } = Report + +// ── reads (execute freely inside verify steps) ────────────────────────────── + +/** `sysio.epoch::epochstate.current_epoch_index` (a read — the depot-liveness metric). */ +async function readCurrentEpochIndex( + ctx: ClusterBuildContext +): Promise { + const { rows } = await ctx.wire.getEpochState() + Assert.ok( + rows.length >= 1, + "sysio.epoch::epochstate has no row — did the bootstrap's EpochBootstrap run?" + ) + return rows[0].current_epoch_index +} + +/** The base units of an ABI asset string (`"2.000000000 LIQSOL"` → `2000000000n`). */ +function assetUnits(quantity: string): bigint { + return BigInt(Asset.from(quantity).units.toString()) +} + +/** The user's persisted Solana public key, as the 32 bytes `sysio.liq` parks against. */ +function readUserPubkeyBytes(ctx: ClusterBuildContext): Uint8Array { + return SolanaFundingTool.loadKeypair( + ctx.config.dataPath, + Constants.UserKeypairName + ).publicKey.toBytes() +} + +/** + * The shadow `sysio.liq` has parked against the user's Solana key (base + * units), `0n` when nothing is parked — the depot's ledger for a syndication + * whose key is not yet AuthX-linked. + */ +async function readParkedShadow(ctx: ClusterBuildContext): Promise { + const pubkeyHex = Buffer.from(readUserPubkeyBytes(ctx)).toString("hex"), + { rows } = await ctx.wire + .getSysioContract(SysioContractName.liq) + .tables.parked.query({ limit: Constants.TableQueryLimit }), + row = rows.find( + parked => + parked.pubkey === pubkeyHex && + matchesProtoEnum( + parked.chain_kind, + SysioLiqChainkind, + SysioLiqChainkind.CHAIN_KIND_SVM + ) + ) + return row == null ? 0n : assetUnits(row.holding.balance) +} + +/** The user's own shadow row (scope = the user, key = the shadow symbol), or nothing yet (a read). */ +async function readHolding( + ctx: ClusterBuildContext +): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.liq) + .tables.accounts.query({ + scope: Constants.UserAccount, + limit: Constants.TableQueryLimit + }) + return rows.find( + row => Asset.from(row.balance).symbol.name === Constants.LIQTokenCodename + ) +} + +/** + * The shadow's distribution state — the cumulative index, the pot and the carry + * (a read). The row exists only from the first distribution on; before that it + * is the zero state the contract itself computes against. + */ +async function readYieldIndex( + ctx: ClusterBuildContext +): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.liq) + .tables.yieldidx.query({ + ...WireClient.symbolCodeKeyRange( + Constants.SymbolKeyField, + Constants.LIQTokenCodename + ), + limit: 1 + }) + return rows.length === 0 ? Constants.InitialYieldIndex : rows[0] +} + +/** Reported yield the depot holds outside supply, not yet queued to the swap (base units; `0n` without a row). */ +async function readPendingYield(ctx: ClusterBuildContext): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.liq) + .tables.liqpending.query({ + ...WireClient.symbolCodeKeyRange( + Constants.SymbolKeyField, + Constants.LIQTokenCodename + ), + limit: 1 + }) + return rows.length === 0 ? 0n : assetUnits(rows[0].quantity) +} + +/** The shadow's circulating supply (base units). */ +async function readShadowSupply(ctx: ClusterBuildContext): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.liq) + .tables.stat.query({ + ...WireClient.symbolCodeKeyRange( + Constants.SymbolKeyField, + Constants.LIQTokenCodename + ), + limit: 1 + }) + Assert.ok( + rows.length === 1, + `sysio.liq::stat has no ${Constants.LIQTokenCodename} row — the shadow was never opened` + ) + return assetUnits(rows[0].supply) +} + +/** The user's WIRE balance on `sysio.token` (base units; `0n` before any transfer reaches them). */ +async function readWireBalance(ctx: ClusterBuildContext): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.token) + .tables.accounts.query({ + scope: Constants.UserAccount, + limit: Constants.TableQueryLimit + }) + const row = rows.find( + account => Asset.from(account.balance).symbol.name === WireSymbolCode + ) + return row == null ? 0n : assetUnits(row.balance) +} + +/** The symbol code of the WIRE token the yield is paid in. */ +const WireSymbolCode = "WIRE" + +/** + * WIRE the user is owed on their shadow row at `index`, exactly as + * `sysio.liq` computes it: the banked `owed_wire` plus the accrual since the + * row's checkpoint, `balance × (index − checkpoint) / YIELD_INDEX_SCALE`. + */ +function owedWire( + holding: SysioContracts.SysioLiqAccountType, + index: bigint +): bigint { + const accrued = + (assetUnits(holding.balance) * (index - BigInt(holding.index_checkpoint))) / + Constants.YieldIndexScale + return BigInt(holding.owed_wire) + accrued +} + +/** + * Every `DesyndicateLIQ` the depot has queued into a DEPOT_OUTPOST_SOLANA + * envelope, decoded by its OWN generated message class (a read over the + * `data/opp-debugging/` artifacts). + */ +async function readCirculatedDesyndications( + ctx: ClusterBuildContext +): Promise { + return ( + await readEnvelopeAttestations( + oppDebuggingPath(ctx.config.clusterPath), + DebugOutpostEndpointsType.DEPOT_OUTPOST_SOLANA, + AttestationType.DESYNDICATE_LIQ + ) + ).map(payload => DesyndicateLIQ.fromBinary(payload)) +} + +/** + * LIQ yield — the reward flow end to end, on the real `liqsol_core` paths and + * the real depot contracts: nothing injected, nothing cranked by hand. + * + * 1. **SnapshotDepotEpoch** — record `current_epoch_index` BEFORE any write. + * 2. **ProvisionUser** — the user's WIRE account and their Solana wallet. + * 3. **Syndicate** — map the liq token, deposit for liqSOL, flip Launching → + * PostLaunch, `synd`; `SYNDICATE_LIQ` reaches the depot, which PARKS the + * shadow against the still-unlinked Solana key. + * 4. **Link** — the user links that key through `sysio.authex::createlink`; + * its inline sweep delivers the parked shadow into the user's own row. + * 5. **ReportLiqYield** — a bonus donation + the permissionless + * `report_liq_yield` crank queue `LIQ_YIELD`; the depot lands it in + * `liqpending`, outside supply. + * 6. **QueueYield** — the batch operators' `queueyield` crank mints the pending + * yield as protocol-owned shadow and hands it to the swap's reservoir. + * 7. **SellYield** — their `tickyield` crank sells the reservoir into the pool; + * `addyield` bumps the cumulative index, and the reservoir empties. + * 8. **Claim** — the user claims exactly what the index says they are owed, + * in WIRE; the pot falls by the same. + * 9. **Desyndicate** — the user burns their shadow; `DESYNDICATE_LIQ` reaches + * a depot → Solana envelope, and the outpost pays their liqSOL ATA inline. + * 10. **DepotKeepsAdvancing** — `current_epoch_index` advances past the + * snapshot, so consensus kept closing epochs across every attestation. + */ +export class LIQYieldScenario extends FlowScenario { + readonly name = "flow-liq-yield" + readonly description = + "Syndicated liqSOL is credited on the depot, its reported yield is minted, sold through sysio.swap and claimed as WIRE, and the position is redeemed on the outpost" + + override readonly defaults: ClusterBuildOptions = { + // The yield pool `queueyield` funds and `tickyield` sells through — + // `regliqpool` is epoch-0-gated by the depot, so it rides the bootstrap. + enableMockLiqPools: true, + epochDurationSec: Constants.EpochDurationSec, + producerCount: Constants.ProducerCount, + batchOperatorCount: Constants.BatchOperatorCount, + underwriterCount: Constants.UnderwriterCount + } + + plan(cluster: ClusterBuild): void { + const outpostWriteStepOptions = { + timeoutMs: Constants.OutpostWriteTimeoutMs + }, + depotWriteStepOptions = { timeoutMs: Constants.DepotWriteTimeoutMs }, + circulationStepOptions = { + timeoutMs: + Constants.CirculationTimeoutMs + ProtocolTiming.PollDeadlineBufferMs + }, + crankStepOptions = { + timeoutMs: Constants.CrankTimeoutMs + ProtocolTiming.PollDeadlineBufferMs + }, + epochAdvanceStepOptions = { + timeoutMs: + Constants.epochAdvanceDeadlineMs() + + ProtocolTiming.PollDeadlineBufferMs + } + + // ── 1. Snapshot the depot's epoch index BEFORE any write ── + ClusterBuildPhase.create( + cluster, + "SnapshotDepotEpoch", + "Record the depot's current_epoch_index before any liq attestation is produced" + ).push( + verifyStep( + Actor.Sysio, + "snapshot-epoch-index", + "record sysio.epoch::epochstate.current_epoch_index", + async ctx => { + ctx.outputs.set( + LIQYieldScenario.EpochIndexBeforeKey, + await readCurrentEpochIndex(ctx) + ) + } + ) + ) + + // ── 2. The user: a WIRE account and a Solana wallet ── + ClusterBuildPhase.create( + cluster, + "ProvisionUser", + "Provision the user's WIRE account and fund their Solana wallet" + ).push( + Steps.user.planProvisionWire( + Actor.User, + "provision-wire-account", + `provision ${Constants.UserAccount} (unfunded: the yield is their first WIRE)`, + depotWriteStepOptions, + Constants.UserAccount, + 0n + ), + SolanaFundingTool.planKeypairAirdrop( + Actor.User, + "airdrop-user", + `top the user's Solana wallet up to ${Constants.UserFloorLamports} lamports`, + outpostWriteStepOptions, + Constants.UserKeypairName, + Constants.UserFloorLamports + ) + ) + + // ── 3. A real syndication, parked on the depot against the unlinked key ── + ClusterBuildPhase.create( + cluster, + "Syndicate", + "The user deposits SOL for liqSOL and syndicates it PostLaunch; the depot parks the shadow against their unlinked key" + ).push( + // FIRST, and a read: the phase's writes are all one-way, so a cluster + // this scenario has already run against must be refused before it + // spends one — PostLaunch is TERMINAL, so the scenario is single-shot + // per cluster (use a fresh --cluster-path). + verifyStep( + Actor.SolanaOutpost, + "outpost-is-pre-launch", + "the outpost GlobalState is still PreLaunch — the state this scenario transitions from", + async ctx => { + const current = await SolanaLiqSyndicationTool.readWireState(ctx) + Assert.strictEqual( + current, + WireState.preLaunch, + `the outpost is in ${current}, not ${WireState.preLaunch}` + ) + } + ), + SolanaLiqSyndicationTool.planSetLiqTokenAddress( + Actor.SolanaOutpost, + "map-liq-token", + `bind the liqSOL mint to depot token code ${Constants.LIQTokenCode} on the outpost config`, + outpostWriteStepOptions, + Constants.LIQTokenCode + ), + SolanaLiqSyndicationTool.planDepositForLiqsol( + Actor.User, + "deposit-for-liqsol", + `deposit ${Constants.DepositLamports} lamports for liqSOL 1:1`, + outpostWriteStepOptions, + Constants.UserKeypairName, + Constants.DepositLamports + ), + SolanaLiqSyndicationTool.planSetWireState( + Actor.SolanaOutpost, + "set-wire-state-launching", + "move the outpost GlobalState to Launching", + outpostWriteStepOptions, + WireState.launching + ), + SolanaLiqSyndicationTool.planSetWireState( + Actor.SolanaOutpost, + "set-wire-state-post-launch", + "move the outpost GlobalState to PostLaunch (the depot becomes the syndication ledger)", + outpostWriteStepOptions, + WireState.postLaunch + ), + SolanaLiqSyndicationTool.planSynd( + Actor.User, + "syndicate-liqsol", + `syndicate ${Constants.SyndicateAmount} liqSOL base units into the outpost-owned pool`, + outpostWriteStepOptions, + Constants.UserKeypairName, + Constants.SyndicateAmount + ), + verifyStep( + Actor.BatchOperator, + "syndicate-liq-circulates", + "SYNDICATE_LIQ appears in an OUTPOST_SOLANA_DEPOT envelope artifact", + async ctx => { + await pollUntil( + "SYNDICATE_LIQ in an OUTPOST_SOLANA_DEPOT envelope", + async () => + containsSyndicateLIQ(oppDebuggingPath(ctx.config.clusterPath)), + Constants.CirculationTimeoutMs, + Constants.CirculationPollMs + ) + }, + circulationStepOptions + ), + verifyStep( + Actor.Sysio, + "depot-parks-the-syndication", + "sysio.liq parks exactly the syndicated amount against the user's unlinked Solana key", + async ctx => { + await pollUntil( + `${Constants.SyndicateAmount} shadow parked against the user's key`, + async () => (await readParkedShadow(ctx)) === Constants.SyndicateAmount, + Constants.CirculationTimeoutMs, + Constants.CirculationPollMs + ) + Assert.strictEqual(await readHolding(ctx), undefined, + "the user must hold no shadow before linking — the credit is parked, not delivered") + }, + circulationStepOptions + ) + ) + + // ── 4. The link delivers the parked shadow ── + ClusterBuildPhase.create( + cluster, + "Link", + "The user links their Solana key; createlink's inline sweep delivers the parked shadow" + ).push( + LIQYieldScenarioUserSteps.planLinkSolanaKey( + Actor.User, + "link-solana-key", + `link the user's Solana key to ${Constants.UserAccount} through sysio.authex::createlink`, + depotWriteStepOptions, + Constants.UserAccount, + Constants.UserKeypairName + ), + verifyStep( + Actor.Sysio, + "parked-shadow-delivered", + "the user's own shadow row holds the syndicated amount and nothing stays parked", + async ctx => { + const holding = await readHolding(ctx) + Assert.ok(holding != null, "createlink's sweep opened no shadow row for the user") + Assert.strictEqual(assetUnits(holding.balance), Constants.SyndicateAmount) + Assert.strictEqual(await readParkedShadow(ctx), 0n) + } + ) + ) + + // ── 5. Reported yield lands on the depot, outside supply ── + ClusterBuildPhase.create( + cluster, + "ReportLiqYield", + "Bonus pool yield is donated and the permissionless crank queues LIQ_YIELD; the depot lands it in liqpending" + ).push( + verifyStep( + Actor.SolanaOutpost, + "snapshot-liq-yield-state", + "record GlobalState's liq-yield watermark + sequence before the donation", + async ctx => { + ctx.outputs.set( + LIQYieldScenario.LiqYieldStateBeforeKey, + await SolanaLiqSyndicationTool.readLiqYieldState(ctx) + ) + } + ), + SolanaLiqSyndicationTool.planInjectBonusSyndYield( + Actor.SolanaOutpost, + "inject-bonus-synd-yield", + `donate ${Constants.BonusYieldLamports} lamports of bonus yield to the syndicated pool`, + outpostWriteStepOptions, + SolanaFundingTool.DeployerKeypairName, + Constants.BonusYieldLamports + ), + SolanaLiqSyndicationTool.planReportLiqYield( + Actor.User, + "report-liq-yield", + "crank report_liq_yield as a non-admin signer", + outpostWriteStepOptions, + Constants.UserKeypairName + ), + verifyStep( + Actor.Sysio, + "liq-yield-lands-in-pending", + "LIQ_YIELD circulates and the depot's liqpending holds exactly the reported delta", + async ctx => { + const before = ctx.outputs.assert( + LIQYieldScenario.LiqYieldStateBeforeKey + ), + after = await SolanaLiqSyndicationTool.readLiqYieldState(ctx), + reportedAmount = after.liqYieldReported - before.liqYieldReported + Assert.ok( + reportedAmount >= Constants.BonusYieldLamports, + `report_liq_yield advanced the watermark by ${reportedAmount}, less than the ` + + `${Constants.BonusYieldLamports} lamports donated — the donation was not claimed` + ) + ctx.outputs.set(LIQYieldScenario.ReportedYieldKey, reportedAmount) + ctx.outputs.set( + LIQYieldScenario.SupplyBeforeYieldKey, + await readShadowSupply(ctx) + ) + await pollUntil( + `LIQ_YIELD in an OUTPOST_SOLANA_DEPOT envelope and ${reportedAmount} pending on sysio.liq`, + async () => + containsLIQYield(oppDebuggingPath(ctx.config.clusterPath)) && + (await readPendingYield(ctx)) === reportedAmount, + Constants.CirculationTimeoutMs, + Constants.CirculationPollMs + ) + }, + circulationStepOptions + ) + ) + + // ── 6. The operators' queueyield crank mints the yield into the reservoir ── + ClusterBuildPhase.create( + cluster, + "QueueYield", + "The batch operators' queueyield crank mints the pending yield as protocol-owned shadow and funds the swap's reservoir" + ).push( + verifyStep( + Actor.BatchOperator, + "pending-yield-queued", + "liqpending empties and the shadow supply grows by exactly the reported yield", + async ctx => { + const reported = ctx.outputs.assert(LIQYieldScenario.ReportedYieldKey), + supplyBefore = ctx.outputs.assert( + LIQYieldScenario.SupplyBeforeYieldKey + ) + await pollUntil( + "queueyield moved the pending yield into supply", + async () => + (await readPendingYield(ctx)) === 0n && + (await readShadowSupply(ctx)) === supplyBefore + reported, + Constants.CrankTimeoutMs, + Constants.CirculationPollMs + ) + }, + crankStepOptions + ) + ) + + // ── 7. The operators' tickyield crank sells it; addyield bumps the index ── + ClusterBuildPhase.create( + cluster, + "SellYield", + "The batch operators' tickyield crank sells the reservoir through the pool; addyield distributes the WIRE to every holder" + ).push( + verifyStep( + Actor.BatchOperator, + "yield-index-advances", + "the cumulative index moves off zero and the reservoir sells out, so the ledger is stable for an exact claim", + async ctx => { + await pollUntil( + "yieldidx.index > 0 and the reservoir empty", + async () => + BigInt((await readYieldIndex(ctx)).index) > 0n && + (await readReservoirQueued(ctx)) === 0n, + Constants.CrankTimeoutMs, + Constants.CirculationPollMs + ) + const index = await readYieldIndex(ctx), + holding = await readHolding(ctx), + owed = owedWire(holding, BigInt(index.index)) + Assert.ok(owed > 0n, "the user holds shadow through a distribution, so they must be owed WIRE") + ctx.outputs.set(LIQYieldScenario.OwedWireKey, owed) + ctx.outputs.set(LIQYieldScenario.PotBeforeClaimKey, BigInt(index.pot)) + }, + crankStepOptions + ) + ) + + // ── 8. The claim pays exactly what the index says ── + ClusterBuildPhase.create( + cluster, + "Claim", + "The user claims their yield: exactly the pre-computed owed WIRE, and the pot falls by the same" + ).push( + Steps.contracts.sysio.liq.planClaim( + Actor.User, + "claim-yield", + `claim the WIRE owed to ${Constants.UserAccount}'s ${Constants.LIQTokenCodename} row`, + depotWriteStepOptions, + { holder: Constants.UserAccount, sym: Constants.LIQTokenCodename } + ), + verifyStep( + Actor.Sysio, + "claim-is-exact", + "the user's WIRE balance is exactly the owed amount, the row is settled, and the pot fell by the payout", + async ctx => { + const owed = ctx.outputs.assert(LIQYieldScenario.OwedWireKey), + potBefore = ctx.outputs.assert(LIQYieldScenario.PotBeforeClaimKey), + index = await readYieldIndex(ctx), + holding = await readHolding(ctx) + Assert.strictEqual(await readWireBalance(ctx), owed) + Assert.strictEqual(BigInt(holding.owed_wire), 0n) + Assert.strictEqual(BigInt(holding.index_checkpoint), BigInt(index.index)) + Assert.strictEqual(BigInt(index.pot), potBefore - owed) + } + ) + ) + + // ── 9. The redemption: burn on the depot, paid inline on the outpost ── + ClusterBuildPhase.create( + cluster, + "Desyndicate", + "The user burns their shadow; DESYNDICATE_LIQ reaches the Solana outpost, which pays their liqSOL ATA inline" + ).push( + verifyStep( + Actor.User, + "snapshot-liqsol-balance", + "record the user's liqSOL ATA balance before the redemption", + async ctx => { + ctx.outputs.set( + LIQYieldScenario.LiqsolBalanceBeforeKey, + await SolanaLiqSyndicationTool.readLiqsolBalance( + ctx, + Constants.UserKeypairName + ) + ) + } + ), + Steps.contracts.sysio.liq.planDesyndicate( + Actor.User, + "desyndicate", + `burn ${Constants.SyndicateAmount} shadow base units and queue DESYNDICATE_LIQ`, + depotWriteStepOptions, + { + holder: Constants.UserAccount, + quantity: Asset.fromUnits( + Constants.SyndicateAmount.toString(), + Constants.ShadowSymbol + ).toString() + } + ), + verifyStep( + Actor.Sysio, + "shadow-burned", + "the user's shadow row is empty and the supply fell by the burned amount", + async ctx => { + const holding = await readHolding(ctx) + Assert.strictEqual(assetUnits(holding.balance), 0n) + } + ), + verifyStep( + Actor.BatchOperator, + "desyndicate-liq-circulates", + "DESYNDICATE_LIQ appears in a DEPOT_OUTPOST_SOLANA envelope with the user's key, the amount and a request id", + async ctx => { + const pubkey = readUserPubkeyBytes(ctx) + await pollUntil( + "DESYNDICATE_LIQ in a DEPOT_OUTPOST_SOLANA envelope", + async () => + containsDesyndicateLIQ(oppDebuggingPath(ctx.config.clusterPath)) && + (await readCirculatedDesyndications(ctx)).some( + redemption => + redemption.chainCode === Constants.SolanaChainCode && + redemption.amount?.tokenCode === Constants.LIQTokenCode && + redemption.amount?.amount === Constants.SyndicateAmount && + redemption.user?.kind === ChainKind.SVM && + Buffer.from(redemption.user.address).equals(Buffer.from(pubkey)) && + redemption.requestId > 0n + ), + Constants.CirculationTimeoutMs, + Constants.CirculationPollMs + ) + }, + circulationStepOptions + ), + verifyStep( + Actor.SolanaOutpost, + "outpost-pays-the-redemption", + "the user's liqSOL ATA grows by exactly the burned amount", + async ctx => { + const before = ctx.outputs.assert( + LIQYieldScenario.LiqsolBalanceBeforeKey + ) + await pollUntil( + `the user's liqSOL balance reaches ${before + Constants.SyndicateAmount}`, + async () => + (await SolanaLiqSyndicationTool.readLiqsolBalance( + ctx, + Constants.UserKeypairName + )) === + before + Constants.SyndicateAmount, + Constants.CirculationTimeoutMs, + Constants.CirculationPollMs + ) + }, + circulationStepOptions + ) + ) + + // ── 10. The depot kept advancing throughout ── + ClusterBuildPhase.create( + cluster, + "DepotKeepsAdvancing", + "The depot advances its epoch past the snapshot — consensus kept closing epochs across every liq attestation" + ).push( + verifyStep( + Actor.Sysio, + "epoch-index-advances", + "sysio.epoch::epochstate.current_epoch_index advances past the pre-syndication snapshot", + async ctx => { + const before = ctx.outputs.assert(LIQYieldScenario.EpochIndexBeforeKey) + await pollUntil( + `current_epoch_index advances past ${before}`, + async () => (await readCurrentEpochIndex(ctx)) > before, + Constants.epochAdvanceDeadlineMs(), + Constants.EpochAdvancePollMs + ) + }, + epochAdvanceStepOptions + ) + ) + } +} + +/** The shadow queued in the pool's reservoir, waiting to be sold (base units; `0n` once sold out). */ +async function readReservoirQueued(ctx: ClusterBuildContext): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.swap) + .tables.reservoirs.query({ limit: Constants.TableQueryLimit }) + return rows + .filter( + reservoir => + Asset.from(reservoir.balance.quantity).symbol.name === + Constants.LIQTokenCodename + ) + .reduce((sum, reservoir) => sum + assetUnits(reservoir.balance.quantity), 0n) +} + +/** Typed cross-step output keys for the liq-yield scenario. */ +export namespace LIQYieldScenario { + /** `epochstate.current_epoch_index` snapshotted before the syndication. */ + export const EpochIndexBeforeKey = outputKey( + "LIQYieldScenario.epochIndexBefore", + "the depot's current_epoch_index before any liq attestation was produced" + ) + /** `GlobalState`'s liq-yield accounting snapshotted before the bonus donation. */ + export const LiqYieldStateBeforeKey = + outputKey( + "LIQYieldScenario.liqYieldStateBefore", + "the outpost's liq-yield watermark + sequence before the bonus-yield donation" + ) + /** The yield `report_liq_yield` reported — the amount every depot stage must carry. */ + export const ReportedYieldKey = outputKey( + "LIQYieldScenario.reportedYield", + "liqSOL base units report_liq_yield advanced the watermark by" + ) + /** The shadow supply before the reported yield was minted into it. */ + export const SupplyBeforeYieldKey = outputKey( + "LIQYieldScenario.supplyBeforeYield", + "sysio.liq's shadow supply before queueyield minted the reported yield" + ) + /** The WIRE the index says the user is owed, computed once the ledger is stable. */ + export const OwedWireKey = outputKey( + "LIQYieldScenario.owedWire", + "WIRE base units owed to the user's shadow row before the claim" + ) + /** `yieldidx.pot` before the claim — the claim must draw exactly the owed amount from it. */ + export const PotBeforeClaimKey = outputKey( + "LIQYieldScenario.potBeforeClaim", + "the shadow's undistributed WIRE pot before the user's claim" + ) + /** The user's liqSOL ATA balance before the redemption. */ + export const LiqsolBalanceBeforeKey = outputKey( + "LIQYieldScenario.liqsolBalanceBefore", + "the user's liqSOL balance before DESYNDICATE_LIQ paid them" + ) +} diff --git a/packages/flow-liq-yield/src/LIQYieldScenarioConstants.ts b/packages/flow-liq-yield/src/LIQYieldScenarioConstants.ts new file mode 100644 index 000000000..7e63bba50 --- /dev/null +++ b/packages/flow-liq-yield/src/LIQYieldScenarioConstants.ts @@ -0,0 +1,117 @@ +import { SlugName, SysioContracts } from "@wireio/sdk-core" +import { + ProtocolTiming, + SolanaLiqSyndicationTool, + SolanaOutpostBootstrapper +} from "@wireio/cluster-tool" + +/** + * Constants for the liq-yield flow. The topology mirrors the other OPP-ferry + * flows so the envelope path under test is the already-validated one. Amounts + * are liqSOL base units (9 decimals, 1:1 with the depot frame), and every + * deadline derives from {@link ProtocolTiming} rather than a stopwatch. + */ +export namespace LIQYieldScenarioConstants { + /** Epoch duration (s) — the `sysio.epoch::setconfig` floor is 60. */ + export const EpochDurationSec = 60 + /** Producer nodes stood up by the bootstrap. */ + export const ProducerCount = 3 + /** Batch operators ferrying OPP envelopes and cranking the yield path. */ + export const BatchOperatorCount = 3 + /** Underwriters provisioned by the bootstrap. */ + export const UnderwriterCount = 1 + + /** Registered chain slug code of the Solana outpost — what `OutpostConfig.chain_code` stamps. */ + export const SolanaChainCode: bigint = BigInt( + SlugName.from(SolanaOutpostBootstrapper.SolanaChainCodename) + ) + /** Depot token codename the outpost's REAL liqSOL mint is registered under. */ + export const LIQTokenCodename = "LIQSOL" + /** {@link LIQTokenCodename}'s slug code — what `synd` and `report_liq_yield` stamp. */ + export const LIQTokenCode: bigint = BigInt(SlugName.from(LIQTokenCodename)) + /** The depot frame every liq token is registered at, which its shadow symbol carries. */ + export const ShadowPrecision = 9 + /** The shadow symbol `sysio.liq` opened for liqSOL, in ABI form. */ + export const ShadowSymbol = `${ShadowPrecision},${LIQTokenCodename}` + /** The one key field of `sysio.liq`'s symbol-keyed KV tables (`stat`, `liqpending`, `yieldidx`). */ + export const SymbolKeyField: keyof SysioContracts.SysioLiqSymbolKeyType = "symbol_code" + /** + * The shadow's distribution state before its first distribution: `sysio.liq` + * creates the `yieldidx` row on the first `addyield`, and until then computes + * against a value-initialized `yield_index` — index, pot and carry all zero. + */ + export const InitialYieldIndex: SysioContracts.SysioLiqYieldIndexType = { + index: "0", + pot: 0, + carry: 0 + } + /** + * `sysio.opp.common`'s `YIELD_INDEX_SCALE`: the fixed-point scale of the + * cumulative yield index, so `owed = balance × Δindex / scale`. + */ + export const YieldIndexScale = 1_000_000_000_000n + + /** + * Durable handle of the flow's user keypair on Solana. Created and persisted + * per-cluster by the airdrop step and re-loaded by every later step, so the + * depositor, the syndicator, the linked key and the redeemed wallet are one + * identity. + */ + export const UserKeypairName = "liq-yield-user" + /** The user's WIRE account — the holder the depot credits once the key is linked. */ + export const UserAccount = "liq.yielder" + + /** Lamports the user's wallet is topped up to — the deposit plus fee + tx headroom. */ + export const UserFloorLamports = 20_000_000_000n + /** Lamports the user deposits through `sol_to_liqsol` (minted 1:1 as liqSOL). */ + export const DepositLamports = 5_000_000_000n + /** liqSOL base units the user syndicates — a strict subset of the deposit. */ + export const SyndicateAmount = 2_000_000_000n + /** + * Lamports the deployer donates as bonus pool yield. A positive multiple of + * {@link SolanaLiqSyndicationTool.BonusYieldLamportGranularity} (0.1 SOL), as + * the program requires. + */ + export const BonusYieldLamports = + 3n * SolanaLiqSyndicationTool.BonusYieldLamportGranularity + + /** Deadline for an attestation to cross one outpost ↔ depot hop (envelope class). */ + export const CirculationTimeoutMs = ProtocolTiming.SingleHopBudgetMs + /** Interval between opp-debugging artifact scans and depot table reads (ms). */ + export const CirculationPollMs = 2_000 + /** + * Deadline for the batch operators' depot cranks (`queueyield`, `tickyield`) + * to move the yield one stage — a crank on the next epoch-state poll plus + * irreversibility, the verification-gate class. + */ + export const CrankTimeoutMs = ProtocolTiming.CollateralVerifyBudgetMs + + /** Ceiling on each Solana write step (one test-validator tx + confirmation). */ + export const OutpostWriteTimeoutMs = ProtocolTiming.OutpostWriteBudgetMs + /** Ceiling on each depot write step (one transaction at irreversible finality). */ + export const DepotWriteTimeoutMs = ProtocolTiming.IrreversibilityBaseMs + + /** + * Effective-epoch windows budgeted for `current_epoch_index` to move past the + * pre-syndication snapshot — the shared epoch-advance liveness envelope. + */ + export const EpochAdvanceEpochBudget = ProtocolTiming.EpochVerifyEpochCount + /** Interval between epoch-state reads while waiting for the advance (ms). */ + export const EpochAdvancePollMs = ProtocolTiming.EpochVerifyPollIntervalMs + + /** Row page size for the depot table scans (every table read here is small). */ + export const TableQueryLimit = 100 + + /** + * Deadline for the depot to advance past the snapshotted epoch index (ms). + * + * @returns The deadline in milliseconds. + */ + export function epochAdvanceDeadlineMs(): number { + return ( + ProtocolTiming.effectiveEpochSec(EpochDurationSec) * + EpochAdvanceEpochBudget * + ProtocolTiming.MsPerSecond + ) + } +} diff --git a/packages/flow-liq-yield/src/index.ts b/packages/flow-liq-yield/src/index.ts new file mode 100644 index 000000000..23b28a1b0 --- /dev/null +++ b/packages/flow-liq-yield/src/index.ts @@ -0,0 +1,10 @@ +import { FlowCLI } from "@wireio/cluster-tool" +import { LIQYieldScenario } from "./LIQYieldScenario.js" + +/** Run the liq-yield flow as an executable — exit code = report success. */ +async function main(): Promise { + const report = await FlowCLI.create(LIQYieldScenario).run() + process.exit(report.succeeded ? 0 : 1) +} + +void main() diff --git a/packages/flow-liq-yield/src/steps/LIQYieldScenarioUserSteps.ts b/packages/flow-liq-yield/src/steps/LIQYieldScenarioUserSteps.ts new file mode 100644 index 000000000..c7aa75d64 --- /dev/null +++ b/packages/flow-liq-yield/src/steps/LIQYieldScenarioUserSteps.ts @@ -0,0 +1,88 @@ +import { Base58, KeyType } from "@wireio/sdk-core" +import { ChainKind } from "@wireio/opp-typescript-models" +import { + AuthExLinkTool, + ClusterBuildStep, + SolanaFundingTool, + privateKeyFromNativeString, + type ClusterBuildContext, + type ClusterBuildStepOptions, + type Report, + type StepInput +} from "@wireio/cluster-tool" + +/** + * Flow-local user Steps — the AuthX link that turns the depot's parked shadow + * into the user's own holding. The Solana identity is the flow's persisted + * keypair, so the linked key is exactly the one `synd` stamped on + * `SYNDICATE_LIQ`. + */ +export namespace LIQYieldScenarioUserSteps { + /** Input for {@link planLinkSolanaKey}. */ + export interface LinkSolanaKeyInput extends StepInput { + readonly kind: "LIQYieldScenarioUserSteps.LinkSolanaKeyInput" + /** WIRE account the key is linked to — the `createlink` signer. */ + readonly account: string + /** Durable handle of the user's persisted Solana keypair. */ + readonly keypairName: string + } + + /** + * Link the user's Solana key to their WIRE account through a user-created + * `sysio.authex::createlink`. The link's inline sweep delivers whatever + * `sysio.liq` parked against that key. + * + * @param actor - The narrative subject (the user). + * @param name - Step name (report row). + * @param description - One-line description. + * @param options - Step option overrides. + * @param account - WIRE account the key is linked to. + * @param keypairName - Durable handle of the user's persisted Solana keypair. + * @returns The definition step. + */ + export function planLinkSolanaKey< + C extends ClusterBuildContext = ClusterBuildContext + >( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + account: string, + keypairName: string + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { + kind: "LIQYieldScenarioUserSteps.LinkSolanaKeyInput", + account, + keypairName + }, + runLinkSolanaKey + ) + } + + /** Named runner — ONE `createlink`, signed by the account, proven by the ED key. */ + export async function runLinkSolanaKey( + ctx: C, + input: LinkSolanaKeyInput, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + const keypair = SolanaFundingTool.loadKeypair( + ctx.config.dataPath, + input.keypairName + ) + await AuthExLinkTool.createLink(ctx.wire, { + chainKind: ChainKind.SVM, + account: input.account, + // The web3 secret key IS the ED key's native form: base58 of its 64 bytes. + privateKey: privateKeyFromNativeString( + KeyType.ED, + Base58.encode(keypair.secretKey) + ) + }) + } +} diff --git a/packages/flow-liq-yield/src/steps/index.ts b/packages/flow-liq-yield/src/steps/index.ts new file mode 100644 index 000000000..16493b335 --- /dev/null +++ b/packages/flow-liq-yield/src/steps/index.ts @@ -0,0 +1 @@ +export * from "./LIQYieldScenarioUserSteps.js" diff --git a/packages/flow-liq-yield/tsconfig.json b/packages/flow-liq-yield/tsconfig.json new file mode 100644 index 000000000..82b017421 --- /dev/null +++ b/packages/flow-liq-yield/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../etc/tsconfig/tsconfig.base.jest.json", + "compilerOptions": { + + }, + "include": [], + "references": [ + { "path": "../cluster-tool/tsconfig.json" }, + { "path": "./tsconfig.src.json" } + ] +} diff --git a/packages/flow-liq-yield/tsconfig.src.json b/packages/flow-liq-yield/tsconfig.src.json new file mode 100644 index 000000000..e67621b57 --- /dev/null +++ b/packages/flow-liq-yield/tsconfig.src.json @@ -0,0 +1,14 @@ +{ + "extends": "../../etc/tsconfig/tsconfig.base.jest.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "paths": { + "@wireio/cluster-tool": ["../cluster-tool/src"] + } + }, + "include": ["src"], + "references": [ + { "path": "../cluster-tool/tsconfig.json" } + ] +} diff --git a/packages/flow-swap-from-wire/src/SwapFromWireScenario.ts b/packages/flow-swap-from-wire/src/SwapFromWireScenario.ts index 36616731a..0f1c34f2b 100644 --- a/packages/flow-swap-from-wire/src/SwapFromWireScenario.ts +++ b/packages/flow-swap-from-wire/src/SwapFromWireScenario.ts @@ -7,6 +7,7 @@ import { Constants as HarnessConstants, FlowScenario, Report, + Steps, SwapScenarioContext, SwapUserIdentities, WireReserveTool, @@ -22,10 +23,7 @@ import { } from "@wireio/cluster-tool" import { SwapFromWireScenarioConstants as Constants } from "./SwapFromWireScenarioConstants.js" import { SwapFromWireScenarioOutputs as Outputs } from "./SwapFromWireScenarioOutputs.js" -import { - SwapFromWireScenarioUserSteps, - SwapFromWireScenarioUwritSteps -} from "./steps/index.js" +import { SwapFromWireScenarioUwritSteps } from "./steps/index.js" const { SysioContractName, @@ -244,7 +242,7 @@ export class SwapFromWireScenario extends FlowScenario { "ProvisionDepositor", "Provision the WIRE depositor, funded from the treasury" ).push( - SwapFromWireScenarioUserSteps.planProvisionWire( + Steps.user.planProvisionWire( Actor.User, "provision-depositor", `provision ${Constants.DepositorAccount} + fund ${Constants.DepositorFunding} WIRE base units from the treasury`, diff --git a/packages/flow-swap-from-wire/src/steps/index.ts b/packages/flow-swap-from-wire/src/steps/index.ts index 6eb8633c7..9c8459fe5 100644 --- a/packages/flow-swap-from-wire/src/steps/index.ts +++ b/packages/flow-swap-from-wire/src/steps/index.ts @@ -1,2 +1 @@ -export * from "./SwapFromWireScenarioUserSteps.js" export * from "./SwapFromWireScenarioUwritSteps.js" diff --git a/tsconfig.json b/tsconfig.json index 4f22148e1..c7b537d8c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ { "path": "./packages/flow-emissions-soak/tsconfig.json" }, { "path": "./packages/flow-yield-distribution/tsconfig.json" }, { "path": "./packages/flow-liq-syndication/tsconfig.json" }, + { "path": "./packages/flow-liq-yield/tsconfig.json" }, { "path": "./packages/flow-underwriter-slashing/tsconfig.json" }, { "path": "./packages/debugging-shared/tsconfig.json" }, { "path": "./packages/debugging-server/tsconfig.json" }, From 2dd52365f5d7a1ae8e41e1b6ee3f3843498d4ea4 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 24 Sep 2026 13:52:56 -0500 Subject: [PATCH 2/2] chore(deps): update @wireio/* to latest sdk-core and shared to 1.0.92, the wire-libraries-ts release carrying the regenerated SysioContractTypes for sysio.liq and sysio.swap (wire-libraries-ts#84). Produced by scripts/update-wireio-deps.mjs. Change-Id: I803eca9e735ebf7082843418b8945adfb537fac0 --- packages/flow-liq-yield/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flow-liq-yield/package.json b/packages/flow-liq-yield/package.json index a36b047c1..b181039bf 100644 --- a/packages/flow-liq-yield/package.json +++ b/packages/flow-liq-yield/package.json @@ -12,8 +12,8 @@ "dependencies": { "@wireio/cluster-tool": "workspace:*", "@wireio/debugging-shared": "workspace:*", - "@wireio/sdk-core": "^1.0.88", - "@wireio/opp-typescript-models": "^1.0.48" + "@wireio/sdk-core": "^1.0.92", + "@wireio/opp-typescript-models": "^1.0.50" }, "devDependencies": { "@types/node": "25.5.0"