diff --git a/.changeset/big-clowns-like.md b/.changeset/big-clowns-like.md new file mode 100644 index 000000000..cfb7078f6 --- /dev/null +++ b/.changeset/big-clowns-like.md @@ -0,0 +1,6 @@ +--- +'@celo/actions': minor +'@celo/core': minor +--- + +Add utilies for generating a proof-of-possession and parsingSignatures diff --git a/.changeset/odd-memes-work.md b/.changeset/odd-memes-work.md new file mode 100644 index 000000000..b17980285 --- /dev/null +++ b/.changeset/odd-memes-work.md @@ -0,0 +1,5 @@ +--- +'@celo/celocli': patch +--- + +Convert account:proof-of-posession and account:authorize to use viem instead of web3 based functions diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 000000000..2ac0b9f7f --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,32 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "@celo/actions": "0.2.0", + "@celo/celocli": "8.0.1", + "@celo/core": "0.0.1", + "@celo/dev-utils": "0.1.2", + "@celo/base": "7.0.3", + "@celo/connect": "7.0.0", + "@celo/contractkit": "10.0.2", + "@celo/cryptographic-utils": "6.0.0", + "@celo/explorer": "5.0.18", + "@celo/governance": "5.1.9", + "@celo/keystores": "5.0.16", + "@celo/metadata-claims": "1.0.4", + "@celo/phone-utils": "6.0.7", + "@celo/transactions-uri": "5.0.15", + "@celo/utils": "8.0.3", + "@celo/wallet-base": "8.0.2", + "@celo/wallet-hsm": "8.0.2", + "@celo/wallet-hsm-aws": "8.0.2", + "@celo/wallet-hsm-azure": "8.0.2", + "@celo/wallet-hsm-gcp": "8.0.2", + "@celo/wallet-ledger": "8.0.2", + "@celo/wallet-local": "8.0.2", + "@celo/wallet-remote": "8.0.2", + "@celo/typescript": "0.0.1", + "@celo/viem-account-ledger": "1.2.2" + }, + "changesets": [] +} diff --git a/packages/actions/package.json b/packages/actions/package.json index 0a6e9403d..c5f1b726e 100644 --- a/packages/actions/package.json +++ b/packages/actions/package.json @@ -20,6 +20,11 @@ "types": "./dist/cjs/multicontract-interactions/stake/index.d.ts", "import": "./dist/mjs/multicontract-interactions/stake/index.js", "require": "./dist/cjs/multicontract-interactions/stake/index.js" + }, + "./authorization": { + "types": "./dist/cjs/multicontract-interactions/authorize/index.d.ts", + "import": "./dist/mjs/multicontract-interactions/authorize/index.js", + "require": "./dist/cjs/multicontract-interactions/authorize/index.js" } }, "author": "cLabs", @@ -73,12 +78,12 @@ { "name": "require('@celo/actions') (cjs)", "path": "dist/cjs/index.js", - "limit": "120 kB" + "limit": "200 kB" }, { "name": "import * from '@celo/actions' (esm)", "path": "dist/mjs/index.js", - "limit": "25 kB", + "limit": "50 kB", "import": "*" }, { @@ -99,11 +104,23 @@ "limit": "50 kB", "import": "{ getAccountsContract }" }, + { + "name": "import { authorizeVoteSigner } from '@celo/actions/contracts/accounts' (esm)", + "path": "dist/mjs/contracts/accounts.js", + "limit": "50 kB", + "import": "{ authorizeVoteSigner }" + }, { "name": "import * from '@celo/actions/staking' (esm)", "path": "dist/mjs/multicontract-interactions/stake/index.js", "limit": "60 kB", "import": "*" + }, + { + "name": "import * from '@celo/actions/authorization' (esm)", + "path": "dist/mjs/multicontract-interactions/authorize/index.js", + "limit": "40 kB", + "import": "*" } ] } diff --git a/packages/actions/src/contracts/accounts.ts b/packages/actions/src/contracts/accounts.ts index cfd05ea0b..3485d4deb 100644 --- a/packages/actions/src/contracts/accounts.ts +++ b/packages/actions/src/contracts/accounts.ts @@ -1,6 +1,7 @@ import { accountsABI } from '@celo/abis' import { Address, getContract, GetContractReturnType } from 'viem' import { Clients, PublicCeloClient } from '../client.js' +import type { ProofOfPossession } from '../multicontract-interactions/authorize/proof-of-possession.js' import { resolveAddress } from './registry.js' export type AccountsContract = GetContractReturnType< @@ -29,3 +30,41 @@ export const signerToAccount = async ( args: [signer], }) } + +// AUTHORIZATION FUNCTIONS + +export const authorizeVoteSigner = async ( + clients: Required, + signer: Address, + proofOfSigningKeyPossession: ProofOfPossession +) => { + return clients.wallet.writeContract({ + address: await resolveAddress(clients.public, 'Accounts'), + abi: accountsABI, + functionName: 'authorizeVoteSigner', + args: [ + signer, + proofOfSigningKeyPossession.v, + proofOfSigningKeyPossession.r, + proofOfSigningKeyPossession.s, + ], + }) +} + +export const authorizeValidatorSigner = async ( + clients: Required, + signer: Address, + proofOfSigningKeyPossession: ProofOfPossession +) => { + return clients.wallet.writeContract({ + address: await resolveAddress(clients.public, 'Accounts'), + abi: accountsABI, + functionName: 'authorizeValidatorSigner', + args: [ + signer, + proofOfSigningKeyPossession.v, + proofOfSigningKeyPossession.r, + proofOfSigningKeyPossession.s, + ], + }) +} diff --git a/packages/actions/src/contracts/validators.ts b/packages/actions/src/contracts/validators.ts index 5950a81eb..795d533bb 100644 --- a/packages/actions/src/contracts/validators.ts +++ b/packages/actions/src/contracts/validators.ts @@ -1,6 +1,6 @@ import { validatorsABI } from '@celo/abis' -import { getContract, GetContractReturnType } from 'viem' -import { Clients } from '../client.js' +import { Address, getContract, GetContractReturnType } from 'viem' +import { Clients, PublicCeloClient } from '../client.js' import { resolveAddress } from './registry.js' export async function getValidatorsContract( @@ -16,3 +16,14 @@ export type ValidatorsContract = GetContractReturnT typeof validatorsABI, T > + +// METHODS + +export const isValidator = async (client: PublicCeloClient, account: Address): Promise => { + return client.readContract({ + address: await resolveAddress(client, 'Validators'), + abi: validatorsABI, + functionName: 'isValidator', + args: [account], + }) +} diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index 419e56a59..5bc482c87 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -1,4 +1,10 @@ export * from './client.js' export { ContractName } from './contract-name.js' export { resolveAddress } from './contracts/registry.js' +export { isValidator } from './contracts/validators.js' +export { + generateProofOfKeyPossession, + generateProofOfKeyPossessionLocally, + parseSignatureOfAddress, +} from './multicontract-interactions/authorize/proof-of-possession.js' export { getGasPriceOnCelo } from './rpc-methods.js' diff --git a/packages/actions/src/multicontract-interactions/authorize/index.ts b/packages/actions/src/multicontract-interactions/authorize/index.ts new file mode 100644 index 000000000..a7270de6a --- /dev/null +++ b/packages/actions/src/multicontract-interactions/authorize/index.ts @@ -0,0 +1 @@ +export * from './proof-of-possession.js' diff --git a/packages/actions/src/multicontract-interactions/authorize/poof-of-poessession.test.ts b/packages/actions/src/multicontract-interactions/authorize/poof-of-poessession.test.ts new file mode 100644 index 000000000..4dac38294 --- /dev/null +++ b/packages/actions/src/multicontract-interactions/authorize/poof-of-poessession.test.ts @@ -0,0 +1,72 @@ +import { serializeSignature } from '@celo/core' +import { privateKeyToAccount } from 'viem/accounts' +import { describe, expect, it } from 'vitest' +import { + generateProofOfKeyPossessionLocally, + parseSignatureOfAddress, +} from './proof-of-possession.js' + +// Test constants +const TEST_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' +const TEST_SIGNER = privateKeyToAccount(TEST_PRIVATE_KEY).address +const TEST_ACCOUNT = TEST_SIGNER // Use the same address for proof-of-possession tests + +describe('authorize proof-of-possession functions', () => { + // Note: wallet client tests are not included because anvil doesn't support personal_sign + // These tests focus on local signing which works independently + + describe('generateProofOfKeyPossessionLocally', () => { + it('generates valid proof of possession signature locally', async () => { + const result = await generateProofOfKeyPossessionLocally(TEST_PRIVATE_KEY, TEST_ACCOUNT) + + expect(result).toHaveProperty('v') + expect(result).toHaveProperty('r') + expect(result).toHaveProperty('s') + expect(typeof result.v).toBe('number') + expect([27, 28]).toContain(result.v) + expect(result.r).toMatch(/^0x[a-fA-F0-9]{64}$/) + expect(result.s).toMatch(/^0x[a-fA-F0-9]{64}$/) + }) + + it('works with different private keys', async () => { + const privateKey1 = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + const privateKey2 = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' + + // Use different account addresses to avoid signature validation errors + const account1 = privateKeyToAccount(privateKey1).address + const account2 = privateKeyToAccount(privateKey2).address + + const result1 = await generateProofOfKeyPossessionLocally(privateKey1, account1) + const result2 = await generateProofOfKeyPossessionLocally(privateKey2, account2) + + expect(serializeSignature(result1)).not.toBe(serializeSignature(result2)) + }) + }) + + describe('parseSignatureOfAddress', () => { + it('parses signature correctly', async () => { + // First generate a signature + const signature = await generateProofOfKeyPossessionLocally(TEST_PRIVATE_KEY, TEST_ACCOUNT) + const serializedSig = serializeSignature(signature) + + // Then parse it + const parsed = await parseSignatureOfAddress(TEST_ACCOUNT, TEST_SIGNER, serializedSig) + + expect(parsed.v).toBe(signature.v) + expect(parsed.r).toBe(signature.r) + expect(parsed.s).toBe(signature.s) + }) + + it('throws error for invalid signer', async () => { + // Generate a signature with one signer but try to parse with different expected signer + const signature = await generateProofOfKeyPossessionLocally(TEST_PRIVATE_KEY, TEST_ACCOUNT) + const serializedSig = serializeSignature(signature) + + const wrongSigner = '0x6Ecbe1DB9EF729CBe972C83Fb886247691Fb6beb' + + await expect( + parseSignatureOfAddress(TEST_ACCOUNT, wrongSigner, serializedSig) + ).rejects.toThrow('Unable to parse signature') + }) + }) +}) diff --git a/packages/actions/src/multicontract-interactions/authorize/proof-of-possession.ts b/packages/actions/src/multicontract-interactions/authorize/proof-of-possession.ts new file mode 100644 index 000000000..d77f00e77 --- /dev/null +++ b/packages/actions/src/multicontract-interactions/authorize/proof-of-possession.ts @@ -0,0 +1,59 @@ +import { parseSignature } from '@celo/core' +import { Account, Address, encodePacked, Hex, keccak256 } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { WalletCeloClient } from '../../client' + +export type ProofOfPossession = { + v: number + r: Hex + s: Hex +} + +// PROOF OF POSSESSION FUNCTIONS + +export const generateProofOfKeyPossession = async ( + client: WalletCeloClient, + account: Address, + signer: Account +): Promise => { + // Use the same hash generation as soliditySha3({ type: 'address', value: account }) + const hash = keccak256(encodePacked(['address'], [account])) + + const signature = await client.signMessage({ + account: signer, + message: { raw: hash }, + }) + return parseSignature(hash, signature, signer.address) +} + +export const generateProofOfKeyPossessionLocally = async ( + privateKey: Hex, + account: Address +): Promise => { + const hash = keccak256(encodePacked(['address'], [account])) + + // To match ContractKit behavior, we need to add Ethereum message prefix + // ContractKit passes the hash as a "message" to signMessage, which adds the prefix + const messageLength = 32 // hash is always 32 bytes + const prefix = `\x19Ethereum Signed Message:\n${messageLength}` + const prefixedHash = keccak256(encodePacked(['string', 'bytes'], [prefix, hash])) + + const localAccount = privateKeyToAccount(privateKey) + const signature = await localAccount.sign({ hash: prefixedHash }) + const signerAddress = localAccount.address + + // Parse using the prefixed hash for validation + return parseSignature(prefixedHash, signature, signerAddress) +} +// For parsing existing signatures (equivalent to parseSignatureOfAddress) + +export const parseSignatureOfAddress = (address: Address, signer: Address, signature: Hex) => { + const hash = keccak256(encodePacked(['address'], [address])) + + // To match ContractKit behavior, use prefixed hash for parsing + const messageLength = 32 // hash is always 32 bytes + const prefix = `\x19Ethereum Signed Message:\n${messageLength}` + const prefixedHash = keccak256(encodePacked(['string', 'bytes'], [prefix, hash])) + + return parseSignature(prefixedHash, signature, signer) +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 35d00a9e3..fcdd07a05 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@celo/abis": "13.0.0-post-audit.0", + "@celo/core": "0.0.1", "@celo/actions": "0.2.0", "@celo/base": "^7.0.3", "@celo/compliance": "1.0.28", diff --git a/packages/cli/src/base.ts b/packages/cli/src/base.ts index 3856b7927..5ca33d035 100644 --- a/packages/cli/src/base.ts +++ b/packages/cli/src/base.ts @@ -277,7 +277,7 @@ export abstract class BaseCommand extends Command { // NOTE: adjust logic here later to take in account commands which // don't use --from but --account or other flags to pass in which account // should be used - const accountAddress = res.flags.from as StrongAddress + const accountAddress = (res.flags.from || res.flags.signer) as StrongAddress if (res.flags.useLedger) { try { diff --git a/packages/cli/src/commands/account/authorize.ts b/packages/cli/src/commands/account/authorize.ts index 70614993d..70de33154 100644 --- a/packages/cli/src/commands/account/authorize.ts +++ b/packages/cli/src/commands/account/authorize.ts @@ -1,12 +1,15 @@ +import { authorizeValidatorSigner, authorizeVoteSigner } from '@celo/actions/contracts/accounts' +import { parseProofOfPossession } from '@celo/core' import { Flags } from '@oclif/core' +import { Hex } from 'viem' import { BaseCommand } from '../../base' import { newCheckBuilder } from '../../utils/checks' -import { displaySendTx } from '../../utils/cli' +import { displaySendTx, displayViemTx } from '../../utils/cli' import { CustomFlags } from '../../utils/command' export default class Authorize extends BaseCommand { static description = - 'Keep your locked Gold more secure by authorizing alternative keys to be used for signing attestations, voting, or validating. By doing so, you can continue to participate in the protocol while keeping the key with access to your locked Gold in cold storage. You must include a "proof-of-possession" of the key being authorized, which can be generated with the "account:proof-of-possession" command.' + 'Keep your locked CELO more secure by authorizing alternative keys to be used for signing attestations, voting, or validating. By doing so, you can continue to participate in the protocol while keeping the key with access to your locked Gold in cold storage. You must include a "proof-of-possession" of the key being authorized, which can be generated with the "account:proof-of-possession" command.' static flags = { ...BaseCommand.flags, @@ -35,40 +38,46 @@ export default class Authorize extends BaseCommand { static examples = [ 'authorize --from 0x5409ED021D9299bf6814279A6A1411A7e866A631 --role vote --signer 0x6ecbe1db9ef729cbe972c83fb886247691fb6beb --signature 0x1b9fca4bbb5bfb1dbe69ef1cddbd9b4202dcb6b134c5170611e1e36ecfa468d7b46c85328d504934fce6c2a1571603a50ae224d2b32685e84d4d1a1eebad8452eb', - 'authorize --from 0x5409ED021D9299bf6814279A6A1411A7e866A631 --role validator --signer 0x6ecbe1db9ef729cbe972c83fb886247691fb6beb --signature 0x1b9fca4bbb5bfb1dbe69ef1cddbd9b4202dcb6b134c5170611e1e36ecfa468d7b46c85328d504934fce6c2a1571603a50ae224d2b32685e84d4d1a1eebad8452eb --blsKey 0x4fa3f67fc913878b068d1fa1cdddc54913d3bf988dbe5a36a20fa888f20d4894c408a6773f3d7bde11154f2a3076b700d345a42fd25a0e5e83f4db5586ac7979ac2053cd95d8f2efd3e959571ceccaa743e02cf4be3f5d7aaddb0b06fc9aff00 --blsPop 0xcdb77255037eb68897cd487fdd85388cbda448f617f874449d4b11588b0b7ad8ddc20d9bb450b513bb35664ea3923900', + 'authorize --from 0x5409ED021D9299bf6814279A6A1411A7e866A631 --role validator --signer 0x6ecbe1db9ef729cbe972c83fb886247691fb6beb --signature 0x1b9fca4bbb5bfb1dbe69ef1cddbd9b4202dcb6b134c5170611e1e36ecfa468d7b46c85328d504934fce6c2a1571603a50ae224d2b32685e84d4d1a1eebad8452eb', ] async run() { const res = await this.parse(Authorize) - const kit = await this.getKit() - const accounts = await kit.contracts.getAccounts() - const sig = accounts.parseSignatureOfAddress( - res.flags.from, - res.flags.signer, - res.flags.signature - ) + const publicClient = await this.getPublicClient() + const walletClient = await this.getWalletClient() + const clients = { public: publicClient, wallet: walletClient } + + // Parse signature using core package + const sig = await parseProofOfPossession(res.flags.from, res.flags.signer, res.flags.signature) if (res.flags.role === 'validator') { if (res.flags.blsKey || res.flags.blsPop) { this.error('BLS keys are not supported anymore', { exit: 1 }) } } - const checker = newCheckBuilder(this).isAccount(res.flags.from) + const checker = newCheckBuilder(this).isAccount(res.flags.from) await checker.runChecks() - let tx: any + let txHash: Promise if (res.flags.role === 'vote') { - tx = await accounts.authorizeVoteSigner(res.flags.signer, sig) + // dont await here as the displayViemTx needs the promise + txHash = authorizeVoteSigner(clients, res.flags.signer, sig) } else if (res.flags.role === 'validator') { - const validatorsWrapper = await kit.contracts.getValidators() - tx = await accounts.authorizeValidatorSigner(res.flags.signer, sig, validatorsWrapper) + // dont await here as the displayViemTx needs the promise + txHash = authorizeValidatorSigner(clients, res.flags.signer, sig) } else if (res.flags.role === 'attestation') { - tx = await accounts.authorizeAttestationSigner(res.flags.signer, sig) + // Keep attestation authorization in CLI using ContractKit (deprecated) + const kit = await this.getKit() + const accounts = await kit.contracts.getAccounts() + const tx = await accounts.authorizeAttestationSigner(res.flags.signer, sig) + await displaySendTx('authorizeTx', tx) + return } else { this.error(`Invalid role provided`) return } - await displaySendTx('authorizeTx', tx) + + await displayViemTx('authorizeTx', txHash, publicClient) } } diff --git a/packages/cli/src/commands/account/proof-of-possession.compatibility.test.ts b/packages/cli/src/commands/account/proof-of-possession.compatibility.test.ts new file mode 100644 index 000000000..def40f2e0 --- /dev/null +++ b/packages/cli/src/commands/account/proof-of-possession.compatibility.test.ts @@ -0,0 +1,191 @@ +import { + generateProofOfKeyPossessionLocally, + parseSignatureOfAddress, +} from '@celo/actions/authorization' +import { newKitFromWeb3 } from '@celo/contractkit' +import { serializeSignature } from '@celo/core' +import { testWithAnvilL2 } from '@celo/dev-utils/anvil-test' +import { encodePacked, keccak256 } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import Web3 from 'web3' + +const TIMEOUT = 30_000 + +// Test data - use different addresses for account and signer (real-world scenario) +const TEST_SIGNER_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' +const TEST_ACCOUNT_PRIVATE_KEY = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' +const TEST_SIGNER = privateKeyToAccount(TEST_SIGNER_PRIVATE_KEY).address +const TEST_ACCOUNT = privateKeyToAccount(TEST_ACCOUNT_PRIVATE_KEY).address + +describe('Proof of Possession Compatibility Tests', () => { + describe('Hash Generation Compatibility', () => { + it('viem hash matches web3 soliditySha3 hash', async () => { + // Web3 approach + const web3Hash = Web3.utils.soliditySha3({ type: 'address', value: TEST_ACCOUNT }) + + // Viem approach + const viemHash = keccak256(encodePacked(['address'], [TEST_ACCOUNT])) + + expect(viemHash).toBe(web3Hash) + }) + }) + + describe('Local Signature Compatibility', () => { + testWithAnvilL2('local implementations produce identical results', (web3: Web3) => { + it( + 'both implementations produce identical signatures with different account/signer', + async () => { + // Old ContractKit local approach + const kit = newKitFromWeb3(web3) + const accounts = await kit.contracts.getAccounts() + const oldResult = await accounts.generateProofOfKeyPossessionLocally( + TEST_ACCOUNT, + TEST_SIGNER, + TEST_SIGNER_PRIVATE_KEY + ) + + // New viem approach - signer proves possession for account + const newResult = await generateProofOfKeyPossessionLocally( + TEST_SIGNER_PRIVATE_KEY, + TEST_ACCOUNT + ) + + // Both should produce valid signature structures + expect(oldResult).toHaveProperty('v') + expect(oldResult).toHaveProperty('r') + expect(oldResult).toHaveProperty('s') + expect(typeof oldResult.v).toBe('number') + expect([27, 28]).toContain(oldResult.v) + + expect(newResult).toHaveProperty('v') + expect(newResult).toHaveProperty('r') + expect(newResult).toHaveProperty('s') + expect(typeof newResult.v).toBe('number') + expect([27, 28]).toContain(newResult.v) + + // Test cross-compatibility: both implementations should produce identical signatures + // when signer proves possession for a different account + const oldSerialized = serializeSignature(oldResult) + const newSerialized = serializeSignature(newResult) + + // New implementation should be able to parse its own signatures + const newParsed = await parseSignatureOfAddress(TEST_ACCOUNT, TEST_SIGNER, newSerialized) + expect(newParsed.v).toBe(newResult.v) + expect(newParsed.r).toBe(newResult.r) + expect(newParsed.s).toBe(newResult.s) + + expect(oldSerialized).toBe(newSerialized) + }, + TIMEOUT + ) + }) + }) + + describe('End-to-End Compatibility', () => { + testWithAnvilL2('old implementation works', (web3: Web3) => { + it( + 'generates proof with ContractKit', + async () => { + const kit = newKitFromWeb3(web3) + const accounts = await kit.contracts.getAccounts() + + // Fund the account (not the signer) + const fundingAccount = (await web3.eth.getAccounts())[0] + await web3.eth.sendTransaction({ + from: fundingAccount, + to: TEST_ACCOUNT, + value: web3.utils.toWei('1', 'ether'), + }) + + // We need to add both account and signer private keys for this test + kit.connection.addAccount(TEST_ACCOUNT_PRIVATE_KEY) + kit.connection.addAccount(TEST_SIGNER_PRIVATE_KEY) + kit.defaultAccount = TEST_SIGNER // Set signer as default for signing + + const oldResult = await accounts.generateProofOfKeyPossession(TEST_ACCOUNT, TEST_SIGNER) + const oldSerialized = serializeSignature(oldResult) + + expect(oldSerialized).toMatch(/^0x[a-fA-F0-9]{130}$/) + + // Store for comparison with new implementation + expect(oldResult).toHaveProperty('v') + expect(oldResult).toHaveProperty('r') + expect(oldResult).toHaveProperty('s') + }, + TIMEOUT + ) + }) + + it( + 'new implementation has correct structure', + async () => { + // Test local signing without RPC calls - signer proves possession for account + const newResult = await generateProofOfKeyPossessionLocally( + TEST_SIGNER_PRIVATE_KEY, + TEST_ACCOUNT + ) + const newSerialized = serializeSignature(newResult) + + expect(newSerialized).toMatch(/^0x[a-fA-F0-9]{130}$/) + + // Verify structure + expect(newResult).toHaveProperty('v') + expect(newResult).toHaveProperty('r') + expect(newResult).toHaveProperty('s') + }, + TIMEOUT + ) + }) + + describe('Signature Format Compatibility', () => { + testWithAnvilL2('signatures can be parsed by both implementations', (_web3: Web3) => { + it( + 'new implementation parses signatures correctly', + async () => { + // Generate signature with new implementation - signer proves possession for account + const newSignature = await generateProofOfKeyPossessionLocally( + TEST_SIGNER_PRIVATE_KEY, + TEST_ACCOUNT + ) + const serializedSig = serializeSignature(newSignature) + + // Parse with new implementation + const parsedByNew = await parseSignatureOfAddress( + TEST_ACCOUNT, + TEST_SIGNER, + serializedSig + ) + + expect(parsedByNew.v).toBe(newSignature.v) + expect(parsedByNew.r).toBe(newSignature.r) + expect(parsedByNew.s).toBe(newSignature.s) + }, + TIMEOUT + ) + }) + }) + + describe('Known Test Vector Compatibility', () => { + it('produces known test signature correctly', async () => { + // Using the constants from test-utils/constants.ts + const knownAccount = '0x5409ED021D9299bf6814279A6A1411A7e866A631' + + // This test assumes we know the private key that corresponds to the known signer + // For now, we'll just test that our new implementation produces consistent results + // TODO: Use knownAccount when we have the corresponding private key + void knownAccount // Keep for future use + const result1 = await generateProofOfKeyPossessionLocally( + TEST_SIGNER_PRIVATE_KEY, + TEST_ACCOUNT + ) + + const result2 = await generateProofOfKeyPossessionLocally( + TEST_SIGNER_PRIVATE_KEY, + TEST_ACCOUNT + ) + + expect(serializeSignature(result1)).toBe(serializeSignature(result2)) + }) + }) +}) diff --git a/packages/cli/src/commands/account/proof-of-possession.ts b/packages/cli/src/commands/account/proof-of-possession.ts index f13df9259..b11302cb4 100644 --- a/packages/cli/src/commands/account/proof-of-possession.ts +++ b/packages/cli/src/commands/account/proof-of-possession.ts @@ -1,4 +1,10 @@ -import { serializeSignature } from '@celo/utils/lib/signatureUtils' +import { + generateProofOfKeyPossession, + generateProofOfKeyPossessionLocally, +} from '@celo/actions/authorization' +import { ensureLeading0x } from '@celo/base' +import { serializeSignature } from '@celo/core' +import { Hex } from 'viem' import { BaseCommand } from '../../base' import { printValueMap } from '../../utils/cli' import { CustomFlags } from '../../utils/command' @@ -23,10 +29,22 @@ export default class ProofOfPossession extends BaseCommand { ] async run() { - const kit = await this.getKit() const res = await this.parse(ProofOfPossession) - const accounts = await kit.contracts.getAccounts() - const pop = await accounts.generateProofOfKeyPossession(res.flags.account, res.flags.signer) + const walletClient = await this.getWalletClient() + let pop: { v: number; r: Hex; s: Hex } + if (res.flags.privateKey) { + pop = await generateProofOfKeyPossessionLocally( + ensureLeading0x(res.flags.privateKey), + ensureLeading0x(res.flags.account) + ) + } else { + pop = await generateProofOfKeyPossession( + walletClient, + res.flags.account, + walletClient.account + ) + } + printValueMap({ signature: serializeSignature(pop) }) } } diff --git a/packages/cli/src/commands/account/verify-proof-of-possession.ts b/packages/cli/src/commands/account/verify-proof-of-possession.ts index 3fc21c28e..6ddb9d529 100644 --- a/packages/cli/src/commands/account/verify-proof-of-possession.ts +++ b/packages/cli/src/commands/account/verify-proof-of-possession.ts @@ -1,4 +1,5 @@ import { serializeSignature } from '@celo/utils/lib/signatureUtils' +import { Hex } from 'viem' import { BaseCommand } from '../../base' import { printValueMap } from '../../utils/cli' import { CustomFlags } from '../../utils/command' @@ -38,7 +39,7 @@ export default class VerifyProofOfPossession extends BaseCommand { res.flags.signer, res.flags.signature ) - signature = serializeSignature({ v, r, s }) + signature = serializeSignature({ v, r, s }) as Hex valid = true } catch (error) { console.error('Error: Failed to parse signature') diff --git a/packages/cli/src/utils/command.ts b/packages/cli/src/utils/command.ts index c667c8eb6..02eb93f9e 100644 --- a/packages/cli/src/utils/command.ts +++ b/packages/cli/src/utils/command.ts @@ -31,7 +31,7 @@ const parseEcdsaPublicKey: ParseFn = async (input) => { : parseBytes(input, 64, `${input} is not an ECDSA public key`) } -const parseProofOfPossession: ParseFn = async (input) => { +const parseProofOfPossession: ParseFn = async (input) => { return parseBytes(input, POP_SIZE, `${input} is not a proof-of-possession`) } const parseAddress: ParseFn = async (input) => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bf9d696c1..37ba912ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,3 @@ export * from './governing/index.js' export * from './staking/index.js' +export * from './proof-of-possession/index.js' diff --git a/packages/core/src/proof-of-possession/index.test.ts b/packages/core/src/proof-of-possession/index.test.ts new file mode 100644 index 000000000..4471807fa --- /dev/null +++ b/packages/core/src/proof-of-possession/index.test.ts @@ -0,0 +1,83 @@ +import { encodePacked, keccak256 } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { describe, expect, it, vi } from 'vitest' +import { generateProofOfPossessionHash, parseProofOfPossession, parseSignature } from './index.js' + +describe('generateProofOfPossessionHash', () => { + const testAddress = '0x5409ED021D9299bf6814279A6A1411A7e866A631' as const + + it('generates correct hash for address', () => { + const hash = generateProofOfPossessionHash(testAddress) + + // Should match what viem's keccak256(encodePacked(['address'], [address])) produces + const expectedHash = keccak256(encodePacked(['address'], [testAddress])) + expect(hash).toBe(expectedHash) + }) + + it('produces consistent results for same address', () => { + const hash1 = generateProofOfPossessionHash(testAddress) + const hash2 = generateProofOfPossessionHash(testAddress) + expect(hash1).toBe(hash2) + }) + + it('produces different results for different addresses', () => { + const address1 = '0x5409ED021D9299bf6814279A6A1411A7e866A631' as const + const address2 = '0x6Ecbe1DB9EF729CBe972C83Fb886247691Fb6beb' as const + + const hash1 = generateProofOfPossessionHash(address1) + const hash2 = generateProofOfPossessionHash(address2) + + expect(hash1).not.toBe(hash2) + }) + + it('returns 32-byte hex string', () => { + const hash = generateProofOfPossessionHash(testAddress) + + // Should be 0x + 64 hex chars (32 bytes) + expect(hash).toMatch(/^0x[a-fA-F0-9]{64}$/) + }) +}) + +describe('parseProofOfPossession', () => { + const testAddress = '0x5409ED021D9299bf6814279A6A1411A7e866A631' as const + + // Use a known private key for testing + const testPrivateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + const testAccount = privateKeyToAccount(testPrivateKey) + const testSigner = testAccount.address + + it('parses signature correctly', async () => { + // Generate the hash and sign it directly (without message prefix) + const hash = generateProofOfPossessionHash(testAddress) + const signature = await testAccount.sign({ hash }) + + const result = await parseProofOfPossession(testAddress, testSigner, signature) + + expect(result).toHaveProperty('v') + expect(result).toHaveProperty('r') + expect(result).toHaveProperty('s') + expect(typeof result.v).toBe('number') + expect(typeof result.r).toBe('string') + expect(typeof result.s).toBe('string') + }) + + it('uses correct hash in parsing', async () => { + const expectedHash = generateProofOfPossessionHash(testAddress) + + // Generate a valid signature for the test + const hash = generateProofOfPossessionHash(testAddress) + const signature = await testAccount.sign({ hash }) + + // Rather than mocking, just verify that parseProofOfPossession calls with the expected hash + // We can do this by checking that it uses the same hash as we generate + const result = await parseProofOfPossession(testAddress, testSigner, signature) + + // The fact that this doesn't throw an error means the correct hash was used + expect(result).toHaveProperty('v') + expect(result).toHaveProperty('r') + expect(result).toHaveProperty('s') + + // Verify the hash matches what we expect + expect(hash).toBe(expectedHash) + }) +}) diff --git a/packages/core/src/proof-of-possession/index.ts b/packages/core/src/proof-of-possession/index.ts new file mode 100644 index 000000000..ee415beff --- /dev/null +++ b/packages/core/src/proof-of-possession/index.ts @@ -0,0 +1,78 @@ +import { + Address, + encodePacked, + Hex, + isAddress, + keccak256, + recoverAddress, + parseSignature as viemParseSignature, + serializeSignature as viemSerializeSignature, +} from 'viem' + +export const generateProofOfPossessionHash = (address: Address): Hex => { + // Exactly matches web3.utils.soliditySha3({ type: 'address', value: address }) + return keccak256(encodePacked(['address'], [address])) +} + +/** + * Parse and validate a signature for proof of possession + * @param messageHash The hash that was signed + * @param signature The signature to parse + * @param expectedSigner The expected signer address + * @returns Parsed signature with v, r, s components + */ +export const parseSignature = async ( + messageHash: Hex, + signature: Hex, + expectedSigner: Address +): Promise<{ v: number; r: Hex; s: Hex }> => { + // Parse signature using viem + const parsed = viemParseSignature(signature) + + // Convert yParity to v (yParity is 0 or 1, v is 27 or 28) + const v = parsed.yParity === 0 ? 27 : 28 + + // Recover the address to validate + const recoveredAddress = await recoverAddress({ + hash: messageHash, + signature: signature, + }) + + // Validate that the recovered address matches expected signer + if (!isAddress(expectedSigner)) { + throw new Error(`Invalid signer address: ${expectedSigner}`) + } + + if (recoveredAddress.toLowerCase() !== expectedSigner.toLowerCase()) { + throw new Error( + `Unable to parse signature (expected signer ${expectedSigner}, got ${recoveredAddress})` + ) + } + + return { + v, + r: parsed.r, + s: parsed.s, + } +} + +/** + * Serialize a structured signature into hex format + * @param signature The structured signature with v, r, s components + * @returns Serialized hex signature + */ +export const serializeSignature = (signature: { v: number; r: string; s: string }): Hex => { + // Convert v back to yParity for viem (v is 27 or 28, yParity is 0 or 1) + const yParity = signature.v === 27 ? 0 : 1 + + return viemSerializeSignature({ + r: signature.r as Hex, + s: signature.s as Hex, + yParity, + }) +} + +export const parseProofOfPossession = async (address: Address, signer: Address, signature: Hex) => { + const hash = generateProofOfPossessionHash(address) + return parseSignature(hash, signature, signer) +}