diff --git a/.gitignore b/.gitignore index 918d6e29..3c895cf5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules coverage .env *.temp -~/node_modules \ No newline at end of file +~/node_modules +cache diff --git a/.vscode/settings.json b/.vscode/settings.json index 34a6040a..12d111f4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,11 @@ { - "editor.codeActionsOnSave": { - "source.addMissingImports": "explicit", - "source.organizeImports": "explicit", - "source.formatDocument": "explicit", - "source.fixAll": "explicit" - }, - "editor.showUnused": true, - "editor.formatOnPaste": true, - "editor.formatOnSave": true + "editor.codeActionsOnSave": { + "source.addMissingImports": true, + "source.fixAll": true, + "source.formatDocument": true, + "source.organizeImports": true + }, + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.showUnused": true } diff --git a/forge/script/Counter.s.sol b/forge/script/Counter.s.sol deleted file mode 100644 index 1a47b40b..00000000 --- a/forge/script/Counter.s.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console2} from "forge-std/Script.sol"; - -contract CounterScript is Script { - function setUp() public {} - - function run() public { - vm.broadcast(); - } -} diff --git a/forge/src/Counter.sol b/forge/src/Counter.sol deleted file mode 100644 index aded7997..00000000 --- a/forge/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/forge/src/NegativeAmountHandler.sol b/forge/src/NegativeAmountHandler.sol index c30c70c5..6a80ff51 100644 --- a/forge/src/NegativeAmountHandler.sol +++ b/forge/src/NegativeAmountHandler.sol @@ -5,52 +5,30 @@ import "openzeppelin-contracts/contracts/utils/cryptography/EIP712.sol"; import "openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; contract NegativeAmountHandler is EIP712 { - struct Payment { - address to; - int256 amount; // Can be negative to indicate deduction or penalty - uint256 nonce; - } - - mapping(address => uint256) public nonces; + string private constant SIGNING_DOMAIN = "NegativeAmountHandler"; + string private constant SIGNATURE_VERSION = "1"; - // EIP712 Domain Separator initialization in constructor - constructor() EIP712("NegativeAmountHandler", "1") {} + struct Data { + int256 amount; + string message; + } - // Function to handle a negative amount logic - function handlePayment(Payment calldata payment, bytes calldata signature) external { - require(_verify(payment, _hash(payment), signature), "Invalid signature"); - // require(payment.amount < 0, "Amount must be negative"); - - // Logic for handling negative amounts - emit PaymentHandled(payment.to, payment.amount, msg.sender); + constructor() EIP712(SIGNING_DOMAIN, SIGNATURE_VERSION) {} - // Increment nonce to prevent replay attacks - nonces[payment.to]++; + function verify(Data calldata data, bytes calldata signature) public view returns (bool) { + address signer = _verify(_hash(data), signature); + return signer == msg.sender; // Ensure that the signer is the sender of the message } - // Create a hash of the payment details (EIP712 Typed Data) - function _hash(Payment calldata payment) internal view returns (bytes32) { + function _hash(Data calldata data) internal view returns (bytes32) { return _hashTypedDataV4(keccak256(abi.encode( - keccak256("Payment(address to,int256 amount,uint256 nonce)"), - payment.to, - payment.amount, - payment.nonce + keccak256("Data(int256 amount,string message)"), + data.amount, + keccak256(bytes(data.message)) ))); } - // Verify the signature - function _verify(Payment calldata payment, bytes32 digest, bytes calldata signature) internal view returns (bool) { - address signer = ECDSA.recover(digest, signature); - return signer == msg.sender && nonces[signer] == payment.nonce; + function _verify(bytes32 digest, bytes memory signature) internal view returns (address) { + return ECDSA.recover(digest, signature); } - - event PaymentHandled(address indexed to, int256 amount, address indexed executor); } -cast calldata "Payment(address to,int256 amount,uint256 nonce)" \ - 0x1234567890123456789012345678901234567890 -100 0 - - cast wallet sign 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ - --typed-data domain "NegativeAmountHandler" 1.0.0 1 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 \ - --typed-data Payment "address to" 0x1234567890123456789012345678901234567890 \ - --typed-data Payment "int256 amount" -100 \ - --typed-data Payment "uint256 nonce" 0 \ No newline at end of file diff --git a/forge/test/Counter.t.sol b/forge/test/Counter.t.sol deleted file mode 100644 index e9b9e6ac..00000000 --- a/forge/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console2} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/src/__test__/e2e/contracts.test.ts b/src/__test__/e2e/contracts.test.ts index 60603a13..07b78a73 100644 --- a/src/__test__/e2e/contracts.test.ts +++ b/src/__test__/e2e/contracts.test.ts @@ -1,183 +1,110 @@ -import { BigNumber, Contract, ethers } from 'ethers'; - -import { Common, Hardfork } from '@ethereumjs/common'; -import { TransactionFactory } from '@ethereumjs/tx'; -import { JsonRpcProvider } from '@ethersproject/providers'; -import dotenv from 'dotenv'; -import { joinSignature, parseUnits } from 'ethers/lib/utils'; -import { startsWith } from 'lodash'; +import * as dotenv from 'dotenv'; +import { BigNumber, Contract, Wallet, providers } from 'ethers'; +import { joinSignature } from 'ethers/lib/utils'; import { question } from 'readline-sync'; -import { encode as rlpEncode } from 'rlp'; -import { pair, sign, signMessage } from '../..'; -import Counter from '../../../forge/out/Counter.sol/Counter.json'; +import { pair, signMessage } from '../..'; import NegativeAmountHandler from '../../../forge/out/NegativeAmountHandler.sol/NegativeAmountHandler.json'; +import { deployContract } from '../utils/contracts'; import { setupClient } from '../utils/setup'; + dotenv.config(); -export const addHexPrefix = (value: string): string => - startsWith(value, '0x') ? value : `0x${value}`; +const ETH_PROVIDER_URL = 'http://localhost:8545'; +const WALLET_PRIVATE_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; -const WALLET_ADDRESS = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'; +describe('NegativeAmountHandler', () => { + let contract: Contract; + let wallet: Wallet; + let CONTRACT_ADDRESS: string; + let chainId: number; + let domain; + let data; + let types; -describe('NegativeAmountHandler', async () => { - test('pair', async () => { - const isPaired = await setupClient(); - if (!isPaired) { - const secret = question('Please enter the pairing secret: '); - await pair(secret.toUpperCase()); - } - }); + beforeAll(async () => { + CONTRACT_ADDRESS = await deployContract('NegativeAmountHandler'); - test('handle negative amount', async () => { - const provider = new ethers.providers.JsonRpcProvider( - 'http://localhost:8545', - ); - const contractAddress = '0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9'; - const negativeAmountHandler = new ethers.Contract( - contractAddress, - NegativeAmountHandler.abi, - provider, - ); + const provider = new providers.JsonRpcProvider(ETH_PROVIDER_URL); + chainId = (await provider.getNetwork()).chainId; + wallet = new Wallet(WALLET_PRIVATE_KEY, provider); - const common = Common.custom( - { chainId: 31337 }, - { hardfork: Hardfork.London }, + contract = new Contract( + CONTRACT_ADDRESS, + NegativeAmountHandler.abi, + wallet, ); - const payment = { - to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // Specify recipient address - amount: 100000000000, - nonce: await provider.getTransactionCount(WALLET_ADDRESS, 'latest'), + domain = { + name: 'NegativeAmountHandler', + version: '1', + chainId, + verifyingContract: CONTRACT_ADDRESS, }; - const msg = { - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - Payment: [ - { name: 'to', type: 'address' }, - { name: 'amount', type: 'int256' }, - { name: 'nonce', type: 'uint256' }, - ], - }, - domain: { - name: 'NegativeAmountHandler', - version: '1', - chainId: 31337, - verifyingContract: contractAddress, - }, - primaryType: 'Payment', - message: payment, + types = { + Data: [ + { name: 'amount', type: 'int256' }, + { name: 'message', type: 'string' }, + ], }; - // Sign the EIP712 message - const response = await signMessage(msg); - - const signature = joinSignature({ - r: addHexPrefix(response.sig.r.toString('hex')), - s: addHexPrefix(response.sig.s.toString('hex')), - v: BigNumber.from( - addHexPrefix(response.sig.v.toString('hex')), - ).toNumber(), - }); - const data = negativeAmountHandler.interface.encodeFunctionData( - 'handlePayment', - [payment, signature], - ); - - // Create transaction request - const txRequest = { - to: contractAddress, - from: WALLET_ADDRESS, - data, - nonce: await provider.getTransactionCount(WALLET_ADDRESS, 'latest'), - gasLimit: '0x' + parseUnits('1000000', 'wei').toString(), - gasPrice: '0x' + parseUnits('10', 'gwei').toString(), + data = { + amount: -100, + message: 'Negative payment test', }; + }); - const tx = TransactionFactory.fromTxData(txRequest, { common }); - const payload = rlpEncode(tx.getMessageToSign(false)); - - const signedTx = await sign(payload); - - const txToBroadcast = TransactionFactory.fromTxData( - { - ...txRequest, - r: addHexPrefix(signedTx.sig.r.toString('hex')), - s: addHexPrefix(signedTx.sig.s.toString('hex')), - v: BigNumber.from( - addHexPrefix(signedTx.sig.v.toString('hex')), - ).toNumber(), - }, - { common }, - ); - - const signedTxPayload = txToBroadcast.serialize(); - - // Send the transaction - const txResponse = await provider.sendTransaction( - `0x${signedTxPayload.toString('hex')}`, - ); - const receipt = await txResponse.wait(); - - console.log(`Transaction confirmed in block: ${receipt.blockNumber}`); + test('pair', async () => { + const isPaired = await setupClient(); + if (!isPaired) { + const secret = question('Please enter the pairing secret: '); + await pair(secret.toUpperCase()); + } }); - test.skip('increment', async () => { - const provider = new JsonRpcProvider('http://localhost:8545'); - const counter = new Contract( - '0x5FbDB2315678afecb367f032d93F642f64180aa3', - Counter.abi, - provider, - ); + test('Sign Negative Amount EIP712 Contract', async () => { + /** + * Sign the contract with Ethers + */ + const ethersSignature = await wallet._signTypedData(domain, types, data); + const ethersTx = await contract.verify(data, ethersSignature, { + gasLimit: 100000, + }); + expect(ethersTx).toBeTruthy(); + + /** + * Sign the contract with Lattice + */ + const _types = { + ...types, + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + }; - const txRequest = { - to: '0x5FbDB2315678afecb367f032d93F642f64180aa3', - from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - data: counter.interface.encodeFunctionData('increment'), - nonce: await provider.getTransactionCount( - '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - 'latest', - ), - gasLimit: parseUnits('100000', 'wei').toString(), - gasPrice: parseUnits('10', 'gwei').toString(), + const msg = { + types: _types, + domain, + primaryType: 'Data', + message: data, }; - const common = Common.custom( - { chainId: 31337 }, - { hardfork: Hardfork.London }, - ); - const tx = TransactionFactory.fromTxData(txRequest, { common }); - const payload = rlpEncode(tx.getMessageToSign(false)); - - const signedTx = await sign(payload); - - const v = `0x${Buffer.from(signedTx.sig.v).toString('hex')}`; - const r = `0x${Buffer.from(signedTx.sig.r).toString('hex')}`; - const s = `0x${Buffer.from(signedTx.sig.s).toString('hex')}`; - - const txToBroadcast = TransactionFactory.fromTxData( - { - ...txRequest, - v, - r, - s, - }, - { common }, - ); + const response = await signMessage(msg); + const r = `0x${response.sig.r.toString('hex')}`; + const s = `0x${response.sig.s.toString('hex')}`; + const v = BigNumber.from(response.sig.v).toNumber(); + const latticeSignature = joinSignature({ r, s, v }); - const signedTxPayload = txToBroadcast.serialize(); + expect(latticeSignature).toEqual(ethersSignature); - // Broadcast the signed transaction - const txResponse = await provider.sendTransaction( - `0x${signedTxPayload.toString('hex')}`, - ); - const receipt = await txResponse.wait(); + const tx = await contract.verify(data, latticeSignature, { + gasLimit: 100000, + }); - console.log(`Transaction confirmed in block: ${receipt.blockNumber}`); + expect(tx).toBeTruthy(); }); }); diff --git a/src/__test__/e2e/eth.msg.test.ts b/src/__test__/e2e/eth.msg.test.ts index 03e0edec..f02be5fe 100644 --- a/src/__test__/e2e/eth.msg.test.ts +++ b/src/__test__/e2e/eth.msg.test.ts @@ -28,6 +28,94 @@ const numRandom = getN() ? getN() : 20; // Number of random tests to conduct describe('ETH Messages', () => { const client = initializeClient(); + describe('Test ETH personalSign', function () { + it('Should throw error when message contains non-ASCII characters', async () => { + const protocol = 'signPersonal'; + const msg = '⚠️'; + const msg2 = 'ASCII plus ⚠️'; + await expect(client.sign(buildEthMsgReq(msg, protocol))).rejects.toThrow( + /Lattice can only display ASCII/, + ); + await expect(client.sign(buildEthMsgReq(msg2, protocol))).rejects.toThrow( + /Lattice can only display ASCII/, + ); + }); + + it('Should test ASCII buffers', async () => { + await runEthMsg( + buildEthMsgReq(Buffer.from('i am an ascii buffer'), 'signPersonal'), + client, + ); + await runEthMsg( + buildEthMsgReq(Buffer.from('{\n\ttest: foo\n}'), 'signPersonal'), + client, + ); + }); + + it('Should test hex buffers', async () => { + await runEthMsg( + buildEthMsgReq(Buffer.from('abcdef', 'hex'), 'signPersonal'), + client, + ); + }); + + it('Should test a message that needs to be prehashed', async () => { + await runEthMsg( + buildEthMsgReq(randomBytes(4000), 'signPersonal'), + client, + ); + }); + + it('Msg: sign_personal boundary conditions and auto-rejected requests', async () => { + const protocol = 'signPersonal'; + const fwConstants = client.getFwConstants(); + // `personal_sign` requests have a max size smaller than other requests because a header + // is displayed in the text region of the screen. The size of this is captured + // by `fwConstants.personalSignHeaderSz`. + const maxMsgSz = + fwConstants.ethMaxMsgSz + + fwConstants.personalSignHeaderSz + + fwConstants.extraDataMaxFrames * fwConstants.extraDataFrameSz; + const maxValid = `0x${randomBytes(maxMsgSz).toString('hex')}`; + const minInvalid = `0x${randomBytes(maxMsgSz + 1).toString('hex')}`; + const zeroInvalid = '0x'; + // The largest non-hardened index which will take the most chars to print + const x = HARDENED_OFFSET - 1; + // Okay sooo this is a bit awkward. We have to use a known coin_type here (e.g. ETH) + // or else firmware will return an error, but the maxSz is based on the max length + // of a path, which is larger than we can actually print. + // I guess all this tests is that the first one is shown in plaintext while the second + // one (which is too large) gets prehashed. + const largeSignPath = [x, HARDENED_OFFSET + 60, x, x, x] as SigningPath; + await runEthMsg( + buildEthMsgReq(maxValid, protocol, largeSignPath), + client, + ); + await runEthMsg( + buildEthMsgReq(minInvalid, protocol, largeSignPath), + client, + ); + // Using a zero length payload should auto-reject + await expect( + client.sign(buildEthMsgReq(zeroInvalid, protocol)), + ).rejects.toThrow(/Invalid Request/); + }); + + describe(`Test ${numRandom} random payloads`, () => { + for (let i = 0; i < numRandom; i++) { + it(`Payload: ${i}`, async () => { + await runEthMsg( + buildEthMsgReq( + buildRandomMsg('signPersonal', client), + 'signPersonal', + ), + client, + ); + }); + } + }); + }); + describe('Test ETH EIP712', function () { it('Should test a message that needs to be prehashed', async () => { const msg = { diff --git a/src/__test__/utils/contracts.ts b/src/__test__/utils/contracts.ts new file mode 100644 index 00000000..96566315 --- /dev/null +++ b/src/__test__/utils/contracts.ts @@ -0,0 +1,23 @@ +import { exec } from 'child_process'; +import * as dotenv from 'dotenv'; +import { promisify } from 'util'; +dotenv.config(); +const ETH_PROVIDER_URL = 'http://localhost:8545'; +const WALLET_PRIVATE_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; +const execAsync = promisify(exec); + +export async function deployContract(contractName: string): Promise { + const forgeLocation = `${__dirname}/../../../forge/src`; + console.log('Building the contract...'); + await execAsync(`cd ${forgeLocation} && forge build`); + + console.log('Deploying the contract...'); + const createResult = await execAsync( + `cd ${forgeLocation} && forge create src/${contractName}.sol:${contractName} --rpc-url ${ETH_PROVIDER_URL} --private-key ${WALLET_PRIVATE_KEY} --json`, + ); + + const output = JSON.parse(createResult.stdout); + console.log('Contract deployed at address:', output.deployedTo); + return output.deployedTo; +} diff --git a/src/api/signing.ts b/src/api/signing.ts index 25e454f9..8935e9fe 100644 --- a/src/api/signing.ts +++ b/src/api/signing.ts @@ -33,6 +33,8 @@ export const signMessage = async ( const tx = { data: { signerPath: DEFAULT_ETH_DERIVATION, + curveType: Constants.SIGNING.CURVES.SECP256K1, + hashType: Constants.SIGNING.HASHES.KECCAK256, protocol: 'signPersonal', payload, ...overrides,