Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading