diff --git a/packages/flow-batch-operator-termination/src/TerminationScenario.ts b/packages/flow-batch-operator-termination/src/TerminationScenario.ts index 983b2eb6..77d99a67 100644 --- a/packages/flow-batch-operator-termination/src/TerminationScenario.ts +++ b/packages/flow-batch-operator-termination/src/TerminationScenario.ts @@ -2,9 +2,16 @@ import Assert from "node:assert" import { PublicKey } from "@solana/web3.js" import type { BN } from "@coral-xyz/anchor" import { SysioContracts } from "@wireio/sdk-core" -import { OperatorType } from "@wireio/opp-typescript-models" import { + AttestationType, + BatchOperatorGroups, + Envelope, + OperatorType +} from "@wireio/opp-typescript-models" +import { + BatchOperatorSchedule, ClusterBuildPhase, + ClusterConfigProvider, EthereumCollateralTool, FlowScenario, Report, @@ -14,6 +21,7 @@ import { Steps, WireOperatorProvisioningTool, getLogger, + loadOutpostContract, matchesProtoEnum, outputKey, packedSlugValue, @@ -137,6 +145,16 @@ interface SolanaAccountClient { fetch(address: PublicKey): Promise } +/** The outpost cursors advance only after an inbound envelope is accepted. */ +interface EthereumInboundView { + nextEpochIndex(): Promise +} + +/** Read-only projection of the Solana outpost configuration's inbound epoch cursor. */ +interface SolanaOutpostConfigAccount { + nextEpochIndex: number +} + /** The SOL outpost's on-chain collateral ledger from the `OperatorRegistry` PDA (a read). */ async function readSolanaCollateralLedger( ctx: ClusterBuildContext @@ -188,11 +206,14 @@ async function readSolanaCollateralLedger( * outpost's escrow ledger returns to 0, and each wallet is credited the * exact bond amount (wei/lamport-exact — any drift means the outpost decoded * a different amount than the depot encoded). + * 9. **ContinuedRotation** — standing operators fill the vacated seat, every + * observed published window excludes the terminated operator, and both + * outposts accept another complete rotation after the remits land. */ export class TerminationScenario extends FlowScenario { readonly name = "flow-batch-operator-termination" readonly description = - "Non-bootstrapped batch operator bonds ETH + SOL, misses its scheduled deliveries, is terminated, and both bonds are remitted back" + "Batch operator termination remits both bonds and standing operators keep both outposts advancing" override readonly defaults: ClusterBuildOptions = { epochDurationSec: Constants.EpochDurationSec, @@ -709,5 +730,144 @@ export class TerminationScenario extends FlowScenario { quickStepOptions ) ) + + // ── 9. Remittance is not enough: the following duty groups must deliver ── + ClusterBuildPhase.create( + cluster, + "ContinuedRotation", + "Standing operators absorb the termination and both outposts complete another rotation" + ).push( + verifyStep( + Actor.Sysio, + "post-remit-rotation", + "published groups exclude the terminated operator and both outposts advance through a full window", + async ctx => { + const operator = ctx.keyStore.assertOperator( + Constants.DoomedOperatorLabel + ), + addresses = EthereumCollateralTool.loadOutpostAddresses( + ClusterConfigProvider.ethereumDeploymentsPath(ctx.config) + ), + ethereum = loadOutpostContract( + ctx.config.ethereumPath, + addresses, + "OPPInbound", + ["outpost"], + ctx.ethereum.wallet.signer + ), + program = SolanaCollateralTool.loadOppOutpostProgram( + ctx, + solanaKeypair(operator.solana) + ), + configAddress = SolanaOutpostProgramTool.derivePda( + program.programId, + Buffer.from(SolanaOutpostBootstrapper.PdaSeed.OutpostConfig) + ), + accounts: Record = program.account, + readCursors = async () => { + const [ethNext, solConfig] = await Promise.all([ + ethereum.nextEpochIndex(), + accounts.outpostConfig.fetch(configAddress) + ]) + return [ + Number(ethNext), + Number((solConfig as SolanaOutpostConfigAccount).nextEpochIndex) + ] + }, + baseline = await readCursors(), + start = await Steps.contracts.sysio.epoch.readEpochState(ctx), + standing = new Set( + ctx.keyStore.operators + .filter( + entry => + entry.type === OperatorType.BATCH && + entry.account !== operator.account + ) + .map(entry => entry.account) + ), + groupCount = start.batch_op_groups.length, + groupSize = BatchOperatorSchedule.resolve( + ctx.config + ).operatorsPerEpoch, + targetEpoch = + Math.max(Number(start.current_epoch_index), ...baseline) + + groupCount, + chains = [Constants.EthereumChainCode, Constants.SolanaChainCode] + + Assert.ok( + groupCount > 1, + "termination regression requires multiple duty groups" + ) + await pollUntil( + `both outposts accept post-remit epochs through ${targetEpoch}`, + async () => { + const { rows } = await ctx.wire.getOutboundEnvelopes() + for (const chain of chains) { + const row = rows.find( + entry => packedSlugValue(entry.chain_code) === chain + ) + Assert.ok( + row != null, + `missing outbound envelope for chain ${chain}` + ) + const envelope = Envelope.fromBinary( + Buffer.from(row.raw_envelope, "hex") + ), + announcements = envelope.messages.flatMap(message => + (message.payload?.attestations ?? []) + .filter( + entry => + entry.type === AttestationType.BATCH_OPERATOR_GROUPS + ) + .map(entry => BatchOperatorGroups.fromBinary(entry.data)) + ) + Assert.ok( + announcements.length > 0, + `no published group window at epoch ${row.epoch_index}` + ) + for (const announcement of announcements) { + const groups = announcement.groups.map(group => + group.operators.map(member => + Buffer.from(member.address).toString("utf8") + ) + ), + members = groups.flat() + Assert.equal( + groups.length, + groupCount, + "published window lost a group" + ) + Assert.ok( + groups.every(group => group.length === groupSize), + "standing operators did not fill every seat" + ) + Assert.ok( + !members.includes(operator.account), + "terminated operator re-entered a published group" + ) + Assert.equal( + new Set(members).size, + groupCount * groupSize, + "published groups repeat an operator" + ) + Assert.ok( + members.every(account => standing.has(account)), + "replacement is not a standing operator" + ) + } + } + const cursors = await readCursors() + log.info( + `[${this.name}] post-remit ETH/SOL next epochs=${cursors.join("/")}; target>${targetEpoch}` + ) + return cursors.every(epoch => epoch > targetEpoch) + }, + Constants.remitDeadlineMs(), + Constants.PollIntervalMs + ) + }, + remitStepOptions + ) + ) } } diff --git a/packages/flow-operator-collateral-deposit/src/CollateralLifecycleScenario.ts b/packages/flow-operator-collateral-deposit/src/CollateralLifecycleScenario.ts index 31b289b3..021a833b 100644 --- a/packages/flow-operator-collateral-deposit/src/CollateralLifecycleScenario.ts +++ b/packages/flow-operator-collateral-deposit/src/CollateralLifecycleScenario.ts @@ -58,7 +58,7 @@ async function readWithdrawQueueRows( * schedule prefers non-bootstrapped operators, and its group must relay). * 3. **DepositEthereum** — bond on the ETH outpost → depot credits the balance row. * 4. **DepositSolana** — bond on the SOL outpost → all-chain rule met → ACTIVE. - * 5. **WithdrawRequest** — release half the ETH bond → depot queues it. + * 5. **WithdrawRequest** — release half the ETH bond; verify reserved collateral leaves the operator ACTIVE. * 6. **WaitAndFlush** — the wait window elapses; `flushwtdw` drains the queue. * 7. **ProcessRemit** — WITHDRAW_REMIT lands on the ETH outpost; escrow decrements. */ @@ -78,12 +78,12 @@ export class CollateralLifecycleScenario extends FlowScenario { { chainCode: Constants.EthereumChainCode, tokenCode: Constants.EthereumTokenCode, - minimumBond: Number(Constants.BondAmount) + minimumBond: Number(Constants.MinimumBond) }, { chainCode: Constants.SolanaChainCode, tokenCode: Constants.SolanaTokenCode, - minimumBond: Number(Constants.BondAmount) + minimumBond: Number(Constants.MinimumBond) } ] } @@ -210,11 +210,11 @@ export class CollateralLifecycleScenario extends FlowScenario { ) ) - // ── 5. Withdraw half the ETH bond → depot queues it ── + // ── 5. Withdraw excess ETH collateral while retaining relay eligibility ── ClusterBuildPhase.create( cluster, "WithdrawRequest", - "Release half the ETH bond; depot enqueues wtdwqueue" + "Release half the ETH bond; depot enqueues wtdwqueue and retains eligibility" ).push( EthereumCollateralTool.planWithdrawal( Actor.User, @@ -246,6 +246,41 @@ export class CollateralLifecycleScenario extends FlowScenario { ) }, stepOptions + ), + verifyStep( + Actor.Sysio, + "depot-status-active-after-withdraw", + "reserved withdrawal retains the minimum ETH collateral and ACTIVE status", + async ctx => { + const operator = await readDepositorRow(ctx), + requests = await readWithdrawQueueRows(ctx), + ethBalance = operator?.balances.find( + balance => + slugValue(balance.chain_code) === Constants.EthereumChainCode && + slugValue(balance.token_code) === Constants.EthereumTokenCode + ), + reservedAmount = requests + .filter( + request => + slugValue(request.chain_code) === Constants.EthereumChainCode && + slugValue(request.token_code) === Constants.EthereumTokenCode + ) + .reduce((sum, request) => sum + BigInt(request.amount), 0n) + if ( + ethBalance == null || + BigInt(ethBalance.balance) - reservedAmount < Constants.MinimumBond || + !matchesProtoEnum( + operator.status, + SysioOpregOperatorstatus, + SysioOpregOperatorstatus.OPERATOR_STATUS_ACTIVE + ) + ) { + throw new Error( + `Withdrawing excess ETH collateral must retain the minimum bond and ACTIVE status; balance=${ethBalance?.balance}, reserved=${reservedAmount}, status=${operator?.status}` + ) + } + }, + stepOptions ) ) diff --git a/packages/flow-operator-collateral-deposit/src/CollateralLifecycleScenarioConstants.ts b/packages/flow-operator-collateral-deposit/src/CollateralLifecycleScenarioConstants.ts index 43d257e3..7010ae48 100644 --- a/packages/flow-operator-collateral-deposit/src/CollateralLifecycleScenarioConstants.ts +++ b/packages/flow-operator-collateral-deposit/src/CollateralLifecycleScenarioConstants.ts @@ -2,10 +2,9 @@ import { SlugName } from "@wireio/sdk-core" import { ProtocolTiming } from "@wireio/cluster-tool" /** - * Constants for the collateral-lifecycle flow. Amounts + epoch budgets carry - * over from the previously-validated flow run (2026-06): the bond is deposited + * Constants for the collateral-lifecycle flow. Twice the minimum bond is deposited * on BOTH outpost chains (all-chain collateral invariant), half the ETH bond is - * withdrawn mid-flow, and every poll deadline derives from extension-inclusive + * withdrawn while retaining the minimum. Every poll deadline uses extension-inclusive * epochs ({@link ProtocolTiming.effectiveEpochSec}) so the flow scales with the * epoch duration and survives extended epochs. */ @@ -29,9 +28,11 @@ export namespace CollateralLifecycleScenarioConstants { */ export const AdHocDaemonCount = 1 + /** Minimum collateral retained per chain to keep the depositor eligible to relay. */ + export const MinimumBond = 1_000_000n /** Collateral bonded per chain (raw outpost units — wei / lamports). */ export const BondAmount = 2_000_000n - /** ETH bond released mid-flow (half — stays above the minimum on the rest). */ + /** ETH bond released mid-flow (half — leaves exactly the required minimum). */ export const WithdrawAmount = 1_000_000n /** Escrow expected on the ETH outpost after the withdraw remit. */ export const ExpectedRemainingBalance = BondAmount - WithdrawAmount