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
131 changes: 64 additions & 67 deletions src/Keystore.sol

Large diffs are not rendered by default.

7 changes: 2 additions & 5 deletions src/accounts/DefaultAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ address constant TRUSTED_EXECUTOR = address(uint160(uint256(keccak256("trustedEx
/// example ERC-4337 on a chain without native EIP-8130 support) should delegate or deploy to a purpose-built
/// account, not to this one. If this bytecode is deployed anyway, it MUST sit behind an upgradeable (UUPS)
/// proxy: adopting those features later means swapping in different bytecode, which is only possible if the
/// deployment is upgradeable. An upgradeable (UUPS) variant is provided as an unaudited example in a separate
/// repository.
/// deployment is upgradeable.
///
/// @author Coinbase
contract DefaultAccount is Receiver {
Expand Down Expand Up @@ -82,9 +81,7 @@ contract DefaultAccount is Receiver {

/// @notice Executes a single call from the account.
///
/// @dev Equivalent to a one-element {executeBatch}. Selector-compatible with the widely deployed
/// CoinbaseSmartWallet V1 `execute(address,uint256,bytes)` (0xb61d27f6), so integrations that call that ABI
/// directly (e.g. SpendPermissionManager) keep working against this account.
/// @dev Equivalent to a one-element {executeBatch}. Included for selector-compatibility with common existing wallet implementations.
/// @dev Reverts with UnauthorizedCaller when the caller is neither the account nor a TRUSTED_EXECUTOR actor.
/// @dev Bubbles up the inner call's revert reason verbatim (a reason-less revert propagates as an empty revert).
///
Expand Down
2 changes: 1 addition & 1 deletion src/interfaces/ITransactionContext.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pragma solidity ^0.8.0;
/// @dev Canonical address of the EIP-8130 Transaction Context precompile. Populated by the protocol only while it
/// is dispatching a transaction's calls; STATICCALL returns zero/default values elsewhere (e.g. during
/// validation, or on non-8130 chains where no code lives here).
// 8310 spec puts this at 0x813000000000000000000000000000000000aa02
// 8130 spec puts this at 0x813000000000000000000000000000000000aa02
address constant TX_CONTEXT_ADDRESS = 0x813000000000000000000000000000000000aa02;

/// @notice Reference interface for the EIP-8130 Transaction Context precompile at TX_CONTEXT_ADDRESS.
Expand Down
5 changes: 3 additions & 2 deletions src/policies/PolicyManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ contract PolicyManager is ReentrancyGuard {

/// @dev External-path validation: live external-pull actor, manager-match, live commitment vs binding, then
/// enforce. The external path has no protocol auth, so the manager gates the caller itself with a single
/// liveness-resolved {Keystore.getActor} read: the actor must carry EXTERNAL_POLICY_AUTHENTICATOR (the
/// liveness-resolved {Keystore.getActorWithPolicy} read: the actor must carry EXTERNAL_POLICY_AUTHENTICATOR (the
/// no-code sentinel marking an external-pull actor) and be gated to this manager. A non-live or ungated
/// actor resolves to a zero manager, failing the manager-match.
function _enforceExternal(
Expand All @@ -219,7 +219,8 @@ contract PolicyManager is ReentrancyGuard {
) internal {
address account = binding.account;

(Keystore.ActorConfig memory config, address manager, bytes32 signed) = KEYSTORE.getActor(account, actorId);
(Keystore.ActorConfig memory config, address manager, bytes32 signed) =
KEYSTORE.getActorWithPolicy(account, actorId);
if (config.authenticator != EXTERNAL_POLICY_AUTHENTICATOR) revert InvalidActor(actorId);
if (manager != address(this)) revert NoActivePolicy(actorId);

Expand Down
4 changes: 2 additions & 2 deletions src/policies/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ protocol-side, not enforced by this contract.
1. **Authorize + commit.** The account authorizes the session key with `scope = Scopes.POLICY`,
`policy_manager = PolicyManager`, and `policy_commitment = keccak256` of an account-authorized
[`PolicyBinding`](./PolicyManager.sol). The Keystore contract exposes this via
[`getActor(account, actorId)`](../Keystore.sol) (or the granular `getPolicyManager` / `getPolicyCommitment`).
[`getActorWithPolicy(account, actorId)`](../Keystore.sol) (or the granular `getPolicyManager` / `getPolicyCommitment`).
That signed actor change *is* the authorization — there is no separate install step on the manager.
2. **Use.** When the session key transacts, the protocol gate resolves the key's allowed target
(`policy_manager(account, actorId)`) and reverts any call whose `call.to` isn't that address before dispatch, so
Expand All @@ -28,7 +28,7 @@ protocol-side, not enforced by this contract.
revoked *or expired* key reads back a zero commitment and stops immediately: `getPolicyCommitment` (like every
Keystore read accessor) is liveness-gated and resolves an expired actor to zero, identical to a revoked one.
`execute` doesn't rely on this — protocol authentication already rejects expired actors before dispatch (the
external `executeFor` path enforces expiry itself, via a single `getActor` read) — but the gating means no off-chain
external `executeFor` path enforces expiry itself, via a single `getActorWithPolicy` read) — but the gating means no off-chain
reader ever sees a live-looking commitment for a dead actor. The manager then invokes the policy, forwards a
non-empty `executeBatch` plan to the account, and calls `onPostExecute` when applicable.

Expand Down
3 changes: 1 addition & 2 deletions src/policies/RecurringAllowance.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ pragma solidity ^0.8.30;
///
/// @notice Reusable recurring-allowance accounting for policies (weekly/periodic spend limits).
///
/// @dev Keyed by `commitment` so the manager can remain fully stateless. Ported from
/// base/account-policies for the EIP-8130 reference example.
/// @dev Keyed by `commitment` so the manager can remain fully stateless.
library RecurringAllowance {
/// @notice Allowance bounds for a recurring spend window.
struct Limit {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/Keystore/applyAccountChange.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ contract AccountEnvironmentTest is KeystoreTest {
assertEq(seq1, 1);

// The pre-signed sequenced-at-0 batch no longer matches the advanced counter.
vm.expectRevert(Keystore.BadSequence.selector);
vm.expectRevert(abi.encodeWithSelector(Keystore.BadSequence.selector, uint64(seq1), uint64(0)));
keystore.applySignedAccountChanges(account, seqZero);
}

Expand Down
4 changes: 3 additions & 1 deletion test/unit/Keystore/applyKeyChange.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -608,8 +608,10 @@ contract ApplySignedAccountChangesTest is KeystoreTest {
_one(_authorizeChange(ACTOR_A, address(k1Authenticator), SENDER, _future(1 days), ""))
);

(, uint32 seqSigned) = _localEpochSeq(account);
keystore.applySignedAccountChanges(account, s);
vm.expectRevert(Keystore.BadSequence.selector);
// The first apply advanced localSequence past the value the batch was signed at.
vm.expectRevert(abi.encodeWithSelector(Keystore.BadSequence.selector, uint64(seqSigned + 1), uint64(seqSigned)));
keystore.applySignedAccountChanges(account, s);
}

Expand Down
59 changes: 57 additions & 2 deletions test/unit/Keystore/createAccount.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pragma solidity ^0.8.30;

import {Keystore} from "../../../src/Keystore.sol";
import {KeystoreTest} from "../../lib/KeystoreTest.sol";
import {Scopes} from "../../../src/libraries/Scopes.sol";

/// @dev Fully fuzzed, branch-complete suite for Keystore.createAccount and the pure/view
/// machinery it drives: computeAddress, _buildDeploymentCode, _computeEffectiveSalt / _computeActorsCommitment,
Expand Down Expand Up @@ -53,6 +54,25 @@ contract CreateAccountTest is KeystoreTest {
if (bc[0] == 0xEF) bc[0] = 0x00; // EIP-3541: leading 0xEF is rejected as runtime code
}

/// @dev Test-side reproduction of _buildDeploymentCode from the documented loader opcodes (PUSH2 n; PUSH1 0x0e;
/// PUSH1 0x00; CODECOPY; PUSH2 n; PUSH1 0x00; RETURN), independent of the contract's implementation so drift
/// in either is caught.
function _reproduceInitCode(bytes memory bytecode) internal pure returns (bytes memory) {
uint16 n = uint16(bytecode.length);
return abi.encodePacked(bytes1(0x61), bytes2(n), hex"600e600039", bytes1(0x61), bytes2(n), hex"6000f3", bytecode);
}

/// @dev Test-side reproduction of _computeActorsCommitment from the documented client scheme:
/// leaf_i = keccak256(actorId || authenticator || scope || policyData); commitment = keccak256(leaf_0 || ...).
function _reproduceActorsCommitment(Keystore.InitialActor[] memory actors) internal pure returns (bytes32) {
bytes32[] memory leaves = new bytes32[](actors.length);
for (uint256 i; i < actors.length; i++) {
leaves[i] =
keccak256(abi.encodePacked(actors[i].actorId, actors[i].authenticator, actors[i].scope, actors[i].policyData));
}
return keccak256(abi.encodePacked(leaves));
}

// ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
// REVERTS (source-execution order)
// ≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
Expand Down Expand Up @@ -302,7 +322,9 @@ contract CreateAccountTest is KeystoreTest {

/// @notice Verifies arbitrary valid runtime bytecode of a fuzzed length/content deploys successfully
/// @dev The deployment header CODECOPY+RETURNs the bytes as runtime code without executing them, so any EIP-170-
/// sized, non-0xEF-leading payload deploys; the deployed code length matches the requested bytecode length
/// sized, non-0xEF-leading payload deploys. Asserting the deployed code equals the requested bytecode
/// exactly (not just its length) validates the loader's CODECOPY/RETURN offsets in _buildDeploymentCode: a
/// shifted offset could preserve the length while corrupting the returned bytes.
function test_createAccount_success_arbitraryBytecode(uint256 lenSeed, bytes32 content, uint256 pk, bytes32 salt)
public
{
Expand All @@ -312,7 +334,7 @@ contract CreateAccountTest is KeystoreTest {

address account = keystore.createAccount(salt, bytecode, actors);

assertEq(account.code.length, bytecode.length);
assertEq(account.code, bytecode);
}

/// @notice Verifies bytecode of exactly 0xFFFF bytes is accepted (the encodable maximum is inclusive)
Expand Down Expand Up @@ -525,4 +547,37 @@ contract CreateAccountTest is KeystoreTest {

assertTrue(addrA != addrB);
}

/// @notice Verifies computeAddress matches an independent CREATE2 oracle for a mixed (ungated + policy-gated) actor
/// set, validating the full salt/commitment/initcode/formula chain against a reproduction that never calls
/// the contract's own helpers.
/// @dev The actor set mixes an ungated actor (scope 0, empty policyData) and a gated one (Scopes.POLICY, 52-byte
/// policyData = manager(20) || commitment(32)) so the packed leaf encoding is exercised for both scope and
/// policyData. The CREATE2 formula itself is checked via forge-std's vm.computeCreate2Address, an
/// implementation independent of _prepareDeployment.
function test_computeAddress_success_matchesIndependentOracle(
bytes32 userSalt,
uint256 lenSeed,
bytes32 content,
address manager,
bytes32 policyCommitment
) public view {
Keystore.InitialActor[] memory actors = new Keystore.InitialActor[](2);
actors[0] = Keystore.InitialActor({
actorId: bytes32(uint256(1)), authenticator: address(k1Authenticator), scope: 0, policyData: ""
});
actors[1] = Keystore.InitialActor({
actorId: bytes32(uint256(2)),
authenticator: address(k1Authenticator),
scope: Scopes.POLICY,
policyData: abi.encodePacked(manager, policyCommitment) // 20 + 32 = 52 bytes
});
bytes memory bytecode = _validBytecode(lenSeed, content);

bytes32 effectiveSalt = keccak256(abi.encodePacked(userSalt, _reproduceActorsCommitment(actors)));
bytes32 initCodeHash = keccak256(_reproduceInitCode(bytecode));
address expected = vm.computeCreate2Address(effectiveSalt, initCodeHash, address(keystore));

assertEq(keystore.computeAddress(userSalt, bytecode, actors), expected);
}
}
Loading
Loading