diff --git a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol index 0bc00815..9b4ac61e 100644 --- a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol +++ b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol @@ -167,26 +167,27 @@ contract ViewsFacet { // Cross-account hijack defense (#575). This view is the exact read the // node uses to resolve a derivation path before signing/decrypting, so // enforce the global first-owner binding HERE, not just at registration: - // if the wallet has an owner and the resolving (master) account is not - // that owner, the local pkpData entry is a stale pre-fix hijack - // registration — fail closed instead of leaking the victim's path. - // owner == 0 means a pre-migration wallet not yet backfilled: fall - // through so signing keeps working until backfillPkpOwners runs. + // the resolving (master) account must be the pkpId's owner, otherwise + // the local pkpData entry is a stale hijack registration — fail closed + // instead of leaking the victim's path. + // + // registerWalletDerivation always sets pkpIdToOwnerMaster when it writes + // pkpData, and the one-time #575 backfill bound every pre-existing PKP, + // so a non-zero derivation always has a non-zero owner. An owner of 0 + // here therefore means an unexpected/legacy state and fails closed. AppStorage.AccountConfigStorage storage s = AppStorage.getStorage(); uint256 owner = s.pkpIdToOwnerMaster[walletAddress]; - if (owner != 0) { - uint256 resolvedMaster = s.allApiKeyHashesToMaster[apiKeyHash]; - if (owner != resolvedMaster) { - revert AppStorage.InvalidRequest("PKP owned by another account"); - } + uint256 resolvedMaster = s.allApiKeyHashesToMaster[apiKeyHash]; + if (owner != resolvedMaster) { + revert AppStorage.InvalidRequest("PKP owned by another account"); } return derivation; } - /// @notice Return the master apiKeyHash that first registered a pkpId, or 0 if - /// the pkpId has never been bound (pre-migration wallet or never registered). - /// @dev The binding survives removeWalletDerivation by design; used to audit the - /// global first-owner rule and the one-time backfill migration. + /// @notice Return the master apiKeyHash that owns a pkpId, or 0 if the pkpId + /// has never been registered. + /// @dev The binding survives removeWalletDerivation by design; used to audit + /// the global first-owner rule. function getPkpOwnerMaster(address pkpId) public view returns (uint256) { AppStorage.AccountConfigStorage storage s = AppStorage.getStorage(); return s.pkpIdToOwnerMaster[pkpId]; diff --git a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol index c4fb8391..fa049526 100644 --- a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol +++ b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol @@ -68,10 +68,6 @@ contract WritesFacet { uint256 indexed apiKeyHash, address indexed pkpId ); - event PkpOwnerBackfilled( - address indexed pkpId, - uint256 indexed masterHash - ); event UsageApiKeyRemoved( uint256 indexed accountApiKeyHash, uint256 indexed usageApiKeyHash @@ -762,35 +758,6 @@ contract WritesFacet { emit WalletDerivationRemoved(apiKeyHash, pkpId); } - /// @notice One-time migration helper: bind wallets registered before the global - /// owner binding existed to their original master account. - /// @dev Pairs should be derived off-chain from the EARLIEST - /// `WalletDerivationRegistered(masterHash, pkpId, ...)` event per pkpId - /// (first registration wins, matching the rule `registerWalletDerivation` - /// now enforces). Already-bound pkpIds are skipped, never re-assigned, so - /// the call is idempotent and safe to run in batches / re-run. Restricted - /// to the diamond owner or config operator. - function backfillPkpOwners( - address[] calldata pkpIds, - uint256[] calldata masterHashes - ) public { - SecurityLib.revertIfNotConfigOperatorOrOwner(msg.sender); - if (pkpIds.length != masterHashes.length) { - revert AppStorage.InvalidRequest("array length mismatch"); - } - AppStorage.AccountConfigStorage storage s = AppStorage.getStorage(); - for (uint256 i = 0; i < pkpIds.length; i++) { - if (masterHashes[i] == 0) { - revert AppStorage.InvalidRequest("masterHash must be non-zero"); - } - if (s.pkpIdToOwnerMaster[pkpIds[i]] != 0) { - continue; // already bound — never re-assign ownership - } - s.pkpIdToOwnerMaster[pkpIds[i]] = masterHashes[i]; - emit PkpOwnerBackfilled(pkpIds[i], masterHashes[i]); - } - } - function setNodeConfiguration( string memory key, string memory value diff --git a/lit-api-server/blockchain/lit_node_express/hardhat.config.ts b/lit-api-server/blockchain/lit_node_express/hardhat.config.ts index a42910af..20c43507 100644 --- a/lit-api-server/blockchain/lit_node_express/hardhat.config.ts +++ b/lit-api-server/blockchain/lit_node_express/hardhat.config.ts @@ -12,6 +12,7 @@ import "./tasks/transfer-ownership"; import "./tasks/verify-compose-hash"; import "./tasks/verify-diamond-facets"; import "./tasks/backfill-pkp-owners"; +import "./tasks/pkp-backfill-safe"; const config: HardhatUserConfig = { solidity: { diff --git a/lit-api-server/blockchain/lit_node_express/tasks/README-pkp-backfill.md b/lit-api-server/blockchain/lit_node_express/tasks/README-pkp-backfill.md new file mode 100644 index 00000000..4504b1fa --- /dev/null +++ b/lit-api-server/blockchain/lit_node_express/tasks/README-pkp-backfill.md @@ -0,0 +1,69 @@ +# PKP owner backfill (#575) + +> **HISTORICAL — migration complete.** The one-time backfill was executed on +> Base mainnet (all 14,070 pre-existing PKPs bound; verified 0 unbound, 0 +> conflicts). The on-chain `backfillPkpOwners` function and the +> `getWalletDerivation` `owner == 0` compatibility fallback were removed in the +> facet upgrade that accompanied this cleanup, so the `gen-safe` / EOA execution +> tasks below will revert if run against the upgraded diamond. Kept as the +> record of how the migration was performed. `getPkpOwnerMaster` remains for +> ongoing audits. + +After the #575 facet upgrade, `registerWalletDerivation` binds each pkpId to its +first owner (`pkpIdToOwnerMaster`) and `getWalletDerivation` refuses to serve a +pkpId to any account that isn't the owner. Existing PKPs have an empty binding +(`owner == 0`), which falls through so signing keeps working — but also means +they are still claimable until backfilled. This tooling generates and verifies +the one-time backfill. + +## Source of truth: account enumeration, not events + +Ownership is reconstructed by enumerating every account and reading its on-chain +`pkpData` via `listPkps` — the exact mapping the node reads to sign. This matters +because some PKPs were migrated into the diamond's storage without ever emitting +a `WalletDerivationRegistered` event; an event-only scan silently misses them. +A pkpId held by more than one account is an on-chain hijack and is excluded +unless you pass `--allow-conflicts` (which then disambiguates via the +first-registrant event). + +## Diamond / admin (Base mainnet) + +- Diamond: `0xaAaAA9120fE271F653cfDb6bf400dB93D2DEa7Aa` +- `backfillPkpOwners` is callable by the diamond owner (the 2-of-4 Safe + `0xF688411c0FFc300cAb33EB1dA651DBb3E6891098`) or the `configOperator` EOA. + +## Safe path (recommended) + +Set `ALCHEMY_API_KEY` in `.context/.env` (or `BASE_RPC_URL` in the env), then: + +```bash +# 1. Generate Safe Transaction Builder JSON (one file per Safe tx, multisend-bundled). +npx hardhat pkp-backfill:gen-safe + +# 2. Verify the generated JSON against authoritative on-chain ownership. +# Decodes every call, checks each pkpId is owned by the bound account, that it +# is currently unbound or already correct, and that no unbound PKP is missing. +npx hardhat pkp-backfill:verify-safe --dir .context/pkp-backfill +``` + +Output lands in `.context/pkp-backfill/` (gitignored): `safe-backfill-NN.json` +plus `manifest.json`. Import each `safe-backfill-NN.json` into the Safe +**Transaction Builder** app, then sign + execute. Order does not matter and +re-running is safe — `backfillPkpOwners` skips already-bound pkpIds. + +**Re-verify after execution**: re-run `verify-safe`; every executed pkpId should +report "already correct" and 0 remaining to write. + +Useful flags: `--batch-size` (pkpIds per `backfillPkpOwners` call, default 1000), +`--calls-per-file` (calls bundled into one Safe tx, default 2 ≈ ~53M gas/file on +Base's 400M limit), `--diamond`, `--rpc-url`. + +## Direct EOA path (if you hold the configOperator/owner key) + +```bash +CONFIG_OPERATOR_PRIVATE_KEY=0x... \ + npx hardhat backfill-pkp-owners --diamond 0xaAaAA9120fE271F653cfDb6bf400dB93D2DEa7Aa --network base --execute +``` + +Dry-run (no `--execute`) prints what would be written. Conflicts are a hard stop +under `--execute` unless `--allow-conflicts`. diff --git a/lit-api-server/blockchain/lit_node_express/tasks/backfill-pkp-owners.ts b/lit-api-server/blockchain/lit_node_express/tasks/backfill-pkp-owners.ts index 6dec3560..342e24a9 100644 --- a/lit-api-server/blockchain/lit_node_express/tasks/backfill-pkp-owners.ts +++ b/lit-api-server/blockchain/lit_node_express/tasks/backfill-pkp-owners.ts @@ -1,186 +1,107 @@ import { task } from "hardhat/config"; import { ethers } from "ethers"; - -// Minimal ABI: the event we scan plus the migration entry points. -const DIAMOND_ABI = [ - "event WalletDerivationRegistered(uint256 indexed apiKeyHash, address indexed pkpId, uint256 derivationPath)", - "function backfillPkpOwners(address[] pkpIds, uint256[] masterHashes)", - "function getPkpOwnerMaster(address pkpId) view returns (uint256)", -]; - -interface FirstRegistration { - pkpId: string; - masterHash: bigint; - blockNumber: number; - txHash: string; - conflicts: bigint[]; // other masters that later registered the same pkpId -} +import { + DIAMOND_ABI, + buildOwnershipFromAccounts, + chunk, + estimateBackfillGas, + mapLimit, + Pair, +} from "./lib/pkp-owners"; /** - * One-time migration for issue #575: wallets registered before the global - * `pkpIdToOwnerMaster` binding existed have no owner entry, so any account - * could still claim them via registerWalletDerivation. This task rebuilds the - * binding from history: it scans every WalletDerivationRegistered event, takes - * the FIRST registration per pkpId (the same rule the contract now enforces), - * and submits backfillPkpOwners in batches. Already-bound pkpIds are skipped - * on-chain, so the task is idempotent and safe to re-run until it reports - * nothing left to bind. + * One-time migration for issue #575: PKPs registered before the global + * `pkpIdToOwnerMaster` binding existed have no owner entry, so any account could + * still claim them via registerWalletDerivation. + * + * Ownership is reconstructed from account enumeration (listPkps) rather than + * WalletDerivationRegistered events. Events miss PKPs that were migrated into + * this diamond's storage without emitting (there are ~hundreds), whereas + * listPkps reads the exact pkpData mapping the node uses to sign. A pkpId held + * by more than one account is an on-chain hijack and is excluded unless + * --allow-conflicts. + * + * backfillPkpOwners skips already-bound pkpIds on-chain, so this is idempotent + * and safe to re-run until it reports nothing left to bind. + * + * NOTE: this path signs with a raw key (config-operator or owner). If the admin + * is a Safe, use `pkp-backfill:gen-safe` / `pkp-backfill:verify-safe` instead. */ task( "backfill-pkp-owners", - "Backfill pkpIdToOwnerMaster for wallets registered before the #575 fix" + "Backfill pkpIdToOwnerMaster for PKPs registered before the #575 fix" ) .addParam("diamond", "Diamond proxy contract address") - .addOptionalParam("fromBlock", "Block to start scanning events from", "0") - .addOptionalParam("chunkSize", "getLogs block range per request", "10000") - .addOptionalParam("batchSize", "pkpIds per backfill transaction", "200") - .addOptionalParam( - "confirmations", - "Blocks to stay behind chain head to avoid reorgs (0 = use the 'finalized' tag)", - "0" - ) + // 450 pairs ≈ 15M gasLimit with headroom — under the 16,777,216 (2^24) + // per-transaction gas cap (EIP-7825) enforced by the Base sequencer. + .addOptionalParam("batchSize", "pkpIds per backfill transaction", "450") .addFlag("execute", "Send the backfill transactions (default is dry-run)") .addFlag( "allowConflicts", - "Proceed even if pkpIds were registered by multiple accounts (pre-fix hijacks). Off by default: conflicts are a hard stop under --execute." + "Include pkpIds held by multiple accounts (on-chain hijacks). Off by default: a hard stop under --execute." ) .setAction(async (taskArgs, hre) => { - const { diamond: diamondAddress } = taskArgs; - const fromBlock = parseInt(taskArgs.fromBlock, 10); - const chunkSize = parseInt(taskArgs.chunkSize, 10); + const diamondAddress = ethers.getAddress(taskArgs.diamond); const batchSize = parseInt(taskArgs.batchSize, 10); - const confirmations = parseInt(taskArgs.confirmations, 10); const rpcUrl = (hre.network.config as { url?: string }).url || "https://mainnet.base.org"; const provider = new ethers.JsonRpcProvider(rpcUrl); - const readOnly = new ethers.Contract(diamondAddress, DIAMOND_ABI, provider); console.log(`Network: ${hre.network.name}`); console.log(`Diamond: ${diamondAddress}`); - // 1. Scan all WalletDerivationRegistered events. The event's first indexed - // arg is the master apiKeyHash (WritesFacet emits masterHash, never a - // usage-key hash), so it is exactly the value pkpIdToOwnerMaster needs. - // Scan to a finalized/confirmed head, not `latest`: a reorg that reranks - // the first registrant would otherwise bind the wrong owner. The scanned - // upper bound is fixed up front so the whole run reasons over one range. - let latestBlock: number; - if (confirmations > 0) { - latestBlock = (await provider.getBlockNumber()) - confirmations; - } else { - const finalized = await provider.getBlock("finalized"); - if (!finalized) { - throw new Error( - "Provider does not support the 'finalized' block tag; pass --confirmations N instead" - ); - } - latestBlock = finalized.number; - } - if (latestBlock < fromBlock) { - console.log("No finalized blocks in range yet. Nothing to do."); - return; - } + // 1. Authoritative ownership from account enumeration. + console.log("Enumerating accounts + pkpData..."); + const own = await buildOwnershipFromAccounts(provider, diamondAddress, { + log: (m) => process.stdout.write("\r" + m), + }); console.log( - `Scanning events from block ${fromBlock} to ${latestBlock} (finalized head)...` - ); - - const filter = readOnly.filters.WalletDerivationRegistered(); - const allLogs: ethers.EventLog[] = []; - for (let start = fromBlock; start <= latestBlock; start += chunkSize) { - const end = Math.min(start + chunkSize - 1, latestBlock); - const logs = await readOnly.queryFilter(filter, start, end); - for (const log of logs) allLogs.push(log as ethers.EventLog); - if (end < latestBlock) { - process.stdout.write( - `\r scanned up to block ${end} (${allLogs.length} events)` - ); - } - } - - // Sort explicitly by (blockNumber, transactionIndex, logIndex) rather than - // trusting provider ordering — "first registration wins" is only correct if - // we actually process logs in chain order, and getLogs ordering is not - // guaranteed across or within chunks. - allLogs.sort( - (a, b) => - a.blockNumber - b.blockNumber || - a.transactionIndex - b.transactionIndex || - a.index - b.index + `\naccounts=${own.accountCount} pkpCount=${own.pkpCount} distinct=${own.byPkp.size} conflicts=${own.conflicted.length}` ); - - const firstByPkp = new Map(); - for (const log of allLogs) { - const masterHash = log.args[0] as bigint; - const pkpId = (log.args[1] as string).toLowerCase(); - const existing = firstByPkp.get(pkpId); - if (!existing) { - firstByPkp.set(pkpId, { - pkpId: log.args[1] as string, - masterHash, - blockNumber: log.blockNumber, - txHash: log.transactionHash, - conflicts: [], - }); - } else if ( - existing.masterHash !== masterHash && - !existing.conflicts.includes(masterHash) - ) { - existing.conflicts.push(masterHash); - } + if (own.byPkp.size !== own.pkpCount) { + console.log( + `⚠️ distinct owned (${own.byPkp.size}) != pkpCount (${own.pkpCount}). Investigate before proceeding.` + ); } - console.log( - `\nFound ${allLogs.length} registration events across ${firstByPkp.size} distinct pkpIds.` - ); - // 2. Surface pkpIds registered by more than one master account. Each is a - // wallet another account also claimed pre-fix — a probable hijack. The - // backfill binds the FIRST registrant, and the hardened getWalletDerivation - // then refuses to serve the later registrant's stale local entry. But a - // conflict still means: (a) verify the first registrant is genuinely the - // rightful owner (an attacker who registered BEFORE the victim would be - // bound as owner here), and (b) the later account's pkpData row should be - // removed. So conflicts are a HARD STOP under --execute unless the - // operator has reviewed them and passes --allow-conflicts. - const conflicted = [...firstByPkp.values()].filter( - (r) => r.conflicts.length > 0 - ); - if (conflicted.length > 0) { + if (own.conflicted.length > 0) { console.log( - `\n⚠️ ${conflicted.length} pkpId(s) were registered by MULTIPLE master accounts (probable pre-fix hijack):` + `\n⚠️ ${own.conflicted.length} pkpId(s) held by MULTIPLE accounts (on-chain hijack):` ); - for (const r of conflicted) { + for (const o of own.conflicted.slice(0, 20)) { console.log( - ` ${r.pkpId} first=0x${r.masterHash.toString(16)} (block ${r.blockNumber}, ${r.txHash})` + ` ${o.pkpId}: ${o.masters.map((m) => "0x" + m.toString(16)).join(", ")}` ); - for (const other of r.conflicts) { - console.log(` also registered by 0x${other.toString(16)}`); - } } - console.log( - " Backfill binds the FIRST registrant; the later registrant's stale pkpData row must be removed separately." - ); if (taskArgs.execute && !taskArgs.allowConflicts) { throw new Error( - `Refusing to --execute with ${conflicted.length} unresolved conflict(s). Review them, remediate the later registrants, then re-run with --allow-conflicts.` + `Refusing to --execute with ${own.conflicted.length} conflict(s). Review, remediate, then re-run with --allow-conflicts.` ); } } - // 3. Drop pkpIds that are already bound (post-fix registrations, or a - // previous run of this task). - console.log("\nChecking current on-chain bindings..."); - const toBind: FirstRegistration[] = []; - for (const r of firstByPkp.values()) { - const owner: bigint = await readOnly.getPkpOwnerMaster(r.pkpId); - if (owner === 0n) { - toBind.push(r); - } else if (owner !== r.masterHash) { + // 2. Single-owner pkpIds are unambiguous; multi-owner excluded (or handled + // manually) — conflict disambiguation via events belongs in gen-safe. + const candidates: Pair[] = [...own.byPkp.values()] + .filter((o) => o.masters.length === 1) + .map((o) => ({ pkpId: o.pkpId, masterHash: o.masters[0] })); + + // 3. Drop already-bound pkpIds. + console.log("Checking current on-chain bindings..."); + const readOnly = new ethers.Contract(diamondAddress, DIAMOND_ABI, provider); + const owners = (await mapLimit(candidates, 25, (r) => + readOnly.getPkpOwnerMaster(r.pkpId) + )) as bigint[]; + const toBind: Pair[] = []; + candidates.forEach((r, i) => { + const o = owners[i]; + if (o === 0n) toBind.push(r); + else if (o !== r.masterHash) console.log( - ` ⚠️ ${r.pkpId} already bound to 0x${owner.toString(16)} which is NOT its first registrant 0x${r.masterHash.toString(16)} — investigate` + ` ⚠️ ${r.pkpId} bound to 0x${o.toString(16)}, expected 0x${r.masterHash.toString(16)}` ); - } - } + }); console.log(`${toBind.length} pkpId(s) need backfilling.`); if (toBind.length === 0) { console.log("Nothing to do."); @@ -189,52 +110,81 @@ task( if (!taskArgs.execute) { console.log("\nDry run (pass --execute to send transactions):"); - for (const r of toBind) { + for (const r of toBind.slice(0, 10)) console.log(` ${r.pkpId} -> 0x${r.masterHash.toString(16)}`); - } + if (toBind.length > 10) console.log(` ... and ${toBind.length - 10} more`); return; } - // 4. Send backfillPkpOwners in batches. Caller must be the diamond owner - // or config operator. + // 4. Send backfillPkpOwners in batches (config-operator or owner key). const signerKey = process.env.CONFIG_OPERATOR_PRIVATE_KEY || process.env.OWNER_PRIVATE_KEY; if (!signerKey) { throw new Error( - "CONFIG_OPERATOR_PRIVATE_KEY or OWNER_PRIVATE_KEY environment variable is required with --execute" + "CONFIG_OPERATOR_PRIVATE_KEY or OWNER_PRIVATE_KEY required with --execute" ); } const wallet = new ethers.Wallet(signerKey, provider); const diamond = new ethers.Contract(diamondAddress, DIAMOND_ABI, wallet); console.log(`\nSending backfill as ${wallet.address}...`); - for (let i = 0; i < toBind.length; i += batchSize) { - const batch = toBind.slice(i, i + batchSize); - const tx = await diamond.backfillPkpOwners( - batch.map((r) => r.pkpId), - batch.map((r) => r.masterHash) - ); - console.log( - ` batch ${i / batchSize + 1} (${batch.length} pkpIds): ${tx.hash}` + // Work through batches with adaptive splitting. Two provider/sequencer + // limits bite here, so we can't rely on estimation or a fixed size: + // - eth_estimateGas is capped by the RPC (~13-20M on Alchemy), so we pass + // an explicit gasLimit (~26.5k/pair + 25% headroom) and never estimate; + // - eth_sendRawTransaction is capped by the sequencer's per-tx gas limit + // ("exceeds max transaction gas limit"), which isn't queryable, so on + // that rejection we halve the batch and retry. Rejected sends are never + // mined (no gas spent, no nonce consumed), so splitting is free. + const queue: Pair[][] = chunk(toBind, batchSize); + let sent = 0; + let bound = 0; + while (queue.length > 0) { + const batch = queue.shift()!; + const gasLimit = BigInt( + Math.ceil(estimateBackfillGas(batch.length) * 1.25) ); - const receipt = await tx.wait(); - console.log(` confirmed in block ${receipt.blockNumber}`); + try { + const tx = await diamond.backfillPkpOwners( + batch.map((r) => r.pkpId), + batch.map((r) => r.masterHash), + { gasLimit } + ); + sent++; + console.log( + ` tx ${sent} (${batch.length} pkpIds, gasLimit ${(Number(gasLimit) / 1e6).toFixed(1)}M): ${tx.hash}` + ); + const receipt = await tx.wait(); + bound += batch.length; + console.log( + ` confirmed in block ${receipt.blockNumber} (gas used ${receipt.gasUsed}) — ${bound}/${toBind.length} bound` + ); + } catch (e: any) { + const msg = (e?.info?.error?.message || e?.message || "").toLowerCase(); + if (msg.includes("max transaction gas") && batch.length > 1) { + const mid = Math.ceil(batch.length / 2); + console.log( + ` sequencer rejected ${batch.length}-pair batch (per-tx gas cap) — splitting into ${mid} + ${batch.length - mid}` + ); + queue.unshift(batch.slice(0, mid), batch.slice(mid)); + continue; + } + throw e; + } } - // 5. Verify every pair landed. + // 5. Verify. console.log("\nVerifying..."); + const after = (await mapLimit(toBind, 25, (r) => + readOnly.getPkpOwnerMaster(r.pkpId) + )) as bigint[]; let failures = 0; - for (const r of toBind) { - const owner: bigint = await readOnly.getPkpOwnerMaster(r.pkpId); - if (owner !== r.masterHash) { + toBind.forEach((r, i) => { + if (after[i] !== r.masterHash) { failures++; - console.log( - ` ❌ ${r.pkpId}: expected 0x${r.masterHash.toString(16)}, got 0x${owner.toString(16)}` - ); + console.log(` ❌ ${r.pkpId}: expected 0x${r.masterHash.toString(16)}, got 0x${after[i].toString(16)}`); } - } - if (failures > 0) { - throw new Error(`${failures} binding(s) failed verification`); - } + }); + if (failures > 0) throw new Error(`${failures} binding(s) failed verification`); console.log(`All ${toBind.length} bindings verified. Backfill complete.`); }); diff --git a/lit-api-server/blockchain/lit_node_express/tasks/lib/pkp-owners.ts b/lit-api-server/blockchain/lit_node_express/tasks/lib/pkp-owners.ts new file mode 100644 index 00000000..34e7f7bd --- /dev/null +++ b/lit-api-server/blockchain/lit_node_express/tasks/lib/pkp-owners.ts @@ -0,0 +1,360 @@ +// Shared logic for the #575 PKP owner backfill: reconstruct the first-owner +// binding from on-chain history, build Safe Transaction Builder batches, and +// decode/verify those batches back against the chain. Used by both +// `pkp-backfill:gen-safe` and `pkp-backfill:verify-safe` so the generator and +// verifier agree on the source-of-truth rule (first WalletDerivationRegistered +// event per pkpId wins — the exact rule registerWalletDerivation now enforces). + +import { ethers } from "ethers"; + +export const DIAMOND_ABI = [ + "event WalletDerivationRegistered(uint256 indexed apiKeyHash, address indexed pkpId, uint256 derivationPath)", + "function backfillPkpOwners(address[] pkpIds, uint256[] masterHashes)", + "function getPkpOwnerMaster(address pkpId) view returns (uint256)", + "function allPkpIdsAt(uint256 index) view returns (address)", + "function pkpCount() view returns (uint256)", + "function accountCount() view returns (uint256)", + "function indexToAccountHashAt(uint256 index) view returns (uint256)", + "function listPkps(uint256 apiKeyHash, uint256 pageNumber, uint256 pageSize) view returns (tuple(uint256 id, address pkpId, string name, string description)[])", +]; + +export const diamondIface = new ethers.Interface(DIAMOND_ABI); +export const BACKFILL_SELECTOR = + diamondIface.getFunction("backfillPkpOwners")!.selector; + +// Safe MultiSend / MultiSendCallOnly. Only needed to decode a bundled tx if the +// operator hands us a raw multiSend blob instead of the Transaction Builder JSON. +export const MULTISEND_ABI = ["function multiSend(bytes transactions)"]; +export const multiSendIface = new ethers.Interface(MULTISEND_ABI); +export const MULTISEND_SELECTOR = + multiSendIface.getFunction("multiSend")!.selector; + +export interface FirstOwner { + pkpId: string; // checksummed address + masterHash: bigint; + blockNumber: number; + txHash: string; + conflicts: bigint[]; // other masters that later registered the same pkpId +} + +export interface ScanResult { + firstByPkp: Map; // key = lowercase pkpId + conflicted: FirstOwner[]; + scannedTo: number; + totalEvents: number; +} + +/** Resolve the upper scan bound: a finalized head (or N confirmations behind). */ +export async function resolveScanHead( + provider: ethers.Provider, + confirmations: number +): Promise { + if (confirmations > 0) { + return (await provider.getBlockNumber()) - confirmations; + } + const finalized = await provider.getBlock("finalized"); + if (!finalized) { + throw new Error( + "Provider does not support the 'finalized' block tag; pass --confirmations N instead" + ); + } + return finalized.number; +} + +/** + * Scan WalletDerivationRegistered events and reconstruct the first owner per + * pkpId. Logs are collected across chunks then sorted by + * (blockNumber, transactionIndex, logIndex) so "first registration wins" is + * enforced regardless of provider ordering. + */ +export async function scanFirstOwners( + provider: ethers.Provider, + diamondAddress: string, + opts: { + fromBlock: number; + toBlock: number; + chunkSize: number; + log?: (msg: string) => void; + } +): Promise { + const { fromBlock, toBlock, chunkSize } = opts; + const readOnly = new ethers.Contract(diamondAddress, DIAMOND_ABI, provider); + const filter = readOnly.filters.WalletDerivationRegistered(); + + const allLogs: ethers.EventLog[] = []; + for (let start = fromBlock; start <= toBlock; start += chunkSize) { + const end = Math.min(start + chunkSize - 1, toBlock); + const logs = await readOnly.queryFilter(filter, start, end); + for (const log of logs) allLogs.push(log as ethers.EventLog); + if (opts.log && end < toBlock) { + opts.log(` scanned up to block ${end} (${allLogs.length} events)`); + } + } + + allLogs.sort( + (a, b) => + a.blockNumber - b.blockNumber || + a.transactionIndex - b.transactionIndex || + a.index - b.index + ); + + const firstByPkp = new Map(); + for (const log of allLogs) { + const masterHash = log.args[0] as bigint; + const pkpChecksummed = ethers.getAddress(log.args[1] as string); + const key = pkpChecksummed.toLowerCase(); + const existing = firstByPkp.get(key); + if (!existing) { + firstByPkp.set(key, { + pkpId: pkpChecksummed, + masterHash, + blockNumber: log.blockNumber, + txHash: log.transactionHash, + conflicts: [], + }); + } else if ( + existing.masterHash !== masterHash && + !existing.conflicts.includes(masterHash) + ) { + existing.conflicts.push(masterHash); + } + } + + const conflicted = [...firstByPkp.values()].filter( + (r) => r.conflicts.length > 0 + ); + return { firstByPkp, conflicted, scannedTo: toBlock, totalEvents: allLogs.length }; +} + +export interface OwnedPkp { + pkpId: string; // checksummed + masters: bigint[]; // account master hashes whose pkpData contains this pkpId +} + +export interface OwnershipResult { + byPkp: Map; // key = lowercase pkpId + conflicted: OwnedPkp[]; // held by >1 account (on-chain hijack evidence) + accountCount: number; + pkpCount: number; + totalRows: number; +} + +/** + * Authoritative ownership: enumerate every account and read its pkpData via + * listPkps. This is the exact mapping the node reads to resolve a signing key, + * and unlike WalletDerivationRegistered events it covers PKPs migrated into + * storage without emitting an event. A pkpId held by more than one account is a + * hijack that already landed on-chain. + */ +export async function buildOwnershipFromAccounts( + provider: ethers.Provider, + diamondAddress: string, + opts: { concurrency?: number; pageSize?: number; log?: (m: string) => void } = {} +): Promise { + const concurrency = opts.concurrency ?? 20; + const pageSize = opts.pageSize ?? 500; + const c = new ethers.Contract(diamondAddress, DIAMOND_ABI, provider); + const accountCount = Number(await c.accountCount()); + const pkpCount = Number(await c.pkpCount()); + const idx = Array.from({ length: accountCount }, (_, i) => i + 1); + const hashes = (await mapLimit(idx, concurrency, (i) => + c.indexToAccountHashAt(i) + )) as bigint[]; + + const byPkp = new Map(); + let totalRows = 0; + let done = 0; + await mapLimit(hashes, concurrency, async (h) => { + let page = 0; + while (true) { + const rows = await c.listPkps(h, page, pageSize); + for (const r of rows) { + const pkpChecksummed = ethers.getAddress(r.pkpId as string); + const key = pkpChecksummed.toLowerCase(); + let entry = byPkp.get(key); + if (!entry) { + entry = { pkpId: pkpChecksummed, masters: [] }; + byPkp.set(key, entry); + } + if (!entry.masters.includes(h)) entry.masters.push(h); + totalRows++; + } + if (rows.length < pageSize) break; + page++; + } + if (opts.log) opts.log(` accounts read: ${++done}/${accountCount}`); + }); + + const conflicted = [...byPkp.values()].filter((o) => o.masters.length > 1); + return { byPkp, conflicted, accountCount, pkpCount, totalRows }; +} + +export function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +export interface Pair { + pkpId: string; + masterHash: bigint; +} + +/** ABI-encode a single backfillPkpOwners(address[],uint256[]) call. */ +export function encodeBackfillCall(pairs: Pair[]): string { + return diamondIface.encodeFunctionData("backfillPkpOwners", [ + pairs.map((p) => p.pkpId), + pairs.map((p) => p.masterHash), + ]); +} + +export interface SafeTx { + to: string; + value: string; + data: string; + contractMethod: null; + contractInputsValues: null; +} + +export interface SafeBatch { + version: string; + chainId: string; + createdAt: number; + meta: { name: string; description: string; txBuilderVersion: string }; + transactions: SafeTx[]; +} + +export function buildSafeTx(diamondAddress: string, data: string): SafeTx { + return { + to: ethers.getAddress(diamondAddress), + value: "0", + data, + contractMethod: null, + contractInputsValues: null, + }; +} + +export function buildSafeBatch( + chainId: number | bigint, + createdAt: number, + name: string, + description: string, + transactions: SafeTx[] +): SafeBatch { + return { + version: "1.0", + chainId: chainId.toString(), + createdAt, + meta: { + name, + description, + txBuilderVersion: "1.16.5", + }, + transactions, + }; +} + +/** + * Rough gas estimate for a backfillPkpOwners call: one cold zero->nonzero + * SSTORE (~22.1k) + event (~1.9k) per pair, plus per-pair calldata (~2 words * + * ~40 gas amortized) and a fixed base. Deliberately conservative so operators + * don't under-size a Safe tx. + */ +export function estimateBackfillGas(pairCount: number): number { + return 50_000 + pairCount * 26_500; +} + +/** Decode Safe MultiSend packed bytes into individual inner calls. */ +export function decodeMultiSend( + multiSendData: string +): { to: string; value: bigint; data: string }[] { + const [packed] = multiSendIface.decodeFunctionData( + "multiSend", + multiSendData + ); + const bytes = ethers.getBytes(packed); + const calls: { to: string; value: bigint; data: string }[] = []; + let i = 0; + while (i < bytes.length) { + // operation(1) + to(20) + value(32) + dataLength(32) + data(dataLength) + i += 1; // skip operation + const to = ethers.getAddress(ethers.hexlify(bytes.slice(i, i + 20))); + i += 20; + const value = BigInt(ethers.hexlify(bytes.slice(i, i + 32))); + i += 32; + const len = Number(BigInt(ethers.hexlify(bytes.slice(i, i + 32)))); + i += 32; + const data = ethers.hexlify(bytes.slice(i, i + len)); + i += len; + calls.push({ to, value, data }); + } + return calls; +} + +/** + * Decode a list of Safe transactions (Transaction Builder `transactions[]`, or + * raw {to,data} calls) into backfill pairs. Handles direct backfillPkpOwners + * calls and MultiSend-wrapped bundles. Returns pairs plus the set of target + * addresses seen (so the caller can assert everything targets the diamond). + */ +export function decodeTransactionsToPairs( + txs: { to: string; data: string }[] +): { pairs: Pair[]; targets: Set } { + const pairs: Pair[] = []; + const targets = new Set(); + + const handleCall = (to: string, data: string) => { + const selector = data.slice(0, 10).toLowerCase(); + if (selector === MULTISEND_SELECTOR.toLowerCase()) { + for (const inner of decodeMultiSend(data)) { + handleCall(inner.to, inner.data); + } + return; + } + if (selector !== BACKFILL_SELECTOR.toLowerCase()) { + throw new Error( + `Unexpected function selector ${selector} for target ${to} (expected backfillPkpOwners ${BACKFILL_SELECTOR} or multiSend)` + ); + } + targets.add(ethers.getAddress(to).toLowerCase()); + const [pkpIds, masterHashes] = diamondIface.decodeFunctionData( + "backfillPkpOwners", + data + ); + if (pkpIds.length !== masterHashes.length) { + throw new Error( + `backfillPkpOwners call has mismatched array lengths (${pkpIds.length} vs ${masterHashes.length})` + ); + } + for (let k = 0; k < pkpIds.length; k++) { + pairs.push({ + pkpId: ethers.getAddress(pkpIds[k] as string), + masterHash: masterHashes[k] as bigint, + }); + } + }; + + for (const tx of txs) handleCall(tx.to, tx.data); + return { pairs, targets }; +} + +/** Simple concurrency-limited map for on-chain reads. */ +export async function mapLimit( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + async function worker() { + while (true) { + const i = next++; + if (i >= items.length) return; + results[i] = await fn(items[i], i); + } + } + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, () => worker()) + ); + return results; +} diff --git a/lit-api-server/blockchain/lit_node_express/tasks/pkp-backfill-safe.ts b/lit-api-server/blockchain/lit_node_express/tasks/pkp-backfill-safe.ts new file mode 100644 index 00000000..79b62256 --- /dev/null +++ b/lit-api-server/blockchain/lit_node_express/tasks/pkp-backfill-safe.ts @@ -0,0 +1,365 @@ +import { task } from "hardhat/config"; +import { ethers } from "ethers"; +import * as fs from "fs"; +import * as path from "path"; +import { + DIAMOND_ABI, + buildOwnershipFromAccounts, + buildSafeBatch, + buildSafeTx, + chunk, + decodeTransactionsToPairs, + encodeBackfillCall, + estimateBackfillGas, + mapLimit, + OwnedPkp, + Pair, + resolveScanHead, + scanFirstOwners, +} from "./lib/pkp-owners"; + +const DEFAULT_DIAMOND = "0xaAaAA9120fE271F653cfDb6bf400dB93D2DEa7Aa"; +const BASE_CHAIN_ID = 8453n; + +/** + * Resolve a Base RPC URL. Priority: explicit --rpc-url, then BASE_RPC_URL, then + * ALCHEMY_API_KEY (env or .context/.env), then the public endpoint (rate-limited + * for a full account enumeration). + */ +function resolveRpcUrl(explicit: string | undefined, repoRoot: string): string { + if (explicit) return explicit; + if (process.env.BASE_RPC_URL) return process.env.BASE_RPC_URL; + let alchemy = process.env.ALCHEMY_API_KEY; + if (!alchemy) { + const envPath = path.join(repoRoot, ".context", ".env"); + if (fs.existsSync(envPath)) { + const m = fs + .readFileSync(envPath, "utf8") + .match(/^\s*ALCHEMY_API_KEY\s*=\s*(.+?)\s*$/m); + if (m) alchemy = m[1].trim(); + } + } + if (alchemy) return `https://base-mainnet.g.alchemy.com/v2/${alchemy}`; + return "https://mainnet.base.org"; +} + +function findRepoRoot(): string { + let dir = process.cwd(); + for (let i = 0; i < 8; i++) { + if (fs.existsSync(path.join(dir, ".context"))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return process.cwd(); +} + +/** + * Resolve one master per pkpId. Single-owner pkpIds are unambiguous. Multi-owner + * pkpIds are on-chain hijacks: pick the first WalletDerivationRegistered + * registrant among the holders (the rule the contract enforces). Returns the + * chosen pairs plus the conflicts that could not be resolved. + */ +async function resolveOwners( + provider: ethers.Provider, + diamond: string, + owned: OwnedPkp[], + allowConflicts: boolean, + fromBlock: number, + chunkSize: number, + toBlock: number +): Promise<{ pairs: Pair[]; unresolved: OwnedPkp[] }> { + const single = owned.filter((o) => o.masters.length === 1); + const multi = owned.filter((o) => o.masters.length > 1); + const pairs: Pair[] = single.map((o) => ({ + pkpId: o.pkpId, + masterHash: o.masters[0], + })); + if (multi.length === 0 || !allowConflicts) { + return { pairs, unresolved: multi }; + } + // Disambiguate conflicts via events. + const scan = await scanFirstOwners(provider, diamond, { + fromBlock, + toBlock, + chunkSize, + }); + const unresolved: OwnedPkp[] = []; + for (const o of multi) { + const first = scan.firstByPkp.get(o.pkpId.toLowerCase()); + if (first && o.masters.some((m) => m === first.masterHash)) { + pairs.push({ pkpId: o.pkpId, masterHash: first.masterHash }); + } else { + unresolved.push(o); // no event, or first registrant no longer holds it + } + } + return { pairs, unresolved }; +} + +task( + "pkp-backfill:gen-safe", + "Generate Safe Transaction Builder JSON to backfill pkpIdToOwnerMaster (#575)" +) + .addOptionalParam("diamond", "Diamond proxy address", DEFAULT_DIAMOND) + .addOptionalParam("rpcUrl", "Base RPC URL (else BASE_RPC_URL / ALCHEMY_API_KEY)") + .addOptionalParam("batchSize", "pkpIds per backfillPkpOwners call", "1000") + .addOptionalParam( + "callsPerFile", + "backfillPkpOwners calls bundled into one Safe tx / file", + "2" + ) + .addOptionalParam("outDir", "Directory to write Safe JSON files", ".context/pkp-backfill") + .addOptionalParam("fromBlock", "Event scan start (conflict disambiguation only)", "0") + .addOptionalParam("chunkSize", "getLogs block range (conflict disambiguation)", "20000") + .addFlag( + "allowConflicts", + "Resolve multi-account pkpIds via first-registrant events instead of excluding them" + ) + .setAction(async (args, hre) => { + const repoRoot = findRepoRoot(); + const provider = new ethers.JsonRpcProvider( + resolveRpcUrl(args.rpcUrl, repoRoot) + ); + const diamond = ethers.getAddress(args.diamond); + const batchSize = parseInt(args.batchSize, 10); + const callsPerFile = parseInt(args.callsPerFile, 10); + + const net = await provider.getNetwork(); + console.log(`RPC chainId: ${net.chainId} diamond: ${diamond}`); + if (net.chainId !== BASE_CHAIN_ID) { + console.log(`⚠️ Not Base mainnet (${BASE_CHAIN_ID}). Continuing anyway.`); + } + + // Authoritative ownership from account enumeration (covers migrated PKPs). + console.log("Enumerating accounts + pkpData (authoritative ownership)..."); + const own = await buildOwnershipFromAccounts(provider, diamond, { + log: (m) => process.stdout.write("\r" + m), + }); + console.log( + `\naccounts=${own.accountCount} pkpCount=${own.pkpCount} distinct=${own.byPkp.size} conflicts=${own.conflicted.length}` + ); + if (own.byPkp.size !== own.pkpCount) { + console.log( + `⚠️ distinct owned (${own.byPkp.size}) != pkpCount (${own.pkpCount}). Investigate before proceeding.` + ); + } + + if (own.conflicted.length > 0) { + console.log(`\n⚠️ ${own.conflicted.length} pkpId(s) held by MULTIPLE accounts (on-chain hijack):`); + for (const o of own.conflicted.slice(0, 20)) { + console.log(` ${o.pkpId}: ${o.masters.map((m) => "0x" + m.toString(16)).join(", ")}`); + } + if (!args.allowConflicts) + console.log(" Excluded from batch. Investigate; re-run with --allow-conflicts to bind first-registrant."); + } + + const toBlock = await resolveScanHead(provider, 0); + const { pairs: resolved, unresolved } = await resolveOwners( + provider, + diamond, + [...own.byPkp.values()], + args.allowConflicts, + parseInt(args.fromBlock, 10), + parseInt(args.chunkSize, 10), + toBlock + ); + if (unresolved.length > 0) { + console.log(`⚠️ ${unresolved.length} conflict(s) could not be auto-resolved and are excluded.`); + } + + // Drop already-bound pkpIds (post-fix registrations or a prior run). + console.log("Checking current on-chain bindings..."); + const readOnly = new ethers.Contract(diamond, DIAMOND_ABI, provider); + const owners = (await mapLimit(resolved, 25, (r) => + readOnly.getPkpOwnerMaster(r.pkpId) + )) as bigint[]; + const toBind: Pair[] = []; + let alreadyCorrect = 0; + let boundWrong = 0; + resolved.forEach((r, i) => { + const o = owners[i]; + if (o === 0n) toBind.push(r); + else if (o === r.masterHash) alreadyCorrect++; + else { + boundWrong++; + console.log(` ⚠️ ${r.pkpId} bound to 0x${o.toString(16)}, expected 0x${r.masterHash.toString(16)}`); + } + }); + console.log(`${toBind.length} to bind, ${alreadyCorrect} already correct, ${boundWrong} bound elsewhere.`); + if (toBind.length === 0) { + console.log("Nothing to generate."); + return; + } + + // Batch into calls, bundle calls into per-file Safe transactions. + const calls = chunk(toBind, batchSize); + const files = chunk(calls, callsPerFile); + const outDir = path.isAbsolute(args.outDir) + ? args.outDir + : path.join(repoRoot, args.outDir); + fs.mkdirSync(outDir, { recursive: true }); + + const createdAt = Date.now(); + const manifest: any = { + diamond, + chainId: net.chainId.toString(), + scannedToBlock: toBlock, + accountCount: own.accountCount, + pkpCount: own.pkpCount, + totalToBind: toBind.length, + batchSize, + callsPerFile, + files: [] as any[], + }; + + files.forEach((fileCalls, fi) => { + const txs = fileCalls.map((pairs) => + buildSafeTx(diamond, encodeBackfillCall(pairs)) + ); + const pairsInFile = fileCalls.reduce((n, c) => n + c.length, 0); + const gas = fileCalls.reduce((g, c) => g + estimateBackfillGas(c.length), 0); + const batch = buildSafeBatch( + net.chainId, + createdAt, + `PKP owner backfill (#575) part ${fi + 1}/${files.length}`, + `${pairsInFile} pkpIds in ${txs.length} call(s). Diamond ${diamond}.`, + txs + ); + const fname = `safe-backfill-${String(fi + 1).padStart(2, "0")}.json`; + fs.writeFileSync(path.join(outDir, fname), JSON.stringify(batch, null, 2)); + manifest.files.push({ file: fname, calls: txs.length, pkpIds: pairsInFile, estGas: gas }); + console.log(` ${fname}: ${txs.length} call(s), ${pairsInFile} pkpIds, ~${(gas / 1e6).toFixed(1)}M gas`); + }); + + fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2)); + console.log(`\nWrote ${files.length} Safe batch file(s) + manifest.json to ${outDir}`); + console.log(`Verify before signing:\n npx hardhat pkp-backfill:verify-safe --dir ${args.outDir}`); + }); + +task( + "pkp-backfill:verify-safe", + "Verify Safe backfill JSON against authoritative on-chain ownership (#575)" +) + .addParam("dir", "Directory of safe-backfill-*.json files (or a single --file)") + .addOptionalParam("file", "Verify a single JSON file instead of a directory") + .addOptionalParam("diamond", "Diamond proxy address", DEFAULT_DIAMOND) + .addOptionalParam("rpcUrl", "Base RPC URL (else BASE_RPC_URL / ALCHEMY_API_KEY)") + .addFlag("allowConflicts", "Do not fail if conflicted (multi-account) pkpIds are included") + .setAction(async (args, hre) => { + const repoRoot = findRepoRoot(); + const provider = new ethers.JsonRpcProvider( + resolveRpcUrl(args.rpcUrl, repoRoot) + ); + const diamond = ethers.getAddress(args.diamond); + const net = await provider.getNetwork(); + + // 1. Load + decode every Safe transaction into (pkpId -> master) pairs. + const resolveIn = (p: string) => + path.isAbsolute(p) ? p : path.join(repoRoot, p); + let jsonFiles: string[]; + if (args.file) { + jsonFiles = [resolveIn(args.file)]; + } else { + const dir = resolveIn(args.dir); + jsonFiles = fs + .readdirSync(dir) + .filter((f) => f.startsWith("safe-backfill-") && f.endsWith(".json")) + .sort() + .map((f) => path.join(dir, f)); + } + if (jsonFiles.length === 0) throw new Error("No safe-backfill-*.json found"); + console.log(`Verifying ${jsonFiles.length} file(s).`); + + const errors: string[] = []; + const jsonPairs = new Map(); + let totalCalls = 0; + for (const f of jsonFiles) { + const batch = JSON.parse(fs.readFileSync(f, "utf8")); + if (batch.chainId && batch.chainId !== net.chainId.toString()) { + errors.push(`${path.basename(f)}: chainId ${batch.chainId} != RPC ${net.chainId}`); + } + const txs = (batch.transactions || []).map((t: any) => ({ to: t.to, data: t.data })); + totalCalls += txs.length; + const { pairs, targets } = decodeTransactionsToPairs(txs); + for (const t of targets) + if (t !== diamond.toLowerCase()) + errors.push(`${path.basename(f)}: call targets ${t}, not diamond ${diamond}`); + for (const p of pairs) { + const key = p.pkpId.toLowerCase(); + if (p.masterHash === 0n) errors.push(`${p.pkpId}: masterHash 0`); + const prev = jsonPairs.get(key); + if (prev !== undefined && prev !== p.masterHash) + errors.push(`${p.pkpId}: two different masters in JSON`); + jsonPairs.set(key, p.masterHash); + } + } + console.log(`Decoded ${jsonPairs.size} distinct bindings across ${totalCalls} call(s).`); + + // 2. Authoritative ownership from account enumeration. + console.log("Enumerating accounts + pkpData to re-derive truth..."); + const own = await buildOwnershipFromAccounts(provider, diamond, { + log: (m) => process.stdout.write("\r" + m), + }); + console.log(`\naccounts=${own.accountCount} distinct=${own.byPkp.size} conflicts=${own.conflicted.length}`); + + // 3. Every JSON pair must match an actual account owner (and be single-owner). + for (const [key, master] of jsonPairs) { + const truth = own.byPkp.get(key); + if (!truth) { + errors.push(`${key}: in JSON but not owned by any account`); + continue; + } + if (!truth.masters.includes(master)) + errors.push( + `${truth.pkpId}: JSON binds 0x${master.toString(16)} but no account holds it (holders: ${truth.masters + .map((m) => "0x" + m.toString(16)) + .join(",")})` + ); + if (!args.allowConflicts && truth.masters.length > 1) + errors.push(`${truth.pkpId}: conflicted (multi-account) without --allow-conflicts`); + } + + // 4. On-chain current binding: unbound (will write) or already correct. + console.log("Checking current on-chain bindings..."); + const readOnly = new ethers.Contract(diamond, DIAMOND_ABI, provider); + const keys = [...jsonPairs.keys()]; + const owners = (await mapLimit(keys, 25, (k) => readOnly.getPkpOwnerMaster(k))) as bigint[]; + let willWrite = 0; + let noop = 0; + keys.forEach((k, i) => { + const o = owners[i]; + const m = jsonPairs.get(k)!; + if (o === 0n) willWrite++; + else if (o === m) noop++; + else errors.push(`${k}: on-chain owner 0x${o.toString(16)} != JSON 0x${m.toString(16)}`); + }); + console.log(` ${willWrite} will be written, ${noop} already correct.`); + + // 5. Coverage: every unbound, single-owner pkpId must be in the JSON. + console.log("Coverage: every unbound owned pkpId present in JSON..."); + const ownedKeys = [...own.byPkp.keys()].filter( + (k) => args.allowConflicts || own.byPkp.get(k)!.masters.length === 1 + ); + const ownedBindings = (await mapLimit(ownedKeys, 25, (k) => + readOnly.getPkpOwnerMaster(k) + )) as bigint[]; + let missing = 0; + ownedKeys.forEach((k, i) => { + if (ownedBindings[i] === 0n && !jsonPairs.has(k)) { + missing++; + if (missing <= 20) errors.push(`${own.byPkp.get(k)!.pkpId}: unbound but MISSING from JSON`); + } + }); + if (missing > 20) errors.push(`... and ${missing - 20} more missing`); + console.log(` ${missing} unbound owned pkpId(s) missing from JSON.`); + + console.log("\n" + "=".repeat(60)); + if (errors.length === 0) { + console.log("VERIFY: PASS — the Safe JSON matches authoritative on-chain ownership."); + } else { + console.log(`VERIFY: FAIL — ${errors.length} problem(s):`); + for (const e of errors.slice(0, 50)) console.log(` ❌ ${e}`); + if (errors.length > 50) console.log(` ... and ${errors.length - 50} more`); + process.exitCode = 1; + } + }); diff --git a/lit-api-server/blockchain/lit_node_express/test/Accounts.t.sol b/lit-api-server/blockchain/lit_node_express/test/Accounts.t.sol index 09ac8ec2..00ca54c4 100644 --- a/lit-api-server/blockchain/lit_node_express/test/Accounts.t.sol +++ b/lit-api-server/blockchain/lit_node_express/test/Accounts.t.sol @@ -409,60 +409,6 @@ contract AccountsTest is BaseTest { assertEq(views_.getPkpOwnerMaster(pkpAddr), victimHash); } - function test_backfillPkpOwners_bindsLegacyWalletsAndBlocksHijack() public { - vm.prank(user); - writes.newChainSecuredAccount("legacy", "legacy"); - uint256 legacyHash = apiKeyHashOf(user); - vm.prank(stranger); - writes.newChainSecuredAccount("attacker", "attacker"); - uint256 attackerHash = apiKeyHashOf(stranger); - - // Simulate a pre-migration wallet: registered in the account but with no - // global owner binding (as if registered before the upgrade). - address legacyPkp = address(0x1E9AC7); - assertEq(views_.getPkpOwnerMaster(legacyPkp), 0); - - address[] memory pkpIds = new address[](1); - pkpIds[0] = legacyPkp; - uint256[] memory masters = new uint256[](1); - masters[0] = legacyHash; - - // Only the diamond owner or config operator may backfill. - vm.prank(stranger); - vm.expectRevert( - abi.encodeWithSelector( - AppStorage.OnlyConfigOperatorOrOwner.selector, - stranger - ) - ); - writes.backfillPkpOwners(pkpIds, masters); - - vm.prank(owner); - writes.backfillPkpOwners(pkpIds, masters); - assertEq(views_.getPkpOwnerMaster(legacyPkp), legacyHash); - - // Backfill never re-assigns an existing binding (idempotent, skip-if-set). - masters[0] = attackerHash; - vm.prank(owner); - writes.backfillPkpOwners(pkpIds, masters); - assertEq(views_.getPkpOwnerMaster(legacyPkp), legacyHash); - - // Post-backfill, the attacker cannot register the legacy wallet... - vm.prank(stranger); - vm.expectRevert( - abi.encodeWithSelector( - AppStorage.InvalidRequest.selector, - "PKP owned by another account" - ) - ); - writes.registerWalletDerivation(attackerHash, legacyPkp, 7, "a", "a"); - - // ...but the legacy owner can (e.g. #450 recovery re-registration). - vm.prank(user); - writes.registerWalletDerivation(legacyHash, legacyPkp, 7, "l", "l"); - assertEq(views_.getWalletDerivation(legacyHash, legacyPkp), 7); - } - /// @dev Storage slot of `pkpIdToOwnerMaster[pkpId]`. The mapping is the last /// field of AccountConfigStorage (field index 18, counting the two /// EnumerableSet fields as 2 slots each) at base slot @@ -510,10 +456,12 @@ contract AccountsTest is BaseTest { views_.getWalletDerivation(attackerHash, pkpAddr); } - function test_getWalletDerivation_legacyUnboundStillReadable() public { - // A pre-migration wallet has a local entry but no owner binding yet - // (owner==0). It must keep resolving so signing doesn't break in the - // window between the facet upgrade and the backfill run. + function test_getWalletDerivation_ownerZeroFailsClosed() public { + // Post-migration the compatibility fallback is gone: enforcement is + // unconditional. A local pkpData entry whose owner binding is 0 (an + // unexpected/legacy state that can no longer occur via the public API, + // since registerWalletDerivation always sets the owner) must fail closed + // rather than leak the path. vm.prank(stranger); writes.newChainSecuredAccount("u", "u"); uint256 hash = apiKeyHashOf(stranger); @@ -521,12 +469,19 @@ contract AccountsTest is BaseTest { vm.prank(stranger); writes.registerWalletDerivation(hash, pkpAddr, 42, "a", "a"); - // Clear the binding to reproduce the not-yet-backfilled legacy state. + // Normal case: owner is set to the account, read resolves. + assertEq(views_.getWalletDerivation(hash, pkpAddr), 42); + + // Force the anomalous owner==0 state and confirm it now reverts. vm.store(address(views_), _pkpOwnerSlot(pkpAddr), bytes32(uint256(0))); assertEq(views_.getPkpOwnerMaster(pkpAddr), 0); - - // owner==0 falls through: the wallet still resolves for its account. - assertEq(views_.getWalletDerivation(hash, pkpAddr), 42); + vm.expectRevert( + abi.encodeWithSelector( + AppStorage.InvalidRequest.selector, + "PKP owned by another account" + ) + ); + views_.getWalletDerivation(hash, pkpAddr); } function test_removeWalletDerivation_unregisteredReverts() public { diff --git a/lit-api-server/blockchain/lit_node_express/test/helpers/DiamondDeploy.sol b/lit-api-server/blockchain/lit_node_express/test/helpers/DiamondDeploy.sol index 5fdb905c..f631d627 100644 --- a/lit-api-server/blockchain/lit_node_express/test/helpers/DiamondDeploy.sol +++ b/lit-api-server/blockchain/lit_node_express/test/helpers/DiamondDeploy.sol @@ -146,7 +146,7 @@ library DiamondDeploy { } function writesSelectors() internal pure returns (bytes4[] memory s) { - s = new bytes4[](21); + s = new bytes4[](20); s[0] = WritesFacet.newChainSecuredAccount.selector; s[1] = WritesFacet.newAccount.selector; s[2] = WritesFacet.convertToChainSecuredAccount.selector; @@ -167,7 +167,6 @@ library DiamondDeploy { s[17] = WritesFacet.registerWalletDerivation.selector; s[18] = WritesFacet.removeWalletDerivation.selector; s[19] = WritesFacet.setNodeConfiguration.selector; - s[20] = WritesFacet.backfillPkpOwners.selector; } function apiConfigSelectors() internal pure returns (bytes4[] memory s) { diff --git a/lit-api-server/blockchain/lit_node_express/test/pkpBackfillSafe.test.ts b/lit-api-server/blockchain/lit_node_express/test/pkpBackfillSafe.test.ts new file mode 100644 index 00000000..c7d4122e --- /dev/null +++ b/lit-api-server/blockchain/lit_node_express/test/pkpBackfillSafe.test.ts @@ -0,0 +1,128 @@ +import { expect } from "chai"; +import { ethers, artifacts } from "hardhat"; +import { + BACKFILL_SELECTOR, + buildSafeBatch, + buildSafeTx, + chunk, + decodeMultiSend, + decodeTransactionsToPairs, + diamondIface, + encodeBackfillCall, + multiSendIface, + Pair, +} from "../tasks/lib/pkp-owners"; + +function pair(n: number): Pair { + return { + pkpId: ethers.getAddress( + "0x" + BigInt(n + 0x1000).toString(16).padStart(40, "0") + ), + masterHash: BigInt(n) * 7n + 1n, + }; +} + +/** Manually pack Safe MultiSend bytes for a list of {to,data} calls. */ +function packMultiSend(calls: { to: string; data: string }[]): string { + let packed = "0x"; + for (const c of calls) { + const data = ethers.getBytes(c.data); + packed += ethers.solidityPacked( + ["uint8", "address", "uint256", "uint256", "bytes"], + [0, c.to, 0, data.length, c.data] + ).slice(2); + } + return multiSendIface.encodeFunctionData("multiSend", [packed]); +} + +describe("pkp-backfill-safe lib", () => { + const diamond = ethers.getAddress( + "0xaAaAA9120fE271F653cfDb6bf400dB93D2DEa7Aa" + ); + + // NOTE: backfillPkpOwners was removed from the contract after the one-time + // #575 migration completed on-chain, so this tooling is historical. The + // BACKFILL_SELECTOR here is a frozen literal (0x41275609) used only to decode + // the migration batches that were executed; it is intentionally no longer + // cross-checked against the compiled WritesFacet (the function is gone). + it("DIAMOND_ABI selectors match the compiled facets (no ABI drift)", async () => { + const views = await artifacts.readArtifact("ViewsFacet"); + const writes = await artifacts.readArtifact("WritesFacet"); + const viewsIface = new ethers.Interface(views.abi); + const writesIface = new ethers.Interface(writes.abi); + + expect(diamondIface.getFunction("getPkpOwnerMaster")!.selector).to.equal( + viewsIface.getFunction("getPkpOwnerMaster")!.selector + ); + // The event topic the scan filters on must match the contract's event. + expect(diamondIface.getEvent("WalletDerivationRegistered")!.topicHash).to.equal( + writesIface.getEvent("WalletDerivationRegistered")!.topicHash + ); + // Frozen selector for the removed migration function. + expect(BACKFILL_SELECTOR).to.equal("0x41275609"); + }); + + it("encodes and decodes a backfill call round-trip", () => { + const pairs = [pair(1), pair(2), pair(3)]; + const data = encodeBackfillCall(pairs); + const tx = buildSafeTx(diamond, data); + expect(tx.to).to.equal(diamond); + expect(tx.value).to.equal("0"); + + const { pairs: decoded, targets } = decodeTransactionsToPairs([tx]); + expect(decoded).to.have.length(3); + expect([...targets]).to.deep.equal([diamond.toLowerCase()]); + for (let i = 0; i < pairs.length; i++) { + expect(decoded[i].pkpId).to.equal(pairs[i].pkpId); + expect(decoded[i].masterHash).to.equal(pairs[i].masterHash); + } + }); + + it("decodes multiple calls in one Safe batch file", () => { + const c1 = [pair(1), pair(2)]; + const c2 = [pair(3), pair(4), pair(5)]; + const batch = buildSafeBatch(8453, 123, "t", "d", [ + buildSafeTx(diamond, encodeBackfillCall(c1)), + buildSafeTx(diamond, encodeBackfillCall(c2)), + ]); + const { pairs } = decodeTransactionsToPairs( + batch.transactions.map((t) => ({ to: t.to, data: t.data })) + ); + expect(pairs.map((p) => p.pkpId)).to.deep.equal( + [...c1, ...c2].map((p) => p.pkpId) + ); + }); + + it("unwraps a MultiSend-bundled tx into backfill pairs", () => { + const c1 = [pair(10), pair(11)]; + const c2 = [pair(12)]; + const bundle = packMultiSend([ + { to: diamond, data: encodeBackfillCall(c1) }, + { to: diamond, data: encodeBackfillCall(c2) }, + ]); + const inner = decodeMultiSend(bundle); + expect(inner).to.have.length(2); + expect(inner[0].to).to.equal(diamond); + + const { pairs, targets } = decodeTransactionsToPairs([ + { to: "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", data: bundle }, + ]); + expect(pairs.map((p) => p.masterHash)).to.deep.equal( + [...c1, ...c2].map((p) => p.masterHash) + ); + expect([...targets]).to.deep.equal([diamond.toLowerCase()]); + }); + + it("rejects an unexpected selector", () => { + const bogus = "0xdeadbeef" + "00".repeat(32); + expect(() => + decodeTransactionsToPairs([{ to: diamond, data: bogus }]) + ).to.throw(/Unexpected function selector/); + }); + + it("chunk() splits evenly with a final short batch", () => { + const arr = Array.from({ length: 2500 }, (_, i) => i); + const batches = chunk(arr, 1000); + expect(batches.map((b) => b.length)).to.deep.equal([1000, 1000, 500]); + }); +}); diff --git a/lit-api-server/src/accounts/contracts/AccountConfig.json b/lit-api-server/src/accounts/contracts/AccountConfig.json index f5a18d8a..81f5c958 100644 --- a/lit-api-server/src/accounts/contracts/AccountConfig.json +++ b/lit-api-server/src/accounts/contracts/AccountConfig.json @@ -190,17 +190,6 @@ "name": "OnlyApiPayerOrOwner", "type": "error" }, - { - "inputs": [ - { - "internalType": "address", - "name": "caller", - "type": "address" - } - ], - "name": "OnlyConfigOperatorOrOwner", - "type": "error" - }, { "inputs": [ { @@ -477,25 +466,6 @@ "name": "PkpAddedToGroup", "type": "event" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "pkpId", - "type": "address" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "masterHash", - "type": "uint256" - } - ], - "name": "PkpOwnerBackfilled", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -716,24 +686,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address[]", - "name": "pkpIds", - "type": "address[]" - }, - { - "internalType": "uint256[]", - "name": "masterHashes", - "type": "uint256[]" - } - ], - "name": "backfillPkpOwners", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -2165,6 +2117,17 @@ "name": "NotContractOwner", "type": "error" }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "OnlyConfigOperatorOrOwner", + "type": "error" + }, { "anonymous": false, "inputs": [ diff --git a/lit-api-server/src/accounts/contracts/account_config_contract.rs b/lit-api-server/src/accounts/contracts/account_config_contract.rs index d55f1cef..a34f99fc 100644 --- a/lit-api-server/src/accounts/contracts/account_config_contract.rs +++ b/lit-api-server/src/accounts/contracts/account_config_contract.rs @@ -1642,7 +1642,6 @@ interface AccountConfig { event GroupRemoved(uint256 indexed apiKeyHash, uint256 indexed groupId); event GroupUpdated(uint256 indexed accountApiKeyHash, uint256 indexed groupId); event PkpAddedToGroup(uint256 indexed apiKeyHash, uint256 indexed groupId, address pkpId); - event PkpOwnerBackfilled(address indexed pkpId, uint256 indexed masterHash); event PkpRemovedFromGroup(uint256 indexed apiKeyHash, uint256 indexed groupId, address pkpId); event PricingOperatorUpdated(address indexed newPricingOperator); event PricingUpdated(uint256 indexed pricingItemId, uint256 price); @@ -1665,7 +1664,6 @@ interface AccountConfig { function apiKeyCanExecuteForAnyGroup(uint256 apiKeyHash, uint256[] memory groupIds) external view returns (bool); function apiPayerCount() external view returns (uint256); function api_payers() external view returns (address[] memory); - function backfillPkpOwners(address[] memory pkpIds, uint256[] memory masterHashes) external; function canExecuteAction(uint256 apiKeyHash, uint256 cidHash) external view returns (bool); function canExecuteActionAndUseWallet(uint256 apiKeyHash, uint256 cidHash, address walletAddress) external view returns (bool canExecute, bool canUseWallet); function canExecuteActionFast(uint256 apiKeyHash, uint256 cidHash) external view returns (bool); @@ -1955,24 +1953,6 @@ interface AccountConfig { ], "stateMutability": "view" }, - { - "type": "function", - "name": "backfillPkpOwners", - "inputs": [ - { - "name": "pkpIds", - "type": "address[]", - "internalType": "address[]" - }, - { - "name": "masterHashes", - "type": "uint256[]", - "internalType": "uint256[]" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, { "type": "function", "name": "canExecuteAction", @@ -3754,25 +3734,6 @@ interface AccountConfig { ], "anonymous": false }, - { - "type": "event", - "name": "PkpOwnerBackfilled", - "inputs": [ - { - "name": "pkpId", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "masterHash", - "type": "uint256", - "indexed": true, - "internalType": "uint256" - } - ], - "anonymous": false - }, { "type": "event", "name": "PkpRemovedFromGroup", @@ -7762,120 +7723,6 @@ pub mod AccountConfig { } }; #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] - /*Event with signature `PkpOwnerBackfilled(address,uint256)` and selector `0x30af16efa92e1e313413b3b542aaad6085038078dfc320d3b4436ace4d48b65b`. - ```solidity - event PkpOwnerBackfilled(address indexed pkpId, uint256 indexed masterHash); - ```*/ - #[allow( - non_camel_case_types, - non_snake_case, - clippy::pub_underscore_fields, - clippy::style - )] - #[derive(Clone)] - pub struct PkpOwnerBackfilled { - #[allow(missing_docs)] - pub pkpId: alloy::sol_types::private::Address, - #[allow(missing_docs)] - pub masterHash: alloy::sol_types::private::primitives::aliases::U256, - } - #[allow( - non_camel_case_types, - non_snake_case, - clippy::pub_underscore_fields, - clippy::style - )] - const _: () = { - use alloy::sol_types as alloy_sol_types; - #[automatically_derived] - impl alloy_sol_types::SolEvent for PkpOwnerBackfilled { - type DataTuple<'a> = (); - type DataToken<'a> = as alloy_sol_types::SolType>::Token<'a>; - type TopicList = ( - alloy_sol_types::sol_data::FixedBytes<32>, - alloy::sol_types::sol_data::Address, - alloy::sol_types::sol_data::Uint<256>, - ); - const SIGNATURE: &'static str = "PkpOwnerBackfilled(address,uint256)"; - const SIGNATURE_HASH: alloy_sol_types::private::B256 = - alloy_sol_types::private::B256::new([ - 48u8, 175u8, 22u8, 239u8, 169u8, 46u8, 30u8, 49u8, 52u8, 19u8, 179u8, 181u8, - 66u8, 170u8, 173u8, 96u8, 133u8, 3u8, 128u8, 120u8, 223u8, 195u8, 32u8, 211u8, - 180u8, 67u8, 106u8, 206u8, 77u8, 72u8, 182u8, 91u8, - ]); - const ANONYMOUS: bool = false; - #[allow(unused_variables)] - #[inline] - fn new( - topics: ::RustType, - data: as alloy_sol_types::SolType>::RustType, - ) -> Self { - Self { - pkpId: topics.1, - masterHash: topics.2, - } - } - #[inline] - fn check_signature( - topics: &::RustType, - ) -> alloy_sol_types::Result<()> { - if topics.0 != Self::SIGNATURE_HASH { - return Err(alloy_sol_types::Error::invalid_event_signature_hash( - Self::SIGNATURE, - topics.0, - Self::SIGNATURE_HASH, - )); - } - Ok(()) - } - #[inline] - fn tokenize_body(&self) -> Self::DataToken<'_> { - () - } - #[inline] - fn topics(&self) -> ::RustType { - ( - Self::SIGNATURE_HASH.into(), - self.pkpId.clone(), - self.masterHash.clone(), - ) - } - #[inline] - fn encode_topics_raw( - &self, - out: &mut [alloy_sol_types::abi::token::WordToken], - ) -> alloy_sol_types::Result<()> { - if out.len() < ::COUNT { - return Err(alloy_sol_types::Error::Overrun); - } - out[0usize] = alloy_sol_types::abi::token::WordToken(Self::SIGNATURE_HASH); - out[1usize] = ::encode_topic( - &self.pkpId, - ); - out[2usize] = as alloy_sol_types::EventTopic>::encode_topic(&self.masterHash); - Ok(()) - } - } - #[automatically_derived] - impl alloy_sol_types::private::IntoLogData for PkpOwnerBackfilled { - fn to_log_data(&self) -> alloy_sol_types::private::LogData { - From::from(self) - } - fn into_log_data(self) -> alloy_sol_types::private::LogData { - From::from(&self) - } - } - #[automatically_derived] - impl From<&PkpOwnerBackfilled> for alloy_sol_types::private::LogData { - #[inline] - fn from(this: &PkpOwnerBackfilled) -> alloy_sol_types::private::LogData { - alloy_sol_types::SolEvent::encode_log_data(this) - } - } - }; - #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] /*Event with signature `PkpRemovedFromGroup(uint256,uint256,address)` and selector `0x071e211a142d40078c2724eb4fc9fc3bd257912c5a496497605f420355521145`. ```solidity event PkpRemovedFromGroup(uint256 indexed apiKeyHash, uint256 indexed groupId, address pkpId); @@ -10618,157 +10465,6 @@ pub mod AccountConfig { } }; #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] - /*Function with signature `backfillPkpOwners(address[],uint256[])` and selector `0x41275609`. - ```solidity - function backfillPkpOwners(address[] memory pkpIds, uint256[] memory masterHashes) external; - ```*/ - #[allow(non_camel_case_types, non_snake_case, clippy::pub_underscore_fields)] - #[derive(Clone)] - pub struct backfillPkpOwnersCall { - #[allow(missing_docs)] - pub pkpIds: alloy::sol_types::private::Vec, - #[allow(missing_docs)] - pub masterHashes: - alloy::sol_types::private::Vec, - } - //Container type for the return parameters of the [`backfillPkpOwners(address[],uint256[])`](backfillPkpOwnersCall) function. - #[allow(non_camel_case_types, non_snake_case, clippy::pub_underscore_fields)] - #[derive(Clone)] - pub struct backfillPkpOwnersReturn {} - #[allow( - non_camel_case_types, - non_snake_case, - clippy::pub_underscore_fields, - clippy::style - )] - const _: () = { - use alloy::sol_types as alloy_sol_types; - { - #[doc(hidden)] - #[allow(dead_code)] - type UnderlyingSolTuple<'a> = ( - alloy::sol_types::sol_data::Array, - alloy::sol_types::sol_data::Array>, - ); - #[doc(hidden)] - type UnderlyingRustTuple<'a> = ( - alloy::sol_types::private::Vec, - alloy::sol_types::private::Vec< - alloy::sol_types::private::primitives::aliases::U256, - >, - ); - #[cfg(test)] - #[allow(dead_code, unreachable_patterns)] - fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq) { - match _t { - alloy_sol_types::private::AssertTypeEq::< - ::RustType, - >(_) => {} - } - } - #[automatically_derived] - #[doc(hidden)] - impl ::core::convert::From for UnderlyingRustTuple<'_> { - fn from(value: backfillPkpOwnersCall) -> Self { - (value.pkpIds, value.masterHashes) - } - } - #[automatically_derived] - #[doc(hidden)] - impl ::core::convert::From> for backfillPkpOwnersCall { - fn from(tuple: UnderlyingRustTuple<'_>) -> Self { - Self { - pkpIds: tuple.0, - masterHashes: tuple.1, - } - } - } - } - { - #[doc(hidden)] - #[allow(dead_code)] - type UnderlyingSolTuple<'a> = (); - #[doc(hidden)] - type UnderlyingRustTuple<'a> = (); - #[cfg(test)] - #[allow(dead_code, unreachable_patterns)] - fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq) { - match _t { - alloy_sol_types::private::AssertTypeEq::< - ::RustType, - >(_) => {} - } - } - #[automatically_derived] - #[doc(hidden)] - impl ::core::convert::From for UnderlyingRustTuple<'_> { - fn from(value: backfillPkpOwnersReturn) -> Self { - () - } - } - #[automatically_derived] - #[doc(hidden)] - impl ::core::convert::From> for backfillPkpOwnersReturn { - fn from(tuple: UnderlyingRustTuple<'_>) -> Self { - Self {} - } - } - } - impl backfillPkpOwnersReturn { - fn _tokenize( - &self, - ) -> ::ReturnToken<'_> { - () - } - } - #[automatically_derived] - impl alloy_sol_types::SolCall for backfillPkpOwnersCall { - type Parameters<'a> = ( - alloy::sol_types::sol_data::Array, - alloy::sol_types::sol_data::Array>, - ); - type Token<'a> = as alloy_sol_types::SolType>::Token<'a>; - type Return = backfillPkpOwnersReturn; - type ReturnTuple<'a> = (); - type ReturnToken<'a> = as alloy_sol_types::SolType>::Token<'a>; - const SIGNATURE: &'static str = "backfillPkpOwners(address[],uint256[])"; - const SELECTOR: [u8; 4] = [65u8, 39u8, 86u8, 9u8]; - #[inline] - fn new<'a>( - tuple: as alloy_sol_types::SolType>::RustType, - ) -> Self { - tuple.into() - } - #[inline] - fn tokenize(&self) -> Self::Token<'_> { - ( - as alloy_sol_types::SolType>::tokenize(&self.pkpIds), - , - > as alloy_sol_types::SolType>::tokenize(&self.masterHashes), - ) - } - #[inline] - fn tokenize_returns(ret: &Self::Return) -> Self::ReturnToken<'_> { - backfillPkpOwnersReturn::_tokenize(ret) - } - #[inline] - fn abi_decode_returns(data: &[u8]) -> alloy_sol_types::Result { - as alloy_sol_types::SolType>::abi_decode_sequence(data) - .map(Into::into) - } - #[inline] - fn abi_decode_returns_validate(data: &[u8]) -> alloy_sol_types::Result { - as alloy_sol_types::SolType>::abi_decode_sequence_validate( - data, - ) - .map(Into::into) - } - } - }; - #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] /*Function with signature `canExecuteAction(uint256,uint256)` and selector `0xff1fe00c`. ```solidity function canExecuteAction(uint256 apiKeyHash, uint256 cidHash) external view returns (bool); @@ -19449,8 +19145,6 @@ pub mod AccountConfig { #[allow(missing_docs)] api_payers(api_payersCall), #[allow(missing_docs)] - backfillPkpOwners(backfillPkpOwnersCall), - #[allow(missing_docs)] canExecuteAction(canExecuteActionCall), #[allow(missing_docs)] canExecuteActionAndUseWallet(canExecuteActionAndUseWalletCall), @@ -19590,7 +19284,6 @@ pub mod AccountConfig { [56u8, 54u8, 3u8, 254u8], [64u8, 180u8, 212u8, 83u8], [64u8, 212u8, 65u8, 27u8], - [65u8, 39u8, 86u8, 9u8], [74u8, 66u8, 196u8, 10u8], [77u8, 190u8, 191u8, 229u8], [80u8, 64u8, 144u8, 1u8], @@ -19661,7 +19354,6 @@ pub mod AccountConfig { ::core::stringify!(adminApiPayerAccount), ::core::stringify!(debitApiKey), ::core::stringify!(removeAction), - ::core::stringify!(backfillPkpOwners), ::core::stringify!(removePkpFromGroup), ::core::stringify!(newChainSecuredAccount), ::core::stringify!(listPkps), @@ -19732,7 +19424,6 @@ pub mod AccountConfig { ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, - ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, @@ -19805,7 +19496,7 @@ pub mod AccountConfig { impl alloy_sol_types::SolInterface for AccountConfigCalls { const NAME: &'static str = "AccountConfigCalls"; const MIN_DATA_LENGTH: usize = 0usize; - const COUNT: usize = 68usize; + const COUNT: usize = 67usize; #[inline] fn selector(&self) -> [u8; 4] { match self { @@ -19828,9 +19519,6 @@ pub mod AccountConfig { } Self::apiPayerCount(_) => ::SELECTOR, Self::api_payers(_) => ::SELECTOR, - Self::backfillPkpOwners(_) => { - ::SELECTOR - } Self::canExecuteAction(_) => { ::SELECTOR } @@ -20146,15 +19834,6 @@ pub mod AccountConfig { } removeAction }, - { - fn backfillPkpOwners( - data: &[u8], - ) -> alloy_sol_types::Result { - ::abi_decode_raw(data) - .map(AccountConfigCalls::backfillPkpOwners) - } - backfillPkpOwners - }, { fn removePkpFromGroup( data: &[u8], @@ -20785,17 +20464,6 @@ pub mod AccountConfig { } removeAction }, - { - fn backfillPkpOwners( - data: &[u8], - ) -> alloy_sol_types::Result { - ::abi_decode_raw_validate( - data, - ) - .map(AccountConfigCalls::backfillPkpOwners) - } - backfillPkpOwners - }, { fn removePkpFromGroup( data: &[u8], @@ -21325,11 +20993,6 @@ pub mod AccountConfig { Self::api_payers(inner) => { ::abi_encoded_size(inner) } - Self::backfillPkpOwners(inner) => { - ::abi_encoded_size( - inner, - ) - } Self::canExecuteAction(inner) => { ::abi_encoded_size( inner, @@ -21667,12 +21330,6 @@ pub mod AccountConfig { out, ) } - Self::backfillPkpOwners(inner) => { - ::abi_encode_raw( - inner, - out, - ) - } Self::canExecuteAction(inner) => { ::abi_encode_raw( inner, @@ -22885,8 +22542,6 @@ pub mod AccountConfig { #[allow(missing_docs)] PkpAddedToGroup(PkpAddedToGroup), #[allow(missing_docs)] - PkpOwnerBackfilled(PkpOwnerBackfilled), - #[allow(missing_docs)] PkpRemovedFromGroup(PkpRemovedFromGroup), #[allow(missing_docs)] PricingOperatorUpdated(PricingOperatorUpdated), @@ -22980,11 +22635,6 @@ pub mod AccountConfig { 188u8, 176u8, 95u8, 160u8, 2u8, 71u8, 184u8, 248u8, 24u8, 40u8, 236u8, 4u8, 201u8, 119u8, 55u8, 139u8, 233u8, 237u8, 223u8, 93u8, ], - [ - 48u8, 175u8, 22u8, 239u8, 169u8, 46u8, 30u8, 49u8, 52u8, 19u8, 179u8, 181u8, 66u8, - 170u8, 173u8, 96u8, 133u8, 3u8, 128u8, 120u8, 223u8, 195u8, 32u8, 211u8, 180u8, - 67u8, 106u8, 206u8, 77u8, 72u8, 182u8, 91u8, - ], [ 51u8, 95u8, 90u8, 252u8, 131u8, 254u8, 140u8, 90u8, 1u8, 26u8, 150u8, 220u8, 57u8, 188u8, 206u8, 159u8, 185u8, 212u8, 111u8, 181u8, 152u8, 101u8, 2u8, 247u8, 4u8, @@ -23066,7 +22716,6 @@ pub mod AccountConfig { ::core::stringify!(WalletDerivationRegistered), ::core::stringify!(ConfigOperatorUpdated), ::core::stringify!(GroupRemoved), - ::core::stringify!(PkpOwnerBackfilled), ::core::stringify!(PricingUpdated), ::core::stringify!(WalletDerivationRemoved), ::core::stringify!(GroupUpdated), @@ -23096,7 +22745,6 @@ pub mod AccountConfig { ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, - ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, @@ -23133,7 +22781,7 @@ pub mod AccountConfig { #[automatically_derived] impl alloy_sol_types::SolEventInterface for AccountConfigEvents { const NAME: &'static str = "AccountConfigEvents"; - const COUNT: usize = 27usize; + const COUNT: usize = 26usize; fn decode_raw_log( topics: &[alloy_sol_types::Word], data: &[u8], @@ -23263,15 +22911,6 @@ pub mod AccountConfig { ) .map(Self::PkpAddedToGroup) } - Some( - ::SIGNATURE_HASH, - ) => { - ::decode_raw_log( - topics, - data, - ) - .map(Self::PkpOwnerBackfilled) - } Some( ::SIGNATURE_HASH, ) => { @@ -23422,9 +23061,6 @@ pub mod AccountConfig { Self::PkpAddedToGroup(inner) => { alloy_sol_types::private::IntoLogData::to_log_data(inner) } - Self::PkpOwnerBackfilled(inner) => { - alloy_sol_types::private::IntoLogData::to_log_data(inner) - } Self::PkpRemovedFromGroup(inner) => { alloy_sol_types::private::IntoLogData::to_log_data(inner) } @@ -23507,9 +23143,6 @@ pub mod AccountConfig { Self::PkpAddedToGroup(inner) => { alloy_sol_types::private::IntoLogData::into_log_data(inner) } - Self::PkpOwnerBackfilled(inner) => { - alloy_sol_types::private::IntoLogData::into_log_data(inner) - } Self::PkpRemovedFromGroup(inner) => { alloy_sol_types::private::IntoLogData::into_log_data(inner) } @@ -23749,19 +23382,6 @@ pub mod AccountConfig { pub fn api_payers(&self) -> alloy_contract::SolCallBuilder<&P, api_payersCall, N> { self.call_builder(&api_payersCall) } - //Creates a new call builder for the [`backfillPkpOwners`] function. - pub fn backfillPkpOwners( - &self, - pkpIds: alloy::sol_types::private::Vec, - masterHashes: alloy::sol_types::private::Vec< - alloy::sol_types::private::primitives::aliases::U256, - >, - ) -> alloy_contract::SolCallBuilder<&P, backfillPkpOwnersCall, N> { - self.call_builder(&backfillPkpOwnersCall { - pkpIds, - masterHashes, - }) - } //Creates a new call builder for the [`canExecuteAction`] function. pub fn canExecuteAction( &self, @@ -24460,12 +24080,6 @@ pub mod AccountConfig { pub fn PkpAddedToGroup_filter(&self) -> alloy_contract::Event<&P, PkpAddedToGroup, N> { self.event_filter::() } - //Creates a new event filter for the [`PkpOwnerBackfilled`] event. - pub fn PkpOwnerBackfilled_filter( - &self, - ) -> alloy_contract::Event<&P, PkpOwnerBackfilled, N> { - self.event_filter::() - } //Creates a new event filter for the [`PkpRemovedFromGroup`] event. pub fn PkpRemovedFromGroup_filter( &self,