diff --git a/packages/cluster-tool-shared/src/config/ClusterConfig.ts b/packages/cluster-tool-shared/src/config/ClusterConfig.ts index 64397ced..fb8972c4 100644 --- a/packages/cluster-tool-shared/src/config/ClusterConfig.ts +++ b/packages/cluster-tool-shared/src/config/ClusterConfig.ts @@ -104,6 +104,15 @@ export interface ClusterConfig { warmupEpochs: number /** Staking cooldown, in epochs. */ cooldownEpochs: number + /** + * Warp the solana-test-validator past Solana epoch 3 at launch, so a flow + * driving `flush_staking_yield` (which requires `Clock.epoch >= 3`) can run. + * Off for every flow except `flow-yield-distribution`, which opts in via its + * scenario `defaults`: warping advances the Solana clock minutes ahead, which + * trips the depot's `sysio.authex` 10-minute nonce window on cross-chain SOL + * deposits — so no swap/deposit flow may enable it. + */ + solanaEpochWarp: boolean /** wire-ethereum repo root. */ ethereumPath: string /** wire-solana repo root. */ diff --git a/packages/cluster-tool-shared/tests/config/ClusterConfig.test.ts b/packages/cluster-tool-shared/tests/config/ClusterConfig.test.ts index 95d8cc29..a1396acd 100644 --- a/packages/cluster-tool-shared/tests/config/ClusterConfig.test.ts +++ b/packages/cluster-tool-shared/tests/config/ClusterConfig.test.ts @@ -18,6 +18,7 @@ describe("ClusterConfig shape", () => { epochDurationSec: 60, warmupEpochs: 1, cooldownEpochs: 1, + solanaEpochWarp: false, ethereumPath: "/eth", solanaPath: "/sol", bind: { diff --git a/packages/cluster-tool/src/cli/ClusterBuildOptionsArgs.ts b/packages/cluster-tool/src/cli/ClusterBuildOptionsArgs.ts index 268ae910..968558ec 100644 --- a/packages/cluster-tool/src/cli/ClusterBuildOptionsArgs.ts +++ b/packages/cluster-tool/src/cli/ClusterBuildOptionsArgs.ts @@ -323,6 +323,10 @@ export function buildOptionShape( OptionLeafType.number, "cooldown epochs after the measured window" ), + solanaEpochWarp: leaf( + false, + "warp the solana-test-validator past Solana epoch 3 for the staking-yield flush" + ), // ── termination tuning ── terminateMaxConsecutiveMisses: optionalLeaf( OptionLeafType.number, diff --git a/packages/cluster-tool/src/cluster/processes/SolanaValidatorProcess.ts b/packages/cluster-tool/src/cluster/processes/SolanaValidatorProcess.ts index 20cf9cbe..5bb23a7e 100644 --- a/packages/cluster-tool/src/cluster/processes/SolanaValidatorProcess.ts +++ b/packages/cluster-tool/src/cluster/processes/SolanaValidatorProcess.ts @@ -27,6 +27,14 @@ export interface SolanaValidatorProgram { name: string programId: string soFile: string + /** + * When set, the program is added to genesis as an UPGRADEABLE program + * (`--upgradeable-program … `) so a `ProgramData` account + * exists with this pubkey as its upgrade authority — required by the + * integrated liqsol `initialize_global_config`. When omitted, the program is + * loaded non-upgradeable (`--bpf-program`). + */ + upgradeAuthority?: string } /** Caller options for the solana-test-validator. */ @@ -148,11 +156,16 @@ export class SolanaValidatorProcess extends ManagedProcess { String(this.config.limitLedgerSizeShreds), ...(verbose ? [] : ["--quiet"]), ...(this.config.ledgerPath ? ["--ledger", this.config.ledgerPath] : []), - ...this.config.programs.flatMap(program => [ - "--bpf-program", - program.programId, - program.soFile - ]), + ...this.config.programs.flatMap(program => + program.upgradeAuthority + ? [ + "--upgradeable-program", + program.programId, + program.soFile, + program.upgradeAuthority + ] + : ["--bpf-program", program.programId, program.soFile] + ), ...this.config.extraArgs ] } diff --git a/packages/cluster-tool/src/config/ClusterBuildOptions.ts b/packages/cluster-tool/src/config/ClusterBuildOptions.ts index 28498985..29d6649b 100644 --- a/packages/cluster-tool/src/config/ClusterBuildOptions.ts +++ b/packages/cluster-tool/src/config/ClusterBuildOptions.ts @@ -34,6 +34,7 @@ export interface ClusterBuildOptions { epochDurationSec?: number warmupEpochs?: number cooldownEpochs?: number + solanaEpochWarp?: boolean // network binding bindAll?: boolean bind?: BindOptions diff --git a/packages/cluster-tool/src/config/ClusterConfigProvider.ts b/packages/cluster-tool/src/config/ClusterConfigProvider.ts index cb2c627b..7f861c77 100644 --- a/packages/cluster-tool/src/config/ClusterConfigProvider.ts +++ b/packages/cluster-tool/src/config/ClusterConfigProvider.ts @@ -50,6 +50,13 @@ export namespace ClusterConfigProvider { export const DefaultBatchOperatorCount = 3 export const DefaultUnderwriterCount = 1 export const DefaultEpochDurationSec = 90 + /** + * Default for {@link ClusterConfig.solanaEpochWarp} — OFF. Only + * `flow-yield-distribution` opts in (via its scenario `defaults`); warping the + * Solana clock trips the depot's cross-chain deposit nonce window, so it is + * never the cluster-wide default. + */ + export const DefaultSolanaEpochWarp = false /** * Resolve defaults → validate → return a ready config (the only forward @@ -86,6 +93,7 @@ export namespace ClusterConfigProvider { epochDurationSec: options.epochDurationSec ?? DefaultEpochDurationSec, warmupEpochs: options.warmupEpochs ?? 1, cooldownEpochs: options.cooldownEpochs ?? 1, + solanaEpochWarp: options.solanaEpochWarp ?? DefaultSolanaEpochWarp, ethereumPath: assertOption(options.ethereumPath, "ethereumPath"), solanaPath: assertOption(options.solanaPath, "solanaPath"), bind, diff --git a/packages/cluster-tool/src/orchestration/solana/OppSolProgram.ts b/packages/cluster-tool/src/orchestration/solana/OppSolProgram.ts new file mode 100644 index 00000000..a0774c6f --- /dev/null +++ b/packages/cluster-tool/src/orchestration/solana/OppSolProgram.ts @@ -0,0 +1,66 @@ +import Path from "node:path" + +/** + * Constants for the folded-`liqsol_core` OPP Solana outpost. The artifact + * identity (program keypair / `.so` / IDL / init instruction) is owned by + * {@link SolanaOutpostProgramTool}, which targets the `liqsol_core` program + * directly; this namespace carries the extras that program's OPP interface + * requires: the upgradeable-deploy + `global_config` gating, and the epoch-warp + * validator arguments for the staking-yield flush. + * + * Every OPP admin op (`initialize_outpost`, `set_token_address`, + * `set_token_precision`, `init_reserve`, `create_reserve_native`, + * `create_reserve_spl_authority`) is gated by a `global_config` PDA whose + * `admin` is the program's on-chain upgrade authority — so the program is + * deployed UPGRADEABLE (a `ProgramData` account exists) and + * `initialize_global_config` runs once before the outpost is initialized. + * + * The epoch warp is NOT a global here: it is a per-cluster + * {@link ClusterConfig.solanaEpochWarp} option a flow opts into via its + * scenario `defaults` (only `flow-yield-distribution` does). These constants + * are the validator arguments that warp applies. + */ +export namespace OppSolProgram { + /** + * `--slots-per-epoch` for the warp — epochs stretched so all + * `dev_seed_staker_yield` seeding plus the flush land in ONE epoch (the reward's + * `external_epoch_ref` derives from the Solana epoch, so a mid-flow rollover + * would move the ref out from under the depot's dedupe check). + */ + export const solanaWarpSlotsPerEpoch: string = "4096" + /** + * `--warp-slot` target — just past the epoch-3 boundary (3 * 4096 = 12288). It + * MUST land in epoch 3 exactly: a single-node test-validator can build epoch + * 3's leader schedule from genesis stakes and keep producing, but warping + * straight into epoch 4+ leaves it unable to derive the schedule and it never + * produces a block. + */ + export const solanaWarpSlot: string = "12300" + + /** Seed of the shared liqsol `global_config` PDA (`has_one = admin`). */ + export const globalConfigSeed: string = "global_config" + + /** BPF upgradeable-loader program id — parent of every program's `ProgramData` PDA. */ + export const bpfLoaderUpgradeableProgramId: string = + "BPFLoaderUpgradeab1e11111111111111111111111" + + /** + * Basename of the per-cluster SOL deployer keypair. In integrated mode this + * keypair is the program's upgrade authority (set at validator launch via + * `--upgradeable-program`) AND the outpost `admin`, so it MUST be resolved + * identically at validator-launch and outpost-bootstrap time. + */ + export const deployerKeypairFilename: string = "sol-deployer-keypair.json" + + /** + * Absolute path to the per-cluster SOL deployer keypair under the cluster data + * dir — the single source of truth for the upgrade-authority/admin identity in + * integrated mode. + * + * @param clusterDataPath - cluster data directory. + * @return absolute path to `/sol-deployer-keypair.json`. + */ + export function clusterDeployerKeypairFile(clusterDataPath: string): string { + return Path.join(clusterDataPath, deployerKeypairFilename) + } +} diff --git a/packages/cluster-tool/src/orchestration/solana/SolanaOutpostBootstrapper.ts b/packages/cluster-tool/src/orchestration/solana/SolanaOutpostBootstrapper.ts index 7571f6f4..78284967 100644 --- a/packages/cluster-tool/src/orchestration/solana/SolanaOutpostBootstrapper.ts +++ b/packages/cluster-tool/src/orchestration/solana/SolanaOutpostBootstrapper.ts @@ -5,6 +5,7 @@ import * as anchor from "@coral-xyz/anchor" import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js" import { TOKEN_PROGRAM_ID } from "@solana/spl-token" import { SlugName } from "@wireio/sdk-core" +import { OppSolProgram } from "./OppSolProgram.js" import { mapSeries } from "../../utils/asyncUtils.js" import { SolanaClient } from "../../clients/solana/SolanaClient.js" import { confirmSignature } from "../../clients/solana/utils/signatureUtils.js" @@ -54,9 +55,9 @@ export interface SolanaOutpostBootstrapperConfig { * (+ envelope-log + reserve) PDAs against the already-loaded `liqsol_core` * program (which hosts the OPP outpost interface), seed the native-SOL * reserve, and (when a cluster data path is given) provision mock SPL - * reserves. The program is deployed via `--bpf-program` at validator launch; - * per-epoch `EpochDeliveries` PDAs are allocated lazily by the batch operator - * on first delivery. + * reserves. The program is deployed upgradeable at validator launch (its + * upgrade authority == the outpost `admin`); per-epoch `EpochDeliveries` PDAs + * are allocated lazily by the batch operator on first delivery. * * Test-cluster custody priming (`provisionSplReserves`) lives HERE in the * harness, never in `wire-solana`'s deploy scripts. @@ -66,6 +67,8 @@ export class SolanaOutpostBootstrapper { private readonly connection: Connection /** OPP outpost program id (resolved from the program keypair file), or null when absent. */ programId: PublicKey | null = null + /** liqsol `global_config` PDA, resolved in `ensureGlobalConfig`. */ + private globalConfigPda: PublicKey | null = null constructor(options: SolanaOutpostBootstrapperOptions) { Assert.ok(options.solanaPath, "SolanaOutpostBootstrapper: solanaPath is required") @@ -75,7 +78,9 @@ export class SolanaOutpostBootstrapper { rpcUrl: options.rpcUrl, deployerKeypairFile: options.deployerKeypairFile ?? - SolanaOutpostBootstrapper.defaultDeployerKeypairFile(), + (options.clusterDataPath != null + ? OppSolProgram.clusterDeployerKeypairFile(options.clusterDataPath) + : SolanaOutpostBootstrapper.defaultDeployerKeypairFile()), programKeypairFile: options.programKeypairFile ?? SolanaOutpostProgramTool.programKeypairFile(options.solanaPath), @@ -141,7 +146,7 @@ export class SolanaOutpostBootstrapper { log.info("OPP outpost program is loaded on the validator") else log.warn( - "OPP outpost program not found on validator — it should be deployed via --bpf-program" + "OPP outpost program not found on validator — it should be deployed upgradeable at launch" ) } @@ -252,6 +257,10 @@ export class SolanaOutpostBootstrapper { const idl = JSON.parse(Fs.readFileSync(idlFile, "utf8")) const program = new anchor.Program(idl, provider) + // The OPP admin ops are gated by the liqsol `global_config` + // (`has_one = admin`), which must be initialized once before the outpost. + await this.ensureGlobalConfig(deployer, program) + // `initialize_outpost` takes only the outpost's `chain_code` // (SOL ⇒ "SOLANA"_c) — consensus thresholds are derived on-the-fly per // `epoch_in` and the epoch duration is propagated via the @@ -261,7 +270,7 @@ export class SolanaOutpostBootstrapper { const initializeTransaction = await program.methods .initializeOutpost(solanaChainCode) .accounts({ - authority: deployer.publicKey, + ...this.oppAdminAccounts(deployer), config: configPda, outboundMessageBuffer: outboundMessageBufferPda, operatorRegistry: operatorRegistryPda, @@ -280,7 +289,7 @@ export class SolanaOutpostBootstrapper { const solTokenCode = new anchor.BN(SlugName.from(SolanaOutpostBootstrapper.SolTokenCodename)) const setTokenAddressTransaction = await program.methods .setTokenAddress(solTokenCode, anchor.web3.PublicKey.default) - .accounts({ authority: deployer.publicKey, config: configPda }) + .accounts({ ...this.oppAdminAccounts(deployer), config: configPda }) .signers([deployer]) .transaction() await this.runSimpleAuthorityInstruction(deployer, setTokenAddressTransaction, "set_token_address") @@ -293,7 +302,7 @@ export class SolanaOutpostBootstrapper { // `create_reserve_native` and every SOL swap path can frame-convert. const setSolPrecisionTransaction = await program.methods .setTokenPrecision(solTokenCode, SolanaOutpostBootstrapper.SolTokenDecimals) - .accounts({ authority: deployer.publicKey, config: configPda }) + .accounts({ ...this.oppAdminAccounts(deployer), config: configPda }) .signers([deployer]) .transaction() await this.runSimpleAuthorityInstruction( @@ -311,7 +320,7 @@ export class SolanaOutpostBootstrapper { .initReserve() .accounts({ payer: deployer.publicKey, - authority: deployer.publicKey, + ...this.oppAdminAccounts(deployer), config: configPda, reserveAggregate: reserveAggregatePda, systemProgram: anchor.web3.SystemProgram.programId @@ -346,7 +355,7 @@ export class SolanaOutpostBootstrapper { ) .accounts({ payer: deployer.publicKey, - authority: deployer.publicKey, + ...this.oppAdminAccounts(deployer), config: configPda, reserve: solReservePda, systemProgram: anchor.web3.SystemProgram.programId @@ -414,7 +423,7 @@ export class SolanaOutpostBootstrapper { const setAddressTransaction = await program.methods .setTokenAddress(codeBigNumber, mint) - .accounts({ authority: deployer.publicKey, config: configPda }) + .accounts({ ...this.oppAdminAccounts(deployer), config: configPda }) .signers([deployer]) .transaction() await this.runSimpleAuthorityInstruction( @@ -425,7 +434,7 @@ export class SolanaOutpostBootstrapper { const setPrecisionTransaction = await program.methods .setTokenPrecision(codeBigNumber, specification.decimals) - .accounts({ authority: deployer.publicKey, config: configPda }) + .accounts({ ...this.oppAdminAccounts(deployer), config: configPda }) .signers([deployer]) .transaction() await this.runSimpleAuthorityInstruction( @@ -459,12 +468,12 @@ export class SolanaOutpostBootstrapper { ) .accounts({ payer: deployer.publicKey, - authority: deployer.publicKey, + ...this.oppAdminAccounts(deployer), config: configPda, reserve: reservePda, reserveVault: reserveVaultPda, mint, - authorityAta: deployerAta, + adminAta: deployerAta, tokenProgram: TOKEN_PROGRAM_ID, systemProgram: anchor.web3.SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY @@ -488,6 +497,66 @@ export class SolanaOutpostBootstrapper { log.info(`[solana] persisted ${persisted.length} mock SPL mint(s) to ${persistedFile}`) } + /** + * The signer/authority accounts every OPP admin instruction shares: the + * liqsol program takes `admin` + the gating `global_config` PDA + * (`has_one = admin`). + * + * @param deployer - the deployer keypair (the outpost `admin`). + * @return the account fragment to spread into an admin instruction's `.accounts`. + */ + private oppAdminAccounts(deployer: Keypair): Record { + Assert.ok( + this.globalConfigPda != null, + "oppAdminAccounts: global_config not initialized" + ) + return { admin: deployer.publicKey, globalConfig: this.globalConfigPda } + } + + /** + * Initialize the liqsol `global_config` PDA (idempotent) so its `admin` is set + * to the program's on-chain upgrade authority — which the validator launched + * as this same `deployer`. Every OPP admin op then passes `admin = deployer` + * and `global_config` to satisfy the `has_one = admin` gate. + * + * @param deployer - the deployer keypair (== program upgrade authority). + * @param program - the liqsol Anchor program bound to the deployer. + */ + private async ensureGlobalConfig( + deployer: Keypair, + program: anchor.Program + ): Promise { + const programId = this.programId + Assert.ok(programId != null, "ensureGlobalConfig: programId required") + const [globalConfig] = PublicKey.findProgramAddressSync( + [Buffer.from(OppSolProgram.globalConfigSeed)], + programId + ) + this.globalConfigPda = globalConfig + const existing = await this.connection.getAccountInfo(globalConfig) + if (existing != null && existing.data.length > 0) { + log.info("liqsol global_config already initialized") + return + } + const [programData] = PublicKey.findProgramAddressSync( + [programId.toBuffer()], + new PublicKey(OppSolProgram.bpfLoaderUpgradeableProgramId) + ) + const transaction = await program.methods + .initializeGlobalConfig() + .accounts({ + globalConfig, + payer: deployer.publicKey, + program: programId, + programData, + systemProgram: anchor.web3.SystemProgram.programId + }) + .signers([deployer]) + .transaction() + await this.runSimpleAuthorityInstruction(deployer, transaction, "initialize_global_config") + log.info(`liqsol global_config initialized (admin=${deployer.publicKey.toBase58()})`) + } + /** * Submit a pre-built transaction signed by `signer`, then poll-confirm it. * Shared by `set_token_address` / `set_token_precision` / the reserve-create diff --git a/packages/cluster-tool/src/orchestration/steps/processes/SolanaValidatorProcessSteps.ts b/packages/cluster-tool/src/orchestration/steps/processes/SolanaValidatorProcessSteps.ts index b74c5056..5db87a26 100644 --- a/packages/cluster-tool/src/orchestration/steps/processes/SolanaValidatorProcessSteps.ts +++ b/packages/cluster-tool/src/orchestration/steps/processes/SolanaValidatorProcessSteps.ts @@ -1,8 +1,11 @@ +import Fs from "node:fs" import Path from "node:path" +import { Keypair } from "@solana/web3.js" import { SolanaValidatorProcess } from "../../../cluster/processes/SolanaValidatorProcess.js" import { SolanaOutpostProgramTool } from "../../../tools/solana/SolanaOutpostProgramTool.js" import { Report } from "../../../report/Report.js" import { ClusterBuildContext } from "../../ClusterBuildContext.js" +import { OppSolProgram } from "../../solana/OppSolProgram.js" import { ClusterBuildStep, type ClusterBuildStepOptions @@ -10,10 +13,29 @@ import { /** Steps that manage the cluster's solana-test-validator process. */ export namespace SolanaValidatorProcessSteps { + /** + * Resolve (creating on first call) the per-cluster SOL deployer keypair and + * return its base58 pubkey — used as the program's upgrade authority. + * Persisted at `deployerFile` so `SolanaOutpostBootstrapper` loads the + * identical keypair to sign OPP admin instructions. + * + * @param deployerFile - absolute path to the deployer keypair JSON. + * @return the deployer's base58 public key. + */ + function resolveUpgradeAuthority(deployerFile: string): string { + if (!Fs.existsSync(deployerFile)) { + Fs.mkdirSync(Path.dirname(deployerFile), { recursive: true }) + Fs.writeFileSync(deployerFile, JSON.stringify(Array.from(Keypair.generate().secretKey))) + } + return Keypair.fromSecretKey( + Uint8Array.from(JSON.parse(Fs.readFileSync(deployerFile, "utf8"))) + ).publicKey.toBase58() + } + /** * Start the solana-test-validator (get-or-create from `ctx.processManager`) * with the `liqsol_core` program (hosting the OPP outpost interface) loaded - * via `--bpf-program` (the SOL outpost deploy depends on it). Idempotent. + * upgradeable, the per-cluster deployer as its upgrade authority. Idempotent. */ export function planStart( actor: Report.Actor, @@ -37,13 +59,39 @@ export namespace SolanaValidatorProcessSteps { .toBase58() const soFile = SolanaOutpostProgramTool.programSoFile(ctx.config.solanaPath) + // The program is deployed UPGRADEABLE with the per-cluster deployer as its + // upgrade authority — that same deployer becomes the `global_config.admin` + // the OPP admin ops require. Materialize the keypair here (before launch) so + // `SolanaOutpostBootstrapper` loads the identical identity. + const upgradeAuthority = resolveUpgradeAuthority( + OppSolProgram.clusterDeployerKeypairFile(ctx.config.dataPath) + ) + + // Per-cluster opt-in (`ClusterConfig.solanaEpochWarp`, set only by + // `flow-yield-distribution`'s scenario `defaults`): warp the validator past + // Solana epoch 3 for `flush_staking_yield` (which requires `Clock.epoch >= + // 3`). It is NOT the cluster default: warping advances the Solana clock + // minutes ahead, tripping the depot's `sysio.authex` 10-minute nonce window + // on cross-chain SOL deposits, so only the yield flow (whose reward routes + // through `sysio.dclaim`, no such check) enables it — every other flow keeps + // the real-time clock. Warp arguments come from OppSolProgram. + const extraArgs = ctx.config.solanaEpochWarp + ? [ + "--slots-per-epoch", + OppSolProgram.solanaWarpSlotsPerEpoch, + "--warp-slot", + OppSolProgram.solanaWarpSlot + ] + : undefined + const validator = await SolanaValidatorProcess.create(ctx.processManager, { rpcPort: ctx.config.bind.solana.ports.http, faucetPort: ctx.config.bind.solana.ports.faucet, gossipPort: ctx.config.bind.solana.ports.gossip, dynamicPortRange: ctx.config.bind.solana.ports.dynamicRange, ledgerPath: Path.join(ctx.config.dataPath, SolanaValidatorProcess.LedgerSubpath), - programs: [{ name: SolanaOutpostProgramTool.ProgramName, programId, soFile }] + programs: [{ name: SolanaOutpostProgramTool.ProgramName, programId, soFile, upgradeAuthority }], + ...(extraArgs ? { extraArgs } : {}) }) await validator.start() } diff --git a/packages/cluster-tool/src/tools/solana/SolanaYieldEmitterTool.ts b/packages/cluster-tool/src/tools/solana/SolanaYieldEmitterTool.ts index 85f3a04a..768c9542 100644 --- a/packages/cluster-tool/src/tools/solana/SolanaYieldEmitterTool.ts +++ b/packages/cluster-tool/src/tools/solana/SolanaYieldEmitterTool.ts @@ -1,37 +1,29 @@ /** - * SolanaYieldEmitterTool — drive synthetic STAKING_REWARD attestations - * into the Solana outpost's `OutboundMessageBuffer` via the existing - * `opp_outpost::add_attestation` CPI target. + * SolanaYieldEmitterTool — drive a genuine STAKING_REWARD emission out of the + * folded `liqsol_core` outpost's real yield pipeline. * * The flow-yield-distribution test uses this to mirror the ETH side's - * `MockYieldEmitter.sol` without standing up a separate Anchor program: - * `add_attestation` is the exact CPI surface a real yield-aware Solana - * contract would invoke. The helper signs each call with the SOL - * outpost deployer keypair (== `OutpostConfig.authority` set during - * Phase 10b bootstrap) and ferries the encoded proto bytes through - * the same envelope path the batch operator polls. + * `MockYieldEmitter.sol`: seed a staker's on-chain yield state via the dev-only + * `dev_seed_staker_yield` (compiled under `--features development`), then crank + * `flush_staking_yield` so the program itself packs a real `StakingReward` into + * the outbound buffer — the exact path a production yield-aware Solana contract + * exercises. Both instructions are signed by the SOL outpost deployer keypair + * (== `global_config.admin`, which is also the `cranker`). * - * Once `add_attestation` lands the entry, the batch-operator plugin - * picks it up, packs the next `BATCH_OPERATOR_GROUPS` envelope, and - * the depot dispatches it as `sysio.dclaim::onreward` — same code - * path a production STAKING_REWARD would exercise. + * Once the flush lands the reward, the batch-operator plugin picks it up, packs + * the next `BATCH_OPERATOR_GROUPS` envelope, and the depot dispatches it as + * `sysio.dclaim::onreward` — same code path a production STAKING_REWARD would. */ import Assert from "node:assert" -import * as crypto from "node:crypto" import * as anchor from "@coral-xyz/anchor" import { Connection, Keypair, PublicKey, - Transaction, - TransactionInstruction + SystemProgram, + Transaction } from "@solana/web3.js" -import { - ChainKind, - type StakingReward, - StakingReward as StakingRewardMsg -} from "@wireio/opp-typescript-models" import { confirmSignature } from "../../clients/solana/utils/signatureUtils.js" /** Seed for the `OutpostConfig` singleton PDA (mirrors @@ -39,10 +31,17 @@ import { confirmSignature } from "../../clients/solana/utils/signatureUtils.js" const OUTPOST_CONFIG_SEED = Buffer.from("outpost_config") /** Seed for the `OutboundMessageBuffer` singleton PDA. */ const OUTBOUND_MESSAGE_BUFFER_SEED = Buffer.from("outbound_message_buffer") +/** Seed for the shared liqsol `global_config` PDA (`has_one = admin`). */ +const GLOBAL_CONFIG_SEED = Buffer.from("global_config") +/** Seed for the outpost `GlobalState` singleton PDA. */ +const OUTPOST_GLOBAL_STATE_SEED = Buffer.from("outpost_global_state") +/** Seed for the `TokenPurchaseHistory` ring PDA. */ +const TOKEN_PURCHASE_HISTORY_SEED = Buffer.from("token_purchase_history") +/** Seed prefix for a staker's `OutpostAccount` PDA (`[b"outpost_account", staker]`). */ +const OUTPOST_ACCOUNT_SEED = Buffer.from("outpost_account") -/** Per-staker entry in an `emitYieldBatch` invocation. Mirrors the ETH - * side's `YieldEntry` shape for ergonomic symmetry across the two - * emitter helpers. */ +/** Per-staker entry in a yield emission. Mirrors the ETH side's `YieldEntry` + * shape for ergonomic symmetry across the two emitter helpers. */ export interface SolanaYieldEntry { /** Solana wallet pubkey of the staker. The 32-byte raw bytes become * the depot-side `StakingReward.staker_native_address.address` @@ -59,147 +58,95 @@ export interface SolanaYieldEntry { } /** - * Build a single STAKING_REWARD attestation's encoded proto bytes. - * Factored out so the test can inspect the payload (e.g. assert the - * encoded bytes round-trip through `StakingRewardMsg.fromBinary`). - * - * @param entry Per-staker triple. - * @param chainCode SlugName-packed `uint64` of the Solana outpost - * (e.g. `SlugName.from("SOLANA")`). Stamped onto - * `chain_code` and `reward_amount.token_code`'s - * containing chain frame. - * @param tokenCode SlugName-packed `uint64` of the reward token - * (e.g. `SlugName.from("SOL")`). - * @param externalEpochRef Monotonic-per-staker reference. The depot's - * `sysio.dclaim::onreward` dedupes against this. - * @param rewardEpochIndex WIRE epoch index — informational. - */ -export function encodeStakingReward( - entry: SolanaYieldEntry, - chainCode: bigint, - tokenCode: bigint, - externalEpochRef: bigint, - rewardEpochIndex: number -): Uint8Array { - const reward: StakingReward = { - chainCode, - stakerWireAccount: { name: entry.wireAccount }, - shareBps: entry.shareBps, - rewardEpochIndex, - externalEpochRef, - rewardAmount: { - tokenCode, - amount: entry.rewardAmount - }, - stakerNativeAddress: { - kind: ChainKind.SVM, - address: entry.staker.toBytes() - } - } - return StakingRewardMsg.toBinary(reward) -} - -/** - * Push a single STAKING_REWARD attestation through - * `opp_outpost::add_attestation`. Multiple entries are submitted one - * call at a time so the depot's per-staker monotonic check sees - * distinct `external_epoch_ref` values when needed; callers that need - * a single-tx batch should iterate this helper inside their own - * `Promise.all` or pass distinct refs per entry. + * Emit a genuine STAKING_REWARD for one staker through the program's real yield + * pipeline: seed the on-chain yield state with the dev instruction + * `dev_seed_staker_yield` (seeds `GlobalState`→PostLaunch + the + * `TokenPurchaseHistory` ring + the staker's `OutpostAccount`), then crank + * `flush_staking_yield` so the program emits the `StakingReward` (WIRE_TOKEN_CODE + * = 0, ref derived from the warped SOLANA epoch) into the outbound buffer. Two + * separate signed+confirmed transactions (seed, then flush) — the flush reads + * the state the seed committed. * - * The Anchor IDL declares `attestation_type` as the protobuf-derived - * `AttestationType` enum. Anchor-TS encodes enum variants as - * `{ : {} }`; the matching variant for STAKING_REWARD is - * `attestationTypeStakingReward`. + * Each instruction is built via the Anchor `Program` (`.instruction()`) and + * submitted through a manual `connection.sendTransaction` + + * {@link confirmSignature} (anchor's `.rpc()` confirm is unreliable in the + * test-validator env). Only `entry.staker` + `entry.rewardAmount` are used — + * the program forces the token code and derives the external epoch ref. * - * @param connection Solana RPC connection (typically `solClient.connection`). - * @param program Anchor `Program` bound to `opp_outpost`. - * @param authority Deployer keypair = `OutpostConfig.authority`. - * @param entry Per-staker triple to emit. - * @param chainCode See {@link encodeStakingReward}. - * @param tokenCode See {@link encodeStakingReward}. - * @param externalEpochRef See {@link encodeStakingReward}. - * @param rewardEpochIndex See {@link encodeStakingReward}. - * @return Confirmed transaction signature. + * @param connection Solana RPC connection (typically `solClient.connection`). + * @param program Anchor `Program` bound to the `liqsol_core` dev IDL + * (exposes `devSeedStakerYield` + `flushStakingYield`). + * @param authority SOL deployer keypair = `global_config.admin`; signs BOTH + * the seed (as `admin`) and the flush (as `cranker`). + * @param entry Per-staker triple — only `staker` + `rewardAmount` are used. + * @return Confirmed signature of the `flush_staking_yield` transaction. */ export async function emitSolanaYield( - connection: Connection, - program: anchor.Program, - authority: Keypair, - entry: SolanaYieldEntry, - chainCode: bigint, - tokenCode: bigint, - externalEpochRef: bigint, - rewardEpochIndex: number + connection: Connection, + program: anchor.Program, + authority: Keypair, + entry: SolanaYieldEntry ): Promise { Assert.ok(entry.rewardAmount > 0n, "SolanaYieldEmitterTool: rewardAmount must be positive") - Assert.ok(externalEpochRef > 0n, "SolanaYieldEmitterTool: externalEpochRef must be positive") - - const encoded = encodeStakingReward( - entry, - chainCode, - tokenCode, - externalEpochRef, - rewardEpochIndex - ) const programId = program.programId + const [globalConfigPda] = PublicKey.findProgramAddressSync([GLOBAL_CONFIG_SEED], programId) + const [globalStatePda] = PublicKey.findProgramAddressSync([OUTPOST_GLOBAL_STATE_SEED], programId) + const [tokenPurchaseHistoryPda] = PublicKey.findProgramAddressSync( + [TOKEN_PURCHASE_HISTORY_SEED], + programId + ) + const [outpostAccountPda] = PublicKey.findProgramAddressSync( + [OUTPOST_ACCOUNT_SEED, entry.staker.toBytes()], + programId + ) const [configPda] = PublicKey.findProgramAddressSync([OUTPOST_CONFIG_SEED], programId) const [outboundMessageBufferPda] = PublicKey.findProgramAddressSync( [OUTBOUND_MESSAGE_BUFFER_SEED], programId ) - // `add_attestation`'s `AttestationType` arg is declared in the IDL as a - // unit enum, but the proto-generated Rust enum carries a custom Borsh - // impl that serializes as `i32` (4-byte LE) — see the wire-opp-solana-models - // crate (types.rs): - // impl borsh::BorshSerialize for AttestationType { ... as i32 ... } - // anchor.Program would encode it as the IDL's 1-byte variant tag, - // producing bytes the program's deserializer reads as a corrupted - // payload (and the OOM the proto-derived enum's `from(i32)` tries to - // allocate a `Vec` over). So we build the instruction by hand with - // the correct Borsh shape: 8-byte Anchor discriminator + i32 LE - // attestation_type + Vec data (4-byte LE length + bytes). - // - // Anchor's instruction discriminator is the first 8 bytes of - // sha256("global:add_attestation") - // — same convention every Anchor-generated client uses. - const ATTESTATION_TYPE_STAKING_REWARD = 60950 // proto enum value + // ── 1. Seed the yield state for this staker ── + const seedIx = await program.methods + .devSeedStakerYield(entry.staker, new anchor.BN(entry.rewardAmount.toString())) + .accounts({ + admin: authority.publicKey, + globalConfig: globalConfigPda, + globalState: globalStatePda, + tokenPurchaseHistory: tokenPurchaseHistoryPda, + outpostAccount: outpostAccountPda, + systemProgram: SystemProgram.programId + }) + .instruction() - const discriminator = crypto - .createHash("sha256") - .update("global:add_attestation") - .digest() - .subarray(0, 8) - - const dataBuf = Buffer.from(encoded) - const ixData = Buffer.alloc(8 + 4 + 4 + dataBuf.length) - let off = 0 - discriminator.copy(ixData, off); off += 8 - ixData.writeInt32LE(ATTESTATION_TYPE_STAKING_REWARD, off); off += 4 - ixData.writeUInt32LE(dataBuf.length, off); off += 4 - dataBuf.copy(ixData, off) - - // `AddAttestation` declares exactly 3 accounts (authority, config, - // outbound_message_buffer) — the keys below must match that list. - const ix = new TransactionInstruction({ - programId, - keys: [ - { pubkey: authority.publicKey, isSigner: true, isWritable: true }, - { pubkey: configPda, isSigner: false, isWritable: false }, - { pubkey: outboundMessageBufferPda, isSigner: false, isWritable: true } - ], - data: ixData - }) - const tx = new Transaction().add(ix) + const seedSig = await connection.sendTransaction( + new Transaction().add(seedIx), + [authority], + { skipPreflight: false } + ) + await confirmSignature(connection, seedSig, "SolanaYieldEmitterTool dev_seed_staker_yield") - const sig = await connection.sendTransaction(tx, [authority], { - skipPreflight: false - }) + // ── 2. Crank the REAL flush — the program emits the StakingReward ── + const flushIx = await program.methods + .flushStakingYield() + .accounts({ + cranker: authority.publicKey, + globalState: globalStatePda, + tokenPurchaseHistory: tokenPurchaseHistoryPda, + config: configPda, + outboundMessageBuffer: outboundMessageBufferPda, + systemProgram: SystemProgram.programId + }) + .remainingAccounts([ + { pubkey: outpostAccountPda, isSigner: false, isWritable: true } + ]) + .instruction() - // Poll for confirmation via the shared bounded poller — anchor's - // .rpc() confirmTransaction is broken in our test-validator env. - await confirmSignature(connection, sig, "SolanaYieldEmitterTool add_attestation") - return sig + const flushSig = await connection.sendTransaction( + new Transaction().add(flushIx), + [authority], + { skipPreflight: false } + ) + await confirmSignature(connection, flushSig, "SolanaYieldEmitterTool flush_staking_yield") + return flushSig } diff --git a/packages/cluster-tool/tests/cli/ClusterBuildOptionsArgs.test.ts b/packages/cluster-tool/tests/cli/ClusterBuildOptionsArgs.test.ts index 5ccc8a71..8b2b27e1 100644 --- a/packages/cluster-tool/tests/cli/ClusterBuildOptionsArgs.test.ts +++ b/packages/cluster-tool/tests/cli/ClusterBuildOptionsArgs.test.ts @@ -128,6 +128,9 @@ describe("flattenOptionLeaves + buildOptionShape", () => { OptionLeafType.number ) expect(leafByFlag(leaves, "force").type).toBe(OptionLeafType.boolean) + expect(leafByFlag(leaves, "solana-epoch-warp").type).toBe( + OptionLeafType.boolean + ) expect(leafByFlag(leaves, "logging-levels-console").type).toBe( OptionLeafType.string ) @@ -319,6 +322,12 @@ describe("toClusterBuildOptions reverse parse", () => { it("coerces boolean flags", () => { expect(toClusterBuildOptions({ "bind-all": true }).bindAll).toBe(true) expect(toClusterBuildOptions({ "bind-all": false }).bindAll).toBe(false) + expect( + toClusterBuildOptions({ "solana-epoch-warp": true }).solanaEpochWarp + ).toBe(true) + expect( + toClusterBuildOptions({ "solana-epoch-warp": false }).solanaEpochWarp + ).toBe(false) }) it("reads the camelCase alias yargs also emits", () => { @@ -344,6 +353,8 @@ describe("register → parse round-trip", () => { expect(options.epochDurationSec).toBe(60) expect(options.nodeCount).toBe(1) expect(options.bindAll).toBe(false) + // solana-epoch-warp defaults OFF — only flow-yield-distribution opts in + expect(options.solanaEpochWarp).toBe(false) // unseeded (null-default) bind ports never materialize expect(options.bind?.kiod?.port).toBeUndefined() }) diff --git a/packages/cluster-tool/tests/config/ClusterConfigProvider.test.ts b/packages/cluster-tool/tests/config/ClusterConfigProvider.test.ts index 642fcc62..f56c9f66 100644 --- a/packages/cluster-tool/tests/config/ClusterConfigProvider.test.ts +++ b/packages/cluster-tool/tests/config/ClusterConfigProvider.test.ts @@ -64,6 +64,18 @@ describe("ClusterConfigProvider", () => { BindConfigProvider.DefaultSolanaFaucet ) }) + + it("round-trips solanaEpochWarp (default OFF, and ON when a flow opts in)", () => { + expect(ClusterConfigProvider.DefaultSolanaEpochWarp).toBe(false) + const off = ClusterConfigProvider.deserialize( + ClusterConfigProvider.serialize(fixtureConfig()) + ) + expect(off.solanaEpochWarp).toBe(false) + const on = ClusterConfigProvider.deserialize( + JSON.stringify({ ...PersistedFixture, solanaEpochWarp: true }) + ) + expect(on.solanaEpochWarp).toBe(true) + }) }) describe("save / loadSync round-trip", () => { diff --git a/packages/cluster-tool/tests/config/clusterConfigFixture.ts b/packages/cluster-tool/tests/config/clusterConfigFixture.ts index 15a2c136..73bccb8a 100644 --- a/packages/cluster-tool/tests/config/clusterConfigFixture.ts +++ b/packages/cluster-tool/tests/config/clusterConfigFixture.ts @@ -34,6 +34,7 @@ export const PersistedFixture: ClusterConfig = { epochDurationSec: 60, warmupEpochs: 1, cooldownEpochs: 1, + solanaEpochWarp: false, ethereumPath: "/eth", solanaPath: "/sol", bind: { diff --git a/packages/debugging-client-shared/tests/local/fixtureCluster.ts b/packages/debugging-client-shared/tests/local/fixtureCluster.ts index e65ef0f8..9c5ffe0d 100644 --- a/packages/debugging-client-shared/tests/local/fixtureCluster.ts +++ b/packages/debugging-client-shared/tests/local/fixtureCluster.ts @@ -91,6 +91,7 @@ export function makeFixtureCluster(): FixtureCluster { epochDurationSec: 60, warmupEpochs: 0, cooldownEpochs: 0, + solanaEpochWarp: false, ethereumPath: "", solanaPath: "", bind: { 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 10f106ac..ecd64a75 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 @@ -31,6 +31,7 @@ const stubConfig: ClusterConfig = { epochDurationSec: 60, warmupEpochs: 0, cooldownEpochs: 0, + solanaEpochWarp: false, ethereumPath: "/eth", solanaPath: "/sol", bind: { diff --git a/packages/flow-yield-distribution/src/YieldDistributionScenario.ts b/packages/flow-yield-distribution/src/YieldDistributionScenario.ts index a2cd9f70..80659e77 100644 --- a/packages/flow-yield-distribution/src/YieldDistributionScenario.ts +++ b/packages/flow-yield-distribution/src/YieldDistributionScenario.ts @@ -1,7 +1,8 @@ import Assert from "node:assert" import { ethers } from "ethers" +import { PublicKey } from "@solana/web3.js" import { ChainKind } from "@wireio/opp-typescript-models" -import { SysioContracts } from "@wireio/sdk-core" +import { Asset, SysioContracts } from "@wireio/sdk-core" import { AuthExLinkTool, ClusterBuildPhase, @@ -87,6 +88,15 @@ function ethereumNativePubkey(address: string): string { return address.toLowerCase().replace(/^0x/, "") } +/** + * Lowercase hex of a Solana pubkey's 32 raw bytes — dclaim renders the + * `unmapped.native_pubkey` `bytes` column as lowercase hex, so an SVM staker's + * key is matched by hex(pubkey.toBytes()) (same spelling the ETH path uses). + */ +function solanaNativePubkey(pubkey: PublicKey): string { + return Buffer.from(pubkey.toBytes()).toString("hex") +} + // ── SetupStakers step inputs + named runners ──────────────────────────────── /** Input for the linked-staker provisioning step. */ @@ -184,7 +194,13 @@ export class YieldDistributionScenario extends FlowScenario { epochDurationSec: Constants.EpochDurationSec, producerCount: Constants.ProducerCount, batchOperatorCount: Constants.BatchOperatorCount, - underwriterCount: Constants.UnderwriterCount + underwriterCount: Constants.UnderwriterCount, + // This flow cranks `flush_staking_yield`, which requires the Solana clock + // at `epoch >= 3`; opt THIS cluster's validator into the epoch warp. No + // other flow may — the warp trips the depot's cross-chain deposit nonce + // window (see `ClusterConfig.solanaEpochWarp`). This is the code-driven + // replacement for the retired `SOL_OPP_SOLANA_EPOCH_WARP` env toggle. + solanaEpochWarp: true } plan(cluster: ClusterBuild): void { @@ -430,30 +446,54 @@ export class YieldDistributionScenario extends FlowScenario { EmitSteps.planSolanaEmit( Actor.SolanaOutpost, "emit-solana-reward", - `emit ${Constants.SolanaRewardPerStaker} lamports STAKING_REWARD for a new unlinked SOL staker`, + `emit ${Constants.SolanaRewardPerStaker} STAKING_REWARD for a new unlinked SOL staker`, emitStepOptions, + YieldDistributionScenario.SolanaStakerAddressKey, Constants.UnlinkedWireAccount, Constants.SolanaRewardPerStaker, - Constants.FullShareBps, - BigInt(Constants.SolanaChainCode), - BigInt(Constants.SolanaTokenCode), - Constants.SolanaStakerExternalEpochRef, - Constants.RewardEpochIndex + Constants.FullShareBps ), verifyStep( Actor.Sysio, - "unmapped-count-grew-solana", - "the unmapped row count grew past the snapshot", + "unmapped-row-solana", + "an unmapped row appears keyed by the SOL staker's native address with the reward amount", async ctx => { const before = ctx.outputs.assert( - YieldDistributionScenario.SolanaUnmappedCountBeforeKey - ) + YieldDistributionScenario.SolanaUnmappedCountBeforeKey + ), + staker = ctx.outputs.assert(YieldDistributionScenario.SolanaStakerAddressKey), + // Match the depot row by the staker's native SOL address — the + // genuine `flush_staking_yield` StakingReward parks here under the + // program-derived ref, so we key off the address (its 32 bytes → + // dclaim's lowercase-hex `native_pubkey`), NOT the scenario's fixed + // ref. The credit is always WIRE-denominated (the `unmapped` row + // carries no token code), and its atomic balance equals the + // emitted reward amount. + expectedPubkey = solanaNativePubkey(staker) await pollUntil( "unmapped row for the unlinked SOL staker", - async () => (await readUnmappedRows(ctx)).length > before, + async () => + (await readUnmappedRows(ctx)).some( + row => row.native_pubkey.toLowerCase() === expectedPubkey + ), Constants.PropagationTimeoutMs, Constants.PropagationPollMs ) + const rows = await readUnmappedRows(ctx), + solanaRow = rows.find(row => row.native_pubkey.toLowerCase() === expectedPubkey) + Assert.ok( + solanaRow != null, + `no unmapped row for the SOL staker ${expectedPubkey}` + ) + Assert.strictEqual( + BigInt(Asset.fromString(solanaRow.balance).units.toString()), + Constants.SolanaRewardPerStaker, + `SOL staker's unmapped balance ${solanaRow.balance} != reward ${Constants.SolanaRewardPerStaker}` + ) + Assert.ok( + rows.length > before, + `unmapped count ${rows.length} did not grow past ${before}` + ) }, propagationStepOptions ) @@ -488,6 +528,11 @@ export namespace YieldDistributionScenario { "yieldDistribution.solanaUnmappedCountBefore", "unmapped row count before the SOL emission" ) + /** The fresh SOL staker's pubkey (published by the emit runner) the verify matches the depot row by. */ + export const SolanaStakerAddressKey = outputKey( + "yieldDistribution.solanaStakerAddress", + "the unlinked SOL staker's native address" + ) /** The linked staker's `pclaims` row count snapshotted before the replay. */ export const ReplayPclaimsCountBeforeKey = outputKey( "yieldDistribution.replayPclaimsCountBefore", diff --git a/packages/flow-yield-distribution/src/YieldDistributionScenarioConstants.ts b/packages/flow-yield-distribution/src/YieldDistributionScenarioConstants.ts index 1ab99ec6..dc118fc8 100644 --- a/packages/flow-yield-distribution/src/YieldDistributionScenarioConstants.ts +++ b/packages/flow-yield-distribution/src/YieldDistributionScenarioConstants.ts @@ -1,4 +1,3 @@ -import { SlugName } from "@wireio/sdk-core" import { ProtocolTiming } from "@wireio/cluster-tool" /** @@ -40,13 +39,6 @@ export namespace YieldDistributionScenarioConstants { export const LinkedStakerExternalEpochRef = 1n /** The unlinked ETH staker's `external_epoch_ref` (counter value 2). */ export const UnlinkedStakerExternalEpochRef = 2n - /** The SOL staker's `external_epoch_ref` (counter value 3). */ - export const SolanaStakerExternalEpochRef = 3n - - /** Registered chain slug code stamped on the SOL-side emission (must match the bootstrap registry seed). */ - export const SolanaChainCode = SlugName.from("SOLANA") - /** Registered token slug code of the SOL-side reward token. */ - export const SolanaTokenCode = SlugName.from("SOL") /** Deadline for an attestation to round-trip emitter → batchop ferry → depot * table — a single outpost→depot hop (envelope class). */ diff --git a/packages/flow-yield-distribution/src/steps/YieldDistributionScenarioEmitSteps.ts b/packages/flow-yield-distribution/src/steps/YieldDistributionScenarioEmitSteps.ts index cd473ab5..265202f5 100644 --- a/packages/flow-yield-distribution/src/steps/YieldDistributionScenarioEmitSteps.ts +++ b/packages/flow-yield-distribution/src/steps/YieldDistributionScenarioEmitSteps.ts @@ -12,7 +12,7 @@ import Assert from "node:assert" import { ethers } from "ethers" -import { Keypair } from "@solana/web3.js" +import { Keypair, PublicKey } from "@solana/web3.js" import { EthereumCollateralTool, Report, @@ -224,45 +224,42 @@ export namespace YieldDistributionScenarioEmitSteps { ) } - // ── Step: SOL-side STAKING_REWARD (`opp_outpost::add_attestation`) ──────── + // ── Step: SOL-side STAKING_REWARD (real `flush_staking_yield`) ──────────── - /** Input for {@link planSolanaEmit} — one `opp_outpost::add_attestation` write. */ + /** Input for {@link planSolanaEmit} — one SOL-side STAKING_REWARD write. */ export interface SolanaEmitInput extends StepInput { readonly kind: "YieldDistributionScenarioEmitSteps.SolanaEmitInput" + /** + * Output key the runner publishes the FRESH staker's SOL pubkey into, so a + * downstream verify step can match the depot's `unmapped` row by the + * staker's native address (its 32 raw bytes → dclaim's lowercase-hex + * `native_pubkey`) rather than by a fixed epoch ref. + */ + readonly stakerAddressKey: OutputKey /** WIRE account the depot credits — `""` parks the reward in `unmapped`. */ readonly wireAccount: string /** Reward in lamports. */ readonly rewardAmount: bigint /** Informational share-in-bps. */ readonly shareBps: number - /** SlugName-packed chain code of the Solana outpost. */ - readonly chainCode: bigint - /** SlugName-packed token code of the reward token. */ - readonly tokenCode: bigint - /** Monotonic-per-staker reference the depot dedupes against. */ - readonly externalEpochRef: bigint - /** Informational WIRE epoch index. */ - readonly rewardEpochIndex: number } /** - * A single SOL-side STAKING_REWARD pushed through - * `opp_outpost::add_attestation`, signed by the outpost deployer keypair - * (`OutpostConfig.authority`). The staker is a FRESH keypair generated inside - * the runner — it has no authex link, so the depot parks the credit in - * `unmapped` (the flow's count-based verify needs no cross-step identity). + * A single SOL-side STAKING_REWARD, driven through the REAL program yield path + * (`dev_seed_staker_yield` → `flush_staking_yield`), signed by the SOL deployer + * keypair (`global_config.admin`). The staker is a FRESH keypair generated + * inside the runner — it has no authex link, so the depot parks the credit in + * `unmapped` keyed by the staker's native SOL address, which the runner + * publishes to `stakerAddressKey` for the downstream verify. * * @param actor - The narrative subject (the Solana outpost emits). * @param name - Step name (report row). * @param description - One-line description. * @param options - Per-step tuning (e.g. `timeoutMs`). + * @param stakerAddressKey - Output key the runner publishes the fresh staker's SOL pubkey into. * @param wireAccount - WIRE account to credit (`""` → unmapped park). - * @param rewardAmount - Reward in lamports. + * @param rewardAmount - Reward amount (lamports; seeded as the WIRE yield). * @param shareBps - Informational share-in-bps. - * @param chainCode - SlugName-packed Solana chain code. - * @param tokenCode - SlugName-packed reward token code. - * @param externalEpochRef - Monotonic-per-staker reference. - * @param rewardEpochIndex - Informational WIRE epoch index. * @returns The definition step. */ export function planSolanaEmit< @@ -272,13 +269,10 @@ export namespace YieldDistributionScenarioEmitSteps { name: string, description: string, options: ClusterBuildStepOptions, + stakerAddressKey: OutputKey, wireAccount: string, rewardAmount: bigint, - shareBps: number, - chainCode: bigint, - tokenCode: bigint, - externalEpochRef: bigint, - rewardEpochIndex: number + shareBps: number ): ClusterBuildStep { return ClusterBuildStep.create( actor, @@ -287,19 +281,16 @@ export namespace YieldDistributionScenarioEmitSteps { options, { kind: "YieldDistributionScenarioEmitSteps.SolanaEmitInput", + stakerAddressKey, wireAccount, rewardAmount, - shareBps, - chainCode, - tokenCode, - externalEpochRef, - rewardEpochIndex + shareBps }, runSolanaEmit ) } - /** Named runner — ONE `opp_outpost::add_attestation` ix for a new unlinked staker. */ + /** Named runner — ONE SOL-side STAKING_REWARD for a new unlinked staker. */ export async function runSolanaEmit( ctx: C, input: SolanaEmitInput, @@ -307,6 +298,7 @@ export namespace YieldDistributionScenarioEmitSteps { ): Promise { signal.throwIfAborted() const staker = Keypair.generate() + ctx.outputs.set(input.stakerAddressKey, staker.publicKey) const authority = SolanaFundingTool.loadDeployerKeypair(ctx.config.dataPath) const program = SolanaCollateralTool.loadOppOutpostProgram(ctx, authority) await emitSolanaYield( @@ -318,11 +310,7 @@ export namespace YieldDistributionScenarioEmitSteps { wireAccount: input.wireAccount, rewardAmount: input.rewardAmount, shareBps: input.shareBps - }, - input.chainCode, - input.tokenCode, - input.externalEpochRef, - input.rewardEpochIndex + } ) }