diff --git a/packages/cluster-tool/tests/flow/contexts/SwapScenarioContext.test.ts b/packages/cluster-tool/tests/flow/contexts/SwapScenarioContext.test.ts index 6e90f2d2..4a195e68 100644 --- a/packages/cluster-tool/tests/flow/contexts/SwapScenarioContext.test.ts +++ b/packages/cluster-tool/tests/flow/contexts/SwapScenarioContext.test.ts @@ -90,6 +90,7 @@ function lockRow( amount: 0, created_at_ms: 0, expires_at_ms: 0, + challenge_id: 0, ...overrides } } diff --git a/packages/flow-underwriter-slashing/package.json b/packages/flow-underwriter-slashing/package.json new file mode 100644 index 00000000..fdda2f54 --- /dev/null +++ b/packages/flow-underwriter-slashing/package.json @@ -0,0 +1,21 @@ +{ + "name": "@wireio/test-flow-underwriter-slashing", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "description": "Flow: Underwriter slashing via the bonded Tier-1 challenge vote (WIRE-297)", + "scripts": { + "build": "tsc -b tsconfig.json", + "test": "node lib/index.js", + "clean": "rm -rf lib" + }, + "dependencies": { + "@wireio/cluster-tool": "workspace:*", + "@wireio/sdk-core": "^1.0.17", + "@wireio/shared": "^1.0.17", + "ethers": "^6.14.0" + }, + "devDependencies": { + "@types/node": "25.5.0" + } +} diff --git a/packages/flow-underwriter-slashing/src/UnderwriterSlashingScenario.ts b/packages/flow-underwriter-slashing/src/UnderwriterSlashingScenario.ts new file mode 100644 index 00000000..5fd6b69f --- /dev/null +++ b/packages/flow-underwriter-slashing/src/UnderwriterSlashingScenario.ts @@ -0,0 +1,718 @@ +import Assert from "node:assert" +import type { ClusterConfig } from "@wireio/cluster-tool-shared" +import { SysioContracts } from "@wireio/sdk-core" +import { getLogger, type Logger } from "@wireio/shared" +import { + ClusterBuildPhase, + Constants as ClusterConstants, + FlowScenario, + Report, + Steps, + SwapScenarioContext, + SwapUserIdentities, + WireReserveTool, + WireUnderwriterTool, + matchesProtoEnum, + pollUntil, + verifyStep, + type Books, + type ClusterBuild, + type ClusterBuildOptions, + type ClusterBuildStepOptions, + type OutputKey +} from "@wireio/cluster-tool" +import { UnderwriterSlashingScenarioConstants as Constants } from "./UnderwriterSlashingScenarioConstants.js" +import { UnderwriterSlashingScenarioOutputs as Outputs } from "./UnderwriterSlashingScenarioOutputs.js" +import { + UnderwriterSlashingScenarioChallengeSteps as ChallengeSteps, + UnderwriterSlashingScenarioSwapSteps as SwapSteps +} from "./steps/index.js" + +const { + SysioContractAccount, + SysioContractName, + SysioChalgUwchalBallot, + SysioChalgUwchalVerdict, + SysioOpregOperatorstatus, + SysioUwritUnderwriterequeststatus +} = SysioContracts +const { Actor } = Report +const log = getLogger(__filename) + +/** + * Full-hop (token → WIRE → token) swap quote over a pre-request book + * snapshot — the same thin {@link WireReserveTool.cpOutput} composition the + * other swap flows quote with (both hops fee-less constant products; the fee + * only appears in the settled books, inside the user's variance tolerance). + */ +function swapquote(books: Books, sourceAmount: bigint): bigint { + const wireIntermediate = WireReserveTool.cpOutput( + books.src.chain, + books.src.wire, + sourceAmount + ) + if (wireIntermediate === 0n) return 0n + return WireReserveTool.cpOutput( + books.dst.wire, + books.dst.chain, + wireIntermediate + ) +} + +/** + * Flow: the bonded, council-adjudicated underwriter challenge (WIRE-297) — + * end-to-end against a live cluster. The in-repo C++ suite covers the chalg + * actions in isolation; this flow covers the trigger + slash economics on the + * REAL pipeline: real swaps, a real underwriter daemon winning real races, + * real locks, and the deferred-slash collateral debit. + * + * Substrate: two identical ETH→SOL swaps (flow-swap-with-underwriting's + * Phase A, twice), yielding two CONFIRMED commitments by the same winning + * underwriter — proving challenges are per-commitment, not per-underwriter. + * + * Order matters: BOTH swaps confirm before ANY challenge, because the upheld + * challenge slashes the cluster's only underwriter (a SLASHED underwriter can + * win no further races). The REJECTED challenge therefore runs first: + * + * 1. **ChallengeRejected (commitment B).** The challenger files + escrows the + * bond; all three Tier-1 voters ballot REJECT_FORFEIT; `chkuwchal` resolves + * REJECTED_FORFEIT — the bond is CREDITED to the wrongly-challenged + * underwriter, the lock holds clear, the operator stays ACTIVE, and the + * collateral is untouched. The underwriter then pulls the forfeit via + * `claimbond`. + * 2. **ChallengeUpheld (commitment A).** Same filing; three UPHOLD ballots; + * `chkuwchal` resolves UPHELD — the underwriter flips SLASHED, the locks + * sweep through `releaselock`'s deferred-slash branch (locked collateral + * debited, outbound SLASH attestations queued), the uwreq completes, and the + * bond is credited back to the challenger, who pulls it via `claimbond`. + * Only then does `sysio.chalg` reach zero WIRE custody. + * + * Bonds are CREDITED at resolution and moved only by `claimbond`, so each + * verdict is asserted twice: once that resolution moved no WIRE (the credit + * exists, balances are unchanged), and once that the pull delivered it. That + * split is the point — `chkuwchal` can run inline under the epoch tick, where + * `sysio.token::transfer`'s `require_recipient(to)` would run the recipient's + * own code and let it abort epoch advancement. + */ +export class UnderwriterSlashingScenario extends FlowScenario { + readonly name = "flow-underwriter-slashing" + readonly description = + "Two underwritten swaps; a rejected challenge forfeits its bond to the underwriter, an upheld challenge slashes the winner via the Tier-1 vote" + + override readonly defaults: ClusterBuildOptions = { + // The swaps ride the bootstrap-seeded mock PRIMARY reserves — `regreserve` + // is epoch-0-gated by the depot, so seeding must ride the bootstrap. + enableMockReserves: true, + epochDurationSec: Constants.EpochDurationSec, + // The depot's `meets_role_min` rejects non-bootstrapped underwriters when + // the config is empty — the underwriter must flip ACTIVE for the races to + // land commits (see flow-swap-with-underwriting for the full rationale). + requiredUnderwriterCollateral: [ + { + chainCode: Constants.EthereumChainCode, + tokenCode: Constants.EthereumTokenCode, + minimumBond: Constants.UnderwriterMinimumBond + }, + { + chainCode: Constants.SolanaChainCode, + tokenCode: Constants.SolanaTokenCode, + minimumBond: Constants.UnderwriterMinimumBond + } + ] + } + + override createContext( + config: ClusterConfig, + log: Logger + ): SwapScenarioContext { + return new SwapScenarioContext(config, log) + } + + plan(cluster: ClusterBuild): void { + const config = cluster.context.config, + firstUnderwriter = ClusterConstants.underwriterLabel(0), + underwriterLabels = Array.from( + { length: config.underwriterCount }, + (_, index) => ClusterConstants.underwriterLabel(index) + ), + requestStepOptions = { timeoutMs: Constants.RequestStepTimeoutMs }, + underwriterGateOptions = { + timeoutMs: + Constants.underwriterActiveDeadlineMs() + + Constants.PollDeadlineBufferMs + }, + raceStepOptions = { + timeoutMs: + Constants.UwreqDeadlineMs + + Constants.RaceDeadlineMs + + Constants.PollDeadlineBufferMs + }, + resolveStepOptions = { + timeoutMs: Constants.ResolveDeadlineMs + Constants.PollDeadlineBufferMs + } + + // ── 1. Underwriter collateral on both outposts (flips the winner ACTIVE) ── + WireUnderwriterTool.planCollateralDeposit( + cluster, + "UnderwriterCollateral", + "Bond every underwriter's collateral on the Ethereum + Solana outposts", + requestStepOptions, + underwriterLabels, + config.underwriterCollateral ?? + WireUnderwriterTool.load(null, config.underwriterCount) + ) + + // ── 2. The swap end-user's paired ETH + SOL identity ── + SwapUserIdentities.planIdentityProvisioning( + cluster, + "SwapUser", + "Provision the swap end-user's Ethereum + Solana identities", + {} + ) + + // ── 3. Bootstrap state (chain health + seeded reserves) ── + ClusterBuildPhase.create( + cluster, + "BootstrapState", + "WIRE chain is live and the bootstrap seeded both PRIMARY reserves" + ).push( + verifyStep( + Actor.Sysio, + "wire-chain-producing", + "WIRE chain is producing blocks", + async (ctx: SwapScenarioContext) => { + const info = await ctx.wire.getInfo() + Assert.ok( + Number(info.head_block_num) > 0, + `head_block_num must be positive, got ${info.head_block_num}` + ) + } + ), + verifyStep( + Actor.Sysio, + "reserves-seeded", + "bootstrap seeded ETHEREUM/ETH/PRIMARY + SOLANA/SOL/PRIMARY reserves", + async (ctx: SwapScenarioContext) => { + // `reserveBook` throws when the row is absent — presence IS the check. + await ctx.reserveBook( + Constants.EthereumChainCode, + Constants.EthereumTokenCode, + Constants.PrimaryReserveCode + ) + await ctx.reserveBook( + Constants.SolanaChainCode, + Constants.SolanaTokenCode, + Constants.PrimaryReserveCode + ) + } + ) + ) + + // ── 4. The challenge cast: Tier-1 electorate + funded challenger ── + ClusterBuildPhase.create( + cluster, + "ProvisionChallengeCast", + "Create + register the 3 Tier-1 voters; create + fund the challenger" + ).push( + ...Constants.Tier1VoterNames.flatMap(voter => [ + Steps.account.planCreateKeyed( + Actor.User, + `create-${voter}`, + `create Tier-1 voter account ${voter} (shared dev key)`, + {}, + voter, + ClusterConstants.DEV_K1_PUBLIC_KEY + ), + ChallengeSteps.planForcereg( + Actor.Sysio, + `register-${voter}`, + `register ${voter} Tier-1 via roa::forcereg (the challenge electorate)`, + {}, + { owner: voter, tier: Constants.Tier1 } + ) + ]), + Steps.account.planCreateKeyed( + Actor.User, + "create-challenger", + `create the challenger account ${Constants.ChallengerAccount}`, + {}, + Constants.ChallengerAccount, + ClusterConstants.DEV_K1_PUBLIC_KEY + ), + Steps.contracts.sysio.token.planTransfer( + Actor.Sysio, + "fund-challenger", + "treasury funds the challenger's bond budget", + {}, + { + from: Constants.TreasuryAccount, + to: Constants.ChallengerAccount, + quantity: Constants.ChallengerFundingQuantity, + memo: "underwriter-challenge bond budget" + } + ) + ) + + // ── 5. Swap A + capture its CONFIRMED commitment ── + this.planSwap( + cluster, + "SwapA", + underwriterGateOptions, + raceStepOptions, + requestStepOptions, + firstUnderwriter, + Outputs.swapATargetAmount, + Outputs.commitmentA, + [] + ) + + // ── 6. Swap B + capture (excluding A's uwreq id) ── + this.planSwap( + cluster, + "SwapB", + underwriterGateOptions, + raceStepOptions, + requestStepOptions, + firstUnderwriter, + Outputs.swapBTargetAmount, + Outputs.commitmentB, + [Outputs.commitmentA] + ) + + // ── 7. The REJECTED challenge (commitment B) — runs FIRST so the + // underwriter is still healthy when it wins nothing further. ── + const rejectKeys: ChallengeSteps.ChallengeKeys = { + commitment: Outputs.commitmentB, + chalId: Outputs.challengeBId, + bond: Outputs.challengeBBond, + challengerBalanceBefore: Outputs.challengerBalanceBeforeB + } + ClusterBuildPhase.create( + cluster, + "ChallengeRejected", + "Commitment B is challenged; the council rejects with forfeit — bond to the underwriter, collateral untouched" + ).push( + verifyStep( + Actor.Sysio, + "snapshot-underwriter-balance", + "snapshot the winner's WIRE balance (the forfeit lands exactly on top)", + async (ctx: SwapScenarioContext) => { + const commitment = ctx.outputs.assert(Outputs.commitmentB) + ctx.outputs.set( + Outputs.underwriterBalanceBeforeB, + await ctx.wire.getWireBalance(commitment.underwriterAccount) + ) + } + ), + ChallengeSteps.planOpenuwchal( + Actor.User, + "open-challenge-b", + "challenger files against commitment B and escrows the bond", + requestStepOptions, + rejectKeys, + Constants.ChallengeReason, + "flow: baseless allegation — the council rejects this one" + ), + ...Constants.Tier1VoterNames.map(voter => + ChallengeSteps.planVoteuwchal( + Actor.User, + `vote-reject-${voter}`, + `${voter} ballots REJECT_FORFEIT (frivolous challenge)`, + {}, + Outputs.challengeBId, + voter, + SysioChalgUwchalBallot.REJECT_FORFEIT + ) + ), + ChallengeSteps.planChkuwchal( + Actor.User, + "crank-challenge-b", + "crank the tally — three REJECT_FORFEIT ballots clear the quorum", + requestStepOptions, + Outputs.challengeBId + ), + verifyStep( + Actor.Sysio, + "challenge-b-rejected", + "verdict REJECTED_FORFEIT: bond CREDITED to the underwriter (no transfer); holds clear; operator ACTIVE; uwreq still CONFIRMED", + async (ctx: SwapScenarioContext) => { + const chalId = ctx.outputs.assert(Outputs.challengeBId) + await ChallengeSteps.awaitVerdict( + ctx, + chalId, + SysioChalgUwchalVerdict.REJECTED_FORFEIT + ) + + const commitment = ctx.outputs.assert(Outputs.commitmentB), + bond = ctx.outputs.assert(Outputs.challengeBBond), + underwriterBefore = ctx.outputs.assert( + Outputs.underwriterBalanceBeforeB + ) + // Forfeiture is explicit council judgment — the bond is credited to + // the wrongly-challenged underwriter, to the unit. Resolution moves + // no WIRE: the crank can run under the epoch tick, where a transfer + // would execute the recipient's notification handler and let it + // abort epoch advancement. Custody stays with chalg until the pull. + Assert.strictEqual( + await ChallengeSteps.readBondCredit( + ctx, + commitment.underwriterAccount + ), + bond, + "a rejected-with-forfeit challenge credits the underwriter the whole bond" + ) + Assert.strictEqual( + await ctx.wire.getWireBalance(commitment.underwriterAccount), + underwriterBefore, + "resolution alone must not move WIRE — the credit is claimed, not pushed" + ) + // No fault: the operator stands, the locks persist unheld to their + // natural expiry, the commitment stays CONFIRMED. + const operator = await ChallengeSteps.readOperatorRow( + ctx, + commitment.underwriterAccount + ) + Assert.ok( + matchesProtoEnum( + operator.status, + SysioOpregOperatorstatus, + SysioOpregOperatorstatus.OPERATOR_STATUS_ACTIVE + ), + "a rejected challenge must leave the underwriter ACTIVE" + ) + const locks = await ctx.locksForUwreq(commitment.uwreqId) + Assert.strictEqual( + locks.length, + 2, + "both locks persist after a rejected challenge" + ) + locks.forEach(lock => + Assert.strictEqual( + Number(lock.challenge_id), + 0, + "a rejected challenge clears the lock hold" + ) + ) + log.info( + `[uwchal] challenge ${chalId} REJECTED_FORFEIT — bond ${bond} credited to ${commitment.underwriterAccount}` + ) + }, + resolveStepOptions + ), + ChallengeSteps.planClaimbond( + Actor.User, + "claim-forfeited-bond", + "the wrongly-challenged underwriter pulls its forfeited bond out of chalg custody", + requestStepOptions, + Outputs.commitmentB + ), + verifyStep( + Actor.Sysio, + "challenge-b-forfeit-paid", + "the forfeited bond lands on the underwriter exactly, once claimed", + async (ctx: SwapScenarioContext) => { + const commitment = ctx.outputs.assert(Outputs.commitmentB), + bond = ctx.outputs.assert(Outputs.challengeBBond), + underwriterBefore = ctx.outputs.assert( + Outputs.underwriterBalanceBeforeB + ) + Assert.strictEqual( + await ctx.wire.getWireBalance(commitment.underwriterAccount), + underwriterBefore + bond, + "the claimed forfeit lands on the wrongly-challenged underwriter exactly" + ) + }, + resolveStepOptions + ) + ) + + // ── 8. The UPHELD challenge (commitment A) — slash, sweep, refund. ── + const upholdKeys: ChallengeSteps.ChallengeKeys = { + commitment: Outputs.commitmentA, + chalId: Outputs.challengeAId, + bond: Outputs.challengeABond, + challengerBalanceBefore: Outputs.challengerBalanceBeforeA + } + ClusterBuildPhase.create( + cluster, + "ChallengeUpheld", + "Commitment A is challenged; the council upholds — SLASHED, locked collateral debited, bond refunded" + ).push( + ChallengeSteps.planOpenuwchal( + Actor.User, + "open-challenge-a", + "challenger files against commitment A and escrows the bond", + requestStepOptions, + upholdKeys, + Constants.ChallengeReason, + "flow: the committed source deposit does not exist" + ), + ...Constants.Tier1VoterNames.map(voter => + ChallengeSteps.planVoteuwchal( + Actor.User, + `vote-uphold-${voter}`, + `${voter} ballots UPHOLD (fault proven)`, + {}, + Outputs.challengeAId, + voter, + SysioChalgUwchalBallot.UPHOLD + ) + ), + ChallengeSteps.planChkuwchal( + Actor.User, + "crank-challenge-a", + "crank the tally — three UPHOLD ballots clear the quorum", + requestStepOptions, + Outputs.challengeAId + ), + verifyStep( + Actor.Sysio, + "challenge-a-upheld", + "verdict UPHELD: operator SLASHED; locks swept via deferred-slash; uwreq COMPLETED; bond refunded; chalg custody zero", + async (ctx: SwapScenarioContext) => { + const chalId = ctx.outputs.assert(Outputs.challengeAId) + await ChallengeSteps.awaitVerdict( + ctx, + chalId, + SysioChalgUwchalVerdict.UPHELD + ) + + const commitment = ctx.outputs.assert(Outputs.commitmentA), + bond = ctx.outputs.assert(Outputs.challengeABond), + challengerBefore = ctx.outputs.assert( + Outputs.challengerBalanceBeforeA + ) + // Slash: the verdict flips the winner's operator row SLASHED in the + // same transaction that records it. + const operator = await ChallengeSteps.readOperatorRow( + ctx, + commitment.underwriterAccount + ) + Assert.ok( + matchesProtoEnum( + operator.status, + SysioOpregOperatorstatus, + SysioOpregOperatorstatus.OPERATOR_STATUS_SLASHED + ), + "an upheld challenge must flip the underwriter SLASHED" + ) + // Sweep: releaselock's deferred-slash branch consumes both held + // locks (locked collateral debited, outbound SLASH attestations + // queued) — no lock rows survive. + const locks = await ctx.locksForUwreq(commitment.uwreqId) + Assert.strictEqual( + locks.length, + 0, + "the upheld challenge sweeps both locks via the deferred slash" + ) + // The commitment finalizes COMPLETED — never re-underwritable. + const request = await ChallengeSteps.readUwreq( + ctx, + commitment.uwreqId + ) + Assert.ok( + request != null, + "the challenged uwreq row must survive resolution" + ) + Assert.ok( + matchesProtoEnum( + request.status, + SysioUwritUnderwriterequeststatus, + SysioUwritUnderwriterequeststatus.UNDERWRITE_REQUEST_STATUS_COMPLETED + ), + "an upheld challenge finalizes the uwreq COMPLETED" + ) + // Refund: an upheld challenge credits the bond back. As with the + // forfeit, resolution moves no WIRE — the challenger is still down + // the escrow until it pulls. + Assert.strictEqual( + await ChallengeSteps.readBondCredit( + ctx, + Constants.ChallengerAccount + ), + bond, + "an upheld challenge credits the whole bond back to the challenger" + ) + Assert.strictEqual( + await ctx.wire.getWireBalance(Constants.ChallengerAccount), + challengerBefore - bond, + "resolution alone must not move WIRE — the refund is claimed, not pushed" + ) + log.info( + `[uwchal] challenge ${chalId} UPHELD — ${commitment.underwriterAccount} SLASHED, bond ${bond} credited back` + ) + }, + resolveStepOptions + ), + ChallengeSteps.planClaimbond( + Actor.User, + "claim-refunded-bond", + "the challenger pulls its refunded bond out of chalg custody", + requestStepOptions, + Constants.ChallengerAccount + ), + verifyStep( + Actor.Sysio, + "challenge-a-refund-paid", + "the refund restores the challenger exactly; both bonds settled, chalg custody zero", + async (ctx: SwapScenarioContext) => { + const challengerBefore = ctx.outputs.assert( + Outputs.challengerBalanceBeforeA + ) + Assert.strictEqual( + await ctx.wire.getWireBalance(Constants.ChallengerAccount), + challengerBefore, + "the claimed refund makes the challenger whole" + ) + // Custody: both bonds resolved AND claimed (one forfeited, one + // refunded) — sysio.chalg escrows nothing at rest. + Assert.strictEqual( + await ctx.wire.getWireBalance( + SysioContractAccount[SysioContractName.chalg] + ), + 0n, + "sysio.chalg ends the flow with zero WIRE custody" + ) + }, + resolveStepOptions + ) + ) + } + + /** + * One ETH→SOL swap phase: gate on the underwriter being ACTIVE, quote the + * live books, submit `ReserveManager.requestSwap`, then capture the + * CONFIRMED commitment (uwreq id + winner) and assert its two persistent + * locks. Commitments already captured under `excludeCommitmentKeys` are + * excluded from the capture read, keeping the two same-direction swaps + * distinguishable. + */ + private planSwap( + cluster: ClusterBuild, + phaseName: string, + underwriterGateOptions: ClusterBuildStepOptions, + raceStepOptions: ClusterBuildStepOptions, + requestStepOptions: ClusterBuildStepOptions, + underwriterLabel: string, + targetAmountKey: OutputKey, + commitmentKey: OutputKey, + excludeCommitmentKeys: ReadonlyArray< + OutputKey + > + ): void { + ClusterBuildPhase.create( + cluster, + phaseName, + "ETH→SOL swap — request, underwriter race, and capture of the CONFIRMED commitment" + ).push( + // The collateral DEPOSIT_REQUESTs must complete their OPP round-trip + // before the depot marks the underwriter ACTIVE; without it no commits + // land and the race never resolves. (Already-ACTIVE resolves instantly.) + verifyStep( + Actor.Underwriter, + "underwriter-active", + `${underwriterLabel} is OPERATOR_STATUS_ACTIVE (deposits credited)`, + async (ctx: SwapScenarioContext) => { + const account = ctx.keyStore.assertOperator(underwriterLabel).account + await pollUntil( + `${underwriterLabel} ACTIVE`, + async () => { + const operator = await ChallengeSteps.readOperatorRow( + ctx, + account + ) + return ( + operator != null && + matchesProtoEnum( + operator.status, + SysioOpregOperatorstatus, + SysioOpregOperatorstatus.OPERATOR_STATUS_ACTIVE + ) + ) + }, + Constants.underwriterActiveDeadlineMs(), + Constants.LongPollIntervalMs + ) + }, + underwriterGateOptions + ), + verifyStep( + Actor.Sysio, + "swapquote", + "compute the ETH→SOL swapquote over the live books", + async (ctx: SwapScenarioContext) => { + const books: Books = { + src: await ctx.reserveBook( + Constants.EthereumChainCode, + Constants.EthereumTokenCode, + Constants.PrimaryReserveCode + ), + dst: await ctx.reserveBook( + Constants.SolanaChainCode, + Constants.SolanaTokenCode, + Constants.PrimaryReserveCode + ) + } + // Scale source wei (1e18) → depot 9-decimal units; for SOL the + // depot unit IS the lamport, so the quote needs no outbound scaling. + const quote = swapquote( + books, + Constants.SourceEthereumWei / Constants.WeiPerDepotUnit + ) + Assert.ok( + quote > 0n, + `${phaseName} ETH→SOL swapquote returned no quote` + ) + ctx.outputs.set(targetAmountKey, quote) + log.info(`[${phaseName}] swapquote = ${quote} lamports`) + } + ), + SwapSteps.planRequestSwapEthereum( + Actor.User, + "request-swap", + `user calls ReserveManager.requestSwap (${Constants.SourceEthereumWei} wei ETH → SOL)`, + requestStepOptions, + targetAmountKey, + { + sourceAmountWei: Constants.SourceEthereumWei, + targetToleranceBps: Constants.ToleranceBps + } + ), + verifyStep( + Actor.Underwriter, + "capture-commitment", + "the race resolves CONFIRMED — capture (uwreq id, winner) + assert both locks", + async (ctx: SwapScenarioContext) => { + const excludeUwreqIds = excludeCommitmentKeys.map( + key => ctx.outputs.assert(key).uwreqId + ) + await pollUntil( + `${phaseName} commitment CONFIRMED`, + async () => + (await ChallengeSteps.findConfirmedCommitment( + ctx, + excludeUwreqIds + )) != null, + Constants.UwreqDeadlineMs + Constants.RaceDeadlineMs, + Constants.LongPollIntervalMs + ) + const commitment = await ChallengeSteps.findConfirmedCommitment( + ctx, + excludeUwreqIds + ) + ctx.outputs.set(commitmentKey, commitment) + const locks = await ctx.locksForUwreq(commitment.uwreqId) + Assert.strictEqual( + locks.length, + 2, + "exactly two persistent locks back the CONFIRMED commitment" + ) + log.info( + `[${phaseName}] CONFIRMED uwreq ${commitment.uwreqId} won by ${commitment.underwriterAccount}` + ) + }, + raceStepOptions + ) + ) + } +} diff --git a/packages/flow-underwriter-slashing/src/UnderwriterSlashingScenarioConstants.ts b/packages/flow-underwriter-slashing/src/UnderwriterSlashingScenarioConstants.ts new file mode 100644 index 00000000..b626800d --- /dev/null +++ b/packages/flow-underwriter-slashing/src/UnderwriterSlashingScenarioConstants.ts @@ -0,0 +1,118 @@ +import { SlugName, SysioContracts } from "@wireio/sdk-core" +import { ProtocolTiming } from "@wireio/cluster-tool" + +/** + * Constants for the underwriter-slashing flow (WIRE-297). The swap substrate + * (chains, amounts, tolerances, timing budgets) mirrors + * `flow-swap-with-underwriting`'s Phase A exactly — the same ETH→SOL swap, + * run twice so the flow holds two independently challengeable commitments. + * Every protocol-wait budget derives from the {@link ProtocolTiming} envelope. + */ +export namespace UnderwriterSlashingScenarioConstants { + // ── Timing ──────────────────────────────────────────────────────────────── + + /** Epoch duration (s) — the `sysio.epoch::setconfig` floor is 60. */ + export const EpochDurationSec = 60 + /** UWREQ row on the depot after the source outpost emits SWAP_REQUEST — a + * single outpost→depot hop. */ + export const UwreqDeadlineMs = ProtocolTiming.SingleHopBudgetMs + /** Underwriter race resolution (CONFIRMED): the winning commit lands on the + * destination outpost and relays back to the depot — a single hop. */ + export const RaceDeadlineMs = ProtocolTiming.SingleHopBudgetMs + /** Sleep between long-running chain-state polls. */ + export const LongPollIntervalMs = 3_000 + /** Buffer added on top of each poll deadline for the enclosing step timeout (ms). */ + export const PollDeadlineBufferMs = 30_000 + /** Hard ceiling on each on-chain write step (submit + confirm). */ + export const RequestStepTimeoutMs = 60_000 + /** 1 s in ms — multiplies epoch counts into ms deadlines. */ + export const MsPerSecond = 1_000 + + /** + * Epochs budgeted for the underwriter collateral DEPOSIT_REQUESTs to relay to + * the depot and flip the underwriter ACTIVE — the same 9-epoch budget the + * other underwriting flows validated green. + */ + export const UnderwriterActiveEpochBudget = 9 + + /** Deadline for the underwriter deposit relay + ACTIVE eligibility flip — + * extension-inclusive epochs so consecutive extended epochs still fit. */ + export function underwriterActiveDeadlineMs(): number { + return ( + ProtocolTiming.effectiveEpochSec(EpochDurationSec) * + UnderwriterActiveEpochBudget * + MsPerSecond + ) + } + + /** + * Deadline for a challenge resolution to take effect on the depot + * (verdict + slash + sweep, or verdict + hold-clear — all inline in one + * `chkuwchal` transaction, so a single-hop budget is generous). + */ + export const ResolveDeadlineMs = ProtocolTiming.SingleHopBudgetMs + + // ── Registry slugs (must match the bootstrap registry seed) ────────────── + + /** Registered chain slug codes. */ + export const EthereumChainCode = SlugName.from("ETHEREUM") + export const SolanaChainCode = SlugName.from("SOLANA") + /** Registered token slug codes. */ + export const EthereumTokenCode = SlugName.from("ETH") + export const SolanaTokenCode = SlugName.from("SOL") + /** The bootstrap-seeded reserve slug both swaps ride. */ + export const PrimaryReserveCode = SlugName.from("PRIMARY") + + /** Ethereum outpost contract the swap user calls `requestSwap` on. */ + export const ReserveManagerContractName = "ReserveManager" + + // ── Reserves & amounts (mirror flow-swap-with-underwriting Phase A) ────── + + /** Wei per depot base unit (native ETH is 18-decimal; the depot frame is 9). */ + export const WeiPerDepotUnit = 10n ** 9n + /** Each swap's source: 0.1 ETH = 1e17 wei → 1e8 depot units (1% of the seed). */ + export const SourceEthereumWei = 100_000_000_000_000_000n + /** Variance tolerance attached to each SwapRequest (0.5%). */ + export const ToleranceBps = 50 + /** Per-(chain, token) minimum bond for `req_uw_collat` (see the + * swap-with-underwriting constants for the full rationale). */ + export const UnderwriterMinimumBond = 1_000_000_000 + + // ── The challenge cast ──────────────────────────────────────────────────── + + /** + * The three Tier-1 voters — the flow-provisioned electorate. With the ONE + * bootstrap Tier-1 owner (`wireno`) the snapshot is N = 4, Q = ⌊4/2⌋+1 = 3, + * so all three voting the same way is exactly quorum. The scenario asserts + * the snapshotted quorum ≤ 3 after each open, so a bootstrap-roster change + * fails loudly instead of deadlocking the vote. + */ + export const Tier1VoterNames = ["uwvotera", "uwvoterb", "uwvoterc"] as const + + /** Tier value `roa::forcereg` registers the voters at (Tier-1 electorate). */ + export const Tier1 = 1 + + /** + * The bond-posting challenger. A plain keyed account (shared dev K1 key, so + * the flow signs its actions), funded from the WIRE treasury below — + * deliberately NOT a voter: filing and adjudicating stay separate parties. + */ + export const ChallengerAccount = "uwchallenger" + + /** + * WIRE the treasury grants the challenger (9-decimal units = 10 WIRE). Each + * challenge bond is the WIRE value of two ~1e8-unit legs (~0.2 WIRE against + * the seeded books), so this covers both challenges with margin — and the + * flow proves every bond comes back (refund or forfeit, never burned). + */ + export const ChallengerFundingAmount = 10_000_000_000n + /** The funding transfer's `quantity` string (asset form of the above). */ + export const ChallengerFundingQuantity = "10.000000000 WIRE" + /** The WIRE treasury account the funding transfer draws from. */ + export const TreasuryAccount = "sysio" + + /** The fault class each challenge alleges (what a real challenger would + * claim when the committed source deposit cannot be found). */ + export const ChallengeReason = + SysioContracts.SysioChalgUnderwriteFaultReason.SOURCE_DEPOSIT_MISSING +} diff --git a/packages/flow-underwriter-slashing/src/UnderwriterSlashingScenarioOutputs.ts b/packages/flow-underwriter-slashing/src/UnderwriterSlashingScenarioOutputs.ts new file mode 100644 index 00000000..d0967ad7 --- /dev/null +++ b/packages/flow-underwriter-slashing/src/UnderwriterSlashingScenarioOutputs.ts @@ -0,0 +1,82 @@ +import { outputKey, type OutputKey } from "@wireio/cluster-tool" + +/** + * Typed cross-step output keys for the underwriter-slashing flow. Each swap's + * quote step snapshots its target amount; each capture step records the + * CONFIRMED commitment (uwreq id + winning underwriter account); each + * challenge phase records its chalg row id, the escrowed bond, and the + * balances its resolution is measured against. Cross-step values ride + * `ctx.outputs` — never shared mutable closures. + */ +export namespace UnderwriterSlashingScenarioOutputs { + /** + * A CONFIRMED underwrite commitment under (potential) challenge: the uwreq + * row id and its winning underwriter's ON-CHAIN account (the `winner` + * field — already an account name, not a harness label). + */ + export interface ChallengedCommitment { + readonly uwreqId: number + readonly underwriterAccount: string + } + + /** Swap A's quote-computed target amount (lamports). */ + export const swapATargetAmount: OutputKey = outputKey( + "underwriterSlashing.swapA.targetAmount", + "swap A target amount (lamports) from the ETH→SOL swapquote" + ) + /** Swap B's quote-computed target amount (lamports). */ + export const swapBTargetAmount: OutputKey = outputKey( + "underwriterSlashing.swapB.targetAmount", + "swap B target amount (lamports) from the ETH→SOL swapquote" + ) + + /** Swap A's CONFIRMED commitment — the UPHELD challenge's target. */ + export const commitmentA: OutputKey = outputKey( + "underwriterSlashing.commitmentA", + "swap A's CONFIRMED (uwreq id, winner) — challenged and UPHELD" + ) + /** Swap B's CONFIRMED commitment — the REJECTED challenge's target. */ + export const commitmentB: OutputKey = outputKey( + "underwriterSlashing.commitmentB", + "swap B's CONFIRMED (uwreq id, winner) — challenged and REJECTED" + ) + + /** The uphold challenge's `sysio.chalg::uwchals` row id. */ + export const challengeAId: OutputKey = outputKey( + "underwriterSlashing.challengeA.id", + "chalg uwchals row id of the UPHELD challenge (commitment A)" + ) + /** The reject challenge's `sysio.chalg::uwchals` row id. */ + export const challengeBId: OutputKey = outputKey( + "underwriterSlashing.challengeB.id", + "chalg uwchals row id of the REJECTED challenge (commitment B)" + ) + + /** The WIRE bond the uphold challenge escrowed (9-decimal units). */ + export const challengeABond: OutputKey = outputKey( + "underwriterSlashing.challengeA.bond", + "WIRE bond escrowed by the UPHELD challenge" + ) + /** The WIRE bond the reject challenge escrowed (9-decimal units). */ + export const challengeBBond: OutputKey = outputKey( + "underwriterSlashing.challengeB.bond", + "WIRE bond escrowed by the REJECTED challenge" + ) + + /** Challenger WIRE balance immediately BEFORE filing the uphold challenge. */ + export const challengerBalanceBeforeA: OutputKey = outputKey( + "underwriterSlashing.challengeA.challengerBalanceBefore", + "challenger WIRE balance before the UPHELD challenge's escrow" + ) + /** Challenger WIRE balance immediately BEFORE filing the reject challenge. */ + export const challengerBalanceBeforeB: OutputKey = outputKey( + "underwriterSlashing.challengeB.challengerBalanceBefore", + "challenger WIRE balance before the REJECTED challenge's escrow" + ) + /** The wrongly-challenged underwriter's WIRE balance before the reject + * challenge — the forfeited bond must land exactly on top of it. */ + export const underwriterBalanceBeforeB: OutputKey = outputKey( + "underwriterSlashing.challengeB.underwriterBalanceBefore", + "winner's WIRE balance before the REJECTED challenge (forfeit baseline)" + ) +} diff --git a/packages/flow-underwriter-slashing/src/index.ts b/packages/flow-underwriter-slashing/src/index.ts new file mode 100644 index 00000000..873c2861 --- /dev/null +++ b/packages/flow-underwriter-slashing/src/index.ts @@ -0,0 +1,10 @@ +import { FlowCLI } from "@wireio/cluster-tool" +import { UnderwriterSlashingScenario } from "./UnderwriterSlashingScenario.js" + +/** Run the underwriter-slashing flow as an executable — exit code = report success. */ +async function main(): Promise { + const report = await FlowCLI.create(UnderwriterSlashingScenario).run() + process.exit(report.succeeded ? 0 : 1) +} + +void main() diff --git a/packages/flow-underwriter-slashing/src/steps/UnderwriterSlashingScenarioChallengeSteps.ts b/packages/flow-underwriter-slashing/src/steps/UnderwriterSlashingScenarioChallengeSteps.ts new file mode 100644 index 00000000..485e6b10 --- /dev/null +++ b/packages/flow-underwriter-slashing/src/steps/UnderwriterSlashingScenarioChallengeSteps.ts @@ -0,0 +1,527 @@ +/** + * UnderwriterSlashingScenarioChallengeSteps — the flow-local Step factories for + * every `sysio.chalg` underwriter-challenge WRITE this scenario submits + * (`openuwchal` / `voteuwchal` / `chkuwchal`), plus the plain read helpers the + * verify steps poll with. Every on-chain write is its own + * {@link ClusterBuildStep} so the `Report` records it; cross-step values + * (commitments, challenge ids, bonds, balance baselines) ride `ctx.outputs` + * under {@link UnderwriterSlashingScenarioOutputs}'s typed keys. + * + * Flow-local by the same precedent as `flow-batch-operator-slashing`'s dispute + * steps: these actions are this scenario's content, not shared harness + * surface. + */ + +import Assert from "node:assert" +import { getLogger } from "@wireio/shared" +import { SysioContracts } from "@wireio/sdk-core" +import { + ClusterBuildStep, + matchesProtoEnum, + pollUntil, + slugValue, + type ClusterBuildStepOptions, + type OutputKey, + type Report, + type StepInput, + type SwapScenarioContext +} from "@wireio/cluster-tool" +import { UnderwriterSlashingScenarioConstants as Constants } from "../UnderwriterSlashingScenarioConstants.js" +import { UnderwriterSlashingScenarioOutputs as Outputs } from "../UnderwriterSlashingScenarioOutputs.js" + +const { + SysioContractAccount, + SysioContractName, + SysioChalgUwchalBallot, + SysioChalgUwchalVerdict, + SysioUwritUnderwriterequeststatus +} = SysioContracts + +const log = getLogger(__filename) + +/** The standard `active` permission every flow-signed action authorizes with. */ +const ActivePermission = "active" + +export namespace UnderwriterSlashingScenarioChallengeSteps { + // ── read helpers (plain functions — reads execute freely inside runners) ── + + /** + * The newest CONFIRMED ETH→SOL uwreq NOT in `excludeUwreqIds` — how the two + * same-direction swaps are told apart (`ctx.uwreq` matches on direction + * alone, which is ambiguous once swap B lands). + * + * @param ctx - The build context. + * @param excludeUwreqIds - Uwreq ids already captured (swap A's, for swap B). + * @returns The commitment, or null while the race is still unresolved. + */ + export async function findConfirmedCommitment( + ctx: SwapScenarioContext, + excludeUwreqIds: readonly number[] + ): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.uwrit) + .tables.uwreqs.query() + const row = rows.find( + request => + slugValue(request.src_chain_code) === Constants.EthereumChainCode && + slugValue(request.dst_chain_code) === Constants.SolanaChainCode && + matchesProtoEnum( + request.status, + SysioUwritUnderwriterequeststatus, + SysioUwritUnderwriterequeststatus.UNDERWRITE_REQUEST_STATUS_CONFIRMED + ) && + !excludeUwreqIds.includes(Number(request.id)) + ) + return row == null + ? null + : { uwreqId: Number(row.id), underwriterAccount: String(row.winner) } + } + + /** The `sysio.uwrit::uwreqs` row by id. */ + export async function readUwreq( + ctx: SwapScenarioContext, + uwreqId: number + ): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.uwrit) + .tables.uwreqs.query() + return rows.find(request => Number(request.id) === uwreqId) + } + + /** + * The `sysio.chalg::uwchals` row for one challenged commitment (there is at + * most one, ever — the contract's uniqueness rule). + */ + export async function readUwchalByCommitment( + ctx: SwapScenarioContext, + commitment: Outputs.ChallengedCommitment + ): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.chalg) + .tables.uwchals.query() + return rows.find( + challenge => + Number(challenge.uwreq_id) === commitment.uwreqId && + challenge.underwriter === commitment.underwriterAccount + ) + } + + /** The `sysio.chalg::uwchals` row by id. */ + export async function readUwchal( + ctx: SwapScenarioContext, + chalId: number + ): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.chalg) + .tables.uwchals.query() + return rows.find(challenge => Number(challenge.id) === chalId) + } + + /** + * The WIRE `sysio.chalg` still owes `account` from resolved challenges — + * its `bondcredits` row, or 0n when none exists. + * + * Resolution CREDITS the bond rather than transferring it: `chkuwchal` can + * run inline under the epoch tick, where `sysio.token::transfer`'s + * `require_recipient(to)` would execute the recipient's own code and let it + * abort epoch advancement. The WIRE moves only in `claimbond`. + */ + export async function readBondCredit( + ctx: SwapScenarioContext, + account: string + ): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.chalg) + .tables.bondcredits.query() + const row = rows.find(credit => credit.account === account) + return row == null ? 0n : BigInt(row.amount) + } + + /** The `sysio.opreg::operators` row by ON-CHAIN account name. */ + export async function readOperatorRow( + ctx: SwapScenarioContext, + account: string + ): Promise { + const { rows } = await ctx.wire + .getSysioContract(SysioContractName.opreg) + .tables.operators.query() + return rows.find(operator => operator.account === account) + } + + /** + * Poll until the challenge reaches `verdict`. The tally, slash, sweep (or + * hold-clear), row compaction and bond CREDIT all ride ONE `chkuwchal` + * transaction, so once the verdict lands every consequence asserted + * afterwards is final — except the bond's delivery, which is a separate pull + * ({@link planClaimbond}). The + * verdict cell rides the ABI `enums` extension, so the RPC serializes it as + * the NAME spelling — match via {@link matchesProtoEnum}, never `Number()`. + */ + export async function awaitVerdict( + ctx: SwapScenarioContext, + chalId: number, + verdict: SysioContracts.SysioChalgUwchalVerdict + ): Promise { + await pollUntil( + `challenge ${chalId} reaches verdict ${SysioChalgUwchalVerdict[verdict]}`, + async () => { + const challenge = await readUwchal(ctx, chalId) + return ( + challenge != null && + matchesProtoEnum(challenge.verdict, SysioChalgUwchalVerdict, verdict) + ) + }, + Constants.ResolveDeadlineMs, + Constants.LongPollIntervalMs + ) + } + + // ── Step: sysio.roa::forcereg (write) ───────────────────────────────────── + + /** Input for {@link planForcereg} — the generated `roa::forcereg` data. */ + export interface ForceregInput extends StepInput { + readonly kind: "UnderwriterSlashingScenarioChallengeSteps.ForceregInput" + readonly data: SysioContracts.SysioRoaForceregAction + } + + /** + * `sysio.roa::forcereg` — register one Tier-1 voter into the electorate the + * challenge snapshots. The direct-registration path (no NFT claim): the + * batch-op slashing flow exercises the full claim registration; this flow's + * electorate is infrastructure, not the thing under test. + */ + export function planForcereg( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + data: SysioContracts.SysioRoaForceregAction + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "UnderwriterSlashingScenarioChallengeSteps.ForceregInput", data }, + runForcereg + ) + } + + /** Named runner — one `roa::forcereg` write under `sysio.roa@active`. */ + export async function runForcereg( + ctx: SwapScenarioContext, + input: ForceregInput, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + await ctx.wire + .getSysioContract(SysioContractName.roa) + .actions.forcereg.invoke(input.data, { + authorization: [ + { + actor: SysioContractAccount[SysioContractName.roa], + permission: ActivePermission + } + ] + }) + } + + // ── Step: sysio.chalg::openuwchal (write) ───────────────────────────────── + + /** Input for {@link planOpenuwchal} — the allegation's static parameters. */ + export interface OpenuwchalInput extends StepInput { + readonly kind: "UnderwriterSlashingScenarioChallengeSteps.OpenuwchalInput" + /** Numeric `underwrite_fault_reason` the challenger alleges. */ + readonly reason: SysioContracts.SysioChalgUnderwriteFaultReason + /** Free-text evidence context recorded on the challenge row. */ + readonly detail: string + } + + /** The output keys one challenge phase writes/reads — bundled so both + * challenge phases reuse the same factories with their own key set. */ + export interface ChallengeKeys { + /** The commitment under challenge (set by the capture step). */ + readonly commitment: OutputKey + /** Where the opened challenge's row id lands. */ + readonly chalId: OutputKey + /** Where the escrowed bond amount lands. */ + readonly bond: OutputKey + /** Where the challenger's pre-escrow balance lands. */ + readonly challengerBalanceBefore: OutputKey + } + + /** + * `sysio.chalg::openuwchal` — file the challenge against the captured + * commitment. ONE write; the runner then reads back the challenge row it + * created (id + bond) into `keys`, and asserts the bond actually left the + * challenger (escrow is an inline transfer inside the same transaction) and + * that the snapshotted quorum is reachable by the flow's three voters. + */ + export function planOpenuwchal( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + keys: ChallengeKeys, + reason: SysioContracts.SysioChalgUnderwriteFaultReason, + detail: string + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { + kind: "UnderwriterSlashingScenarioChallengeSteps.OpenuwchalInput", + reason, + detail + }, + async (ctx, input, signal) => runOpenuwchal(ctx, input, signal, keys) + ) + } + + /** Named runner body for {@link planOpenuwchal} (keys bound by the factory). */ + export async function runOpenuwchal( + ctx: SwapScenarioContext, + input: OpenuwchalInput, + signal: AbortSignal, + keys: ChallengeKeys + ): Promise { + signal.throwIfAborted() + const commitment = ctx.outputs.assert(keys.commitment) + const balanceBefore = await ctx.wire.getWireBalance( + Constants.ChallengerAccount + ) + ctx.outputs.set(keys.challengerBalanceBefore, balanceBefore) + + await ctx.wire + .getSysioContract(SysioContractName.chalg) + .actions.openuwchal.invoke( + { + challenger: Constants.ChallengerAccount, + uwreq_id: commitment.uwreqId, + underwriter: commitment.underwriterAccount, + reason: input.reason, + detail: input.detail + }, + { + authorization: [ + { actor: Constants.ChallengerAccount, permission: ActivePermission } + ] + } + ) + + const challenge = await readUwchalByCommitment(ctx, commitment) + Assert.ok( + challenge != null, + `openuwchal landed but no uwchals row exists for uwreq ${commitment.uwreqId}` + ) + const bond = BigInt(challenge.bond_amount) + Assert.ok(bond > 0n, "the escrowed challenge bond must be positive") + Assert.strictEqual( + await ctx.wire.getWireBalance(Constants.ChallengerAccount), + balanceBefore - bond, + "the challenger's WIRE drops by exactly the escrowed bond" + ) + // The flow casts Tier1VoterNames.length identical ballots; a quorum above + // that means the bootstrap Tier-1 roster grew — fail loudly here instead + // of deadlocking the vote downstream. + Assert.ok( + Number(challenge.quorum) <= Constants.Tier1VoterNames.length, + `snapshotted quorum ${challenge.quorum} exceeds the flow's ${Constants.Tier1VoterNames.length} voters` + ) + ctx.outputs.set(keys.chalId, Number(challenge.id)).set(keys.bond, bond) + log.info( + `[uwchal] opened challenge ${challenge.id} on uwreq ${commitment.uwreqId} (bond ${bond})` + ) + } + + // ── Step: sysio.chalg::voteuwchal (write) ───────────────────────────────── + + /** Input for {@link planVoteuwchal}. */ + export interface VoteuwchalInput extends StepInput { + readonly kind: "UnderwriterSlashingScenarioChallengeSteps.VoteuwchalInput" + /** The Tier-1 owner casting the ballot. */ + readonly voter: string + /** Numeric `uwchal_ballot` value. */ + readonly ballot: SysioContracts.SysioChalgUwchalBallot + } + + /** + * `sysio.chalg::voteuwchal` — one Tier-1 owner's ballot (record-only on + * chain; `chkuwchal` tallies). The challenge id is resolved from + * `ctx.outputs` at run time — it does not exist when the plan is built. + */ + export function planVoteuwchal( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + chalIdKey: OutputKey, + voter: string, + ballot: SysioContracts.SysioChalgUwchalBallot + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { + kind: "UnderwriterSlashingScenarioChallengeSteps.VoteuwchalInput", + voter, + ballot + }, + async (ctx, input, signal) => runVoteuwchal(ctx, input, signal, chalIdKey) + ) + } + + /** Named runner body for {@link planVoteuwchal}. */ + export async function runVoteuwchal( + ctx: SwapScenarioContext, + input: VoteuwchalInput, + signal: AbortSignal, + chalIdKey: OutputKey + ): Promise { + signal.throwIfAborted() + const chalId = ctx.outputs.assert(chalIdKey) + await ctx.wire + .getSysioContract(SysioContractName.chalg) + .actions.voteuwchal.invoke( + { owner: input.voter, chal_id: chalId, ballot: input.ballot }, + { + authorization: [{ actor: input.voter, permission: ActivePermission }] + } + ) + log.info( + `[uwchal] ${input.voter} voted ${SysioChalgUwchalBallot[input.ballot]} on challenge ${chalId}` + ) + } + + // ── Step: sysio.chalg::chkuwchal (write — the permissionless tally) ────── + + /** Input for {@link planChkuwchal}. */ + export interface ChkuwchalInput extends StepInput { + readonly kind: "UnderwriterSlashingScenarioChallengeSteps.ChkuwchalInput" + } + + /** + * `sysio.chalg::chkuwchal` — crank the tally right after the deciding vote + * (production needs no caller: `chklocks` pokes it from the epoch tick; the + * flow cranks manually for a deterministic, faster resolution — the same + * relationship `chkdispute` has to its voters). + */ + export function planChkuwchal( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + chalIdKey: OutputKey + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "UnderwriterSlashingScenarioChallengeSteps.ChkuwchalInput" }, + async (ctx, input, signal) => runChkuwchal(ctx, input, signal, chalIdKey) + ) + } + + /** Named runner body for {@link planChkuwchal}. */ + export async function runChkuwchal( + ctx: SwapScenarioContext, + input: ChkuwchalInput, + signal: AbortSignal, + chalIdKey: OutputKey + ): Promise { + signal.throwIfAborted() + const chalId = ctx.outputs.assert(chalIdKey) + await ctx.wire + .getSysioContract(SysioContractName.chalg) + .actions.chkuwchal.invoke( + { chal_id: chalId }, + { + authorization: [ + { actor: Constants.ChallengerAccount, permission: ActivePermission } + ] + } + ) + } + + // ── Step: sysio.chalg::claimbond (write — the recipient's own pull) ─────── + + /** Input for {@link planClaimbond}. */ + export interface ClaimbondInput extends StepInput { + readonly kind: "UnderwriterSlashingScenarioChallengeSteps.ClaimbondInput" + } + + /** + * `sysio.chalg::claimbond` — the recipient pulls its resolved bond out of + * chalg custody. This is the ONLY step in the flow where a resolved bond + * actually moves: `chkuwchal` credits a claimable balance instead of + * transferring, because the tally can run inline under the epoch tick + * (`sysio.epoch::advance` -> `sysio.uwrit::chklocks` -> `chkuwchal`) where + * `sysio.token::transfer`'s `require_recipient(to)` would run the + * recipient's own code and hand it a lever on epoch advancement. + * + * `recipient` is either a literal account (the challenger, known when the + * plan is built) or the commitment whose winning underwriter is owed the + * forfeit (resolved from `ctx.outputs` at run time, like the challenge id). + */ + export function planClaimbond( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + recipient: string | OutputKey + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { kind: "UnderwriterSlashingScenarioChallengeSteps.ClaimbondInput" }, + async (ctx, input, signal) => runClaimbond(ctx, input, signal, recipient) + ) + } + + /** Named runner body for {@link planClaimbond}. */ + export async function runClaimbond( + ctx: SwapScenarioContext, + input: ClaimbondInput, + signal: AbortSignal, + recipient: string | OutputKey + ): Promise { + signal.throwIfAborted() + // `OutputKey` is an object, so the literal-vs-key discrimination is total. + const account = + typeof recipient === "string" + ? recipient + : ctx.outputs.assert(recipient).underwriterAccount + const credit = await readBondCredit(ctx, account), + balanceBefore = await ctx.wire.getWireBalance(account) + Assert.ok( + credit > 0n, + `claimbond: ${account} has no claimable bond credit to pull` + ) + + await ctx.wire + .getSysioContract(SysioContractName.chalg) + .actions.claimbond.invoke( + { account }, + { authorization: [{ actor: account, permission: ActivePermission }] } + ) + + Assert.strictEqual( + await ctx.wire.getWireBalance(account), + balanceBefore + credit, + "claimbond pays out the whole credited balance" + ) + Assert.strictEqual( + await readBondCredit(ctx, account), + 0n, + "claimbond erases the credit row it paid out" + ) + log.info(`[uwchal] ${account} claimed bond credit ${credit}`) + } +} diff --git a/packages/flow-underwriter-slashing/src/steps/UnderwriterSlashingScenarioSwapSteps.ts b/packages/flow-underwriter-slashing/src/steps/UnderwriterSlashingScenarioSwapSteps.ts new file mode 100644 index 00000000..4332402f --- /dev/null +++ b/packages/flow-underwriter-slashing/src/steps/UnderwriterSlashingScenarioSwapSteps.ts @@ -0,0 +1,124 @@ +/** + * UnderwriterSlashingScenarioSwapSteps — the flow-local Step factory for the + * ETH→SOL `ReserveManager.requestSwap` writes this scenario submits (one per + * challengeable commitment). The heavy lifting is the harness's + * `requestEthereumSwap` + `EthereumCollateralTool` loaders; this file is the + * per-flow glue every swap flow declares for itself. The target amount is + * keyed per swap so ONE factory serves both. + */ + +import Assert from "node:assert" +import { ethers } from "ethers" +import { getLogger } from "@wireio/shared" +import { + contractView, + ClusterBuildStep, + ClusterConfigProvider, + EthereumCollateralTool, + requestEthereumSwap, + swapUserOutputKey, + type ClusterBuildStepOptions, + type OutputKey, + type Report, + type ReserveManagerRequestSwapContract, + type StepInput, + type SwapScenarioContext +} from "@wireio/cluster-tool" +import { UnderwriterSlashingScenarioConstants as Constants } from "../UnderwriterSlashingScenarioConstants.js" + +const log = getLogger(__filename) + +export namespace UnderwriterSlashingScenarioSwapSteps { + /** Input for {@link planRequestSwapEthereum} — the static SWAP_REQUEST + * parameters (the quote-computed target rides `targetAmountKey`). */ + export interface RequestSwapEthereumInput extends StepInput { + readonly kind: "UnderwriterSlashingScenarioSwapSteps.RequestSwapEthereumInput" + /** Wei to escrow into the source reserve (`msg.value`). */ + readonly sourceAmountWei: bigint + /** Acceptable variance in basis points. */ + readonly targetToleranceBps: number + } + + /** + * A single `ReserveManager.requestSwap(...)` write signed by the swap user — + * one ETH→SOL SWAP_REQUEST emission (the same leg shape as + * flow-swap-with-underwriting's Phase A; chain/token/reserve codes are the + * flow constants). + */ + export function planRequestSwapEthereum( + actor: Report.Actor, + name: string, + description: string, + options: ClusterBuildStepOptions, + targetAmountKey: OutputKey, + request: Omit + ): ClusterBuildStep { + return ClusterBuildStep.create( + actor, + name, + description, + options, + { + kind: "UnderwriterSlashingScenarioSwapSteps.RequestSwapEthereumInput", + ...request + }, + async (ctx, input, signal) => + runRequestSwapEthereum(ctx, input, signal, targetAmountKey) + ) + } + + /** Named runner body — bind `ReserveManager` to the swap user's wallet and + * perform ONE `requestSwap(...)` write. */ + export async function runRequestSwapEthereum( + ctx: SwapScenarioContext, + input: RequestSwapEthereumInput, + signal: AbortSignal, + targetAmountKey: OutputKey + ): Promise { + signal.throwIfAborted() + const swapUser = ctx.outputs.assert(swapUserOutputKey()) + const targetAmount = ctx.outputs.assert(targetAmountKey) + const reserveManager = loadReserveManager(ctx, swapUser.ethereumWallet) + const result = await requestEthereumSwap(reserveManager, { + sourceTokenCode: BigInt(Constants.EthereumTokenCode), + sourceReserveCode: BigInt(Constants.PrimaryReserveCode), + sourceAmountWei: input.sourceAmountWei, + targetChainCode: BigInt(Constants.SolanaChainCode), + targetTokenCode: BigInt(Constants.SolanaTokenCode), + targetReserveCode: BigInt(Constants.PrimaryReserveCode), + targetRecipient: swapUser.solanaPublicKeyBytes, + targetAmount, + targetToleranceBps: input.targetToleranceBps + }) + Assert.ok( + result.transactionHash, + "UnderwriterSlashingScenarioSwapSteps.requestSwapEthereum: no transaction hash" + ) + log.info( + `[uwchal-swap] requestSwap tx=${result.transactionHash} block=${result.blockNumber} target=${targetAmount}` + ) + } + + /** + * Resolve the `ReserveManager` contract from the run's deploy artifacts, + * bound to `wallet` — address from `outpost-addrs.json`, ABI from the + * hardhat artifact (both via {@link EthereumCollateralTool}'s loaders). + */ + export function loadReserveManager( + ctx: SwapScenarioContext, + wallet: ethers.Signer + ): ReserveManagerRequestSwapContract { + const address = EthereumCollateralTool.loadOutpostAddresses( + ClusterConfigProvider.ethereumDeploymentsPath(ctx.config) + )[Constants.ReserveManagerContractName] + Assert.ok( + address != null && /^0x[0-9a-fA-F]{40}$/.test(address), + `UnderwriterSlashingScenarioSwapSteps: ${Constants.ReserveManagerContractName} not in outpost-addrs.json (got ${address})` + ) + const abi = EthereumCollateralTool.loadOutpostAbi( + ctx.config.ethereumPath, + Constants.ReserveManagerContractName + ) + return contractView(address, abi, wallet) + } +} diff --git a/packages/flow-underwriter-slashing/src/steps/index.ts b/packages/flow-underwriter-slashing/src/steps/index.ts new file mode 100644 index 00000000..4a976d78 --- /dev/null +++ b/packages/flow-underwriter-slashing/src/steps/index.ts @@ -0,0 +1,2 @@ +export * from "./UnderwriterSlashingScenarioChallengeSteps.js" +export * from "./UnderwriterSlashingScenarioSwapSteps.js" diff --git a/packages/flow-underwriter-slashing/tsconfig.json b/packages/flow-underwriter-slashing/tsconfig.json new file mode 100644 index 00000000..82b01742 --- /dev/null +++ b/packages/flow-underwriter-slashing/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-underwriter-slashing/tsconfig.src.json b/packages/flow-underwriter-slashing/tsconfig.src.json new file mode 100644 index 00000000..b8bf202f --- /dev/null +++ b/packages/flow-underwriter-slashing/tsconfig.src.json @@ -0,0 +1,26 @@ +{ + "extends": "../../etc/tsconfig/tsconfig.base.jest.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "paths": { + "@wireio/cluster-tool": [ + "../cluster-tool/src" + ], + "@wireio/cluster-tool-shared": [ + "../cluster-tool-shared/src" + ] + } + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../cluster-tool-shared/tsconfig.json" + }, + { + "path": "../cluster-tool/tsconfig.json" + } + ] +} diff --git a/tsconfig.json b/tsconfig.json index 9af85855..00071c9e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ { "path": "./packages/flow-node-owner-nft/tsconfig.json" }, { "path": "./packages/flow-emissions-soak/tsconfig.json" }, { "path": "./packages/flow-yield-distribution/tsconfig.json" }, + { "path": "./packages/flow-underwriter-slashing/tsconfig.json" }, { "path": "./packages/debugging-shared/tsconfig.json" }, { "path": "./packages/debugging-server/tsconfig.json" }, { "path": "./packages/debugging-client-shared/tsconfig.json" },