Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User story: Execute userOp with Passkey and paymaster #357

Merged
merged 18 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
36 changes: 36 additions & 0 deletions modules/passkey/contracts/test/TestPaymaster.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.0;

import {PackedUserOperation} from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol";
import {IEntryPoint} from "@account-abstraction/contracts/interfaces/IEntryPoint.sol";

/**
* @title A TestPaymaster contract that will be used to pay the cost for executing UserOps
* TODO: This is a dummy contract that has no validation logic. Either implement validation logic or remove this contract and use MockContract.
*/
contract TestPaymaster {
function validatePaymasterUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 maxCost
) external returns (bytes memory context, uint256 validationData) {
context = "";
validationData = 0;
}

function postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost, uint256 actualUserOpFeePerGas) external {}

enum PostOpMode {
opSucceeded, // user op succeeded
opReverted, // user op reverted. still has to pay for gas.
postOpReverted // Regardless of the UserOp call status, the postOp reverted, and caused both executions to revert.
}

function stakeEntryPoint(IEntryPoint entryPoint, uint32 unstakeDelaySecs) external payable {
entryPoint.addStake{value: msg.value}(unstakeDelaySecs);
}

function depositTo(IEntryPoint entryPoint) external payable {
entryPoint.depositTo{value: msg.value}(address(this));
}
}
340 changes: 340 additions & 0 deletions modules/passkey/test/userstories/ExecuteWithPaymaster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
import { expect } from 'chai'
akshay-ap marked this conversation as resolved.
Show resolved Hide resolved
import { deployments, ethers } from 'hardhat'
import { WebAuthnCredentials, decodePublicKey, encodeWebAuthnSignature } from '../utils/webauthn'
import { buildSafeUserOpTransaction, buildPackedUserOperationFromSafeUserOperation } from '@safe-global/safe-4337/src/utils/userOp'
import { buildSignatureBytes } from '@safe-global/safe-4337/src/utils/execution'
import { TestPaymaster } from '../../typechain-types'

/**
* User story: Execute transaction with Paymaster
* 1. The test case deploys a Safe with a passkey signer as an Owner. The transaction is sponsored by a Paymaster.
* 2. The test case executes a userOp with an existing Safe using a Paymaster.
*/
describe('Execute userOps with Paymaster: [@userstory]', () => {
/**
* The flow can be summarized as follows:
* Step 1: Setup the contracts.
* Step 2: Create a userOp, sign it with Passkey signer.
* Step 3: Execute the userOp that deploys a Safe with passkey signer as owner using Paymaster
*/
describe('Execute a userOp that deploys a Safe using Paymaster', () => {
// Create a fixture to setup the contracts and signer(s)
const setupTests = deployments.createFixture(async ({ deployments }) => {
const { EntryPoint, Safe4337Module, SafeProxyFactory, SafeModuleSetup, SafeL2, FCLP256Verifier, WebAuthnSignerFactory } =
await deployments.run()

const [relayer] = await ethers.getSigners()

const entryPoint = await ethers.getContractAt('IEntryPoint', EntryPoint.address)
const module = await ethers.getContractAt(Safe4337Module.abi, Safe4337Module.address)
const proxyFactory = await ethers.getContractAt(SafeProxyFactory.abi, SafeProxyFactory.address)
const safeModuleSetup = await ethers.getContractAt(SafeModuleSetup.abi, SafeModuleSetup.address)
const singleton = await ethers.getContractAt(SafeL2.abi, SafeL2.address)
const verifier = await ethers.getContractAt('IP256Verifier', FCLP256Verifier.address)
const signerFactory = await ethers.getContractAt('WebAuthnSignerFactory', WebAuthnSignerFactory.address)

// Deploy a Paymaster contract
const paymaster = (await (await ethers.getContractFactory('TestPaymaster')).deploy()) as unknown as TestPaymaster
// Add stake and deposit
await paymaster.stakeEntryPoint(entryPoint, 1n, { value: ethers.parseEther('10') })
await paymaster.depositTo(entryPoint, { value: ethers.parseEther('10') })

// await user.sendTransaction({ to: safe, value: ethers.parseEther('1') })

const navigator = {
credentials: new WebAuthnCredentials(),
}

// Create a WebAuthn credential for the signer
const credential = navigator.credentials.create({
publicKey: {
rp: {
name: 'Safe',
id: 'safe.global',
},
user: {
id: ethers.getBytes(ethers.id('chucknorris')),
name: 'chucknorris',
displayName: 'Chuck Norris',
},
challenge: ethers.toBeArray(Date.now()),
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
},
})

return {
relayer,
proxyFactory,
safeModuleSetup,
module,
entryPoint,
singleton,
signerFactory,
navigator,
verifier,
SafeL2,
credential,
paymaster,
}
})

it('should execute a userOp and deploy a Safe using Paymaster', async () => {
const {
relayer,
proxyFactory,
safeModuleSetup,
module,
entryPoint,
singleton,
navigator,
SafeL2,
credential,
signerFactory,
verifier,
paymaster,
} = await setupTests()

// Deploy a signer contract
const publicKey = decodePublicKey(credential.response)
// Deploy signer contract
await signerFactory.createSigner(publicKey.x, publicKey.y, await verifier.getAddress())
// Get signer address
const signer = await signerFactory.getSigner(publicKey.x, publicKey.y, await verifier.getAddress())

// Step 2: Create a userOp, sign it with Passkey signer.

// The initializer data to enable the Safe4337Module as a module on a Safe
const initializer = safeModuleSetup.interface.encodeFunctionData('enableModules', [[module.target]])

// Create setup data to deploy a Safe with EOA and passkey signer as owners, threshold 1, Safe4337Module as module and fallback handler
const setupData = singleton.interface.encodeFunctionData('setup', [
[signer],
1n,
safeModuleSetup.target,
initializer,
module.target,
ethers.ZeroAddress,
0,
ethers.ZeroAddress,
])

// Predict the Safe address to construct the userOp, generate
const safeSalt = Date.now()
const safe = await proxyFactory.createProxyWithNonce.staticCall(singleton, setupData, safeSalt)

// Deploy data required in the initCode of the userOp
const deployData = proxyFactory.interface.encodeFunctionData('createProxyWithNonce', [singleton.target, setupData, safeSalt])

// Concatenated values: paymaster address, paymasterVerificationGasLimit, paymasterPostOpGasLimit
const paymasterAndData = ethers.solidityPacked(['address', 'uint128', 'uint128'], [paymaster.target, 600000, 60000])

const safeOp = buildSafeUserOpTransaction(
safe,
ethers.ZeroAddress,
0,
'0x',
await entryPoint.getNonce(safe, 0),
await entryPoint.getAddress(),
false,
true,
{
initCode: ethers.solidityPacked(['address', 'bytes'], [proxyFactory.target, deployData]),
// Set a higher verificationGasLimit to avoid error "AA26 over verificationGasLimit"
verificationGasLimit: 600000,
paymasterAndData: paymasterAndData,
},
)

const packedUserOp = buildPackedUserOperationFromSafeUserOperation({
safeOp,
signature: '0x',
})

// opHash that will be signed using Passkey credentials
const opHash = await module.getOperationHash(packedUserOp)

const assertion = navigator.credentials.get({
publicKey: {
challenge: ethers.getBytes(opHash),
rpId: 'safe.global',
allowCredentials: [{ type: 'public-key', id: new Uint8Array(credential.rawId) }],
userVerification: 'required',
},
})

// Build the contract signature that a Safe will forward to the signer contract
const signature = buildSignatureBytes([
{
signer: signer as string,
data: encodeWebAuthnSignature(assertion.response),
dynamic: true,
},
])

// Set the signature in the packedUserOp
packedUserOp.signature = ethers.solidityPacked(['uint48', 'uint48', 'bytes'], [safeOp.validAfter, safeOp.validUntil, signature])

// Check if Safe is not already created
expect(await ethers.provider.getCode(safe)).to.equal('0x')

// Step 3: Execute the userOp that deploys a safe with passkey signer as owner using Paymaster
await entryPoint.handleOps([packedUserOp], relayer.address)

// Check if Safe is created and uses the expected Singleton
const [implementation] = ethers.AbiCoder.defaultAbiCoder().decode(['address'], await ethers.provider.getStorage(safe, 0))
expect(implementation).to.equal(singleton.target)

// Check if signer is the Safe owner
const safeInstance = await ethers.getContractAt(SafeL2.abi, safe)
expect(await safeInstance.getOwners()).to.deep.equal([signer])
})
})

/**
* The flow can be summarized as follows:
* Step 1: Setup the contracts.
* Step 2: Create a userOp, sign it with Passkey signer.
* Step 3: Execute the userOp that with an existing Safe with passkey signer as owner using Paymaster
*/
describe('Execute a userOp with an existing Safe using Paymaster', () => {
// Create a fixture to setup the contracts and signer(s)
const setupTests = deployments.createFixture(async ({ deployments }) => {
const { EntryPoint, Safe4337Module, SafeProxyFactory, SafeModuleSetup, SafeL2, FCLP256Verifier, WebAuthnSignerFactory } =
await deployments.run()

const [relayer] = await ethers.getSigners()

const entryPoint = await ethers.getContractAt('IEntryPoint', EntryPoint.address)
const module = await ethers.getContractAt(Safe4337Module.abi, Safe4337Module.address)
const proxyFactory = await ethers.getContractAt(SafeProxyFactory.abi, SafeProxyFactory.address)
const safeModuleSetup = await ethers.getContractAt(SafeModuleSetup.abi, SafeModuleSetup.address)
const singleton = await ethers.getContractAt(SafeL2.abi, SafeL2.address)
const verifier = await ethers.getContractAt('IP256Verifier', FCLP256Verifier.address)
const signerFactory = await ethers.getContractAt('WebAuthnSignerFactory', WebAuthnSignerFactory.address)

// Deploy a Paymaster contract
const paymaster = (await (await ethers.getContractFactory('TestPaymaster')).deploy()) as unknown as TestPaymaster
// Add stake and deposit
await paymaster.stakeEntryPoint(entryPoint, 1n, { value: ethers.parseEther('10') })
await paymaster.depositTo(entryPoint, { value: ethers.parseEther('10') })

// await user.sendTransaction({ to: safe, value: ethers.parseEther('1') })

const navigator = {
credentials: new WebAuthnCredentials(),
}

// Create a WebAuthn credential for the signer
const credential = navigator.credentials.create({
publicKey: {
rp: {
name: 'Safe',
id: 'safe.global',
},
user: {
id: ethers.getBytes(ethers.id('chucknorris')),
name: 'chucknorris',
displayName: 'Chuck Norris',
},
challenge: ethers.toBeArray(Date.now()),
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
},
})

const publicKey = decodePublicKey(credential.response)
await signerFactory.createSigner(publicKey.x, publicKey.y, verifier.target)
const signer = await signerFactory.getSigner(publicKey.x, publicKey.y, verifier.target)

// The initializer data to enable the Safe4337Module as a module on a Safe
const initializer = safeModuleSetup.interface.encodeFunctionData('enableModules', [[module.target]])

// Create setup data to deploy a Safe with EOA and passkey signer as owners, threshold 1, Safe4337Module as module and fallback handler
const setupData = singleton.interface.encodeFunctionData('setup', [
[signer],
1n,
safeModuleSetup.target,
initializer,
module.target,
ethers.ZeroAddress,
0,
ethers.ZeroAddress,
])

// Deploy a Safe with EOA and passkey signer as owners
const safeSalt = Date.now()
const safeAddress = await proxyFactory.createProxyWithNonce.staticCall(singleton, setupData, safeSalt)
await proxyFactory.createProxyWithNonce(singleton, setupData, safeSalt)

return {
relayer,
module,
entryPoint,
navigator,
credential,
paymaster,
safeAddress,
signer,
}
})

it('should execute a userOp with an existing Safe using Paymaster', async () => {
const { safeAddress, signer, relayer, module, entryPoint, navigator, credential, paymaster } = await setupTests()

// Concatenated values: paymaster address, paymasterVerificationGasLimit, paymasterPostOpGasLimit
const paymasterAndData = ethers.solidityPacked(['address', 'uint128', 'uint128'], [paymaster.target, 600000, 60000])

const safeOp = buildSafeUserOpTransaction(
safeAddress,
ethers.ZeroAddress,
ethers.parseEther('0.2'),
'0x',
await entryPoint.getNonce(safeAddress, 0),
await entryPoint.getAddress(),
false,
true,
{
paymasterAndData: paymasterAndData,
},
)

const packedUserOp = buildPackedUserOperationFromSafeUserOperation({
safeOp,
signature: '0x',
})

// opHash that will be signed using Passkey credentials
const opHash = await module.getOperationHash(packedUserOp)

const assertion = navigator.credentials.get({
publicKey: {
challenge: ethers.getBytes(opHash),
rpId: 'safe.global',
allowCredentials: [{ type: 'public-key', id: new Uint8Array(credential.rawId) }],
userVerification: 'required',
},
})

// Build the contract signature that a Safe will forward to the signer contract
const signature = buildSignatureBytes([
{
signer: signer as string,
data: encodeWebAuthnSignature(assertion.response),
dynamic: true,
},
])

// Set the signature in the packedUserOp
packedUserOp.signature = ethers.solidityPacked(['uint48', 'uint48', 'bytes'], [safeOp.validAfter, safeOp.validUntil, signature])

// Send 1 ETH to the Safe
await relayer.sendTransaction({ to: safeAddress, value: ethers.parseEther('1') })
const balanceBefore = await ethers.provider.getBalance(ethers.ZeroAddress)
// Step 3: Execute the userOp that deploys a safe with passkey signer as owner using Paymaster
await entryPoint.handleOps([packedUserOp], relayer.address)

// Check if the address(0) received 0.2 ETH
expect(await ethers.provider.getBalance(ethers.ZeroAddress)).to.be.equal(balanceBefore + ethers.parseEther('0.2'))
expect(await ethers.provider.getBalance(safeAddress)).to.be.equal(ethers.parseEther('1') - ethers.parseEther('0.2'))
})
})
})
Loading