Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/cluster-tool-shared/src/config/ClusterConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("ClusterConfig shape", () => {
epochDurationSec: 60,
warmupEpochs: 1,
cooldownEpochs: 1,
solanaEpochWarp: false,
ethereumPath: "/eth",
solanaPath: "/sol",
bind: {
Expand Down
4 changes: 4 additions & 0 deletions packages/cluster-tool/src/cli/ClusterBuildOptionsArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 … <upgradeAuthority>`) 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. */
Expand Down Expand Up @@ -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
]
}
Expand Down
1 change: 1 addition & 0 deletions packages/cluster-tool/src/config/ClusterBuildOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface ClusterBuildOptions {
epochDurationSec?: number
warmupEpochs?: number
cooldownEpochs?: number
solanaEpochWarp?: boolean
// network binding
bindAll?: boolean
bind?: BindOptions
Expand Down
8 changes: 8 additions & 0 deletions packages/cluster-tool/src/config/ClusterConfigProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions packages/cluster-tool/src/orchestration/solana/OppSolProgram.ts
Original file line number Diff line number Diff line change
@@ -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 `<clusterDataPath>/sol-deployer-keypair.json`.
*/
export function clusterDeployerKeypairFile(clusterDataPath: string): string {
return Path.join(clusterDataPath, deployerKeypairFilename)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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")
Expand All @@ -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),
Expand Down Expand Up @@ -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"
)
}

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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")
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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<string, PublicKey> {
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<anchor.Idl>
): Promise<void> {
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
Expand Down
Loading