evmigration(batch): classify vesting-locked targets as needs-funding#177
evmigration(batch): classify vesting-locked targets as needs-funding#177mateeullahmalik wants to merge 3 commits into
Conversation
The classifier in migrate-batch.sh routed any target with TOTAL balance>0 onto the no-funder code path, even when the balance was vesting-locked and the spendable portion was zero. Such accounts then failed at broadcast with "spendable balance 0ulume is smaller than 5000ulume: insufficient funds" because the ante handler charges fees against spendable, not total. This is the dominant shape on mainnet: foundation multisigs are continuous- vesting and remain locked for years (advisors_*, seed_sale_2-5, private_sale_3-6, team_2-6, eco_dev_7, etc — ~24 of 28 multisigs). An operator running the batch driver on launch day would have hit silent failures on all of them. Fix: * Query `bank spendable-balances` alongside `bank balances` in classify. * Re-route to `needs-funding` (funder path) when spendable < self-send fee threshold (10000ulume), even if total balance is large. * Surface spendable in the operator log line, the JSONL audit "classify" event, and the docstring + report-subcommand --help. Verified end-to-end on local rc5 devnet against mainnet-shape genesis: prior run 12/31 ✓, 19/31 ✗ (all vesting-locked). With fix: 31/31 ✓, 0 ✗. Chain total_migrated=31. Audit log shows spendable=0 + needs-funding for every vesting-locked target.
Drove the full PR #177 batch end-to-end on a fresh rc5 local devnet with mainnet-shape genesis, following ONLY the operator-facing docs. The actual PR-#177 broadcast flow worked perfectly (31/31 ✓, 0 ✗, chain total_migrated=31), but the docs left several Day-1 questions unanswered. This commit closes the gaps without changing the migration logic. scripts/migrate-batch.sh — status subcommand: * Surface 'spendable=' next to 'balance=' on every status row so an operator immediately sees WHY a vesting-locked target (balance=5T, spendable=0) is classified needs-funding. The JSONL audit already had this field; the human status table did not. * Add a one-line footer to the status summary explaining that needs-funding covers vesting-locked targets too. scripts/migrate-batch.md: * New 'Prerequisites' section (lumerad version, bash/jq, RPC endpoint, operator keyring location, where to run from) — modeled on migration-scripts.md. * New 'Picking and preparing the funder' section: 3 concrete checks the operator must run before kicking off execute (key in operator keyring, spendable balance budget, key type supported). * Rewrite --funder flag row: now correctly says 'pays fees for any target classified needs-funding, including vesting-locked accounts whose total balance is large but spendable is zero'. The old 'zero-balance targets' framing was wrong post-PR #177. * Rewrite --top-up-amount flag row with the concrete sizing formula operators can apply for non-default fees. * Update status-subcommand descriptions to reflect the spendable-aware classifier and the two real shapes of needs-funding. * Suggested-workflow now dry-runs TWO targets in step 3 — one vested (exercises the no-funder self-send path) and one vesting-locked (exercises the funder path) — so operators verify both code paths before the full batch. * Suggested-workflow step 5 now uses --log-file and --continue-on-error, matching the recommended production shape. docs/evm-integration/user-guides/migration-scripts.md: * Add migrate-batch.sh to the 'Start Here' decision table with a link to scripts/migrate-batch.md. Previously, operators following the user guides were told to 'drop into a loop' over single-account scripts and would never discover the actual batch driver. All static checks green: shellcheck -x -e SC1091,SC2034 scripts/migrate-batch.sh — clean bats tests/scripts/migrate-batch.bats — 11/11 pass Live verification on the rebuilt rc5 devnet: Status output: shows balance + spendable per row, footer note present. Full batch (--log-file --continue-on-error): 31/31 ✓, 0 ✗. Chain total_migrated=31. JSONL audit shows clean spendable= for every vesting-locked target.
Day-1 operator rehearsal on this branch — full evidenceI rebuilt a fresh The actual migration flow worked end-to-end: 31/31 succeeded, 0 failed, chain Following the doc strictly surfaced 7 places where the operator had to guess or look at source. The new commit What the new commit changesScript (
Doc (
Doc (
Evidence from the rehearsal runStep 2 ( Step 4 (single-target real broadcast on
Step 5 (full batch) — all 31 accounts: Chain confirms: { "total_migrated": "31", "total_legacy": "21" }JSONL audit — {"event":"classify","target":"team_3","status":"needs-funding","balance":"8333333330000","spendable":"0"}
{"event":"classify","target":"team_1","status":"needs-pubkey","balance":"8333333340000","spendable":"8333333340000"}Static checks (re-run after the new commit):
What this PR is nowA two-commit PR:
Reviewers: the second commit doesn't change migration logic. It's status-output + docs. |
migrate-batch.sh is a foundation/operator launch tool — it assumes one operator holds every signer mnemonic in a single JSON file on one host. That threat model is fine for the team's coordinated launch ceremony but is the opposite of what the public CLI user-guide tells normal users (single key on their box, never share). Putting the script in that guide's Start Here table is a category error and could be read as encouraging end users to centralize keys. Keep the operator-facing playbook (scripts/migrate-batch.md) and the script-level changes (spendable-aware classifier, status output, JSONL audit) which are what mainnet operators actually need.
|
Fair call — Keeping:
|
There was a problem hiding this comment.
Pull request overview
Fixes migrate-batch.sh target classification so vesting-locked multisigs (total balance > 0 but spendable = 0) route through the funder path instead of attempting an unfundable self-send that fails at broadcast.
Changes:
- Extend
_mb_classify_targetto querybank spendable-balancesand includespendable=<ulume>in both operator output and JSONL audit events. - Introduce a spendable threshold constant intended to decide between
needs-pubkeyvsneeds-funding. - Update operator docs (
migrate-batch.md) to explain spendable vs total balance and funder selection.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
scripts/migrate-batch.sh |
Adds spendable-balance probing and surfaces spendable in status/audit; introduces a spendable threshold for classification. |
scripts/migrate-batch.md |
Documents prerequisites, funder selection, and clarifies status output/meaning with spendable vs total balance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Minimum spendable ulume required for the target to broadcast its own | ||
| # self-send (publishes pubkey). Sized to cover the multisig self-send fee | ||
| # (5000ulume) with headroom. Targets below this threshold MUST be funded by | ||
| # --funder even if their TOTAL balance is large (the vesting-locked case). | ||
| _MB_MIN_SELF_SEND_SPENDABLE_ULUME=10000 |
| # needs-pubkey — auth account exists (or has balance) but no pubkey | ||
| # needs-funding — account not found on auth AND balance==0 | ||
| # AND has enough SPENDABLE balance to self-fund the | ||
| # self-send fee | ||
| # needs-funding — pubkey missing AND spendable balance is insufficient | ||
| # for the self-send fee. This covers two cases: |
| - `bank balances` and `bank spendable-balances` (does the account hold ulume | ||
| it can actually spend on fees?) | ||
|
|
| - `needs-funding` — pubkey missing AND spendable balance is below the | ||
| self-send fee threshold. Covers **two** real cases: | ||
| (a) zero total balance (classic fresh foundation | ||
| account), and (b) non-zero total balance but | ||
| **vesting-locked** so spendable < fee. Both require | ||
| `--funder` during `execute`. |
Summary
migrate-batch.shclassifier silently routed vesting-locked multisigs onto the no-funder code path, where they failed at broadcast.On a fresh
v1.20.0-rc5local devnet booted with mainnet-shape genesis (testnet-2 bundle, voting=15m, expedited=5m,max-txs=10000), runningmigrate-batch.sh executeover the 31 foundation accounts produced:Every one of the 19 failures was a continuous-vesting multisig with
balance > 0butspendable = 0. The chain-side error was always:Root cause
_mb_classify_targetonly queriedbank balances(TOTAL). For vesting accounts that's misleading — the ante handler charges fees against spendable, not total. The decision was:So a vesting multisig with
balance=5T, spendable=0gotneeds-pubkey, the script tried_mb_multisig_self_sendwith no funding, ante rejected, target failed.This is the dominant shape on mainnet — foundation multisigs are continuous-vesting and remain locked for years (
advisors_*,seed_sale_2-5,private_sale_3-6,team_2-6,eco_dev_7, …). An operator running the batch driver on launch day would have hit silent failures on all of them.Fix
bank spendable-balancesin_mb_classify_target._MB_MIN_SELF_SEND_SPENDABLE_ULUME=10000(self-send fee 5000 + headroom).needs-fundingwheneverspendable < threshold, even if total balance is large → funder path tops the target up before the self-send.spendable=<ulume>in:classifyeventreportsubcommand--helpIf the
spendable-balancesquery fails (older binary, transient RPC), treat asspendable=0→ safer to over-fund than to silently broadcast a doomed tx.Verification
Two end-to-end runs on the same rc5 devnet, same mnemonics, same
--funder:Before (PR #163 head)
Failure signature on every locked target:
multisig_self_send_failed→insufficient funds.After (this PR)
Chain confirms:
{ "total_migrated": "31", "total_legacy": "21" }Audit-log evidence for a vesting-locked target:
{"event":"classify","target":"ecosystem_development_7","status":"needs-funding","balance":"11250000000000","spendable":"0"}Includes the non-sequential signer-order multisigs that prompted PR #176 (
team_3[s3,s2,s1],seed_sale_2[s1,s3,s2],team_6[vesting til 2028], etc.) — all addresses reconstructed byte-for-byte and migrated.Static checks
shellcheck -x -e SC1091,SC2034 scripts/migrate-batch.sh→ cleanbats tests/scripts/migrate-batch.bats→ 11/11 pass (report-mode tests unaffected)Operator impact
--funder(with enough balance) when any target needs funding. This was already documented and required for the zero-balance case.--top-up-amountdefault (200000ulume) is already sized for both cases.Risk
claim-multisigceremony is identical, only the pre-step (self-send vs. funder-then-self-send) differs.bank spendable-balancesis unavailable: we treat asspendable=0→ routes to funder. Funder path requires--funder; absent funder, the target errors out cleanly with the existingneeds_funder_not_providedreason (same as the zero-balance path).report/--helpupdated; JSONL audit adds a newspendablefield — additive only.Rollback
Revert the single commit. No state migration. No state on chain depends on this script.
Related
--nosortsign-time guard).