Skip to content
Draft
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
44 changes: 44 additions & 0 deletions docs/diagrams/signed-calls-sequence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Example signed calls sequence

```mermaid
sequenceDiagram
autonumber
participant P as Permit3
participant V as ERC6492Validator
participant H as SignatureHooks
participant A as Smart Account
participant T as Token Contract

P->>V: isValidSignatureNowAllowSideEffects(acct, hash, signature)
Note over P,V: 6492 signature: 0x{prepareTarget}{prepareData}{hooksOwnerSig}{6492}
V->>V: set currentHash[msg.sender] = hash
Note over V,A: Initial failure to trigger prepareData flow
V->>A: try isValidSignature call with {signatureHooksIndex}{emptyBytes}
A->>H: isValidSignature?
H-->>A: return failure (isSigned[hash] == false)
A-->>V: return failure
V->> H: prepareTarget(data)
Note over V,H: executeSignedCalls(acct, calls, actualSig, verifier, hash)
H->>H: validations on msg.sender, currentHash in ERC6492Validator
H->>H: construct signed message hash (calls and permit3 approval)
H->>A: isValidSignatureNow(acct, message, actualSig)
Note over H,A: message incorporates permit3 hash AND calls
A-->>H: success
H-->>H: Calculate transformed replay-safe hash
H->>H: set wallet-transformed permit3 hash as signed
H->>A: executeBatch(calls)
Note over A,T: perform token approval for Permit3 contract
Note over A,T: and potentially other setup calls
A->>T: approve(Permit3, infinity)
A-->>H: return
H-->>V: return
Note over V: Finished calling prepareData, validate "sig"
V->>A: isValidSignature(hash, hooksOwnerSig)
Note over A: hooksOwnerSig = {hooksOwnerIndex}{empty}
A->>H: isValidSignature(replay-safe hash, empty)
Note over H: replay-safe hash was previously marked as signed by acct
H-->>A: return isSigned[msg.sender][replaysafeHash]
A-->>V: return success
V-->>P: return success
P-->>P: approve permit3 permission
```
31 changes: 31 additions & 0 deletions docs/diagrams/spend-with-delegated-hook-sequence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Example spend with delegated hook sequence

```mermaid
sequenceDiagram
autonumber
participant S as Spender
participant P as Permit3
participant SH as Spend Hook
participant V as ERC6492Validator
participant H as SignatureHooks
participant A as Smart Account
participant T as Token Contract

Note over S,T: permit approval phase executes signed calls to set ERC20 approval and to register a hook for the permit
S->>P: approveWithSignature(permit, signature)
P->>V: isValidSignatureNowAllowSideEffects(acct, hash, signature)
V->> H: prepareTarget(data)
H->>A: executeBatch(calls)
A->>T: approve(Permit3, infinity)
A->>P: registerHookForPermit(permit, hook)
V-->>P: return valid sig
P->>P: approve permit ✅
Note over S,T: spender calls spend with hook data
S->>P: spend(permit, value, hookData)
P->>P: look up hook registered for permit
P-->>SH: delegatecall(permit, value, hookdata)
SH-->>P:
P->>A: execute hook logic as owner of smart account (i.e. execute(MagicSpend.withdraw, withdrawRequest) etc.)
P->>T: transferFrom(account, value)
P-->>S: spend complete
```
89 changes: 89 additions & 0 deletions src/CoinbaseSmartWalletSignatureHooks.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {PublicERC6492Validator} from "./PublicERC6492Validator.sol";
import {CoinbaseSmartWallet} from "smart-wallet/CoinbaseSmartWallet.sol";
import {SignatureCheckerLib} from "solady/utils/SignatureCheckerLib.sol";

/**
* @title CoinbaseSmartWallet-Specific SignatureHooks
*
* @notice Signature hooks implementation that knows CoinbaseSmartWallet's exact ERC1271 transformation.
* This allows us to predict and store the exact hash that will be validated.
*
* Flow:
* 1) executeSignedCallsWithMessage receives originalHash
* 2) We calculate the replaySafeHash using CoinbaseSmartWallet's transformation
* 3) We store isSigned[account][replaySafeHash] = true
* 4) When isValidSignature is called with replaySafeHash, we return true
*/
contract CoinbaseSmartWalletSignatureHooks {
PublicERC6492Validator immutable validator;

bytes4 constant ERC1271_MAGIC_VALUE = 0x1626ba7e;
bytes4 constant ERC1271_FAILED_VALUE = 0xffffffff;

mapping(address => mapping(bytes32 => bool)) isSigned;

constructor(address _validator) {
validator = PublicERC6492Validator(_validator);
}

function isValidSignature(bytes32 hash, bytes calldata) external view returns (bytes4) {
return isSigned[msg.sender][hash] ? ERC1271_MAGIC_VALUE : ERC1271_FAILED_VALUE;
}

/// @param account The account that is executing the calls
/// @param calls The calls to execute
/// @param signature The signature that has been signed by the account
/// @param verifyingContract The Permit3 contract
/// @param hash The hash of the Permit3 permit (original hash)
function executeSignedCallsWithMessage(
address payable account,
CoinbaseSmartWallet.Call[] memory calls,
bytes calldata signature,
address verifyingContract,
bytes32 hash
) external {
if (msg.sender != address(validator)) revert();
if (validator.currentHash(verifyingContract) != hash) revert();

// construct hash including payload hash + calls
bytes32 message = keccak256(abi.encode(verifyingContract, calls, hash));

// validate signature
SignatureCheckerLib.isValidSignatureNow(account, message, signature);

// Calculate the replaySafeHash that CoinbaseSmartWallet will actually validate
bytes32 replaySafeHash = _calculateReplaySafeHash(account, hash);

// prevent replay
if (isSigned[account][replaySafeHash]) revert();
isSigned[account][replaySafeHash] = true;

// execute calls
CoinbaseSmartWallet(account).executeBatch(calls);
}

/// @notice Calculate the exact replaySafeHash that CoinbaseSmartWallet will validate
/// @dev Replicates CoinbaseSmartWallet's ERC1271.replaySafeHash() transformation
function _calculateReplaySafeHash(address wallet, bytes32 originalHash) internal view returns (bytes32) {
// Replicate CoinbaseSmartWallet's domain separator
bytes32 domainSeparator = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("Coinbase Smart Wallet"), // name from _domainNameAndVersion()
keccak256("1"), // version from _domainNameAndVersion()
block.chainid,
wallet // verifyingContract = wallet address
)
);

// Replicate CoinbaseSmartWallet's message typehash and struct hash
bytes32 MESSAGE_TYPEHASH = keccak256("CoinbaseSmartWalletMessage(bytes32 hash)");
bytes32 structHash = keccak256(abi.encode(MESSAGE_TYPEHASH, originalHash));

// Apply EIP-712 encoding (replicates _eip712Hash)
return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
}
}
81 changes: 81 additions & 0 deletions src/MagicSpendHook.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {SpendPermission} from "./Permit3.sol";

import {Permit3} from "./Permit3.sol";
import {MagicSpend} from "magicspend/MagicSpend.sol";
import {CoinbaseSmartWallet} from "smart-wallet/CoinbaseSmartWallet.sol";

/// @title MagicSpendHook
/// @notice Hook implementation for integrating MagicSpend withdrawals with spend permissions
/// @dev This contract is designed to be used as a delegate via delegatecall from Permit3
contract MagicSpendHook {
/// @notice ERC-7528 native token address convention
address public constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

/// @notice Permit3 contract address
Permit3 public permit3;

/// @notice `SpendPermission.token` and `WithdrawRequest.asset` are not equal.
error SpendTokenWithdrawAssetMismatch(address spendToken, address withdrawAsset);

/// @notice Attempted spend value is less than the `WithdrawRequest.amount`.
error SpendValueWithdrawAmountMismatch(uint256 spendValue, uint256 withdrawAmount);

/// @notice `WithdrawRequest.nonce` is not postfixed with the lower 128 bits of the spend permission hash.
error InvalidWithdrawRequestNonce(uint128 noncePostfix, uint128 permissionHashPostfix);

/// @notice Invalid hookData format for MagicSpend withdrawal.
error InvalidMagicSpendHookData();

constructor(Permit3 _permit3) {
permit3 = _permit3;
}

/// @notice Apply MagicSpend withdrawal logic before token transfer
/// @dev Expects hookData to contain ABI-encoded MagicSpend address and WithdrawRequest
/// @param spendPermission Details of the spend permission
/// @param value Amount being spent
/// @param hookData ABI-encoded (address magicSpend, MagicSpend.WithdrawRequest withdrawRequest)
function applyHookData(SpendPermission calldata spendPermission, uint160 value, bytes calldata hookData) external {
// Decode hookData to extract MagicSpend address and withdraw request
(address magicSpend, MagicSpend.WithdrawRequest memory withdrawRequest) =
abi.decode(hookData, (address, MagicSpend.WithdrawRequest));

// Validate hookData was decoded properly
if (magicSpend == address(0)) revert InvalidMagicSpendHookData();

// Check spend token and withdraw asset are the same
if (
!(spendPermission.token == NATIVE_TOKEN && withdrawRequest.asset == address(0))
&& spendPermission.token != withdrawRequest.asset
) {
revert SpendTokenWithdrawAssetMismatch(spendPermission.token, withdrawRequest.asset);
}

// Check spend value is not less than withdraw request amount
if (withdrawRequest.amount > value) {
revert SpendValueWithdrawAmountMismatch(value, withdrawRequest.amount);
}

// Check withdraw request nonce postfix matches spend permission hash postfix
// Note: In delegatecall context, address(this) is the Permit3 contract
bytes32 permissionHash = Permit3(payable(address(this))).getHash(spendPermission);
if (uint128(withdrawRequest.nonce) != uint128(uint256(permissionHash))) {
revert InvalidWithdrawRequestNonce(uint128(withdrawRequest.nonce), uint128(uint256(permissionHash)));
}

// Execute withdraw call on MagicSpend to fund the account
_execute({
account: spendPermission.account,
target: magicSpend,
value: 0,
data: abi.encodeWithSelector(MagicSpend.withdraw.selector, withdrawRequest)
});
}

function _execute(address account, address target, uint256 value, bytes memory data) internal {
CoinbaseSmartWallet(payable(account)).execute({target: target, value: value, data: data});
}
}
Loading