From 7d02ed1101b5544990ad8ee0381068471d2364a2 Mon Sep 17 00:00:00 2001 From: 4player90 <165666544+4player90@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:32:24 +1300 Subject: [PATCH 1/3] Add passkey validation tests and rename batch test contract Cover passkey paths in validateUserOp that had no tests: - a passkey signature over the wrong hash returns 1 - a passkey can sign a replayable (nonce key 8453) UserOp, and the signature stays valid after a chain ID change - a removed passkey owner's signature reverts with InvalidOwnerBytesLength - the FreshCryptoLib fallback verifier accepts the same signature (Foundry can't etch over the P-256 precompile at 0x100, so it is called directly) Rename the contract in ExecuteBatch.t.sol from TestExecuteWithoutChainIdValidation to TestExecuteBatch. Co-Authored-By: Claude Opus 5.5 --- test/CoinbaseSmartWallet/ExecuteBatch.t.sol | 2 +- test/CoinbaseSmartWallet/ValidateUserOp.t.sol | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/test/CoinbaseSmartWallet/ExecuteBatch.t.sol b/test/CoinbaseSmartWallet/ExecuteBatch.t.sol index 7251a39..c19d2e6 100644 --- a/test/CoinbaseSmartWallet/ExecuteBatch.t.sol +++ b/test/CoinbaseSmartWallet/ExecuteBatch.t.sol @@ -6,7 +6,7 @@ import "solady/../test/utils/TestPlus.sol"; import {MockTarget} from "../mocks/MockTarget.sol"; import "./SmartWalletTestBase.sol"; -contract TestExecuteWithoutChainIdValidation is SmartWalletTestBase, TestPlus { +contract TestExecuteBatch is SmartWalletTestBase, TestPlus { function testExecuteBatch() public { vm.deal(address(account), 1 ether); vm.prank(signer); diff --git a/test/CoinbaseSmartWallet/ValidateUserOp.t.sol b/test/CoinbaseSmartWallet/ValidateUserOp.t.sol index 026729f..bbe6908 100644 --- a/test/CoinbaseSmartWallet/ValidateUserOp.t.sol +++ b/test/CoinbaseSmartWallet/ValidateUserOp.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.0; import "webauthn-sol/../test/Utils.sol"; +import {FCL_ecdsa} from "FreshCryptoLib/FCL_ecdsa.sol"; import {MockEntryPoint} from "../mocks/MockEntryPoint.sol"; import "./SmartWalletTestBase.sol"; @@ -78,6 +79,85 @@ contract TestValidateUserOp is SmartWalletTestBase { assertEq(ep.validateUserOp(address(account), userOp, t.userOpHash, t.missingAccountFunds), 0); } + function test_returnsOne_whenPasskeySignedDifferentHash() public { + bytes32 userOpHash = keccak256("123"); + bytes memory sig = _passkeySignature(keccak256("456")); + + vm.etch(account.entryPoint(), address(new MockEntryPoint()).code); + MockEntryPoint ep = MockEntryPoint(payable(account.entryPoint())); + + UserOperation memory userOp; + userOp.signature = sig; + assertEq(ep.validateUserOp(address(account), userOp, userOpHash, 0), 1); + } + + function test_succeedsWithPasskeySigner_onReplayableUserOp() public { + UserOperation memory userOp; + userOp.sender = address(account); + userOp.nonce = account.REPLAYABLE_NONCE_KEY() << 64; + userOp.callData = abi.encodeWithSelector(CoinbaseSmartWallet.executeWithoutChainIdValidation.selector); + // The passkey signs the chain-agnostic hash, not the hash the EntryPoint passes in. + userOp.signature = _passkeySignature(account.getUserOpHashWithoutChainId(userOp)); + + vm.etch(account.entryPoint(), address(new MockEntryPoint()).code); + MockEntryPoint ep = MockEntryPoint(payable(account.entryPoint())); + + assertEq(ep.validateUserOp(address(account), userOp, keccak256("ignored for replayable ops"), 0), 0); + + // The same signature is still valid after switching chains. + vm.chainId(block.chainid + 1); + assertEq(ep.validateUserOp(address(account), userOp, keccak256("ignored for replayable ops"), 0), 0); + } + + function test_reverts_whenPasskeyOwnerRemoved() public { + bytes32 userOpHash = keccak256("123"); + bytes memory sig = _passkeySignature(userOpHash); + + vm.prank(signer); + account.removeOwnerAtIndex(1, passkeyOwner); + + vm.etch(account.entryPoint(), address(new MockEntryPoint()).code); + MockEntryPoint ep = MockEntryPoint(payable(account.entryPoint())); + + UserOperation memory userOp; + userOp.signature = sig; + vm.expectRevert(abi.encodeWithSelector(MultiOwnable.InvalidOwnerBytesLength.selector, "")); + ep.validateUserOp(address(account), userOp, userOpHash, 0); + } + + // Foundry won't etch over the P-256 precompile at 0x100, so check the Solidity fallback + // that WebAuthn uses on chains without it directly. + function test_fallbackVerifierAcceptsPasskeySignature() public view { + WebAuthnInfo memory webAuthn = Utils.getWebAuthnStruct(keccak256("123")); + (bytes32 r, bytes32 s) = vm.signP256(passkeyPrivateKey, webAuthn.messageHash); + s = bytes32(Utils.normalizeS(uint256(s))); + (uint256 x, uint256 y) = abi.decode(passkeyOwner, (uint256, uint256)); + + assertTrue(FCL_ecdsa.ecdsa_verify(webAuthn.messageHash, uint256(r), uint256(s), x, y)); + assertFalse(FCL_ecdsa.ecdsa_verify(keccak256("456"), uint256(r), uint256(s), x, y)); + } + + function _passkeySignature(bytes32 hash) internal view returns (bytes memory) { + WebAuthnInfo memory webAuthn = Utils.getWebAuthnStruct(hash); + (bytes32 r, bytes32 s) = vm.signP256(passkeyPrivateKey, webAuthn.messageHash); + s = bytes32(Utils.normalizeS(uint256(s))); + return abi.encode( + CoinbaseSmartWallet.SignatureWrapper({ + ownerIndex: 1, + signatureData: abi.encode( + WebAuthn.WebAuthnAuth({ + authenticatorData: webAuthn.authenticatorData, + clientDataJSON: webAuthn.clientDataJSON, + typeIndex: 1, + challengeIndex: 23, + r: uint256(r), + s: uint256(s) + }) + ) + }) + ); + } + function test_reverts_whenSelectorInvalidForReplayableNonceKey() public { UserOperation memory userOp; userOp.nonce = 0; From fe3e293e45b31fa252ac0ea349fa812990f1cf48 Mon Sep 17 00:00:00 2001 From: 4player90 <165666544+4player90@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:02:49 +1300 Subject: [PATCH 2/3] Encode calls in replayable passkey test for v1.1.0 v1.1.0 validateUserOp decodes executeWithoutChainIdValidation calls to check upgrade targets, so the test's bare selector made decoding revert. Pass an empty bytes[] instead. Co-Authored-By: Claude Opus 5.5 --- test/CoinbaseSmartWallet/ValidateUserOp.t.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/CoinbaseSmartWallet/ValidateUserOp.t.sol b/test/CoinbaseSmartWallet/ValidateUserOp.t.sol index bbe6908..c0981d3 100644 --- a/test/CoinbaseSmartWallet/ValidateUserOp.t.sol +++ b/test/CoinbaseSmartWallet/ValidateUserOp.t.sol @@ -95,7 +95,9 @@ contract TestValidateUserOp is SmartWalletTestBase { UserOperation memory userOp; userOp.sender = address(account); userOp.nonce = account.REPLAYABLE_NONCE_KEY() << 64; - userOp.callData = abi.encodeWithSelector(CoinbaseSmartWallet.executeWithoutChainIdValidation.selector); + // validateUserOp decodes the calls to check upgrade targets, so they must be ABI-encoded. + userOp.callData = + abi.encodeWithSelector(CoinbaseSmartWallet.executeWithoutChainIdValidation.selector, new bytes[](0)); // The passkey signs the chain-agnostic hash, not the hash the EntryPoint passes in. userOp.signature = _passkeySignature(account.getUserOpHashWithoutChainId(userOp)); From c31ae8b2925e1877c7bf4ca6084f9549b00bcd18 Mon Sep 17 00:00:00 2001 From: 4player90 <165666544+4player90@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:12:04 +1300 Subject: [PATCH 3/3] Add MagicSpend contract and tests --- src/MagicSpend.sol | 105 ++++++++++++++++++++++++++++++++++++++++++ test/MagicSpend.t.sol | 85 ++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 src/MagicSpend.sol create mode 100644 test/MagicSpend.t.sol diff --git a/src/MagicSpend.sol b/src/MagicSpend.sol new file mode 100644 index 0000000..7570093 --- /dev/null +++ b/src/MagicSpend.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol"; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; +import {SignatureCheckerLib} from "solady/utils/SignatureCheckerLib.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; + +contract MagicSpend is Ownable { + constructor() { + _initializeOwner(msg.sender); + } + + struct WithdrawRequest { + bytes signature; + address asset; + uint256 amount; + uint256 nonce; + uint48 expiry; + } + + error InvalidSignature(); + error Expired(); + error InvalidNonce(uint256 nonce); + error InvalidAddress(); + + event MagicSpendWithdrawal(address indexed account, address indexed asset, uint256 amount, uint256 nonce); + + mapping(uint256 => mapping(address => bool)) private _nonceUsed; + + function withdraw(WithdrawRequest memory withdrawRequest) external { + if (block.timestamp > withdrawRequest.expiry) revert Expired(); + if (!isValidWithdrawSignature(msg.sender, withdrawRequest)) revert InvalidSignature(); + _validateRequest(msg.sender, withdrawRequest); + _withdraw(withdrawRequest.asset, msg.sender, withdrawRequest.amount); + emit MagicSpendWithdrawal(msg.sender, withdrawRequest.asset, withdrawRequest.amount, withdrawRequest.nonce); + } + + function ownerWithdraw(address asset, address to, uint256 amount) external onlyOwner { + if (to == address(0)) revert InvalidAddress(); + _withdraw(asset, to, amount); + emit MagicSpendWithdrawal(to, asset, amount, 0); + } + + function entryPointDeposit() external payable onlyOwner { + SafeTransferLib.safeTransferETH(entryPoint(), msg.value); + } + + function entryPointWithdraw(address payable to, uint256 amount) external onlyOwner { + if (to == address(0)) revert InvalidAddress(); + IEntryPoint(entryPoint()).withdrawTo(to, amount); + } + + function entryPointAddStake(uint256 amount, uint32 unstakeDelaySeconds) external payable onlyOwner { + IEntryPoint(entryPoint()).addStake{value: amount}(unstakeDelaySeconds); + } + + function entryPointUnlockStake() external onlyOwner { + IEntryPoint(entryPoint()).unlockStake(); + } + + function entryPointWithdrawStake(address payable to) external onlyOwner { + if (to == address(0)) revert InvalidAddress(); + IEntryPoint(entryPoint()).withdrawStake(to); + } + + function isValidWithdrawSignature(address account, WithdrawRequest memory withdrawRequest) + public view returns (bool) + { + return SignatureCheckerLib.isValidSignatureNow( + owner(), getHash(account, withdrawRequest), withdrawRequest.signature + ); + } + + function getHash(address account, WithdrawRequest memory withdrawRequest) public view returns (bytes32) { + return SignatureCheckerLib.toEthSignedMessageHash( + abi.encode( + address(this), account, block.chainid, + withdrawRequest.asset, withdrawRequest.amount, + withdrawRequest.nonce, withdrawRequest.expiry + ) + ); + } + + function nonceUsed(address account, uint256 nonce) external view returns (bool) { + return _nonceUsed[nonce][account]; + } + + function entryPoint() public pure returns (address) { + return 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789; + } + + function _validateRequest(address account, WithdrawRequest memory withdrawRequest) internal { + if (_nonceUsed[withdrawRequest.nonce][account]) revert InvalidNonce(withdrawRequest.nonce); + _nonceUsed[withdrawRequest.nonce][account] = true; + } + + function _withdraw(address asset, address to, uint256 amount) internal { + if (asset == address(0)) { + SafeTransferLib.safeTransferETH(to, amount); + } else { + SafeTransferLib.safeTransfer(asset, to, amount); + } + } +} diff --git a/test/MagicSpend.t.sol b/test/MagicSpend.t.sol new file mode 100644 index 0000000..70fdda6 --- /dev/null +++ b/test/MagicSpend.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {MagicSpend} from "../src/MagicSpend.sol"; + +contract MagicSpendTest is Test { + MagicSpend magicSpend; + uint256 ownerKey = 0xA11CE; + address owner; + address user = address(0x1234); + + function setUp() public { + owner = vm.addr(ownerKey); + vm.prank(owner); + magicSpend = new MagicSpend(); + vm.deal(address(magicSpend), 10 ether); + } + + function _makeRequest(uint256 amount, uint256 nonce, uint48 expiry, address asset) + internal view returns (MagicSpend.WithdrawRequest memory req) + { + req = MagicSpend.WithdrawRequest({ + signature: "", + asset: asset, + amount: amount, + nonce: nonce, + expiry: expiry + }); + bytes32 hash = magicSpend.getHash(user, req); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, hash); + req.signature = abi.encodePacked(r, s, v); + } + + function test_withdraw_ETH_success() public { + MagicSpend.WithdrawRequest memory req = _makeRequest(1 ether, 0, uint48(block.timestamp + 1 hours), address(0)); + uint256 balBefore = user.balance; + vm.prank(user); + magicSpend.withdraw(req); + assertEq(user.balance - balBefore, 1 ether); + } + + function test_expiredRequest_doesNotBurnNonce() public { + MagicSpend.WithdrawRequest memory req = _makeRequest(1 ether, 7, uint48(block.timestamp - 1), address(0)); + vm.prank(user); + vm.expectRevert(MagicSpend.Expired.selector); + magicSpend.withdraw(req); + assertFalse(magicSpend.nonceUsed(user, 7), "nonce burned by expired request"); + } + + function test_invalidSig_doesNotBurnNonce() public { + MagicSpend.WithdrawRequest memory req = _makeRequest(1 ether, 9, uint48(block.timestamp + 1 hours), address(0)); + req.signature[0] = ~req.signature[0]; + vm.prank(user); + vm.expectRevert(MagicSpend.InvalidSignature.selector); + magicSpend.withdraw(req); + assertFalse(magicSpend.nonceUsed(user, 9), "nonce burned by invalid sig"); + } + + function test_withdraw_revertsOnNonceReplay() public { + MagicSpend.WithdrawRequest memory req = _makeRequest(1 ether, 0, uint48(block.timestamp + 1 hours), address(0)); + vm.prank(user); + magicSpend.withdraw(req); + MagicSpend.WithdrawRequest memory req2 = _makeRequest(1 ether, 0, uint48(block.timestamp + 1 hours), address(0)); + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(MagicSpend.InvalidNonce.selector, 0)); + magicSpend.withdraw(req2); + } + + function test_ownerWithdraw_revertsZeroAddress() public { + vm.prank(owner); + vm.expectRevert(MagicSpend.InvalidAddress.selector); + magicSpend.ownerWithdraw(address(0), address(0), 1 ether); + } + + function test_entryPointDeposit_sendsMsgValue() public { + address ep = magicSpend.entryPoint(); + vm.etch(ep, hex"00"); + uint256 balBefore = ep.balance; + vm.deal(owner, 2 ether); + vm.prank(owner); + magicSpend.entryPointDeposit{value: 1 ether}(); + assertEq(ep.balance - balBefore, 1 ether); + } +}