Skip to content

Merge chain-private main into chain (re-land of #675 with a merge commit) - #681

Merged
n13 merged 40 commits into
mainfrom
n13/merge-chain-private
Aug 31, 2026
Merged

Merge chain-private main into chain (re-land of #675 with a merge commit)#681
n13 merged 40 commits into
mainfrom
n13/merge-chain-private

Conversation

@n13

@n13 n13 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Re-lands #675 with Create a merge commit so all 40 commits of n13/merge-chain-private stay in main's history. Same content as #675; branch tip 54d66001.

Merge order: merge the revert PR #680 first. While the squash is still on main this PR is content-wise a no-op (the squash tree equals the branch tree), so merging it before the revert would keep the squash and make the revert then remove the content. Mark ready for review after #680 merges, and merge with Create a merge commit — not squash.

Once merged, #676 and #679 (already re-targeted to main) will show only their own commits.

illuzen and others added 30 commits August 13, 2026 15:46
Unauthenticated TCP connections could exhaust fds and turn EMFILE into
permanent transport death. Count pending inbound handshakes, back off on
transient accept errors, and isolate a single transport ending.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hash-derived author and aggregator addresses have no signing key, so a
credit without a leaf is permanently frozen. Pair increase_balance with
record_transfer on every wormhole credit path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Swallowing ConnectionAborted without a backoff left the listener Pending with no waker. Always back off, iterate from a stable poll index, and replace never-looping while-lets that clippy rejects.

Co-authored-by: Cursor <cursoragent@cursor.com>
fix: keep litep2p listening after transient accept errors
…dits stay exitable

Fee shares computed in planck can be sub-quantum; hash_leaf then commits 0 and freezes the credited balance on a keyless address. Split the already-quantized fee in whole quanta (burn rounds up, rebate rounds down) and burn any remainder.

Co-authored-by: Cursor <cursoragent@cursor.com>
fix: record zk-tree leaves for wormhole miner fee and aggregator rebate
* fix: reject below-ED transfers that would reap the sender

The deposit gate checked amount+dust while the credit wrote only amount, so a reapable sender could lose funds to a dead dest and desync total issuance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: pin that transfer debit matches the amount gate

Balances returns the requested amount on a reap, and a larger-debit mock that passes can_deposit(amount) credits the full debit instead of silently dropping it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: drop the mining-reward treasury split for a genesis endowment

The treasury is funded once at genesis with half of max supply instead of
taking an ongoing share of each block reward. Miners receive 100% of
emission; set_treasury_portion is removed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: stop pre-funding the treasury in genesis presets

Treasury balances will come from a later endowment list. Presets still
configure the treasury account; they no longer mint it half of max supply.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: align miner payouts to the wormhole leaf quantum

ZK-tree leaves commit amount/10^10, so raw planck credits left an
unexitable remainder on keyless miner addresses. Combine fees and
emission, floor once, and hold dust or a missing author in
CollectedFees for the next miner — never treasury.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…owns it (#3)

`do_schedule_inner` and `do_schedule_named_inner` add a preimage reference
conditionally on success (`lookup_hash.filter(|_| request_preimage)`) but
dropped it unconditionally on every failure path. `request()` runs only after
`place_task` succeeds, so a failed invocation has acquired no reference of its
own and its drop consumed the caller's instead.

Referenda reaches this through the `schedule::v3` shim with
`request_preimage = true`, holding exactly one reference between `submit` and
`conclude_ongoing`. A single failed enactment-scheduling attempt against a full
agenda therefore unpinned an approved proposal's call data: the noter's ordinary
`unnote_preimage` deposit reclaim could then delete the bytes, and the enactment
was discarded as `CallUnavailable` — terminal, with no retry path and an event
indistinguishable from routine cleanup. Debug builds trip pallet-preimage's own
`do_unrequest_preimage failed - counter underflow?` assertion, which release
builds compile out.

Gate the failure-path drops on ownership in both functions. The dispatchables
still pass `request_preimage = false` when their own `bound()` created the note,
so the state-bloat cleanup that motivated the unconditional drop is preserved.

Adds `failed_schedule_keeps_a_preimage_reference_it_does_not_own` as the
regression test, plus `successful_schedule_adds_its_own_preimage_reference` to
pin the other half of the ownership contract.
#9)

* fix(qpow): verify PoW seal in the import-queue verifier so bad blocks penalise the peer

The import-queue verifier (formerly SimplePowVerifier) only popped the seal
and never checked the proof-of-work; the check lived in import_block and ran
after a full check_inherents call. Because import_block returns ConsensusError,
an invalid seal surfaced as BlockImportError::Other, which the sync layer treats
as a transient error: it warns, wipes sync state and restarts, with no DropPeer
and no reputation change. A connected peer could therefore serve blocks with
garbage proof-of-work indefinitely at no cost, forcing repeated sync restarts
that also cancel in-flight requests to honest peers.

Move the seal check into the verifier the import queue calls, before
check_inherents. An invalid seal now returns Err from verify(), which
sc-consensus maps to BlockImportError::VerificationFailed(peer_id, ..),
triggering the existing DropPeer(BadPeer(peer, rep::VERIFICATION_FAIL)) path.
import_block keeps its own check for locally mined blocks, which bypass the
queue verifier. Measured cost of the extra runtime call on the sync path is
~77us per imported block, negligible next to check_inherents and execution.

Adds verifier tests (invalid seal rejected, valid seal accepted, wrong-length
seal rejected) using a mocked QPoWApi.

Addresses report 88219.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* demostrate tip exfiltration on hs account

* fix: reject tips from high-security signers

The call whitelist cannot see ChargeTransactionPayment's tip, so a
compromised key could drain free balance in the same block.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* refactor: rename payment wrapper to HighSecurityChargeTransactionPayment

The wrapper type was shadowing pallet_transaction_payment::ChargeTransactionPayment.
Keep the inner IDENTIFIER so signed payloads and metadata stay unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: cap high-security signers at 16 extrinsics per rolling day

A compromised key can still pay inclusion fees on whitelisted no-ops.
Record each included extrinsic in a 16-slot block-number ring and reject
the next until the oldest falls outside DAYS. Update is O(1).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: drop Vesting::claim from the high-security whitelist

Claim is permissionless and always pays the stored beneficiary, so a
high-security signer does not need it. Leaving it listed was only
another no-op fee path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: restrict high-security batch_all to a flat leaf batch

Reject empty and nested batches and cap children at MaxPendingPerAccount
so a packed wrapper cannot inflate the inclusion fee. A guardian can
still cancel a full pending set in one extrinsic.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: reject padded MultiAddress dest on high-security schedule_transfer (#8)

A stolen HS key can attach MultiAddress::Raw to schedule_transfer and
inflate the length fee (up to ~3.75 UNIT per tx) without a tip. Restrict
the HS whitelist to MultiAddress::Id so the padding is rejected before
fees are withdrawn. batch_all children use the same check.

* fix: cap encoded length of high-security extrinsics

The length fee is the last attacker-controlled fee input after the zero-tip
and Id-only/flat-batch whitelist gates: it is charged on the full encoded
extrinsic pre-dispatch and never refunded, so a stolen key could still pad a
future variable-length field and grind free balance out to a colluding block
author. Reject high-security extrinsics over MAX_HIGH_SECURITY_EXTRINSIC_LEN
(10 KiB) in both validate and prepare, before any fee is withdrawn.

* fix: quota ring rejects instead of panicking on a zero-capacity window

Extract the ring admission predicate into one shared hs_ring_has_room so
mempool validation and inclusion-time recording can never disagree on a
window boundary, and make the at-capacity-and-empty case (a misconfigured
MaxHighSecurityTxsPerWindow = 0) report no room instead of claiming room
and panicking the head eviction inside block execution.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: stop over-charging every extrinsic for the high-security quota write

ReversibleTransactionExtension charged 2 reads + 1 write unconditionally
with no post-dispatch refund, so all non-high-security traffic paid for a
quota ring touch that never happens. Decide the high-security status once
in validate, carry it through Val/Pre, gate the quota check and recording
on it, and refund the unused ring read+write in post_dispatch_details on
the non-HS path (verified end-to-end by the exact-fee assertion in
ml_dsa_65). This also collapses the redundant HighSecurityAccounts reads
on the validation hot path and drops the ensure_signed origin clones in
favour of AsSystemOriginSigner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop dead tip accessor and unreachable prepare-side tip re-check

HighSecurityChargeTransactionPayment::tip() had no caller, and the
prepare-side reject_high_security_tip could never fire: dispatch_transaction
runs validate immediately before prepare on the same state, and neither the
tip nor the signer's high-security status can change between the two.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: deduplicate the signed-extrinsic builder and fee-event helpers

Hoist the hand-maintained 12-element TxExtension tuple + Dilithium signing
into TestCommons::signed_extrinsic and have signed_call / signed_transfer
delegate to it (node/src/benchmarking.rs keeps its copy: the node crate
cannot depend on runtime test code). Collapse paid_tip / paid_fee_or_zero
into one fee_paid event scan, reuse assert_tip_cannot_move_extra_value in
the held-pending test, and replace the whitelist-rejection integration
tests with check_call unit coverage (adding the missing Vesting::claim
unit test).

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: give the high-security batch arity cap its own constant

The batch_all leaf cap reused MaxPendingPerAccount, so a future bump of
pending-transfer capacity would have silently widened the maximum fee
surface of a single high-security extrinsic. MaxHighSecurityBatchLen (16)
decouples the two.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: enforce the high-security zero-tip policy in OnChargeTransaction

The wrapper extension impersonated the stock ChargeTransactionPayment
IDENTIFIER, so a refactor back to the unwrapped extension would compile
with byte-identical metadata while silently reopening the tip channel.
HighSecurityFungibleAdapter (wrapping FungibleAdapter) now rejects a
non-zero tip from a high-security signer in can_withdraw_fee and
withdraw_fee — every fee path goes through it, so the policy survives any
extension-tuple change. The tuple reverts to the stock extension; wire
format and metadata are unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: cap the zero-tip inclusion fee of high-security extrinsics at 1 UNIT

The length cap bounds one fee input; weight is the other, and no shape
rule on today's whitelist constrains a future call's benchmarked weight.
ReversibleTransactionExtension::validate now rejects a high-security
extrinsic whose compute_fee(len, info, 0) exceeds
MAX_HIGH_SECURITY_INCLUSION_FEE (Custom(5)). The ceiling is ~10x the
costliest legitimate call (recover_funds, ~0.098 UNIT) and a headroom
test pins 2x margin, so re-benchmarking drift cannot lock accounts out.
With the 16/day quota this hard-caps a stolen key's fee grind at
16 UNIT per rolling day regardless of future whitelist changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: require guardian acceptance for high-security enrollment

set_high_security enrolled immediately, naming any account as guardian
without its consent: an attacker could saddle an account with guardian
duties, or fill a well-known guardian's 32 bounded GuardianIndex slots
with throwaway enrollments so legitimate users could no longer choose it.

Enrollment is now a handshake. set_high_security parks an offer in
PendingHighSecurityOffers (one per account; a repeat call replaces it,
retract_high_security_offer withdraws it). Nothing takes effect — and no
GuardianIndex slot is consumed — until the named guardian calls
accept_high_security (call indices 8/9). Genesis enrollment stays
immediate, being operator-controlled.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs+test: recommend and pin multisig guardians for high-security accounts

The guardian holds instant, total seizure power (recover_funds sweeps
every hold plus the whole free balance to it, no delay, no second
approver, immutable relationship), making a single-key guardian a single
point of failure for the whole scheme. Document the multisig-guardian
deployment as the recommended setup and pin it with an integration test:
a 2-of-2 pallet_multisig guardian accepts enrollment, cancels a pending
transfer during the delay window, and recovers funds, each dispatched as
the multisig via propose/approve/execute. Also drop the stale
schedule_asset_transfer references from the pallet README.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop the guardian consent step and the on-chain GuardianIndex

GuardianIndex was a bounded (32-slot) guardian -> protected-accounts map
that nothing on-chain ever read, and set_high_security filled it without
the guardian's consent — a stranger could exhaust a popular guardian's
slots with throwaway enrollments so legitimate users could no longer
choose it. Remove the index (and MaxGuardianAccounts /
TooManyGuardianAccounts) instead of gating it behind an acceptance
handshake: with no index there is nothing to fill, and being named
guardian needs no consent because it grants only passive powers and
carries no liability. set_high_security enrolls immediately again;
accept_high_security / retract_high_security_offer and
PendingHighSecurityOffers are gone. Discovery of "which accounts do I
guard?" moves offchain (Subsquid) via HighSecuritySet events.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: charge the actual high-security storage footprint

The extension helpers no longer re-read HighSecurityAccounts after
validate, and the payment WeightInfo now includes the tip-policy reads
so tipped and HS traffic cannot under-declare database work.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* fix tests

* docs+test: pin that a high-security multisig guardian cannot be quota-locked

A single-key HS guardian shares the 16/day quota with its own traffic; an
exemption would be farmable. The recommended multisig guardian is immune
because the derived address never signs, even when itself enrolled as HS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix tests

---------

Co-authored-by: illuzen <illuzen@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: drop self-directed wormhole leaves that record no value

Self-cancels and self-scheduled executions were inserting ZK-tree leaves
for credits that never moved. Drop from==to at the recorder chokepoint,
release rather than transfer_on_hold on owner cancel, and record
scheduled execution only when source and dest differ.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* fix comments

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(multisig): depth-limit inner call decode in propose/execute

Opaque BoundedCall bytes were turned into a RuntimeCall via an unbounded
RuntimeCall::decode. The outer extrinsic depth limit (MAX_EXTRINSIC_DEPTH)
only covers the signed envelope, so a signer could store a canonical, deeply
nested call (e.g. chained Utility::batch_all or Recovery::as_recovered) that
passed pool validation and then exhausted the runtime stack during block
construction, surfacing as a contained Wasm out-of-bounds trap. The resulting
RuntimeApiError made the block builder roll back the fee/tip/nonce/weight and
authorship treated it as a generic invalid, so authors did repeated unpaid
work on rolled-back candidates (Immunefi #88969).

Decode with decode_all_with_depth_limit(MAX_MULTISIG_CALL_DEPTH) in both
propose and execute. The bound equals MAX_EXTRINSIC_DEPTH (256), which
Executive already applies safely to every extrinsic, and all-consuming decode
also rejects trailing garbage. Bounding at the decode boundary covers every
recursive call gadget, unlike a dispatch-time filter that runs after decode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(multisig): keep inner-call decode non-all-consuming

Switching propose/execute to decode_all_with_depth_limit rejected the trailing
padding bytes that pallet_multisig's propose_high_security benchmark appends to
sweep the call-size component, so the generated runtime-benchmark test failed
with InvalidCall and scripts/regenerate_weights.sh could no longer benchmark
the pallet across its declared range.

The vulnerability fix only requires a recursion-depth bound, not all-consuming
input. Use decode_with_depth_limit in both propose and execute to restore the
prior decoder's tolerance of trailing bytes (the high-security path deliberately
stores padded whitelisted calls) while keeping the depth cap that prevents the
stack-exhaustion trap.

Verified: cargo test -p pallet-multisig --features runtime-benchmarks
(65 passed, incl. benchmarking::benchmarks::bench_propose_high_security and
tests::propose_rejects_deeply_nested_call_via_depth_limit).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…ored pallet (#12)

Remove the five pallet_balances calls nothing on this chain needs:

- force_transfer, force_set_balance: root-only seize/mint tools. There is
  no sudo, so these were reachable only through a root-track referendum;
  removing them means governance cannot move or mint balances by
  extrinsic at all. Issuance still changes only through consensus code
  (mining rewards, fees, burn).
- force_unreserve, force_adjust_total_issuance: root-only, never
  referenced anywhere in the runtime, node, CLI or apps.
- upgrade_accounts: lazy legacy-format migration; a no-op on a chain
  whose accounts were all created on the new AccountData format. The
  ensure_upgraded failsafe stays (still used internally) and keeps its
  tests, now exercised directly.

Kept: transfer_allow_death, transfer_keep_alive, transfer_all (recovery
sweeps, send-max) and burn (voluntary supply burn). Their call indices
are unchanged, so wallets and the cold-signer allowlist are unaffected.

Also removed: the AdjustmentDirection type, the TotalIssuanceForced
event, the IssuanceDeactivated/DeltaZero errors (last two variants, no
index shift), their weights and benchmarks.

spec_version 145 -> 146, transaction_version 4 -> 5 (extrinsics
removed).
…eposits (#15)

Social recovery could not be combined with high-security without permanently freezing the owner's config deposit and a rescuer's RecoveryDeposit. Guardian recover_funds is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: charge Quantus ML-DSA-87 overhead for base extrinsic weight

Inherited Substrate Development weights under-accounted signed extrinsics by ~7x after the runtime switched to in-WASM ML-DSA-87 verification.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: restore overhead weight license headers and satisfy format gates

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vesting): align non-final claims to 25 QUAN so leaf rounding cannot be griefed

Permissionless daily claims were fragmenting grants into 1-QUAN Wormhole leaves,
each independently flooring the 4 bps fee. Non-final payouts now quantize to
2500 leaf quanta (an exact 4 bps multiple); the final claim still pays the remainder.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(runtime): drop redundant vesting const asserts

The 25 QUAN pin and the 2500×4 tautology added no compile-time safety
beyond the existing SCALE_DOWN match and the runtime vesting tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(vesting): use saturating ops instead of expect on claim-path math

Generic Balance cannot prove remainder ≤ amount or that 2500×quantum fits.
saturating_sub/mul are total and exact for these invariants.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vesting): check accrual before the dust-reservation branch in claim_plan

Small-remainder schedules (< 2x MinimumPayout) returned ClaimWouldLeaveDust
instead of NothingToClaim when nothing claimable had accrued yet.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
#17)

* fix: bound Noise handshake frame length before allocation

Reject empty or oversized length prefixes so an unauthenticated peer cannot force a 65 KB pre-read per pending connection.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: charge CollectedFees storage on the payment-extension weight

The stock transaction-payment benchmark never ran TransactionFeesCollector, so every paid extrinsic omitted one unique-key read and write.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: do Noise PQ work only after the first handshake frame arrives

A stalled /noise negotiation no longer forces ML-KEM keygen or an ML-DSA signature; the listener reads the bounded frame first, and both sides defer identity signing until a real peer message is in hand.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: reject wormhole exit bundles that mint nothing

A non-dummy zero-output spend is a valid circuit proof but settles no value; refuse it before writing UsedNullifiers so it cannot occupy slots or block weight for free.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: refund unused wormhole batch weight after sparse settlement

Unsigned public batches declared the full 742-exit quota and kept it on success, so one real exit could stuff a block. Report actual settlement weight so leftover quota returns to the block.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* fix: reject valued exit slots with zeroed accounts, review cleanups

The no-mint gate only checked amounts, so a segment whose valued slots all have circuit-zeroed exit accounts could settle without minting; mirror the mint-loop condition exactly. Also dedupe the handshake prefix tests, simplify the settlement weight selection, and drop a tautological weight assertion.

Co-authored-by: Cursor <cursoragent@cursor.com>

* rm pr

* fix: charge nullifier writes independently of minted exits

A valid zero-output segment writes UsedNullifiers while minting nothing, so the settlement refund undercharged public batches that combined one real mint with many such segments. Also reject listener message-1 frames shorter than the ML-KEM-768 public key before keygen.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vesting): retarget as a same-owner wallet swap, never a payout

Retargeting is a lost-key remedy, so paying the outgoing address would
burn funds or pay a thief. Drop the claim-plan settlement, keep the
accrual on the schedule, and flatten the remaining claim/end arithmetic
so the two payout policies share one quantized unpaid helper.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
total_balance included reserved funds, and an exit mints free balance without consuming the reserve. Record only spendable genesis endowments so a future reserved genesis cannot become a second, unlocked copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
…kets (#23)

FRAME's 30s future slack plus 8s retarget bins let one author-supplied timestamp book a difficulty deficit that later +1 catch-up blocks cannot repay. Match Geth's 15s limit and Ethereum's Δt//10 buckets so a max-drift inflate is repayable.

Co-authored-by: Cursor <cursoragent@cursor.com>
…#22)

* fix(runtime): reserve worst-case wormhole weight on Multisig::execute

Execute only carries a proposal id, so the recorder charged nothing for inner transfers and billed the work after the fact. Charge MaxCallSize / min transfer encoding up front, same as recover_funds, and refund unused units after dispatch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* fix(runtime): walk stored multisig calls when reserving recorder weight

MaxCallSize/36 under-counted a stored batch_all of recover_funds plus transfers, so execute reserved fewer proofs than it recorded and the shortfall was not fee-charged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* fix(runtime): price execute proposal walks and skip the prepare re-read

A no-transfer stored call reserved zero extension weight while weight()
and prepare() each decoded the proposal; failed non-signer executes then
refunded that zero. Charge two worst-case walks on every execute and
carry the transfer count so prepare does not walk again.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(litep2p): reject inbound multistream-select frames above 1 KiB

A two-byte varint could force a 16 KiB resident read buffer before the
Noise handshake. Cap the declared length to what negotiation actually
needs so an unauthenticated peer cannot pre-commit that allocation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(multisig): reject duplicate signers instead of silently dropping them

Silent dedup could hide a repeated account and change the effective
threshold. Fail create with DuplicateSigners so the submitted set is
exactly what is stored and hashed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vesting): never block end_schedule on sub-minimum vested dust

A permissionless claim can leave a vested remainder below MinimumPayout,
which previously made clawback impossible until maturity. Ending now pays
the beneficiary the nearest quantum and returns every leftover planck to
the treasury, which does not need a quantized payout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vesting): refund sub-minimum end payouts to the treasury

A nearest-quantum end_schedule payout of one leaf quantum cannot survive
the Wormhole volume fee, so a keyless beneficiary would be stranded.
Amounts below MinimumPayout now ride to the treasury with the unvested
remainder; ending still always succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(dilithium): bind ML-DSA signatures to FIPS 204 contexts

Prevent a signature produced for one purpose (extrinsic vs node identity) from verifying in another, even over the same message and key.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

* docs(runtime): record that spec_version is bumped at release, not in feature PRs

Reviewers keep asking feature PRs to increment VERSION; the Release Proposal workflow already does that when is_runtime_upgrade is set.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(p2p): keep empty ML-DSA context so mixed-version Noise handshakes still work

Node keys are not account keys; extrinsics already use QUANTUS_EXTRINSIC, so a p2p signature cannot verify on-chain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fmt

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…#29)

A declared length prefix used to BytesMut::resize the payload buffer
before any body arrived, committing resident memory on an
unauthenticated socket. Grow only as bytes are actually read.

Co-authored-by: Cursor <cursoragent@cursor.com>
* runtime: derive all absolute-QUAN prices from a single FEE_SCALE dial

* runtime: allow clippy::modulo_one in fee_scaled_coeff while FEE_SCALE is 1/1

* runtime: derive treasury signer seed and test invariants from the FEE_SCALE dial

- Planck treasury signers are now seeded with treasury_signer_seed(), computed
  from the scaled multisig fees/deposit plus the MaxInnerCallWeight inclusion
  prepay, ED, and scaled headroom - turning the dial can no longer strand
  treasury bootstrap. Pinned end-to-end by a new test that runs create_multisig
  and the first propose through Executive::apply_extrinsic on exactly the
  seeded balance.
- pallet-multisig: propose fee formula extracted into pub proposal_fee() and
  reused by genesis.
- tech_collective collateral invariant now uses the runtime's scaled
  preimage_amount() (made pub) instead of a hardcoded unscaled formula.
- RUNTIME_SURFACE.md: ScaledIdentityFee and FEE_SCALE-relative figures.
Sum independently rounded private-segment fees and canonicalize nullifier pool tags so public settlement matches the circuit boundary.
…fails (#31)

A failed inner transfer rolled back the hold release while Scheduler dropped the task. Execute with allow_death and keep bookkeeping committed independently of the inner result.

Co-authored-by: Cursor <cursoragent@cursor.com>
illuzen and others added 10 commits August 23, 2026 13:45
* fix(network): stop advertising inbound /light/2

The protocol was imported from sc-network-light and registered by default, but Quantus has no light-client product. It forwarded peer-chosen runtime methods into execution_proof with no allowlist.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): drop unused Grandpa and mixnet flags

QPoW never starts Grandpa, and MixnetParams was never wired onto RunCmd.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(wormhole): settle public fees per segment

Sum independently rounded private-segment fees and canonicalize nullifier pool tags so public settlement matches the circuit boundary.

* Cap inbound litep2p connections per source IP.
A single host was able to fill the 10k established pool with unique peer IDs; limit each IP (IPv6 /64) to 16 pending+established connections.
…rd (#32)

* fix(node): window ZkTreeApi_get_merkle_proof reached via state_call

The merkle-proof DoS guard in zktree_rpc only covered the
zkTree_getMerkleProof handler. The same proof is built by the
ZkTreeApi::get_merkle_proof runtime API, which any client can invoke
through state_call at a caller-chosen block. sc-rpc 50's state_call has
no Extensions and cannot apply check_if_safe, so an unauthenticated
caller could still force cold trie reads across chain history on an
archive node.

The window cannot live in the runtime API: get_merkle_proof has no
block parameter (the execution block is state_call's third argument),
and historical state cannot see the live tip. Apply the existing
resolve_proof_block guard on the state_call path instead, before the
executor loads state. sc-service drops the unguarded state_call /
state_callAt and the node re-registers a GatedStateCall handler that
windows ZkTreeApi_get_merkle_proof; other runtime APIs are unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(node): gate state_call deep history by DenyUnsafe trust

Window ZkTreeApi_get_merkle_proof on state_call only for untrusted
(public) callers. Trusted callers (local connections, or
--rpc-methods unsafe) keep the node's full archive functionality and may
build proofs at any historical or fork block.

Every node here is forced to ArchiveCanonical, so history-reading
consumers (indexers, explorers) that run their own node expect deep
reads to work; refusing everyone would defeat the reason they run an
archive node. Bounding only the public path closes the unauthenticated
griefing vector without breaking trusted archive use.

GatedStateCall now takes Extensions and reads DenyUnsafe to make that
decision, which the vendored sc-rpc state_call could not do (no
Extensions parameter). Cheap runtime APIs (nonce, metadata, ...) stay
unbounded for all callers so public historical decoding keeps working.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(node): gate archive_v1_call with the same proof-window trust policy

The RPC-v2 archive module (always merged: pruning is forced to archive
mode) exposes archive_v1_call, which reaches the executor at a
caller-chosen hash with no Extensions, recreating the deep-history
merkle-proof DoS that the gated state_call closed. Drop the vendored
method and merge a wire-compatible replacement that routes through the
shared resolve_runtime_call_block gate; the builder now fails startup
if either replacement is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
… cache (#36)

* fix(network): treat LruHashSet capacity as inclusive

An exclusive limit retained N-1 hashes, so a full gossip cache all-missed on every rescan and re-sent the whole set.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(network): bound transaction gossip and raise the known-hash cache

A full ready pool was resent every 2.9s as one notification per transaction, filling Litep2p's 2048-deep sync queue and force-closing the whole connection. Vendor the transactions protocol, cap each pass at 1024, and size the per-peer cache for Quantus's default pool.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(node): size the transaction gossip cache from the ready-pool limit

The pool default and per-peer known-hash cache were independent, so raising --pool-limit could recreate the all-miss resend loop. Drive both from DEFAULT_READY_POOL_LIMIT and the configured ready count.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: taplo-format sc-network-transactions Cargo.toml

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(network): gate transaction sends on sync-channel capacity

MAX_TRANSACTIONS_PER_PROPAGATION only bounded a single propagate pass, so
an undrained peer still accumulated sends across timer passes, and the
per-import path bypassed the cap entirely; either could overflow litep2p's
2048-slot synchronous channel and force-close the connection.

Expose the remaining sync-channel capacity from litep2p through
NotificationService and cap each peer's budget by it. Unsent transactions
are not marked known, so they are retried once the peer drains.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…35)

* fix(sync): separate verified catch-up from peer transport

Require canonical import evidence before major sync, retain peers during catch-up, and pause initial authoring on stale local tips.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(sync): adopt Bitcoin-style IBD gating for authoring

Authoring no longer depends on network sync state at all: the stale-tip
check (nMaxTipAge analogue, latched like Bitcoin's IBD flag) and the
peer-count check are the only mining gates. Major-sync status is reduced
to a pure local-backlog signal (queued blocks we actually hold), used
only for notification batching, download scheduling, and telemetry, so
forged peer height claims can no longer suspend mining or blackhole
notifications. This replaces the verified-batch tracker, which could
never accumulate evidence from request sizes of five blocks or fewer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sync): count the queued batch itself toward the import backlog

The import origin was computed before on_block_queued advanced
best_queued_number, so during smooth catch-up (imports keeping pace with
downloads) every batch was labeled NetworkBroadcast and the informant
printed one Imported line per block. Computing the origin after queuing
labels bulk batches NetworkInitialSync again, keeping per-block logs
only for steady-state broadcast imports.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(qpow): accept historical 111-byte digests at import

The digest-overflow rejection made pre-existing canonical blocks
unimportable: blocks minted before the runtime stopped depositing
RuntimeEnvironmentUpdated on set_code encode their digest to 111 bytes,
one past the committed window, and were accepted by older nodes via
silent hash truncation. Allow exactly one byte of slack
(MAX_ENCODED_DIGEST_SIZE) so fresh nodes can sync through them; in that
shape the uncommitted byte is the seal's last byte, and forging it still
requires passing PoW seal verification. Anything longer stays rejected.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: fail closed on unreadable tip timestamp; activate forged-height regression

The stale-tip gate is the only authoring freshness check, so a runtime
API error now pauses mining and retries like a stale tip instead of
falling through (--force-authoring still bypasses).

The forged-handshake regression moves from the unbuildable
substrate-test feature suite into the default test module, using a
minimal genesis-pinned stub client, so it runs on every cargo test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mining): raise tip-age limit to 24h and expose --max-tip-age

The 30-minute limit could deadlock a stalled chain: a stall longer than
the window plus a miner restart (the freshness latch is process-local)
leaves every node refusing to author the block that would freshen the
tip. Match Bitcoin's 24-hour DEFAULT_MAX_TIP_AGE and let operators tune
it via --max-tip-age without a rebuild.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mining): close remaining authoring gates review found

- Remove the sync-oracle checks from the qpow proposal and seal-submission
  paths (and the oracle from MiningHandle entirely): sync state derives
  from unverified peer data, so a peer could queue structurally valid but
  invalid-PoW blocks to clear builds and discard mined solutions.
- Restrict the 111-byte digest allowance to blocks at or below
  LEGACY_DIGEST_CUTOFF; future headers are capped at the fully
  hash-committed 110 bytes again.
- Fail closed when the wall clock is unreadable in the tip-freshness gate
  instead of treating age as zero and latching the gate off.
- Skip the freshness gate at genesis so a fresh network can author block 1
  without --force-authoring; document the gate in MINING.md.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sync): remove request-signature dedup that livelocked catch-up

The per-peer request_signatures set never expired, so once a range
request failed (timeout, lost response), the only request shape that
could re-fetch the range was permanently suppressed. The max-halving
fallback degraded every retry to a single already-downloaded block,
leaving the node unable to advance past its best block while the
repeated max=1 duplicates tripped remote peers' fatal SAME_REQUEST
penalty and churned the peer set. Upstream substrate has no client-side
dedup here; failed ranges are simply re-requested.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(informant): show claimed sync target during download phase

Since sync state became backlog-driven, the download half of a catch-up
(blocks requested but not yet queued) displayed as Idle with no hint the
node was behind. Repopulate best_seen_block with the median peer-claimed
height — display/monitoring only (informant, system_syncState,
sync_target metric), never gating — and let the informant show
"Syncing ..., target=#N (claimed)" while peers claim to be ahead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(network): avoid listener-port reuse for outbound dials

Use ephemeral source ports so reconnects cannot collide with stale TCP tuples.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mining): gate authoring at genesis

Require explicit force authoring for fresh-chain bootstrap so joining validators cannot latch freshness off before syncing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mining): share authoring gate with proposal worker

Keep proposal construction and seal submission paused whenever mining policy blocks authoring, avoiding wasted builds during initial sync.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Unify the two trees from split 803094f: public main first, then
chain-private main (011b2f5). Conflicts resolved per the recorded
decisions: private digest cutoff, private authoring gates, public
call-carrying Multisig::execute, private spec/tx versions, private
runtime-surface/weight comments, public external-miner protocol.
Header::hash() pads short digests into the 110-byte commitment window, so
a length-only upper bound let seal-only and other noncanonical headers
reach hashing and PoW verification. Restore the exact allowed-shape
check: 110-byte canonical [PreRuntime, Seal], or 111 bytes with exactly
one RuntimeEnvironmentUpdated item at or below LEGACY_DIGEST_CUTOFF.
Public's miner protocol stores the last broadcast job and hands it to
newly connecting miners. The merge kept that server but paused
authoring (stale tip, unreadable clock, offline after grace, shutdown)
without clearing the job, so a miner connecting during a pause ground
stale work. Clear on the enabled-to-disabled edge only; do not gate on
network sync state.
Index 16 stays vacant. No migration and no client-facing recovery
surface: the exercise phase and the removed-pallet section are gone.
@n13
n13 marked this pull request as ready for review August 31, 2026 03:58
@n13
n13 merged commit a4bc1e7 into main Aug 31, 2026
25 checks passed
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