Skip to content

Canoliq mainnet graduation - #618

Open
infoboy27 wants to merge 174 commits into
canopy-network:mainfrom
Canoliq:main
Open

infoboy27 wants to merge 174 commits into
canopy-network:mainfrom
Canoliq:main

Conversation

@infoboy27

Copy link
Copy Markdown

Canoliq mainnet chain configuration and graduation changes.

Sócrates Díaz and others added 30 commits April 29, 2026 11:32
Implements canoLiq as a sibling Go plugin to plugin/go/contract: cCNPY
deposit/redeem, 12% protocol fee with 40/30/15/15 split, CLIQ token
genesis with vesting + cliff schedules, and an EndBlock reward sweep
over the canoLiq committee pool. main.go branches on
CANOPY_PLUGIN_MODE so the same binary still serves the send tutorial.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pins the end-to-end yield math with the 5% Canopy DAO cut layered
upstream of canoLiq's 12% fee. For X=1000, the post-DAO inflow of 950
splits into 881 user yield + 35 treasury + 17 validators + 17 buyback
under integer truncation, matching whitepaper §7 to within rounding.
The conservation assertion guarantees no value is lost to truncation
gaps — the residual rolls into treasury.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pins the end-to-end yield math with the 5% Canopy DAO cut layered
upstream of canoLiq's 12% fee. For X=1000, the post-DAO inflow of 950
splits into 881 user yield + 35 treasury + 17 validators + 17 buyback
under integer truncation, matching whitepaper §7 to within rounding.
The conservation assertion guarantees no value is lost to truncation
gaps — the residual rolls into treasury.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add three tests that cover the Phase 1 verification items previously
deferred to live-localnet: vesting-claim handler end-to-end, multi-block
reward sweep watermark, and a composite deposit→reward→redeem yield
check. Plan updated to retire the localnet boxes those tests cover and
move the remaining live-socket items into a new Phase 1.5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolves the four Phase 2 design decisions against WP §4/§6/§9/§11:
internal accounting swap for buybacks, staked-only voter weight with
creation-height snapshot, hybrid treasury spend (vote alone below
threshold; vote + N-of-M multisig + timelock above), and per-validator
pro-rata as a Phase 1 carryover. Replaces the five-bullet stub with a
10-section checklist matching Phase 1 granularity and pins defaults
(voting period 7d, quorum 33%, pass 50%+1, timelock 48h, insurance
1500 bps of the treasury slice).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the full Phase 2 plan from docs/plans/canoliq-implementation-plan.md:
CLIQ staking with snapshot-based voting weight, on-chain proposal lifecycle
(param change | buyback | treasury spend), buyback execution with BURN and
DISTRIBUTE_STAKERS modes, multisig + timelock-gated treasury spends,
insurance auto-routing per WP §11, and per-validator pro-rata reward
distribution sourced from a plugin-internal ValidatorRegistry.

Tests: 11 new Phase 2 tests covering stake/unstake/claim, proposal
lifecycle, flash-stake snapshot guard, quorum miss, buyback BURN +
DISTRIBUTE, treasury spend below + above threshold (with timelock and
multisig enforcement), non-signer rejection, insurance conservation,
and skewed validator pro-rata. All 23 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
canoliqctl builds, signs (BLS12-381), and submits canoLiq plugin
transactions to a running Canopy node. Subcommands cover deposit,
redeem, claim, cliq-transfer, cliq-claim-vested, plus the Phase 2
surface (stake/unstake/claim, vote, buyback-execute, spend-execute,
multisig-approve). proposal-create is deferred because its Any payload
needs three sub-command variants.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bake go-plugin, pluginctl.sh, canoliqctl, and a bundled canoLiq
genesis/config into the canopy image so the controller can launch the
plugin via plugin/go/pluginctl.sh on boot. Set CANOPY_PLUGIN_MODE=canoliq
and CANOLIQ_CONFIG=... in compose. Install procps in the runtime image
because Alpine's busybox ps lacks the -p flag pluginctl.sh::is_running
relies on. Point canoLiq's bucket recipients at node-1's validator
address so CLIQ allocation is reachable from a single signer for
verification.

Per-node config.json / genesis.json edits live under .docker/volumes,
which is gitignored — see docs/plans/canoliq-implementation-plan.md
Phase 1.5 steps C and D for the manual edits required to exercise the
flow on a fresh checkout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two production-only bugs surfaced during Phase 1.5 live integration
that the in-process fakeStore harness masked:

1. DeliverMessageCanoliqRedeem used height := uint64(0) instead of
   c.currentHeight(), so unbond_complete_height was always equal to
   the placeholder unstaking constant rather than current_height +
   constant. Lowered the placeholder from 14400 (~24h) to 5 blocks so
   localnet verification can exercise claim-after-maturity. The
   original Phase 1 plan called for reading valParams.UnstakingBlocks
   from FSM gov params; that plumbing remains a Phase 2 cleanup.

2. FSM StateRead returns Entry{Value: nil} for missing keys (not zero
   entries), so LoadParams unmarshaled empty bytes into a zero-valued
   CanoliqParams that ValidateParams correctly rejected with
   "split sum 0 != 10000". The fakeStore returned no entries for unset
   keys, hiding the divergence. LoadParams and LoadGlobals now treat
   empty Value as unset.

Plan updated with Phase 1.5 verification outcome: handshake + deposit
+ redeem + claim-after-maturity all confirmed end-to-end on a live
docker compose chain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(canoliq): canoliqctl + Phase 1.5 live integration
Runs an http.ServeMux inside the plugin process so plugin-owned state
(globals, params, pools, proposals, spends, vesting, redemptions,
validator registry) is observable without going through the FSM.
Gated by Config.RpcAddress / CANOLIQ_RPC_ADDR; empty disables.
Closes the Phase 1.5 carryover blocker on live-chain fee reconciliation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The earlier RPC implementation crashed live nodes: plugin-initiated
StateReads from the HTTP handler used random fsmIds, but the FSM only
accepts request IDs from in-flight Check/Deliver/Begin/End calls and
rejects others with state_machine code 107, tearing down the socket.
fakeStore tests bypassed the protocol so the bug went undetected.

The fix: build a Snapshot inside EndBlock (where c.fsmId is the valid
FSM-originated id), atomically swap it into Plugin.snapshot, and serve
HTTP from there with no plugin↔FSM round-trip per request. Trades up to
one block of staleness for a working architecture. Per-address routes
(account/vesting/redemption/buyback/vote) drop from §1 — they cannot be
enumerated from a snapshot built off canoliq's existing indexes — and
move to a §1.1 follow-up. /v1/stakers added (sourced from CLIQStakeIndex).

Verified live in .docker/compose.yaml: 100+ blocks with no protocol
errors; snapshot height tracks the chain; all 11 routes return
well-formed JSON.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Canopy FSM only dispatches PluginGenesisRequest when chain
genesis.json carries a plugin section, which the bundled localnet does
not. Without a trigger the plugin runs forever with
genesisComplete=false: CLIQ supply stays 0, validator registry empty,
ProcessRewards is a permanent no-op (it bails on !GenesisComplete).
That's why live RPC reconciliation could not actually exercise fee
splits previously.

BeginBlock now calls bootstrapGenesisIfNeeded, which runs runGenesis
when GenesisComplete=false and Config.GenesisPath is set. runGenesis
is idempotent, so the existing FSM-dispatched path still works. Tests
with empty GenesisPath skip the branch.

Verified live in .docker/compose.yaml (after wiping volumes): inflow
of 36,000,000 uCNPY reconciles exactly across the canonical
40/30/15/15 split with insurance skim — pool=33,408,000 +
treasury=1,101,600 + insurance=194,400 + validators=648,000 +
buyback=648,000 = 36,000,000 (whitepaper §7).

Closes the Phase 1.5 fee-reconciliation carryover and the Phase 3 §1
live-integration verification box.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The snapshot model from §1 cannot enumerate per-address records (no
global "all addresses ever seen" index). To unblock /v1/account/{addr}
et al., add a bounded *lazyQuery channel: HTTP handlers push a query,
EndBlock drains and fulfills via StateRead, results travel back on a
per-query channel. Worst case latency = one block (~6s localnet); the
request context cancels the wait on client disconnect so abandoned
requests don't pin a goroutine for the full timeout.

New routes (all GET):
- /v1/account/{addr}    composite CNPY + cCNPY + liquid CLIQ + stake +
                        validator-incentive + vesting
- /v1/vesting/{addr}    schedules + cumulative unlocked-to-date
- /v1/redemption/{addr}/{id}
- /v1/vote/{id}/{voter}
- /v1/buyback/{id}

Status mapping: 503 queue-saturated, 504 drain-timeout, plus the
existing 400/404/405/500.

Verified live: hit /v1/account/0x851e90… on a fresh chain and the
56M+44M = 100M CLIQ split (liquid + 3 vesting schedules) reconciles
exactly to genesis.json. 404s clean on missing entities. No code-107
regressions across 100+ blocks.

RedemptionIndex(addr) and UnstakingIndex(addr) write-side indexes
remain future work — listed as deferred in the plan since they need
proto/state changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…klists

§1.1-bis adds RedemptionIndex / UnstakingIndex with proto-free key
helpers (reuse VestingIndex shape) and write-side append/remove
points. §2 fleshes out the alerting hooks: webhook delivery with
debounce + dedup persistence, the four conditions (buyback drain,
stuck redemption — depends on §1.1-bis, validator concentration,
TVL drop), payload schema with Slack/Discord adapters, full test
matrix. §3 fleshes out graduation: threshold tracking on the
snapshot, deterministic state export via canoliqctl, runGenesis
extension to import a GenesisExport, ProposalGraduate governance
flow with grace-window deposits-locked semantics.

Phase 1 Discord coordination bullet now lists what to include.
Stale Phase 3 outline at file bottom replaced with a pointer to
the detailed checklist.

Open boxes go from 11 to 56, reflecting the full remaining scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Eight-step walkthrough from fresh clone to first tx + RPC query: docker
compose build → up → poll /v1/health → probe RPC → build canoliqctl →
deposit → verify via /v1/account → tear down. Plus reset-to-fresh
playbook (the canopy state-DB wipe needed to re-trigger plugin Genesis,
since the self-bootstrap is idempotent on globals.GenesisComplete),
exposed-ports table, and three common-pitfall recipes drawn from the
issues that surfaced during Phase 3 §1 / §1.1 live verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds Config.Profile and Config.RedemptionUnstakingBlocks plus a
profile-aware safety check that refuses to start under profile=testnet
or mainnet when any genesis bucket recipient still equals the localnet
placeholder address (851e90…d123). The hard-coded 5-block redemption
unstaking window in deliver.go now reads from config, with a 5-block
fallback when unset so older configs keep working.

File split:
- genesis.json → genesis.localnet.json (placeholders, single localnet
  key, fast windows)
- new genesis.testnet.json — TODO bucket addresses (pre-distinct from
  the localnet placeholder so the safety check passes the moment they
  are filled in), TODO multisig signers
- canoliq-config.localnet.json + canoliq-config.testnet.json
  committed alongside; Dockerfile bundles both variants and defaults
  CANOLIQ_CONFIG to the localnet file. Docker compose pins localnet
  and documents the testnet override.

README: existing quick-start retitled "localnet" with the localnet
caveat added; new "Configuring a testnet deployment" section walks
through replacing addresses, signers, the redemption window, chainId
coordination, the local safety-check verification, and the Canopy-side
coordination steps (validator opt-in, MessageSubsidy, Discord).

Tests (config_test.go, +8 cases):
- NewConfigFromFile normalizes empty profile + zero redemption window
- SafetyCheck: localnet skips; testnet refuses placeholder; mainnet
  accepts a real address; case- and 0x-prefix-folded detection
- DeliverMessageCanoliqRedeem honors Config.RedemptionUnstakingBlocks
  and falls back to 5 when unset
- The bundled genesis.testnet.json template passes safety check as-is

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the last code-side hard blocker for testnet — operators can now
build and submit MessageCLIQProposalCreate from the CLI for every
payload type (param-change | buyback | treasury-spend), instead of
hand-rolling JSON envelopes.

Sub-commands:
- proposal-create param-change <nick> <params-json> [--description …]
  Reads CanoliqParams from a JSON file using the same shape as the
  genesis "params" block (multisigSigners hex, with/without 0x).
- proposal-create buyback <nick> <cnpy-amount> <price-uCNPY-per-CLIQ>
  <burn|distribute> [--description …]
- proposal-create treasury-spend <nick> <recipient-hex> <amount>
  <cnpy|cliq> [--description …]

Each builds the typed payload, anypb.New-wraps it (TypeUrl matches
what governance.go::unwrapPayload resolves via contract.FromAny),
sets the outer MessageCLIQProposalCreate, and submits through the
existing internal.SubmitPluginTx path.

Tests (proposal_create_test.go, +9 cases):
- Mode/denomination parsers accept all spellings
- Description flag accepted in trailing/embedded/short forms
- paramsJSON.toContract round-trips scalars + decodes hex multisig
  signers; rejects non-hex / wrong-length input
- Each payload Any has the right TypeUrl and round-trips through
  UnmarshalNew (the same path the FSM uses)
- Outer MessageCLIQProposalCreate marshal preserves payload + description
- TestParamsJSONShapeMatchesProto round-trips a fully-populated
  CanoliqParams via proto.Equal — fails loud if the proto adds a
  field that paramsJSON forgot to mirror

README documents the three sub-commands; plan footnote retires the
"deferred" caveat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a `validatorRegistry` block to the GenesisFile JSON shape so the
canoLiq committee validator set is honest from block 1 instead of
falling back to the legacy committee-aggregator address. Each entry
is (address, stake) with stake mirroring Canopy's
Validator.StakedAmount in uCNPY-equivalent.

runGenesis writes the registry under KeyForValidatorRegistry alongside
the bucket distribution; reward.go::distributeValidatorShare picks it
up automatically and pro-rata-splits the 15% validator-incentive slice.
Empty/omitted registry preserves the legacy aggregator path so older
deployments don't break.

genesis.localnet.json now seeds the two real localnet validator
addresses (851e90… and 02cd4e…) at 1B stake each. genesis.testnet.json
template includes a TODO registry block with a comment explaining how
to populate it. Hex addresses tolerate optional 0x prefix; malformed
or wrong-length addresses fail genesis cleanly.

Tests (canoliq_test.go, +4 cases):
- Registry seed lands the right ValidatorRegistry record (with 0x
  prefix on one address to exercise the trim path)
- Empty registry leaves the legacy path intact
- Malformed and 19-byte addresses both fail genesis
- Bundled localnet template carries exactly the two real validators
  with the expected stake

Verified live: fresh docker compose run shows /v1/validators with both
seeded entries and /v1/pools.validatorIncentives splitting 50/50
between the two real validators (4,860,000 uCNPY each from the same
inflow that previously credited 100% to 0xCACA…).

Closes the last soft blocker for testnet — per-validator credit is
now honest from genesis without waiting on the FSM-readback feature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Six-phase guide that builds on the existing testnet section but
ratchets up the rigor for shipping to mainnet:

Phase 0  Readiness gate — audit, multisig key ceremony, real bucket
         addresses, validator opt-in, chainId reservation, subsidy
         proposal, redemptionUnstakingBlocks calibration, Discord
         announcement, monitoring, disaster recovery.
Phase 1  Build production genesis + config as release artifacts;
         hash-anchor and publish.
Phase 2  Pre-flight verification on a private chain: safety banner,
         bucket reconciliation (100M × 10⁶ uCLIQ exactly), full
         lifecycle smoke test, multisig rehearsal across all four
         failure modes.
Phase 3  Cutover (fresh-chain Path A, no in-place migration yet —
         Phase 3 §3 of the implementation plan).
Phase 4  Day-2 ops: routine queries, governance via canoliqctl,
         plugin upgrades coordinated with validators.
Phase 5  Incident response: compromised signer, suspicious proposal,
         plugin crash, state corruption.

Closes with a paste-into-release-ticket checklist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the seven-step testnet section with the same six-phase
runbook format used by the production guide, scaled down where the
cost of a mistake is recoverable:

Phase 0  Readiness gate (lighter than prod — chainId reservation,
         MessageSubsidy, validator opt-in, bucket addresses,
         multisig signers, redemption window, Discord, monitoring;
         audit + key ceremony are explicitly not blocking)
Phase 1  Build genesis + config as release artifacts; hash anchor
         optional
Phase 2  Pre-flight verification — banner check, bucket
         reconciliation against 100M × 10⁶ uCLIQ, lifecycle smoke
         test (deposit → redeem → claim, propose → vote → execute),
         lighter multisig rehearsal (two flows vs production's four)
Phase 3  Cutover — fresh-chain only, in-place migration is Phase 3
         §3 future work
Phase 4  Day-2 ops — same query cadence + governance flow as prod;
         upgrades expected to be more frequent
Phase 5  Incident response — testnet's escape hatch ("spin up a
         fresh chainId") explicitly named for state corruption;
         signer / proposal / plugin-crash recipes match prod

Closes with a paste-ready testnet deployment checklist parallel to
the production one. The "Profile safety check details" subsection
moves to the end of the testnet section and is referenced inline by
both deploy guides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the v1.1 spec-vs-code audit (canoliq-1-1-implementation_plan.md) and
a consolidated plan reorganized by release target — localnet, testnet,
mainnet — so blockers for each milestone are explicit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Updates the four P0 v1.1 spec violations from the audit:
- validator vesting cliff 6→12mo (Tokenomics v1.1 §2.1)
- founders vest 24→36mo (Tokenomics v1.1 §2.5)
- strategic partners vest 18→12mo so total span = 18mo (§2.6 summary)
- FeeBps enforced to 5%-20% bounds (Tokenomics v1.1 §3.3)
- InsuranceBps 1500→500 to match "5% of DAO treasury inflow" reading
  of Tokenomics v1.1 §8

Three reward-split tests recalibrated for the new 5% insurance skim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Whitepaper v1.1 §3.3 removes Canopy's 5% protocol-level DAO pre-cut on
rewards, so the plugin sees its committee share R directly. Updates:
- AGENTS.md §7 rewrites the reconciliation framing (R replaces 0.95X)
- canoliq_test.go §7 docblock + conservation rename postDAO→R
- rpc_test.go conservation test renamed postDao→R
- canoliq_test.go composite-flow comment drops "post-DAO-cut" tag
- Bucket "Plugin & Dev Grants" → "Developer Grants & Ecosystem"
- Bucket "Liquidity Incentives (Farming)" → "Liquidity Incentives"
- README production-template block matches the new bucket names

No behaviour change; production code was already correct under v1.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The lazy-query AccountView could not list a user's pending redemptions
or unstakes — there was no per-address index naming those records. This
blocks the T6 stuck-redemption alert and the M1 state-export round-trip.

Adds:
- proto RedemptionIndex / UnstakingIndex messages (mirrors the existing
  VestingIndex / ProposalIndex / CLIQStakeIndex pattern)
- state.go: KeyForRedemptionIndex / KeyForUnstakingIndex helpers
  (domain bytes 21 / 22) plus a removeUint64 slice helper
- Write-side: each Deliver{Redeem,ClaimRedemption,Unstake,ClaimUnstake}
  appends / removes its id; the index key is deleted entirely once the
  last id is claimed (empty-set encoding)
- buildAccountView batch-reads both new indexes and surfaces them as
  AccountView.Redemptions + AccountView.Unstakes
- 8 new tests in index_test.go covering append/remove invariants,
  out-of-order claims, removeUint64 idempotency, and the composite view

Dedicated /v1/account/{addr}/{redemptions,unstakes} routes deferred —
the composite already exposes both lists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ciliation

T1 — per-action governance tiers (Tokenomics v1.1 §7):
- proto: ActionType enum, GovernanceTier, ProposalValidatorEject /
  ProposalEmergency / ProposalProtocolUpgrade; Proposal gains action_type +
  snapshotted tier; CanoliqParams.governance (additive — scalar knobs kept
  as fallback).
- params: DefaultParams seeds all 7 tiers; ValidateParams checks each;
  tierFor() + actionTypeForPayload() resolvers (treasury small/large split
  at the 1M-CLIQ boundary).
- behaviour: proposal create snapshots tier + voting window; proposalPasses
  reads tier quorum/approval; queueTreasurySpend uses tier timelock. F12
  validator eject (idempotent registry removal + incentive clearing); F13
  emergency fast-track with optional immediate param diff.
- CLI: proposal-create validator-eject + emergency subcommands.
- tests: 8 new T1 tests (t1_governance_test.go); shortGovParams tuned for
  tier-driven voting periods.

Localnet reconciliation verification:
- TestValidateParamsFeeBpsBounds pins the F4 5%-20% fee bound.
- Fix stale insurance_bps comment (1500 -> 500) post-F5.

Docs: release plan updated — T1 section as-built, validator-cliff doc
conflict note (whitepaper §5.2 vs Tokenomics §2.1/§6), live reconciliation
results, and T5 ActionType-vs-payload clarification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tokenomics v1.1 §4.2 vote-escrow: longer CLIQ locks grant higher voting
weight and a reward-share boost (1×/1.5×/2×/3×/4×; +0/10/25/50/75%).

- proto: LockTier enum (NONE/3M/6M/12M/24M); CLIQStake.lock_tier +
  lock_end_height; MessageCLIQStake.lock_tier.
- helpers: tierMultipliers() -> (voteMultBps, boostBps),
  lockTierDurationBlocks() (blocksPerMonth=432000 at 6s), validLockTier(),
  voteWeightFor().
- behaviour: vote weight = raw stake x tier multiplier; buyback
  DISTRIBUTE_STAKERS applies boostBps with remainder to the largest
  LOCK_24M staker; stake aggregation only strengthens locks; unstake
  rejects before lock_end_height (ErrStakeLocked); tier range-checked at
  CheckTx (ErrInvalidLockTier).
- CLI: cliq-stake --lock {none,3m,6m,12m,24m}.
- tests: 6 new T2 tests (t2_voteescrow_test.go).

Deviation from plan: the reward boost is applied only to the staker
buyback-distribution path, not distributeValidatorShare — that path pays
the 15% slice to committee validators (ValidatorRegistry), not CLIQ
stakers, so the §4.2 boost does not belong there. Documented in the plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
infoboy27 and others added 30 commits September 22, 2026 18:54
…assets

The runtime stage only COPYs plugin/go/pluginctl.sh and the compiled
go-plugin binary — never plugin/go/canoliq/*.json (canoliq-config.*.json,
genesis.*.json). CANOLIQ_CONFIG / a config's genesisPath pointing inside
the image (e.g. /app/plugin/go/canoliq/canoliq-config.mainnet.json) then
fails at container start with "no such file or directory", since those
files were never in the built image at all.

Found while building this image for real, for the first time, to run
committee 29's dedicated node.
fix(canoliq): correct mainnet chainId from placeholder 19 to real 29
release: promote development to main (mainnet chainId 29 fix, real multisig signers, plugin Dockerfile fix)
Written after a full verification session on committee 29: confirmed
the plugin's StateWrite path genuinely reaches core consensus state
(not read-only, correcting an earlier wrong assumption), validated the
activation/bootstrap mechanism twice on a disposable zero-stake node
with zero SafetyCheck errors, and found val-a's actual prod deployment
never sets CANOPY_PLUGIN_MODE/CANOLIQ_CONFIG/CANOLIQ_RPC_ADDR at all
(runs the generic tutorial stub, separate from the Dockerfile bug
already fixed in #39/#40).
docs(canoliq): mainnet activation status, blockers, and suggestions
…net registry

bootstrapGenesisIfNeeded used to run genesis at whatever height a node first
started with a genesisPath. A validator activating live at height N and a
full node re-syncing from height 1 would therefore write canoLiq state at
different blocks and fork — the stuck-sync behavior we reproduced on the
poktpool node.

- Config.activationHeight: when > 0, genesis runs only in BeginBlock of
  exactly that height. Before it the plugin is inert; if it passed without
  genesis (config deployed late) the plugin stays inert and warns once —
  never errors, which would halt block production. 0 keeps the legacy
  run-immediately behavior for localnet/devnet.
- SafetyCheck refuses profile=mainnet with genesisPath set and
  activationHeight=0 (checked last so specific genesis errors surface first).
- genesis.mainnet.json: validatorRegistry seeded with val-a
  (737ef80e…, 10,000 CNPY — the sole committee-29 validator), so the 15%
  validator-incentive slice doesn't route to the unspendable aggregator key;
  _comment no longer claims every address is a placeholder.
- Fix two tests broken by #39 (committee id still expected 19; mainnet
  genesis still expected to be a placeholder template).

New tests cover pinned/missed/zero activation, the mainnet SafetyCheck
rule, and an end-to-end run of the real mainnet genesis at the pinned
height. go test ./... and go vet pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cop8X5UT2vAvtpnw2hsrVe
feat(canoliq): pin genesis to an activationHeight; seed val-a in mainnet registry
feat(canoliq): supply_pool_desync alert — the detection half of #34
release: promote development to main (activationHeight, val-a validator registry)
evaluateTVLCap returned awaiting-canopy-stake with a nil error and a zero
cap, and deliver.go only rejects when CapUcnpy > 0. Net effect: whenever
Canopy Supply was readable but Supply.Staked was 0 (or small enough that
mulDiv truncated), canoLiq accepted deposits with no ceiling at all.

A cap of zero bounds nothing. WP §9.4 exists to bound systemic risk, and
accepting here means canoLiq is open for business before its host chain has
a validator set. ErrCanopyStakeUnavailable's own doc comment already
described this case ("unreadable or staked = 0... fails closed per WP
§9.4") — only the code lagged, so no new error code is needed.

awaiting-canopy-stake keeps its own status even though it now rejects like
fail-closed: the two mean different things to an operator. fail-closed is a
misconfiguration someone must act on; awaiting-canopy-stake resolves on its
own once Canopy bonds stake. The tvlCapDecision split is what lets the
deposit handler and /v1/health read different fields off one decision.

newTestCanoliq seeded Supply.Staked = 0 precisely to reach the old accept
branch, so it now seeds a large stake instead: the cap is active, and thus
genuinely exercised, but far above anything a test deposits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLfYT7dsuqHZfpBiyQaT3h
ProcessRewards summed the block-over-block stake growth of every validator
on the committee and credited all of it to cCNPY holders. Canopy compounds
committee reward into each validator's bond, and most of those bonds are
operator collateral, so the sweep was handing cCNPY holders other people's
yield.

On mainnet committee 29 that took a 10 CNPY test deposit to ~562x in forty
minutes: val-a holds ~8,235 CNPY bonded, of which the pool contributed ~10,
and the full ~11.90 CNPY/block emission was landing in totalPooledCnpy. The
bug was latent until a committee-29 validator record existed to observe.

It is also an un-backed mint, not only bad accounting. The phantom reward is
credited into the escrow pool (reward.go), so the escrow-underflow guard in
claim-redemption passes, and claim then credits the account directly. The FSM
applies no conservation check on a plugin account write.

Whitepaper v1.2 §3.3 says R is canoLiq's stake-weighted share. §1.1 is
stronger: operators bond their own CNPY as collateral, and it is their own
CNPY that is slashed. An operator's bond is not canoLiq's stake.

Canopy already expresses this. Validator.output is where a bond and its
early-withdrawal rewards return to (fsm/committee.go), so it is the record's
economic beneficiary. Reward is now attributed only to committee records
whose output is listed in the new params.stake_output_addresses.

Membership and ownership are kept as separate roles: every committee member
still shares the 15% validator-incentive slice by stake weight, because that
pays operators for running the committee regardless of whose CNPY is bonded.
The verdict is persisted on the registry entry so the snapshot, restaking and
alert consumers cannot drift from what the sweep acted on.

The ownership set defaults empty, so R is 0 until an owned bond is declared.
That is the correct reading when the protocol has no staked position:
under-crediting truthfully beats minting yield against stake it does not own.
A delegate record with output set to a canoLiq address makes it non-zero
again; what is missing is a signable identity for the escrow pool, not a
delegation primitive.

No migration is needed. last_processed_reward_pool changes meaning from
aggregate committee stake to aggregate owned stake, and the existing shrink
branch absorbs the stale watermark on the first block: it credits nothing and
writes the watermark down. Covered by TestUpgradeTransitionFromCommitteeWatermark.

Also adds a plausibility clamp (max_reward_bps_per_block, default 1% of owned
stake per block). The excess is deferred in globals.carried_reward rather than
discarded, since nested-delegate rewards arrive by stake-weighted lottery and
a legitimate win is lumpy. It has to be an explicit field: the registry
re-baselines every member at its live stake each block, so the watermark alone
cannot carry it. 0 means "unset" and backfills to the default, because a
params record written before the field existed decodes it as zero and must not
silently inherit "no clamp"; 10_000 is the deliberate off switch.

Recreates fsm/plugin_validator_scan_test.go, which wire_parity_test.go has
named as its counterpart all along without it existing. The cross-module
contract test was one-sided, which is exactly the gap that lets a mirror proto
go stale — and this change adds three fields to that mirror.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLfYT7dsuqHZfpBiyQaT3h
Three T6 alerts so the ownership fix cannot fail quietly in either direction.

reward_attribution_anomaly (crit) fires when one block credits more than
100 bps of the pool it lifts. The mainnet committee-29 incident ran at
11,900 bps, so this pages on the first bad block instead of forty minutes
and ~562x later. The basis is the larger of pooled CNPY and owned stake, so
a small pool behind a large position cannot false-positive.

owned_stake_missing (warn) fires when the committee is compounding, cCNPY is
outstanding, and canoLiq owns none of the stake earning it. Attributing zero
is correct there, but it must not be indistinguishable from a quiet block:
without this, a chain where nobody declared a stake output address would
earn nothing indefinitely with no operator-visible signal.

owned_bond_not_compounding (warn) fires when an owned bond has compound=false
or is unstaking. Canopy pays such a bond's reward to the output address as
liquid CNPY instead of compounding it, so the stake-growth sweep observes
zero while the protocol really is earning. Deliberately an alert rather than
an accounting adjustment: capturing it would mean diffing the output
address's account balance, which also moves for transfers and gas, and
treating those as reward would reintroduce the same class of bug.

Also fixes the same membership/ownership conflation in the restaking
exposure view. snapshot.go summed every registered operator's full bond into
CurrentRestakingAllocation, overstating canoLiq's exposure by whatever the
operators bonded themselves and feeding that overstatement to /v1/restaking
and the concentration surfaces. It now reads only owned records, which also
narrows the per-block read fan-out. restaking.go's header comment stated the
bug as the definition; it now says owned stake, and no longer claims Canopy
lacks a delegation primitive — lib.Validator.delegate is one. What is
missing is a signable identity for the escrow pool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLfYT7dsuqHZfpBiyQaT3h
Genesis can now seed params.stakeOutputAddresses and maxRewardBpsPerBlock,
with the same strict hex decode the multisig signer list uses — a dropped
entry there silently stops attributing reward for that bond, leaving cCNPY
under-earning with nothing in state to explain why.

genesis.mainnet.json deliberately omits stakeOutputAddresses and says so at
length: an empty set means R = 0, which is correct today because canoLiq owns
no bonded position on committee 29 and every bond there is operator
collateral. The comment spells out the two invariants Canopy cannot enforce
— a listed address may only receive bonds that are entirely canoLiq
principal, and those bonds must compound — because getting either wrong
re-creates the same over-attribution with a different trigger.

Docs updated to stop describing the bug as the design:

- AGENTS.md's reward-sweep section now leads with the membership/ownership
  split, the two doc-only invariants, and the test consequences (seed
  ownership in *state*, since ProcessRewards reads LoadParams and not the
  params a test hands a Deliver* call).
- README.md's registry section splits into membership-pays-incentives and
  ownership-earns-reward, with the operational path to making R non-zero.
- how-it-works.mdx tells depositors which rewards count and why a flat
  exchange rate is reported honestly rather than papered over.
- restaking.mdx's exposure formula was literally the bug written as a
  definition (Σ over every operator's bond); it now sums owned bonds.
- alerts.mdx covers the three new conditions, with the incident's 11,900 bps
  as calibration for the 100 bps threshold.

Adds registry ownership-sync tests, params validation tests for the address
set and the clamp bound, and a test pinning that a malformed ownership set is
rejected at LoadParams before the sweep can act on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLfYT7dsuqHZfpBiyQaT3h
…eight

Mainnet committee 29 has run the pre-fix reward sweep since block 4679, and
nodes that sync it re-execute those blocks with the plugin they run today.
PR #47 changes consensus-visible state from the first block it sees (registry
entry bytes, new globals fields, genesis params, the awaiting-stake TVL
branch, persisted alert state), so shipping it ungated would leave any
re-syncing node unable to reproduce committee 29's history.

Gate all of it on MainnetRewardFixHeight (13,950) under the mainnet profile;
other profiles run the fix from genesis. TestMainnetPreFixReplayParity drives
a mainnet-shaped scenario and compares the full plugin state after every block
against hashes captured from plugin-go-v2026.266.23.

At the activation height, a one-time correction restores the real principal:
cCNPY at 1:1 for the two depositors (10 and 508.500661 CNLQ), redemption #0
revalued to 2 CNLQ, escrow = 518.500661 CNLQ, and every balance the sweep
funded from foreign bond growth (treasury, buyback, insurance, validator
incentives, tx-fee accrual) cleared. It checks the pre-state it was written
against and logs and skips on any mismatch rather than halting the chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(canoliq): reward attribution to owned stake, activated at mainnet height 13,950 with state correction
Release: reward attribution fix, active on mainnet at height 13,950
Adds the keyless output 1b48db4c75e6f935c5bfe62ea13550592df5f24c
(sha256("canoliq/owned-reward-bond/v1")[:20]) to
params.stake_output_addresses in the BeginBlock of mainnet height 15,000,
and persists max_reward_bps_per_block = 100 alongside it.

The team funds a compounding delegate record on committee 29 with this
address as output. Canopy only lets the current output change the output,
so the bond and everything it compounds are locked permanently, and the only
spendable copy of that reward is the one the plugin credits to cCNPY holders.

Blocks below 15,000 are unchanged (replay parity still holds). The bond is
baselined on the flip block rather than read as reward; tests cover the
activation, exact distribution of the bond's growth, and idempotence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(canoliq): declare canoLiq's owned bond on mainnet at height 15,000
Release: canoLiq owned bond on mainnet at height 15,000
…et, from 60,000)

The validator-incentive slice (15% of the protocol fee) is meant to pay the
operators running committee 29, but distributeValidatorShare weights the
registry built from validator records on this chain. On a nested chain the
block producers are root-chain validators bonded for committee 29, with no
record here, so the slice went to chain-29 stakers, delegates included and
mostly canoLiq's own owned bond, while the producers got nothing. The
ledger could not be withdrawn either: no message pays it out.

From MainnetProposerIncentiveHeight (60,000) on the mainnet profile:
- Each block's slice is credited to the CNLQ balance of that block's
  proposer, which Canopy passes to EndBlock and draws from the root-chain
  committee by stake weight. Nothing to claim.
- No proposer, a malformed one, or one ejected by governance sends the
  slice to the DAO treasury.
- At that block the old ledger is moved once into the treasury and its
  entries deleted. The validator registry, which shares the key domain,
  is left alone.

Before the activation height, and on other profiles, behaviour is
unchanged; the mainnet replay-parity golden test still passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pay the validator slice to each block's proposer (mainnet, from block 60,000)
Release: validator slice to each block's proposer (activates at block 60,000)
…eight

Sends and CNPY treasury spends to an address with no record wrote the
record with an empty Address. Indexers attribute balances by that field,
so the credit didn't show for the real address.

Setting it changes the record bytes, so it is gated like the earlier
fixes: from MainnetAccountAddressFixHeight (145,000) on mainnet, from
genesis elsewhere. At that height a one-time migration sets Address on
every account record missing it; balances are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(canoliq): set Account.Address on credited records from a pinned height
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(canoliq): bring the account-address fix height forward to 130,500
Release: set Account.Address on credited records (#56)
…ight

The percentage cap bounds the pool to a third of the chain-29 stake,
which leaves almost no room for new depositors, and on a nested chain
that stake doesn't secure consensus. From MainnetFixedTvlCapHeight
(132,200) mainnet deposits are bounded by a fixed 50M CNLQ instead;
health reports it as status "fixed". Earlier blocks and other profiles
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(canoliq): fixed 50M CNLQ TVL ceiling on mainnet from a pinned height
Release: fixed 50M CNLQ TVL ceiling on mainnet (#59)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants