-
Notifications
You must be signed in to change notification settings - Fork 78
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
User story: Execute userOp with Passkey and paymaster (#357)
Fixes #276 Todo: Evaluate if `TestPaymaster` contract is needed with validation logic. If not needed, replace with a `MockContract` instance Changes in PR: - Add a test case showcasing Safe deployment with Passkey signer using Paymaster - Add a test case showcasing userOp execution with existing Safe (with Passkey signer) using Paymaster --------- Co-authored-by: Shebin John <[email protected]> Co-authored-by: Mikhail <[email protected]> Co-authored-by: Nicholas Rodrigues Lordello <[email protected]>
- Loading branch information
1 parent
151d390
commit 74d5a38
Showing
2 changed files
with
367 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
366 changes: 366 additions & 0 deletions
366
modules/passkey/test/userstories/ExecuteWithPaymaster.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,366 @@ | ||
import { expect } from 'chai' | ||
import { deployments, ethers } from 'hardhat' | ||
import { WebAuthnCredentials, decodePublicKey, encodeWebAuthnSignature } from '../utils/webauthn' | ||
import { buildSafeUserOpTransaction, buildPackedUserOperationFromSafeUserOperation } from '@safe-global/safe-4337/dist/src/utils/userOp' | ||
import { buildSignatureBytes } from '@safe-global/safe-4337/dist/src/utils/execution' | ||
|
||
/** | ||
* User story: Execute with Paymaster. | ||
* The test cases here cover following flows: | ||
* 1. Deploy a Safe with a passkey signer as an owner. The userOp gas is sponsored by a Paymaster. | ||
* 2. Execute a userOp with an existing Safe with passkey signer as an owner. The userOp gas is sponsored by a Paymaster. | ||
* | ||
* The paymaster used in the tests is imported from @account-abstraction/contracts/samples/VerifyingPaymaster.sol. | ||
* This contract has a verifyingSigner address which provides approval for sponsoring userOp gas via ECDSA signatures. | ||
* | ||
*/ | ||
describe('Execute userOps with Paymaster: [@userstory]', () => { | ||
const generalSetup = deployments.createFixture(async ({ deployments }) => { | ||
const { EntryPoint, Safe4337Module, SafeProxyFactory, SafeModuleSetup, SafeL2, FCLP256Verifier, SafeWebAuthnSignerFactory } = | ||
await deployments.run() | ||
|
||
const [relayer, verifyingSigner] = 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('SafeWebAuthnSignerFactory', SafeWebAuthnSignerFactory.address) | ||
|
||
// Deploy a Paymaster contract | ||
const paymaster = await (await ethers.getContractFactory('VerifyingPaymaster')).deploy(entryPoint, verifyingSigner) | ||
|
||
// Add deposit in the entrypoint contract so that paymaster can sponsor userOp execution | ||
await paymaster.deposit({ 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) | ||
// 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()) | ||
|
||
return { | ||
relayer, | ||
proxyFactory, | ||
safeModuleSetup, | ||
module, | ||
entryPoint, | ||
singleton, | ||
signerFactory, | ||
navigator, | ||
verifier, | ||
SafeL2, | ||
credential, | ||
paymaster, | ||
verifyingSigner, | ||
signer, | ||
} | ||
}) | ||
|
||
/** | ||
* The flow can be summarized as follows: | ||
* Step 1: Setup the contracts. | ||
* Step 2: Create a userOp with initCode that would deploy a Safe account. | ||
* Step 3: Get signature from verifyingSigner account and create paymaster data. | ||
* Step 4: Sign userOp with passkey signer. | ||
* Step 5: Execute the userOp that deploys a Safe with passkey signer as owner. | ||
*/ | ||
describe('New Safe', () => { | ||
// Create a fixture to setup the contracts and signer(s) | ||
const setupTests = generalSetup | ||
|
||
it('should execute a userOp and deploy a Safe using Paymaster', async () => { | ||
const { | ||
relayer, | ||
proxyFactory, | ||
safeModuleSetup, | ||
module, | ||
entryPoint, | ||
singleton, | ||
navigator, | ||
credential, | ||
paymaster, | ||
verifyingSigner, | ||
SafeL2, | ||
signer, | ||
} = await setupTests() | ||
|
||
// Step 2: Create a userOp with initCode that would deploy a Safe account. | ||
|
||
// 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 passkey signer as owner, 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 | ||
const safeSalt = Date.now() | ||
// Get predicted Safe address. Alternatively, the Safe address can be retrieved from event logs or calculated off-chain via create2 | ||
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]) | ||
|
||
const paymasterVerificationGasLimit = 60000 | ||
const paymasterPostOpGasLimit = 60000 | ||
let paymasterAndData = ethers.solidityPacked( | ||
['address', 'uint128', 'uint128'], | ||
[paymaster.target, paymasterVerificationGasLimit, paymasterPostOpGasLimit], | ||
) | ||
|
||
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', | ||
}) | ||
|
||
// Step 3: Get signature from verifyingSigner account and create paymaster data. | ||
const paymasterValidUntil = 0 | ||
const paymasterValidAfter = 0 | ||
const paymasterHash = await paymaster.getHash(packedUserOp, paymasterValidAfter, paymasterValidUntil) | ||
const paymasterSignature = await verifyingSigner.signMessage(ethers.getBytes(paymasterHash)) | ||
const paymasterData = ethers.solidityPacked( | ||
['bytes', 'bytes'], | ||
[ethers.AbiCoder.defaultAbiCoder().encode(['uint48', 'uint48'], [paymasterValidAfter, paymasterValidUntil]), paymasterSignature], | ||
) | ||
|
||
paymasterAndData = ethers.solidityPacked( | ||
['address', 'uint128', 'uint128', 'bytes'], | ||
[paymaster.target, paymasterVerificationGasLimit, paymasterPostOpGasLimit, paymasterData], | ||
) | ||
|
||
packedUserOp.paymasterAndData = paymasterAndData | ||
|
||
// Step 4: Sign userOp with Passkey signer. | ||
// 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, | ||
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 5: Execute the userOp that deploys a Safe with passkey signer as owner. | ||
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 with callData that transfers 0.2 ethers to address(0). | ||
* Step 3: Get signature from verifyingSigner account and create paymaster data. | ||
* Step 4: Sign userOp with Passkey signer. | ||
* Step 5: Execute the userOp that with an existing Safe with passkey signer as owner. | ||
*/ | ||
describe('Existing Safe', () => { | ||
// Create a fixture to setup the contracts and signer(s) | ||
const setupTests = deployments.createFixture(async ({ deployments }) => { | ||
const { | ||
relayer, | ||
verifyingSigner, | ||
proxyFactory, | ||
safeModuleSetup, | ||
module, | ||
entryPoint, | ||
singleton, | ||
credential, | ||
paymaster, | ||
navigator, | ||
signer, | ||
} = await generalSetup(deployments) | ||
|
||
// 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 passkey signer as owner, 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 passkey signer as owner | ||
const safeSalt = Date.now() | ||
// Get predicted Safe address. Alternatively, the Safe address can be retrieved from event logs or calculated off-chain via create2 | ||
const safeAddress = await proxyFactory.createProxyWithNonce.staticCall(singleton, setupData, safeSalt) | ||
await proxyFactory.createProxyWithNonce(singleton, setupData, safeSalt) | ||
|
||
return { | ||
relayer, | ||
module, | ||
entryPoint, | ||
navigator, | ||
credential, | ||
paymaster, | ||
safeAddress, | ||
signer, | ||
verifyingSigner, | ||
} | ||
}) | ||
|
||
it('should execute a userOp with an existing Safe using Paymaster', async () => { | ||
const { safeAddress, signer, relayer, module, entryPoint, navigator, credential, paymaster, verifyingSigner } = await setupTests() | ||
|
||
const paymasterVerificationGasLimit = 60000 | ||
const paymasterPostOpGasLimit = 60000 | ||
let paymasterAndData = ethers.solidityPacked( | ||
['address', 'uint128', 'uint128'], | ||
[paymaster.target, paymasterVerificationGasLimit, paymasterPostOpGasLimit], | ||
) | ||
|
||
// Step 2: Create a userOp with callData that transfers 0.2 ethers to address(0). | ||
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', | ||
}) | ||
|
||
// Step 3: Get signature from verifyingSigner account and create paymaster data. | ||
const paymasterValidUntil = 0 | ||
const paymasterValidAfter = 0 | ||
const paymasterHash = await paymaster.getHash(packedUserOp, paymasterValidAfter, paymasterValidUntil) | ||
const paymasterSignature = await verifyingSigner.signMessage(ethers.getBytes(paymasterHash)) | ||
const paymasterData = ethers.solidityPacked( | ||
['bytes', 'bytes'], | ||
[ethers.AbiCoder.defaultAbiCoder().encode(['uint48', 'uint48'], [paymasterValidAfter, paymasterValidUntil]), paymasterSignature], | ||
) | ||
|
||
paymasterAndData = ethers.solidityPacked( | ||
['address', 'uint128', 'uint128', 'bytes'], | ||
[paymaster.target, paymasterVerificationGasLimit, paymasterPostOpGasLimit, paymasterData], | ||
) | ||
|
||
packedUserOp.paymasterAndData = paymasterAndData | ||
|
||
// Step 4: Sign userOp with Passkey signer. | ||
// 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, | ||
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 5: Execute the userOp that with an existing 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')) | ||
}) | ||
}) | ||
}) |