diff --git a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index 4f39e90c27..4657246172 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -31,6 +31,10 @@ namespace sysio { [[sysio::action]] void advance(); + /// Internal continuation after epoch-close operator mutations execute. + [[sysio::action]] + void finishadv(uint32_t epoch_index, int64_t emission_amount); + /// Group assignment — reads AVAILABLE batch ops from sysio.opreg. [[sysio::action]] void schbatchgps(); diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index e8711f0935..a37432fa1e 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -19,6 +19,7 @@ #include #include #include +#include namespace sysio { @@ -45,6 +46,7 @@ namespace { constexpr name SYSTEM_ACCOUNT = "sysio"_n; constexpr name TOKEN_ACCOUNT = "sysio.token"_n; +constexpr name FINISH_ADVANCE = "finishadv"_n; /// Action identifiers owned by sysio.chalg and invoked by epoch close. namespace chalg_actions { @@ -594,9 +596,9 @@ void epoch::advance() { // // Invariant — no cross-epoch double slash: opreg::slash THROWS on an already-SLASHED operator, // which would abort advance and stall OPP epoch advancement. These inline slashes execute only - // after advance returns, so the schedule slide below can temporarily place a just-slashed - // operator in its new tail while the operator still reads ACTIVE. That member cannot create a - // later non-canonical observation: sysio.msgch::deliver requires its current sysio.opreg status + // after advance returns. The finishadv continuation waits for those mutations before + // selecting the new tail. A removed member cannot create a later non-canonical + // observation: sysio.msgch::deliver requires its current sysio.opreg status // to be ACTIVE before accepting delivery. Once the slash has executed, the scheduled SLASHED // member cannot deliver or be queued for another non-canonical-delivery slash. The collection // above also deduplicates multiple non-canonical observations for one member in this advance. @@ -612,9 +614,9 @@ void epoch::advance() { ).send(); } - // Preserve the delivery history after slashing. A non-canonical operator is already - // SLASHED here, so opreg::termcheck returns without converting the punitive outcome into a - // termination/remit. Other group members retain their normal delivery accounting. + // Preserve delivery history and ordinary termination accounting. Inline + // slashing completes before these actions, and all mutations complete + // before finishadv reads the registry for outbound attestations. for (const auto& observation : observations) { action( permission_level{get_self(), "owner"_n}, @@ -634,13 +636,71 @@ void epoch::advance() { // erases them once they fall out of its retention window. } - const bool had_expiring_group = state.current_epoch_index > 0; - state.current_epoch_index++; state.current_epoch_start = (state.next_epoch_start.sec_since_epoch() == 0) ? now : state.next_epoch_start; state.next_epoch_start = state.current_epoch_start + microseconds(static_cast(cfg.epoch_duration_sec) * 1'000'000); + state_tbl.set(state, ram_payer); + + // Withdrawal flushing can also change eligibility. Its nested callbacks + // must complete before schedule selection and roster serialization. + // Inline siblings execute their complete subtrees in order, atomically. + action( + permission_level{get_self(), "owner"_n}, + OPREG_ACCOUNT, + "flushwtdw"_n, + std::make_tuple(state.current_epoch_index) + ).send(); + + // Keep the refund subtree at its original depth; refundwire can itself + // transfer a fee or sweep expired claims. The state write above makes the + // new epoch index visible before this action runs, and the action finishes + // before roster publication. Each row queued via + // `sysio.uwrit::swapfromwire` since the last advance is re-validated + // (target reserve ACTIVE + public, variance) and either becomes a + // PENDING uwreq for the single-leg underwriter race or is refunded. + // Runs before `buildenv` so this epoch's envelopes reflect any state + // the drain produced; never throws (refund-and-drop semantics). + action( + permission_level{get_self(), "owner"_n}, + UWRIT_ACCOUNT, + "drainfwq"_n, + std::make_tuple() + ).send(); + + action( + permission_level{get_self(), "owner"_n}, + get_self(), + FINISH_ADVANCE, + std::make_tuple(state.current_epoch_index, gate.emission_amount) + ).send(); + + // Preserve payout depth: finishadv and its accrual/history descendants + // complete before this sibling executes. + if (gate.is_pay_epoch) { + action( + permission_level{get_self(), "owner"_n}, + SYSTEM_ACCOUNT, + "payepoch"_n, + std::make_tuple( + state.current_epoch_index, + std::vector>{}, + gate.period_emission + ) + ).send(); + } +} + +void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { + require_auth(get_self()); + check(get_sender() == get_self(), "finishadv must be sent inline by sysio.epoch"); + epochcfg_t cfg_tbl(get_self()); + const auto cfg = cfg_tbl.get(); + epochstate_t state_tbl(get_self()); + auto state = state_tbl.get(); + check(state.current_epoch_index == epoch_index, "finishadv epoch mismatch"); + const bool had_expiring_group = epoch_index > 1; // ── Slide the schedule window ─────────────────────────────────────────── // Skip on the genesis advance (0 → 1): schbatchgps just placed @@ -723,7 +783,7 @@ void epoch::advance() { // below). Short-but-non-empty is pre-existing behaviour and is not made // safe here -- it is reported so the roster can be repaired off-chain. if (new_tail.size() < cfg.operators_per_epoch) { - sysio::print("sysio.epoch::advance: only ", new_tail.size(), " of ", + sysio::print("sysio.epoch::finishadv: only ", new_tail.size(), " of ", cfg.operators_per_epoch, " eligible batch operators for the new tail group at epoch ", state.current_epoch_index + cfg.batch_op_groups - 1, @@ -748,21 +808,6 @@ void epoch::advance() { state_tbl.set(state, ram_payer); - // Drain matured rows from `sysio.opreg::wtdwqueue`. Operators that queued - // a withdrawal at least WITHDRAW_WAIT_EPOCHS ago are now eligible — opreg - // subtracts from the balance and emits OPERATOR_ACTION(WITHDRAW_REMIT) to - // the matching outpost (or, for WIRE-direct withdraws, CREDITS the operator's - // `sysio.opreg::remitclaims` row, which it pulls with `claimremit` — nothing - // is transferred from this path, precisely because it runs inline from here). - // Slashed-during-the-wait rows are dropped silently inside - // opreg's flushwtdw. See CLAUDE-WIRE-OPERATOR-COLLATERAL-IMPL-PLAN.md §3.3. - action( - permission_level{get_self(), "owner"_n}, - OPREG_ACCOUNT, - "flushwtdw"_n, - std::make_tuple(state.current_epoch_index) - ).send(); - // Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost. // IMPORTANT: Must come before BATCH_OPERATOR_GROUPS so that the ETH outpost's // _handleOperators populates operatorEthAddress before _handleBatchOperatorGroups @@ -852,14 +897,14 @@ void epoch::advance() { // // Only the ATTESTATION looks ahead. The depot's own schedule state is // untouched -- `current_batch_op_group` still names the group on duty NOW, - // and `advance` still slides the window so the front is the current epoch. + // and `finishadv` still slides the window so the front is the current epoch. // Nothing that reads `epoch_state` changes meaning. // // `epoch_index` stays the epoch this envelope IS for; it identifies the // envelope, not the roster, and no outpost reads it. { opp::attestations::BatchOperatorGroups attest; - // The window SLIDES; it does not rotate. `advance` erases the front and + // The window SLIDES; it does not rotate. `finishadv` erases the front and // pushes a new tail, and every write to the cursor pins it to 0 (here, // and `schbatchgps`) -- so the group on duty NEXT is simply the one // after the cursor. @@ -873,7 +918,7 @@ void epoch::advance() { // The bound check is also what keeps an EMPTY schedule off a division. // `group_count == 0` is reachable here: the slide above is guarded by // `!empty()`, but nothing requires a seated schedule before this block, - // and `% 0` is an `i32.rem_u` trap that would abort `advance` and halt + // and `% 0` is an `i32.rem_u` trap that would abort `finishadv` and halt // epoch advancement chain-wide. // // Falling back to the cursor covers the single-group case: the same @@ -917,12 +962,12 @@ void epoch::advance() { // carrying an empty group regardless -- so it buys nothing here. // // Withheld by SKIPPING THE QUEUEOUT ONLY -- never by returning from - // `advance`, which still has the epoch's remaining attestations and + // `finishadv`, which still has the epoch's remaining attestations and // actions to issue after this block. const bool have_next_group = next_group_index < group_count && !state.batch_op_groups[next_group_index].empty(); if (!have_next_group) { - sysio::print("sysio.epoch::advance: no non-empty next group to publish at epoch ", + sysio::print("sysio.epoch::finishadv: no non-empty next group to publish at epoch ", state.current_epoch_index, " (groups=", group_count, ", next_index=", next_group_index, "); withholding BatchOperatorGroups -- outposts retain their " @@ -951,7 +996,7 @@ void epoch::advance() { auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; (void)out(attest); - // `have_next_group` gates the QUEUEOUT, not `advance` -- see above. + // `have_next_group` gates the QUEUEOUT, not `finishadv` -- see above. if (have_next_group) { sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { @@ -970,19 +1015,6 @@ void epoch::advance() { } } - // Drain the swap-from-WIRE queue: each row queued via - // `sysio.uwrit::swapfromwire` since the last advance is re-validated - // (target reserve ACTIVE + public, variance) and either becomes a - // PENDING uwreq for the single-leg underwriter race or is refunded. - // Runs before `buildenv` so this epoch's envelopes reflect any state - // the drain produced; never throws (refund-and-drop semantics). - action( - permission_level{get_self(), "owner"_n}, - UWRIT_ACCOUNT, - "drainfwq"_n, - std::make_tuple() - ).send(); - // Build outbound envelopes for each outpost { sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); @@ -997,7 +1029,7 @@ void epoch::advance() { } } - // Emissions side. Three inline actions queued in FIFO order: + // Emissions side. Accrual and history precede advance's payout sibling: // 1. accrueepoch: always queued. Records this epoch's per-epoch share // onto t5state (pending_emission_amount + batch_group_epochs[group] // + last_epoch_emission for decay continuity). @@ -1006,7 +1038,7 @@ void epoch::advance() { // 3. payepoch: queued only on pay-epochs. Reads the now-updated t5state // (which already includes this epoch's contribution from step 1), // distributes period_emission, and resets the accumulator. - // Both run after advance() returns; their FIFO ordering guarantees + // The continuation completes before that sibling; this ordering guarantees // payepoch sees the post-accrue roster history and state. std::vector active_batch_op_members; if (state.current_batch_op_group < state.batch_op_groups.size()) { @@ -1020,7 +1052,7 @@ void epoch::advance() { std::make_tuple( state.current_epoch_index, state.current_batch_op_group, - gate.emission_amount + emission_amount ) ).send(); @@ -1031,19 +1063,6 @@ void epoch::advance() { std::make_tuple(state.current_epoch_index, active_batch_op_members) ).send(); - if (gate.is_pay_epoch) { - action( - permission_level{get_self(), "owner"_n}, - SYSTEM_ACCOUNT, - "payepoch"_n, - std::make_tuple( - state.current_epoch_index, - std::vector>{}, - gate.period_emission - ) - ).send(); - } - // No scheduled cleanup of `sysio.msgch::envelopes` is needed here: // `deliver` prunes rows older than the previous epoch. The durable audit // trail lives in the `envelope_log` table on the same contract, capped at @@ -1060,7 +1079,7 @@ void epoch::advance() { // them into N groups (`cfg.batch_op_groups`). The resulting window is // [epoch_1_group, epoch_2_group, ..., epoch_N_group]. // -// After this, every per-epoch `advance` pops the front group and pushes +// After this, every per-epoch `finishadv` pops the front group and pushes // a new tail group, where the tail's members are drawn from the ACTIVE // pool MINUS anyone still resident in the N-1 surviving groups. The // window itself encodes "scheduled in the last N-1 epochs"; no separate diff --git a/contracts/sysio.epoch/sysio.epoch.abi b/contracts/sysio.epoch/sysio.epoch.abi index 7d7ed6b341..2f292b432f 100644 --- a/contracts/sysio.epoch/sysio.epoch.abi +++ b/contracts/sysio.epoch/sysio.epoch.abi @@ -121,6 +121,20 @@ } ] }, + { + "name": "finishadv", + "base": "", + "fields": [ + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "emission_amount", + "type": "int64" + } + ] + }, { "name": "pause", "base": "", @@ -169,6 +183,11 @@ "type": "advance", "ricardian_contract": "" }, + { + "name": "finishadv", + "type": "finishadv", + "ricardian_contract": "" + }, { "name": "pause", "type": "pause", diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 3583ddc36b..021da13bb2 100755 Binary files a/contracts/sysio.epoch/sysio.epoch.wasm and b/contracts/sysio.epoch/sysio.epoch.wasm differ diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 6e06e2b82c..32e63fe044 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -778,6 +778,38 @@ class sysio_msgch_chain_tester : public tester { return groups_attestation; } + /// Verify the actual same-epoch OPERATORS snapshot after inline mutations. + /// Existing schedule seats may persist until normal rotation removes them; + /// the authoritative status must nevertheless revoke them immediately. + void require_fresh_roster(uint64_t chain_code, name account, + opp::types::OperatorStatus expected_status) { + const auto row = find_outbound_envelope(chain_code); + BOOST_REQUIRE(!row.is_null()); + const auto env = decode_envelope(row["raw_envelope"].as>()); + BOOST_REQUIRE_EQUAL(env.messages_size(), 1); + bool found_operator = false; + bool have_operators = false; + for (const auto& att : env.messages(0).payload().attestations()) { + if (att.type() == opp::types::ATTESTATION_TYPE_OPERATORS) { + opp::attestations::Operators roster; + BOOST_REQUIRE(roster.ParseFromString(att.data())); + have_operators = true; + for (const auto& entry : roster.operators()) { + const auto registered = get_operator(name{entry.account().name()}); + BOOST_REQUIRE(!registered.is_null()); + BOOST_REQUIRE_EQUAL(entry.status(), registered["status"].as()); + if (entry.account().name() == account.to_string()) { + BOOST_REQUIRE_EQUAL(entry.status(), expected_status); + found_operator = true; + } + } + } else if (att.type() == opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS) { + BOOST_REQUIRE(have_operators); + } + } + BOOST_REQUIRE(found_operator); + } + /// How many BATCH_OPERATOR_GROUPS attestations the most recent `advance` shipped to /// `chain_code` -- 0 when the depot WITHHELD it. Distinct from /// `shipped_batch_operator_groups`, which fails the test on absence: the withhold path @@ -948,7 +980,7 @@ class sysio_msgch_chain_tester : public tester { /// duty once per 3-epoch rotation), the SEC-28 percent rail disabled up to its accepted ceiling /// (99, so an anchored run terminates on the CONSECUTIVE rail), and `terminate_window_ms` set by /// the caller (the exact span bound for this schedule). ETH outpost registered; genesis advance run. - void bootstrap_rotation(uint64_t terminate_window_ms) { + void bootstrap_rotation(uint64_t terminate_window_ms, bool batchop_is_bootstrapped = false) { BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "setconfig"_n, mvo() ("epoch_duration_sec", EPOCH_DURATION_SEC) ("operators_per_epoch", 1) @@ -980,7 +1012,7 @@ class sysio_msgch_chain_tester : public tester { // non-bootstrapped first, so BATCHOP lands in group 0 (on duty at epochs 1, 4, 7, ...). BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() ("account", BATCHOP.to_string())("type", opp::types::OperatorType::OPERATOR_TYPE_BATCH) - ("is_bootstrapped", false))); + ("is_bootstrapped", batchop_is_bootstrapped))); BOOST_REQUIRE_EQUAL(success(), depositinle(BATCHOP, "ETH", "ETH", 1)); for (const auto& op : {BATCHOP_B, BATCHOP_C}) { BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() @@ -990,7 +1022,8 @@ class sysio_msgch_chain_tester : public tester { BOOST_REQUIRE(!get_operator(BATCHOP).is_null()); BOOST_REQUIRE(opp::types::OperatorStatus::OPERATOR_STATUS_ACTIVE == get_operator(BATCHOP)["status"].as()); - BOOST_REQUIRE_EQUAL(0, get_operator(BATCHOP)["is_bootstrapped"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(batchop_is_bootstrapped), + get_operator(BATCHOP)["is_bootstrapped"].as_uint64()); BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "schbatchgps"_n, mvo())); BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "advance"_n, mvo())); @@ -1751,6 +1784,8 @@ BOOST_FIXTURE_TEST_CASE(noncanonical_delivery_slashes_before_termination, sysio_ slash_action_count(BATCHOP, SOL_OUTPOST_ID)); BOOST_REQUIRE_EQUAL(epoch + kEpochAdvanceCount, current_epoch()); BOOST_REQUIRE_EQUAL(kExpectedDeliveredLogCount, delivered_dellog_count(BATCHOP)); + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) + require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_SLASHED); } FC_LOG_AND_RETHROW() } // SEC-28 (huang review): terminate on the CONSECUTIVE-miss rail through the REAL rotation -- a @@ -1808,6 +1843,7 @@ BOOST_FIXTURE_TEST_CASE(terminate_at_duty_rotation_via_advance, sysio_msgch_chai // whereas termination + reason hold either way. BATCHOP delivered exactly once, so exactly // one delivered row must remain. BOOST_REQUIRE_EQUAL(1u, delivered_dellog_count(BATCHOP)); + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED); } else { // Still ACTIVE: BATCHOP must not terminate before its sixth miss (its 7th duty). BOOST_REQUIRE(status == opp::types::OperatorStatus::OPERATOR_STATUS_ACTIVE); @@ -2103,10 +2139,9 @@ BOOST_FIXTURE_TEST_CASE(slash_after_delivery_does_not_count_toward_consensus, sy /// rotation so every group is promised (and verified) at least once. BOOST_FIXTURE_TEST_CASE(advance_ships_lookahead_batch_operator_group, sysio_msgch_chain_tester) { try { constexpr uint32_t kGroups = 3; - // Termination rails are irrelevant here — a comfortably wide window keeps recorddel/termcheck - // quiet while the rotation is walked (same span shape the SEC-28 fixture derives). + // Use bootstrap-exempt operators so this test only exercises healthy rotation. constexpr uint64_t kRotationWindowMs = 12ULL * kGroups * EPOCH_DURATION_SEC * 1000ULL; - bootstrap_rotation(kRotationWindowMs); + bootstrap_rotation(kRotationWindowMs, /*batchop_is_bootstrapped=*/true); for (uint32_t round = 0; round < kGroups + 1; ++round) { const auto shipped = shipped_batch_operator_groups(ETH_OUTPOST_ID); @@ -2164,7 +2199,7 @@ BOOST_FIXTURE_TEST_CASE(advance_ships_group_index_zero_for_single_group, sysio_m /// /// Asserted here: the BATCH_OPERATOR_GROUPS attestation is absent, AND the envelope still exists /// carrying other attestations. The second half is the regression guard that matters — withholding -/// is implemented by skipping ONE queueout, and an early `return` from `advance` would also produce +/// is implemented by skipping ONE queueout, and an early `return` from `finishadv` would also produce /// a missing roster while silently dropping the rest of the epoch's emissions. BOOST_FIXTURE_TEST_CASE(advance_withholds_batch_operator_groups_when_next_group_is_empty, sysio_msgch_chain_tester) { try { @@ -2201,6 +2236,43 @@ BOOST_FIXTURE_TEST_CASE(advance_withholds_batch_operator_groups_when_next_group_ "starved window never withheld BATCH_OPERATOR_GROUPS -- an empty active group was published"); } FC_LOG_AND_RETHROW() } +/// Same-epoch termination must be visible in both outposts' authoritative +/// rosters and in normal tail selection. This does not repair existing seats. +BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, + sysio_msgch_chain_tester) { try { + bootstrap(/*n_batch_ops=*/3, /*batchop_is_bootstrapped=*/false); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) + ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + produce_blocks(); + advance_to_next_epoch(); + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED); + require_fresh_roster(chain, BATCHOP_B, opp::types::OPERATOR_STATUS_ACTIVE); + const auto groups = shipped_batch_operator_groups(chain); + BOOST_REQUIRE_EQUAL(groups.groups_size(), 1); + BOOST_REQUIRE_EQUAL(groups.groups(0).operators_size(), 3); + bool found_standby = false; + for (const auto& member : groups.groups(0).operators()) { + BOOST_REQUIRE_NE(member.address(), BATCHOP.to_string()); + BOOST_REQUIRE_EQUAL(get_operator(name{member.address()})["status"].as(), + opp::types::OPERATOR_STATUS_ACTIVE); + if (member.address() == BATCHOP_D.to_string()) found_standby = true; + } + BOOST_REQUIRE(found_standby); + } +} FC_LOG_AND_RETHROW() } + +/// Even sysio.epoch authority cannot invoke the continuation as a top-level action. +BOOST_FIXTURE_TEST_CASE(finishadv_rejects_direct_calls, sysio_msgch_chain_tester) { try { + bootstrap(); + const auto args = mvo()("epoch_index", current_epoch())("emission_amount", int64_t{0}); + BOOST_REQUIRE_EQUAL(error("missing authority of sysio.epoch"), + push(EPOCH_ACCOUNT, epoch_abi, BATCHOP, "finishadv"_n, args)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: finishadv must be sent inline by sysio.epoch"), + push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "finishadv"_n, args)); +} FC_LOG_AND_RETHROW() } + // --------------------------------------------------------------------------- // Inbound `envelopes` retention: `deliver` prunes rows older than the // previous epoch, a bounded number per call. diff --git a/tests/fixtures/solana-idl-opp-outpost-stub.json b/tests/fixtures/solana-idl-opp-outpost-stub.json index 69451a536d..5454e1c671 100644 --- a/tests/fixtures/solana-idl-opp-outpost-stub.json +++ b/tests/fixtures/solana-idl-opp-outpost-stub.json @@ -184,8 +184,7 @@ "name": "config" }, { - "name": "operator_registry", - "writable": true + "name": "operator_registry" }, { "name": "outbound_message_buffer", @@ -195,6 +194,10 @@ "name": "vault", "writable": true }, + { + "name": "collateral_position", + "writable": true + }, { "name": "system_program" }