Skip to content

feat(contracts): Safe-multisig PKP owner backfill tooling + verifier (#575)#579

Open
clawdbot-glitch003 wants to merge 4 commits into
mainfrom
glitch003/pkp-owner-backfill-safe
Open

feat(contracts): Safe-multisig PKP owner backfill tooling + verifier (#575)#579
clawdbot-glitch003 wants to merge 4 commits into
mainfrom
glitch003/pkp-owner-backfill-safe

Conversation

@clawdbot-glitch003

Copy link
Copy Markdown
Collaborator

Follow-up to #577 (merged). Operational tooling to actually run the #575 backfill through the diamond owner Safe, plus an independent verifier — and a correctness fix to the ownership source.

Context from running it against Base mainnet

The diamond 0xaAaA…7Aa is live with the #575 facets (getPkpOwnerMaster present). Owner is a 2-of-4 Safe (0xF688…1098); configOperator is an EOA. There are 14,070 PKPs across 485 accounts, all currently unbound, zero multi-account conflicts (no hijack has landed on-chain).

The correctness fix: enumerate accounts, don't scan events

While validating, I found the event-only approach (the backfill-pkp-owners task from #577) is incomplete: 436 PKPs exist in the on-chain allPkpIds ledger with no WalletDerivationRegistered event — they were migrated into the diamond's storage at deployment without emitting. allPkpIdsAt(1) (the very first PKP) is one of them. An event-only scan silently drops all 436, and its coverage check couldn't catch the gap because it only looked at event-derived pkpIds.

Fix: reconstruct ownership from account enumeration (listPkps) — the exact pkpData mapping the node reads to sign. It covers every PKP (migrated + evented) and is authoritative. A pkpId held by >1 account is an on-chain hijack (none exist now) and is excluded unless --allow-conflicts, which then disambiguates via first-registrant events. The old EOA task is switched to the same source.

What's here

  • tasks/lib/pkp-owners.ts — shared: account-enumeration ownership, Safe batch build, calldata + MultiSend decode.
  • pkp-backfill:gen-safe — emits safe-backfill-NN.json (Safe Transaction Builder format, multisend-bundled, sized to Base's 400M block limit) + a manifest.
  • pkp-backfill:verify-safe — decodes the JSON and checks every binding against account-enumeration ownership, live getPkpOwnerMaster (unbound or already-correct), and full coverage (no unbound PKP missing). This is the "check the Safe JSON against on-chain" tool.
  • backfill-pkp-owners (EOA path) — switched to account enumeration, fixing the 436-miss gap.
  • tasks/README-pkp-backfill.md — operator runbook.
  • Tests: ABI-drift guard (my ABI's selectors + event topic vs the compiled facets), encode/decode round-trip, MultiSend unwrap, bad-selector rejection.

Verified end-to-end against mainnet (read-only)

Generated 8 Safe files covering all 14,070 bindings (~53M gas each), then verify-safe:

Decoded 14070 distinct bindings across 15 call(s).
accounts=485 distinct=14070 conflicts=0
  14070 will be written, 0 already correct.
  0 unbound owned pkpId(s) missing from JSON.
VERIFY: PASS — the Safe JSON matches authoritative on-chain ownership.

Spot-checked allPkpIdsAt(1) (0x6020…, previously missed): now bound to its holding account's hash.

Generated JSON lands in .context/pkp-backfill/ (gitignored); not committed.

Deploy note

backfillPkpOwners is idempotent (skip-if-set), so the 8 Safe txs can be signed/executed in any order and re-run safely. Re-run verify-safe after execution — every pkpId should report "already correct" and 0 remaining.

🤖 Generated with Claude Code

…575)

The #575 backfill has to run through the diamond owner, which on Base is a
2-of-4 Safe. Add tooling to generate Safe Transaction Builder JSON and to
verify it against on-chain state before signing.

Ownership is reconstructed from account enumeration (listPkps), NOT from
WalletDerivationRegistered events: ~436 PKPs were migrated into the diamond's
storage without emitting an event, so an event-only scan (the original
backfill-pkp-owners task) silently missed them. listPkps reads the exact
pkpData mapping the node uses to sign, so it covers every PKP and is the
authoritative owner source. A pkpId held by >1 account is an on-chain hijack
(none currently) and is excluded unless --allow-conflicts, which then
disambiguates via first-registrant events.

- tasks/lib/pkp-owners.ts: shared ownership reconstruction, Safe batch build,
  and calldata/MultiSend decode used by generator and verifier.
- pkp-backfill:gen-safe: emit safe-backfill-NN.json (multisend-bundled calls,
  sized to Base's block gas limit) + manifest.
- pkp-backfill:verify-safe: decode the JSON and check every binding against
  account-enumeration ownership + live getPkpOwnerMaster + full coverage.
- backfill-pkp-owners (EOA path): switched to account enumeration too, fixing
  the same event-only gap.
- tests: ABI-drift guard (selectors/topic vs compiled facets), encode/decode
  round-trip, MultiSend unwrap, bad-selector rejection.
- tasks/README-pkp-backfill.md: operator runbook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clawdbot-glitch003
clawdbot-glitch003 requested a review from a team July 16, 2026 02:42
glitch003 and others added 3 commits July 16, 2026 21:09
…eGas cap

The EOA backfill path failed with "missing revert data" on estimateGas for
batches over ~500 pairs. Root cause: the transaction is valid (eth_call
succeeds for 1000 pairs at 40M gas), but Alchemy caps eth_estimateGas
simulation at ~13-20M gas, so ethers' auto-estimation runs out of gas and
returns the no-data OOG error. estimateGas(500)=13M works, estimateGas(750+)
fails.

Pass an explicit gasLimit (estimateBackfillGas * 1.25, ~33M for a 1000-batch,
well under Base's 400M block limit) so the send never calls estimateGas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pinned 33M gasLimit fixed estimateGas but the sequencer then rejected
the send with "exceeds max transaction gas limit" — Base enforces the
EIP-7825 per-transaction cap of 16,777,216 gas (2^24), which also explains
where the estimateGas ceiling sat.

- Default batch size 1000 -> 450 pairs (~15M gasLimit with headroom, under
  the cap; ~32 txs for the full backfill).
- Adaptive splitting as a backstop: on a "max transaction gas" rejection the
  batch is halved and retried. Rejected sends are never mined (no gas, no
  nonce), so splitting is free, and a 1-pair failure still aborts loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one-time PKP owner backfill is complete on Base (all 14,070 pre-existing
PKPs bound; 0 unbound, 0 conflicts). Remove the migration machinery from the
contract in the next facet upgrade:

- WritesFacet: drop backfillPkpOwners() and the PkpOwnerBackfilled event.
- ViewsFacet.getWalletDerivation: remove the `owner == 0` compatibility
  fallback so enforcement is unconditional (fail-closed). registerWalletDerivation
  always sets the owner when it writes pkpData and the backfill bound every
  legacy PKP, so a non-zero derivation always has a non-zero owner; owner == 0
  now reverts instead of serving the path.
- Keep getPkpOwnerMaster (ongoing audit) and the core first-owner binding.
- Update Foundry tests (drop backfill test; owner==0 test now asserts
  fail-closed), DiamondDeploy selectors, and regenerate alloy bindings.

The Safe/EOA backfill tasks are kept as the historical record of the migration
and marked as such; their on-chain target no longer exists post-upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clawdbot-glitch003

Copy link
Copy Markdown
Collaborator Author

Added: contract cleanup (this becomes the next facet upgrade)

The one-time backfill is complete on Base mainnet — all 14,070 pre-existing PKPs are bound (verified 0 unbound, 0 conflicts, 0 bound-to-non-owner across all 486 accounts). So this PR now also removes the spent migration machinery from the contract, to ship in the next facet upgrade:

  • WritesFacet: removed backfillPkpOwners() and the PkpOwnerBackfilled event.
  • ViewsFacet.getWalletDerivation: removed the owner == 0 compatibility fallback — enforcement is now unconditional (fail-closed). registerWalletDerivation always sets the owner when it writes pkpData, and the backfill bound every legacy PKP, so a non-zero derivation always has a non-zero owner; an owner == 0 now reverts instead of serving the path.
  • Kept getPkpOwnerMaster (ongoing audits) and the core first-owner binding in registerWalletDerivation.
  • Updated Foundry tests (dropped the backfill test; the owner == 0 test now asserts fail-closed), DiamondDeploy selectors, and regenerated the alloy bindings.

The Safe/EOA backfill tasks are kept as the historical record of how the migration was run and marked HISTORICAL — their on-chain target no longer exists after this upgrade's cut.

Verification: forge test 50/50, cargo check clean, tooling mocha 6/6, tsc clean.

Deploy note: this requires a diamondCut that (a) replaces WritesFacet (drops the backfillPkpOwners selector) and (b) replaces ViewsFacet (getWalletDerivation behavior change). Do it after the configOperator is restored from the temp EOA (0x4141…) back to 0x7774… — don't stack a facet upgrade while the operator role sits on a throwaway key.

@GTC6244

GTC6244 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@glitch003 - do you want this committed, or can we close ?

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.

3 participants