Skip to content

Commit

Permalink
feat: ERC-1271 support and EIP-712 signature demo (#14)
Browse files Browse the repository at this point in the history
* feat: EIP-1271 signature verification

* feat: signature validation and EIP-712 signing demo

* add NatSpec docs
  • Loading branch information
gabspeck authored Nov 25, 2024
1 parent f69966b commit 426d756
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 4 deletions.
29 changes: 27 additions & 2 deletions packages/contracts/contracts/Account.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ pragma solidity ^0.8.23;

import {WebAuthn} from './WebAuthn.sol';
import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol';
import {IERC1271} from '@openzeppelin/contracts/interfaces/IERC1271.sol';
import {Types} from './Types.sol';


/**
A smart wallet implementation that allows you to execute arbitrary functions in contracts
*/
contract Account is ReentrancyGuard {
contract Account is ReentrancyGuard, IERC1271 {
// bytes4(keccak256("isValidSignature(bytes32,bytes)")
bytes4 internal constant ERC1271_MAGICVALUE = 0x1626ba7e;

error InvalidSignature();

Types.PublicKey private publicKey;
Expand All @@ -19,10 +22,17 @@ contract Account is ReentrancyGuard {
publicKey = _publicKey;
}

/**
* Returns the expected challenge for a given call payload
* @param call The call parameters to generate the challenge against
*/
function getChallenge(Types.Call calldata call) public view returns (bytes32) {
return keccak256(bytes.concat(bytes20(address(this)), bytes32(currentNonce), bytes20(call.target), bytes32(call.value), call.data));
}

/**
* Returns the x and y coordinates of the public key associated with this contract
*/
function getPublicKey() public view returns (Types.PublicKey memory) {
return publicKey;
}
Expand All @@ -40,6 +50,11 @@ contract Account is ReentrancyGuard {
// solhint-disable-next-line no-empty-blocks
fallback() external payable {}

/**
* Execute an arbitrary call on a smart contract, optionally sending a value in ETH
* @param signed The parameters of the call to be executed
* @notice The call parameters must be signed with the key associated with this contract
*/
function execute(Types.SignedCall calldata signed) external payable validSignature(bytes.concat(getChallenge(signed.call)), signed.signature) nonReentrant {
(bool success, bytes memory result) = signed.call.target.call{value: signed.call.value}(signed.call.data);
if (!success) {
Expand Down Expand Up @@ -67,4 +82,14 @@ contract Account is ReentrancyGuard {
y: uint256(publicKey.y)
});
}

/**
* @inheritdoc IERC1271
*/
function isValidSignature(bytes32 messageHash, bytes calldata signature) public view override returns (bytes4 magicValue) {
if (_validateSignature(bytes.concat(messageHash), signature)) {
return ERC1271_MAGICVALUE;
}
return 0xffffffff;
}
}
2 changes: 1 addition & 1 deletion packages/contracts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@appliedblockchain/giano-contracts",
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"type": "commonjs",
"scripts": {
Expand Down
16 changes: 16 additions & 0 deletions packages/contracts/test/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,20 @@ describe('Account Contract', () => {
).to.be.revertedWithCustomError(account, 'InvalidSignature');
});
});
describe('ERC-1271 compliance', () => {
it('should return the magic value when checking a valid signature', async () => {
const hash = new Uint8Array(32);
crypto.getRandomValues(hash);
const signature = encodeChallenge(signWebAuthnChallenge(keypair.keyPair.privateKey, hash));
const result = await account.isValidSignature(hash, signature);
expect(result).to.equal('0x1626ba7e');
});
it('should return a constant value when checking an invalid signature', async () => {
const hash = new Uint8Array(32);
crypto.getRandomValues(hash);
const signature = encodeChallenge(signWebAuthnChallenge(keypair.keyPair.privateKey, hexToUint8Array('0xDEADBEEF')));
const result = await account.isValidSignature(hash, signature);
expect(result).to.equal('0xffffffff');
});
});
});
2 changes: 2 additions & 0 deletions services/web/src/client/components/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ const Login: React.FC = () => {
rawId: new Uint8Array(credential.rawId),
credentialId: userId.toString(),
});
} else {
setSnackbarState({ severity: 'error', open: true, message: 'No user found with the selected passkey' });
}
}
} catch (e) {
Expand Down
102 changes: 101 additions & 1 deletion services/web/src/client/components/Wallet.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { FormEvent } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ProxiedContract } from '@appliedblockchain/giano-client';
import { GianoWalletClient } from '@appliedblockchain/giano-client';
import { encodeChallenge, hexToUint8Array } from '@appliedblockchain/giano-common';
import type { GenericERC20, GenericERC721 } from '@appliedblockchain/giano-contracts';
import type { Account, GenericERC20, GenericERC721 } from '@appliedblockchain/giano-contracts';
import { GenericERC20__factory, GenericERC721__factory } from '@appliedblockchain/giano-contracts';
import { Logout } from '@mui/icons-material';
import { Box, Button, Card, CircularProgress, Container, FormControl, MenuItem, Select, Tab, Tabs, TextField, Typography } from '@mui/material';
import type { TypedDataDomain } from 'ethers';
import { ethers } from 'ethers';
import { getCredential } from 'services/web/src/client/common/credentials';
import type { User } from 'services/web/src/client/common/user';
Expand Down Expand Up @@ -104,6 +106,21 @@ type SendCoinsFormProps = {
onChange: (e: React.SyntheticEvent) => void;
};

type SignFormValues = {
name: string;
age: string;
};

type SignFormProps = {
user: User;
account: Account;
provider: ethers.Provider;
values: SignFormValues;
onSuccess: () => void;
onFailure: (message?: string) => void;
onChange: (e: React.SyntheticEvent) => void;
};

const SendCoinsForm: React.FC<SendCoinsFormProps> = ({ user, onSuccess, onFailure, coinContractProxy, values, onChange }) => {
const [sending, setSending] = useState(false);

Expand Down Expand Up @@ -156,6 +173,70 @@ const SubmitButtonWithProgress: React.FC<SubmitButtonWithProgressProps> = ({ run
);
};

const SignForm: React.FC<SignFormProps> = ({ values: formValues, onChange, provider, account, onSuccess, onFailure, user }: SignFormProps) => {
const getEip712Parameters = async (name: string, age: bigint) => {
const domain: TypedDataDomain = {
name: 'Giano Account',
version: '1.0',
chainId: (await provider.getNetwork()).chainId,
verifyingContract: await account.getAddress(),
};
const types = {
SignUpData: [
{ name: 'name', type: 'string' },
{ name: 'age', type: 'uint256' },
],
};
const values = {
name,
age,
};
return { domain, types, values };
};

const validateSignature = async ({ name, age }: { name: string; age: bigint }, signature: string) => {
const ERC1271_MAGIC = '0x1626ba7e';
const { domain, types, values } = await getEip712Parameters(name, age);
const hash = ethers.TypedDataEncoder.hash(domain, types, values);
const check = await account.isValidSignature(hash, signature);
if (check === ERC1271_MAGIC) {
return true;
}
return false;
};

const [signing, setSigning] = useState(false);
const sign = async (e: FormEvent) => {
e.preventDefault();
setSigning(true);
try {
const { domain, types, values } = await getEip712Parameters(formValues.name, BigInt(formValues.age));
const hash = ethers.TypedDataEncoder.hash(domain, types, values);
const signature = await getChallengeSigner(user)(hash);
// pretend this is a backend call
const valid = await validateSignature({ name: formValues.name, age: BigInt(formValues.age) }, signature);
if (valid) {
onSuccess();
} else {
onFailure('Invalid signature!');
}
} catch (e) {
const err: Error = e as any;
console.error(err);
onFailure(err.message);
} finally {
setSigning(false);
}
};
return (
<form style={{ display: 'flex', flexDirection: 'column', gap: 20 }} onSubmit={sign}>
<TextField name="name" value={formValues.name} onChange={onChange} label="Name" variant="standard" disabled={signing} required />
<TextField name="age" value={formValues.age} label="Age" type="number" onChange={onChange} required disabled={signing} />
<SubmitButtonWithProgress running={signing} label="Sign message" />
</form>
);
};

function getChallengeSigner(user: User) {
return async (challengeHex: string) => {
const challenge = hexToUint8Array(challengeHex);
Expand All @@ -175,6 +256,7 @@ const Wallet: React.FC = () => {
const [faucetRunning, setFaucetRunning] = useState(false);
const [transferFormValues, setTransferFormValues] = useState<TransferFormValues>({ recipient: '', tokenId: '' });
const [sendCoinsFormValues, setSendCoinsFormValues] = useState<SendCoinsFormValues>({ recipient: '', amount: '' });
const [signFormValues, setSignFormValues] = useState<SignFormValues>({ name: '', age: '' });

const provider = useMemo(() => new ethers.WebSocketProvider('ws://localhost:8545'), []);
const signer = useMemo(() => new ethers.Wallet('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', provider), [provider]);
Expand Down Expand Up @@ -339,6 +421,7 @@ const Wallet: React.FC = () => {
<Tab label="Transfer" />
<Tab label="Faucet" />
<Tab label="Send" />
<Tab label="Sign" />
</Tabs>
<TabPanel index={0} tab={tab} title="Mint">
<form onSubmit={mint}>
Expand Down Expand Up @@ -433,6 +516,23 @@ const Wallet: React.FC = () => {
}
/>
</TabPanel>
<TabPanel index={4} tab={tab} title="Sign typed data (EIP-712)">
{walletClient && user && (
<SignForm
account={walletClient.account}
provider={provider}
user={user}
values={signFormValues}
onChange={handleFormChange(setSignFormValues)}
onSuccess={() => {
setSnackbarState({ open: true, message: 'Signature validated successfully' });
}}
onFailure={(message?: string) => {
setSnackbarState({ open: true, message: message ?? 'Something went wrong; please check the console' });
}}
/>
)}
</TabPanel>
</Box>
</Card>
<CustomSnackbar {...snackbarState} onClose={onSnackbarClose} />
Expand Down

0 comments on commit 426d756

Please sign in to comment.