Cap: per-staker reward payout and configurable claim window#348
Cap: per-staker reward payout and configurable claim window#348heifner wants to merge 17 commits into
Conversation
New contract on `sysio.cap` that holds pending-WIRE balances owed to LIQ-token stakers and pre-launch pretoken purchasers. The outpost owns share computation; wire-sysio just credits, holds, and pays out. Tables: positions (lifecycle metadata only — shares and principal live at the outpost), pclaims (per-Wire-account WIRE owed), unmapped (per-(chain, native_pubkey) WIRE for unlinked addresses), cdqueue (cooldown queue mirroring opreg::wtdwqueue), capcfg (singleton with the imported_complete one-way flag for the forthcoming importseed action), capcounters (monotonic id counters). Actions: setconfig (initialize the singleton — placeholder while concrete fields are decided), claim (user drains pclaims via inline sysio.token transfer), linkswept (internal sweep from unmapped to pclaims on successful AuthX link), flushcd (drain matured cdqueue rows on sysio.epoch::advance), availstake (readonly rollup; returns 0 today since position-side principal is not tracked yet). Follows part3 conventions: public *_t table types exported from the header, composite uint128 indexes for (account/chain, addr) lookups, helpers in anonymous namespace for shared composite-key construction. Wires into contracts/CMakeLists.txt and the contracts_unit_test fixture; skeleton sysio.cap_tests.cpp covers setconfig and a no-row claim rejection.
`importseed(chain, credits)` is the privileged, batched action that seeds `unmapped_tokens` for pre-launch ETH/SOL holders. Each `import_credit { native_address, wire_atomic }` row credits an entry; same address across batches sums into the existing row. The off-chain converter floors the source pretoken value (1e18 wei-style) at the 1e9 boundary; sub-atomic dust is dropped (9-digit WIRE precision system-wide; per-row dust < 1 atomic WIRE).
`importdone()` flips the one-way `imported_complete` flag on `cap_config`; subsequent `importseed` calls revert. Together they let the bios bring-down script chunk the import across as many transactions as the source data requires.
Hash-collision-safe address lookup: `importseed` uses `lower_bound` on the composite (chain, first 8 bytes) index and walks forward verifying the full `native_pubkey` matches before merging. Tests cover happy-path, negative-atomic rejection, and the post-importdone lock.
`contracts/sysio.cap/tools/convert_import.py` reads the indexer's snapshot JSON (purchasers + stakers arrays per response_*.json) and emits `sysio.cap::importseed` action arg batches as JSON on stdout, with a configurable per-batch cap (default 50). Per-address sum: `purchaser.totalPretokens` (already net of yieldClaimed) plus `staker.pretokenYield - staker.yieldClaimed`. Source values are 1e18-scale; floored at DUST_BASE = 1e9 to produce 9-decimal atomic-WIRE. Sub-atomic dust is summarized on stderr (bounded < num_users × 1 atomic WIRE — well under 10⁻⁷ WIRE at current scale). Output is ready for `clio push action sysio.cap importseed <batch>`; a sibling wrapper that drives clio can land later if useful.
Mirrors the fix in abdffcc applied to the other OPP-using contracts. sysio.cap.hpp transitively includes types.pb.hpp, which depends on magic_enum. cdt-cpp does not honor -isystem, so the magic_enum::magic_enum INTERFACE include must be re-injected as -I via target_include_directories PRIVATE. Required after rebasing feature/sysio-cap onto feature/emissions-configurable.
Verified the converter against today's /opp/balances response (https://index.wire.foundation/opp/balances) -- schema and per-address values match response_1778592566067.json exactly (47 addresses, 217,941.104919 WIRE total, identical batch output). No conversion logic change needed. Two doc/UX-only updates: - Point the docstring at the live endpoint so future runs know where to fetch from, not just the captured snapshot filename. - Print metadata (generatedAt, totalMessages, yieldDust) on stderr so the operator can confirm which snapshot was processed and cross-check dropped_dust against the indexer's own dust counter.
Per Jack (2026-05-13): no cooldown scenarios in the v1 outpost. Stripping the wire-sysio-side scaffolding since it's dead code at launch and re-adding it later is cheap. Removed: - cdqueue table, cd_key, cooldown_entry struct (3-index kv::table) - flushcd action (epoch-tick maturation drain) - availstake read-only rollup (its only job was principal-minus-cooldown) - COOLDOWN_WAIT_EPOCHS constant - EPOCH_ACCOUNT well-known-account constant (only flushcd's auth) - cap_counters::next_cd_id field - make_wire_chain_key helper (only availstake used it) - EPOCH_ACCOUNT from the test fixture's create_accounts WASM 23,571 -> 18,950 bytes; ABI 12,139 -> 9,174 bytes. When withdrawals come online post-launch, the opreg::withdraw_queue pattern (eligible_at_epoch + epoch-driven flush) is the reference shape to add back.
Jack delivered the Solana balances endpoint at /opp/solana/balances
(2026-05-13). Three differences from the ETH side that the converter
now handles:
1. Address encoding: base58 (case-sensitive), 32 raw bytes, vs ETH's
0x-prefixed lowercase hex 20 bytes. Wrong case on a base58 string
gives a different key, so a single .lower() pre-fold cannot be
shared with ETH.
2. Source precision: 9 decimals (lamport-aligned with WIRE atomic)
vs ETH's 18 decimals. Per-chain divisor is now 10^(src - 9):
1 for SOL, 1e9 for ETH. Solana yields zero dust by construction.
3. yieldClaimed is absent on SOL stakers/purchasers; the existing
dict.get("yieldClaimed", "0") fallback already handles that.
Also: per Jack, a Solana address may appear in both `purchasers` and
`stakers` (9 such overlaps in today's snapshot). The accumulator is
now keyed by decoded raw address bytes, so per-array contributions
sum correctly regardless of source string form.
Verified:
ETH output byte-identical to pre-refactor (47 addresses,
217,941.104919 WIRE, 1 batch).
SOL: 31 unique addresses (24 + 16 - 9 overlap), 40,652.206359 WIRE,
zero dust (divisor 1), all 9 overlap addresses summed correctly.
Output `native_address` is the hex spelling of raw decoded bytes (40
chars ETH / 64 chars SOL), which the sysio.cap ABI consumes as `bytes`.
Per Jack (2026-05-13) on Q4: the depot owns the proportional split of bulk WIRE allocations using pretoken amounts + pretoken yield as the weight, snapshot-based at allocation time. Shares are LIQ-supply accounting, irrelevant to wire emissions. No time-weighting. The positions table I scaffolded carried outpost_position_id, status, lifecycle timestamps, and a 3-index kv::table -- none of which fits a pretoken-balance-weight model. Strip it now (same pattern as the cooldown removal in 237356f); a per-user pretoken-balance ledger gets added when the StakingReward inbound handler lands and we know which attestation(s) maintain those balances. Removed: - position struct, position_key, positions_t kv::table - cap_counters::next_position_id field - private `using StakeStatus = opp::types::StakeStatus;` (no remaining caller after dropping position.status) WASM 18,950 -> 18,906 bytes; ABI 9,174 -> 6,184 bytes (-33%).
The accrue-then-claim model means importseed does pure table writes (zero inline transfers) -- the per-row cost is small and we have generous headroom inside the 150ms execution / 500KB trx envelope. Concretely, at today's per-credit estimate, a single trx fits ~12-17K credits before either limit bites. Default --batch-size moves from 50 to 10000. Effect: - today (78 users total): 1 batch per chain (was 1 already) - 50K users per chain: ~5 batches (was 1000) - 500K users per chain: ~50 batches (was 10000) Most launches collapse to a single trx per chain. Operators only need to lower --batch-size if a real on-chain measurement shows a trx hitting the size or execution limit. Docstring updated to call out the one-shot intent and the envelope the default is tuned against.
…ement' into feature/sysio-cap Brings the operator-management line (sliding-window schedule, per-chain bond, path-2 retry, SOL remaining_accounts) and the opp + sysio.reserv -> Reserve/SwapRemit rename onto the sysio.cap branch. Conflict resolution: - sysio.epoch.cpp and opp types.proto resolved by hand: kept the incoming rename/operator-management changes, layered the sysio.cap-side additions on top. - Every system-contract .wasm/.abi regenerated deterministically from the merged source via the contracts_project target (not side-picked), so all artifacts match the merged source byte-for-byte.
StakingReward becomes a per-staker, per-source-chain-period message. Drop period_start_ms/period_end_ms; add reward_epoch_index (audit) and external_epoch_ref (dedupe/pruning) and staker_native_address so an unlinked staker's reward can be parked and swept on AuthX link. share_bps is informational; reward_amount is the staker's absolute prorated portion. sysio.msgch now dispatches STAKING_REWARD two ways: the aggregate native amount to sysio.reserv::onreward (unchanged), plus the per-staker body to the new sysio.cap::onreward. sysio.reserv's constant-product pricing (cp_output/to_unsigned/quote) moves into a shared static header helper; swapquote delegates to it. sysio.cap prices native -> WIRE through reserve::quote against reserv's published reserves table, since there is no synchronous inter-contract call. sysio.cap gains: - onreward: cursor-dedupes on external_epoch_ref, prices the reward, and credits pending_claims (AuthX-linked) or unmapped_tokens (not), staging in native units when no reserve quote exists yet. - retryconvert: bounded crank that re-prices staged rewards once a quote is available. - flushexpired: bounded crank that prunes rows past expires_at_sec; the WIRE stays in the sysio.cap balance, reverting to the capital fund. - setwindow + cap_config.claim_window_sec (default 180 days). - native_stage and reward_cursors tables; expires_at_sec on the claim ledgers; shared chain/outpost key derivation and credit/dedupe helpers. importseed now flows through the shared credit path. Test fixture corrected: sysio.authex is created by the tester bootstrap (no longer re-created), setreserve uses sysio.reserv's flat ABI, and each applied action closes its own block so an intentional replay reaches the contract instead of being rejected chain-side as a duplicate. Regenerates the wasm for all OPP-consuming system contracts (deterministic output of the attestations.proto change).
…ement' into feature/sysio-cap Brings in part3 @ 22afa4d (underwriter gap phases 1-5 + the host-side recover_key_nothrow intrinsic). Conflict resolutions: - sysio.epoch.cpp: kept both inline-action queues at epoch advance - HEAD's emissions accrueepoch/payepoch (FIFO-ordered) and part3's new underwriter chklocks lock-expiry sweep. They are independent. - attestations.proto / opp_table_types.hpp / sysio.msgch.cpp auto-merged; the staking StakingReward shape, serialization order, and msgch dual-dispatch were verified intact. - The 4 binary contract wasm conflicts (epoch/msgch/opreg/uwrit) were resolved by a deterministic contracts_project rebuild; only those four wasm plus the msgch/uwrit abi differ from the pre-merge tree. recover_key direction: part3 added a recover_key_nothrow host intrinsic but never shipped a CDT wrapper for it, so the system contracts could not compile. verify_uic_signature now uses the existing sysio::recover_key. The defensive size/tag bounds still pre-filter structurally invalid signatures; converting recover_key's remaining contract-observable throws to an rc = -1 sentinel (the work on commit 942d812) is tracked as a separate PR off master, which will also remove part3's now-unused recover_key_nothrow host intrinsic. Interim, a crafted but size/tag-valid signature can still throw in verify_uic_signature - acceptable pre-launch and documented at the call site.
…-cap Catches sysio.cap up to the part3 tip: - opp/proto: add ChainTokenAmount message (types.proto); regenerates the CDT/host proto headers, which rebuilds sysio.authex.wasm. - chain: drop the host-side recover_key_nothrow intrinsic in favor of CDT-side try_recover_key (wire-cdt PR #59). Conflicts in contracts/sysio.uwrit/src/sysio.uwrit.cpp and contracts/tests/sysio.uwrit_tests.cpp resolved to the part3 side: verify_uic_signature uses sysio::try_recover_key (no-throw; std::nullopt -> return false), replacing the documented interim plain recover_key. All contracts rebuilt with the paired try_recover_key CDT; only sysio.uwrit.wasm (source change) and sysio.authex.wasm (proto-header regen) differ.
sysio.epoch's epoch-advance fires an unconditional inline sysio.uwrit::chklocks (underwriter lock-expiry sweep, added with the part3 underwriter work). The t5_emissions_tests fixture created OPREG/EPOCH/CHALG/MSGCH but not sysio.uwrit, so every advance_epoch_state() aborted with "inline action's code account sysio.uwrit does not exist". The chain only requires the inline target account to exist. Add sysio.uwrit to create_accounts (bare: no ROA policy, no code) so the inline call is a harmless no-op -- no underwriter locks are staged in these tests. A 500 SYS RAM policy like the other accounts get would overrun nodedaddy's ROA pool.
feature/emissions-configurable carries the opp v6 data-model refactor (Chain/Token/Reserve registry, slug_name codes) and the sysio.epoch emissions readiness gate; this merge brings feature/sysio-cap up to that base while keeping the capital-staking work (the sysio.cap contract and the per-staker StakingReward design). Conflict resolutions: - types.proto, sysio.epoch.cpp: take emissions-configurable's v6 version. - attestations.proto, opp_table_types.hpp: keep sysio-cap's StakingReward (the per-staker redesign: reward_epoch_index / external_epoch_ref, no period_ms) and its serializer; take emissions-configurable's v6 ReserveCreate/* messages. - contracts.hpp.in: union -- cap_* plus chains_*/tokens_*. - sysio.reserv.cpp: take emissions-configurable's v6 reserve refactor. - sysio.msgch.cpp: the STAKING_REWARD handler routes the per-staker body to sysio.cap::onreward; the pre-v6 reserv::onreward deposit-back leg is dropped, since the staking-reward path does not deposit back to a reserve. - emissions_tests.cpp: take the sysio.uwrit fixture fix. sysio.cap v6 port: the v6 reserve refactor removed the reserve::quote helper and TOKEN_KIND_WIRE that the pre-v6 onreward priced against. Per the capital-staking design, native -> WIRE conversion and source-chain precision scaling are outpost-side, so onreward now credits the WIRE-denominated reward directly. The on-chain pricing path is retired: the nativestage table, the retryconvert action, and sysio.reserv.hpp's stale shared quote() helper are removed; onreward drops its TokenKind reward_kind parameter. sysio.cap_tests.cpp updated to the v6 shape. Contract .wasm/.abi regenerated against the merged source.
jglanz
left a comment
There was a problem hiding this comment.
The ONLY required changes are the same CMake changes from emissions, otherwise just naming ambiguity, which is not a blocker
| /// Set the claimable-reward window (seconds). Unclaimed balances older | ||
| /// than this revert to the capital fund on `flushexpired`. Auth=self. | ||
| [[sysio::action]] | ||
| void setwindow(uint32_t window_sec); |
There was a problem hiding this comment.
I'd rename setwindow to setclmwindow (exact 12 chars lol)
There was a problem hiding this comment.
Done in b64bb97 -- renamed the action to setclmwindow.
| * maturation-flush pattern can be added back — see opreg's | ||
| * `withdraw_queue` for reference. | ||
| */ | ||
| class [[sysio::contract("sysio.cap")]] cap : public contract { |
There was a problem hiding this comment.
can we call it sysio.dclaim (or anything a little less ambiguous) - NOT critical
There was a problem hiding this comment.
That is a good call. I agree it is too generic.
There was a problem hiding this comment.
Renamed sysio.cap -> sysio.dclaim in b64bb97: the account, the contracts/sysio.dclaim/ directory, the C++ class, and every reference across sysio.msgch, sysio.system emissions, the tests, and contracts.hpp. The internal table names (capcfg, capcounters, ...) are kept as-is -- they're the contract's schema, not its renamed identity.
| """Convert the indexer JSON dump into sysio.cap::importseed action batches. | ||
|
|
||
| Live sources: | ||
| ETH: curl -H 'x-api-key: <key>' https://index.wire.foundation/opp/balances |
There was a problem hiding this comment.
Can we have this endpoint changed to mirror the solana naming; this just feels lazy (not you, I mean the endpoint deliverable)
There was a problem hiding this comment.
Agreed -- the ETH /opp/balances endpoint should mirror SOL's /opp/solana/balances. That's the indexer service's API surface, though, not something this contract or convert_import.py controls -- the script only documents the URL. Flagging it for the indexer team; the docstring here will be updated once the endpoint is renamed.
| PRIVATE | ||
| ${CDT_CONTRACT_INCLUDE_PATH} | ||
| ) | ||
|
|
||
| target_link_libraries(${target} | ||
| INTERFACE | ||
| magic_enum::magic_enum |
There was a problem hiding this comment.
This is the only required change in the PR
There was a problem hiding this comment.
Dropped the per-contract ${CDT_CONTRACT_INCLUDE_PATH} hack in b64bb97 -- only the magic_enum::magic_enum link remains, consistent with the other OPP contracts. The CDT WASM toolchain already supplies that include path.
PR #348 review feedback. Rename the sysio.cap contract to sysio.dclaim. `cap` was flagged as too generic; `dclaim` (depot claim) names what the contract is -- the depot-side WIRE distribution and claim ledger. This renames the account, the contracts/sysio.dclaim/ directory, the C++ class, and every sysio.cap reference in sysio.msgch, sysio.system emissions, the tests, and contracts.hpp. The internal table names (capcfg, capcounters, ...) are left as-is -- they are the contract's schema, not its renamed identity. Rename the setwindow action to setclmwindow: clearer about which window it sets, and still within the 12-character action-name limit. Drop the per-contract magic_enum -I cmake hack -- the CDT WASM toolchain already supplies that include path, matching how the other OPP contracts resolve magic_enum.
Summary
sysio.cap is the depot-side WIRE distribution + claim ledger for LIQ-token stakers and pre-launch pretoken holders. The outpost owns share computation (each staker's share_bps/reward_amount arrives pre-computed in StakingReward attestations); wire-sysio credits, holds, and pays out. This adds the per-staker WIRE-side payout leg and a configurable claimable window, plus the sysio.msgch / sysio.reserv wiring it needs.
Changes
Base / dependencies
Stacked on feature/opp-part3-operator-management -- depends on its swapquote/onreward and the StakingReward proto. Includes a catch-up merge of the current part3 tip: the ChainTokenAmount proto message and removal of the host-side recover_key_nothrow intrinsic in favor of CDT-side try_recover_key (wire-cdt PR #59). verify_uic_signature uses try_recover_key. Contracts rebuilt with the paired CDT; only sysio.uwrit.wasm (source) and sysio.authex.wasm (proto-header regen) differ.
Notes