From c3a3adb1ee5927b7daa2d7699659f306082e84d3 Mon Sep 17 00:00:00 2001 From: Aaron DeRuvo Date: Thu, 30 Oct 2025 13:14:25 +0100 Subject: [PATCH 1/8] docs(changeset): Add utilies for generating a proof-of-possession and parsingSignatures --- .changeset/big-clowns-like.md | 6 ++ .../actions/src/contracts/accounts.test.ts | 69 +++++++++++++++ packages/actions/src/contracts/accounts.ts | 56 ++++++++++++- packages/actions/src/index.ts | 5 ++ packages/core/src/index.ts | 1 + .../src/proof-of-possession/index.test.ts | 83 +++++++++++++++++++ .../core/src/proof-of-possession/index.ts | 78 +++++++++++++++++ 7 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 .changeset/big-clowns-like.md create mode 100644 packages/actions/src/contracts/accounts.test.ts create mode 100644 packages/core/src/proof-of-possession/index.test.ts create mode 100644 packages/core/src/proof-of-possession/index.ts 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/packages/actions/src/contracts/accounts.test.ts b/packages/actions/src/contracts/accounts.test.ts new file mode 100644 index 000000000..41efc4763 --- /dev/null +++ b/packages/actions/src/contracts/accounts.test.ts @@ -0,0 +1,69 @@ +import { privateKeyToAccount } from 'viem/accounts' +import { describe, expect, it } from 'vitest' +import { serializeSignature } from '@celo/core' +import { generateProofOfKeyPossessionLocally, parseSignatureOfAddress } from './accounts.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('accounts 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/contracts/accounts.ts b/packages/actions/src/contracts/accounts.ts index cfd05ea0b..f733a98a2 100644 --- a/packages/actions/src/contracts/accounts.ts +++ b/packages/actions/src/contracts/accounts.ts @@ -1,6 +1,8 @@ import { accountsABI } from '@celo/abis' -import { Address, getContract, GetContractReturnType } from 'viem' -import { Clients, PublicCeloClient } from '../client.js' +import { parseSignature } from '@celo/core' +import { Address, encodePacked, getContract, GetContractReturnType, Hex, keccak256 } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { Clients, PublicCeloClient, WalletCeloClient } from '../client.js' import { resolveAddress } from './registry.js' export type AccountsContract = GetContractReturnType< @@ -29,3 +31,53 @@ export const signerToAccount = async ( args: [signer], }) } + +// PROOF OF POSSESSION FUNCTIONS + +export const generateProofOfKeyPossession = async ( + client: WalletCeloClient, + account: Address, + signer: Address +): Promise<{ v: number; r: string; s: string }> => { + // 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) +} + +export const generateProofOfKeyPossessionLocally = async ( + privateKey: Hex, + account: Address +): Promise<{ v: number; r: string; s: string }> => { + 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: string, 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/actions/src/index.ts b/packages/actions/src/index.ts index 419e56a59..0ef3fa0f9 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -2,3 +2,8 @@ export * from './client.js' export { ContractName } from './contract-name.js' export { resolveAddress } from './contracts/registry.js' export { getGasPriceOnCelo } from './rpc-methods.js' +export { + generateProofOfKeyPossession, + generateProofOfKeyPossessionLocally, + parseSignatureOfAddress, +} from './contracts/accounts.js' 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..26d2c3899 --- /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: string +): Promise<{ v: number; r: string; s: string }> => { + // 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) +} From c9b540e9808ee0a0680562b67f0b950eac9515f6 Mon Sep 17 00:00:00 2001 From: Aaron DeRuvo Date: Thu, 30 Oct 2025 13:16:17 +0100 Subject: [PATCH 2/8] docs(changeset): Convert account:proof-of-posession to use viem instead of web3 based functions --- .changeset/odd-memes-work.md | 5 + .../cli/src/commands/account/authorize.ts | 2 +- .../proof-of-possession.compatibility.test.ts | 178 ++++++++++++++++++ .../commands/account/proof-of-possession.ts | 14 +- 4 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 .changeset/odd-memes-work.md create mode 100644 packages/cli/src/commands/account/proof-of-possession.compatibility.test.ts diff --git a/.changeset/odd-memes-work.md b/.changeset/odd-memes-work.md new file mode 100644 index 000000000..e9bce6663 --- /dev/null +++ b/.changeset/odd-memes-work.md @@ -0,0 +1,5 @@ +--- +'@celo/celocli': patch +--- + +Convert account:proof-of-posession to use viem instead of web3 based functions diff --git a/packages/cli/src/commands/account/authorize.ts b/packages/cli/src/commands/account/authorize.ts index 70614993d..51f171c39 100644 --- a/packages/cli/src/commands/account/authorize.ts +++ b/packages/cli/src/commands/account/authorize.ts @@ -6,7 +6,7 @@ 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, 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..528417004 --- /dev/null +++ b/packages/cli/src/commands/account/proof-of-possession.compatibility.test.ts @@ -0,0 +1,178 @@ +import { generateProofOfKeyPossessionLocally, parseSignatureOfAddress } from '@celo/actions' +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..46eccd0da 100644 --- a/packages/cli/src/commands/account/proof-of-possession.ts +++ b/packages/cli/src/commands/account/proof-of-possession.ts @@ -1,4 +1,5 @@ -import { serializeSignature } from '@celo/utils/lib/signatureUtils' +import { generateProofOfKeyPossession } from '@celo/actions' +import { serializeSignature } from '@celo/core' import { BaseCommand } from '../../base' import { printValueMap } from '../../utils/cli' import { CustomFlags } from '../../utils/command' @@ -23,10 +24,15 @@ 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() + + const pop = await generateProofOfKeyPossession( + walletClient, + res.flags.account, + res.flags.signer + ) + printValueMap({ signature: serializeSignature(pop) }) } } From e98a96a4af4e0e27c1a443c45b89c3203782a46d Mon Sep 17 00:00:00 2001 From: Aaron DeRuvo Date: Thu, 30 Oct 2025 16:23:39 +0100 Subject: [PATCH 3/8] refactor: migrate account authorize command to use viem instead of web3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract proof-of-possession functionality to dedicated module in actions package - Update account authorize command to use new viem-based authorization functions - Add authorizeVoteSigner and authorizeValidatorSigner functions using viem - Maintain compatibility with existing proof-of-possession signatures - Update changeset to reflect both proof-of-possession and authorize command changes - Add @celo/core dependency to CLI package for proof-of-possession parsing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .changeset/odd-memes-work.md | 2 +- packages/actions/src/contracts/accounts.ts | 83 ++++++++----------- packages/actions/src/contracts/validators.ts | 15 +++- packages/actions/src/index.ts | 9 +- .../authorize/proof-of-possession.ts | 60 ++++++++++++++ packages/cli/package.json | 1 + .../cli/src/commands/account/authorize.ts | 41 +++++---- .../proof-of-possession.compatibility.test.ts | 24 ++++-- .../account/verify-proof-of-possession.ts | 3 +- packages/cli/src/utils/command.ts | 2 +- .../core/src/proof-of-possession/index.ts | 4 +- 11 files changed, 164 insertions(+), 80 deletions(-) create mode 100644 packages/actions/src/multicontract-interactions/authorize/proof-of-possession.ts diff --git a/.changeset/odd-memes-work.md b/.changeset/odd-memes-work.md index e9bce6663..b17980285 100644 --- a/.changeset/odd-memes-work.md +++ b/.changeset/odd-memes-work.md @@ -2,4 +2,4 @@ '@celo/celocli': patch --- -Convert account:proof-of-posession to use viem instead of web3 based functions +Convert account:proof-of-posession and account:authorize to use viem instead of web3 based functions diff --git a/packages/actions/src/contracts/accounts.ts b/packages/actions/src/contracts/accounts.ts index f733a98a2..3485d4deb 100644 --- a/packages/actions/src/contracts/accounts.ts +++ b/packages/actions/src/contracts/accounts.ts @@ -1,8 +1,7 @@ import { accountsABI } from '@celo/abis' -import { parseSignature } from '@celo/core' -import { Address, encodePacked, getContract, GetContractReturnType, Hex, keccak256 } from 'viem' -import { privateKeyToAccount } from 'viem/accounts' -import { Clients, PublicCeloClient, WalletCeloClient } from '../client.js' +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< @@ -32,52 +31,40 @@ export const signerToAccount = async ( }) } -// PROOF OF POSSESSION FUNCTIONS +// AUTHORIZATION FUNCTIONS -export const generateProofOfKeyPossession = async ( - client: WalletCeloClient, - account: Address, - signer: Address -): Promise<{ v: number; r: string; s: string }> => { - // 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 }, +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, + ], }) - - return parseSignature(hash, signature, signer) } -export const generateProofOfKeyPossessionLocally = async ( - privateKey: Hex, - account: Address -): Promise<{ v: number; r: string; s: string }> => { - 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: string, 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) +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 0ef3fa0f9..85cb75e40 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -1,9 +1,14 @@ export * from './client.js' export { ContractName } from './contract-name.js' +export { + authorizeValidatorSigner, + authorizeVoteSigner, +} from './contracts/accounts.js' export { resolveAddress } from './contracts/registry.js' -export { getGasPriceOnCelo } from './rpc-methods.js' +export { isValidator } from './contracts/validators.js' export { generateProofOfKeyPossession, generateProofOfKeyPossessionLocally, parseSignatureOfAddress, -} from './contracts/accounts.js' +} from './multicontract-interactions/authorize/proof-of-possession.js' +export { getGasPriceOnCelo } from './rpc-methods.js' 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..cabb535f7 --- /dev/null +++ b/packages/actions/src/multicontract-interactions/authorize/proof-of-possession.ts @@ -0,0 +1,60 @@ +import { parseSignature } from '@celo/core' +import { 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: Address +): 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) +} + +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/commands/account/authorize.ts b/packages/cli/src/commands/account/authorize.ts index 51f171c39..47a4ada1d 100644 --- a/packages/cli/src/commands/account/authorize.ts +++ b/packages/cli/src/commands/account/authorize.ts @@ -1,7 +1,10 @@ +import { authorizeValidatorSigner, authorizeVoteSigner } from '@celo/actions' +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 { @@ -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 index 528417004..182b0b701 100644 --- a/packages/cli/src/commands/account/proof-of-possession.compatibility.test.ts +++ b/packages/cli/src/commands/account/proof-of-possession.compatibility.test.ts @@ -10,7 +10,8 @@ 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_ACCOUNT_PRIVATE_KEY = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' const TEST_SIGNER = privateKeyToAccount(TEST_SIGNER_PRIVATE_KEY).address const TEST_ACCOUNT = privateKeyToAccount(TEST_ACCOUNT_PRIVATE_KEY).address @@ -64,13 +65,13 @@ describe('Proof of Possession Compatibility Tests', () => { // 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 @@ -96,7 +97,7 @@ describe('Proof of Possession Compatibility Tests', () => { // 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.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) @@ -117,7 +118,10 @@ describe('Proof of Possession Compatibility Tests', () => { '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 newResult = await generateProofOfKeyPossessionLocally( + TEST_SIGNER_PRIVATE_KEY, + TEST_ACCOUNT + ) const newSerialized = serializeSignature(newResult) expect(newSerialized).toMatch(/^0x[a-fA-F0-9]{130}$/) @@ -168,9 +172,15 @@ describe('Proof of Possession Compatibility Tests', () => { // 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 result1 = await generateProofOfKeyPossessionLocally( + TEST_SIGNER_PRIVATE_KEY, + TEST_ACCOUNT + ) - const result2 = 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/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/proof-of-possession/index.ts b/packages/core/src/proof-of-possession/index.ts index 26d2c3899..ee415beff 100644 --- a/packages/core/src/proof-of-possession/index.ts +++ b/packages/core/src/proof-of-possession/index.ts @@ -24,8 +24,8 @@ export const generateProofOfPossessionHash = (address: Address): Hex => { export const parseSignature = async ( messageHash: Hex, signature: Hex, - expectedSigner: string -): Promise<{ v: number; r: string; s: string }> => { + expectedSigner: Address +): Promise<{ v: number; r: Hex; s: Hex }> => { // Parse signature using viem const parsed = viemParseSignature(signature) From 7646e00bae7cdfe0f6c9dd4bbdbccb3a433b501f Mon Sep 17 00:00:00 2001 From: Aaron DeRuvo Date: Thu, 30 Oct 2025 16:24:57 +0100 Subject: [PATCH 4/8] turn beta mode back on --- .changeset/pre.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .changeset/pre.json 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": [] +} From fbf5001f20143f88a93a1808d55e9859d5496e37 Mon Sep 17 00:00:00 2001 From: Aaron DeRuvo Date: Thu, 30 Oct 2025 16:46:52 +0100 Subject: [PATCH 5/8] move test to match file it tests --- .../authorize/poof-of-poessession.test.ts} | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) rename packages/actions/src/{contracts/accounts.test.ts => multicontract-interactions/authorize/poof-of-poessession.test.ts} (94%) diff --git a/packages/actions/src/contracts/accounts.test.ts b/packages/actions/src/multicontract-interactions/authorize/poof-of-poessession.test.ts similarity index 94% rename from packages/actions/src/contracts/accounts.test.ts rename to packages/actions/src/multicontract-interactions/authorize/poof-of-poessession.test.ts index 41efc4763..4dac38294 100644 --- a/packages/actions/src/contracts/accounts.test.ts +++ b/packages/actions/src/multicontract-interactions/authorize/poof-of-poessession.test.ts @@ -1,14 +1,17 @@ +import { serializeSignature } from '@celo/core' import { privateKeyToAccount } from 'viem/accounts' import { describe, expect, it } from 'vitest' -import { serializeSignature } from '@celo/core' -import { generateProofOfKeyPossessionLocally, parseSignatureOfAddress } from './accounts.js' +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('accounts proof-of-possession functions', () => { +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 From 5dc2e0bb1ad0f0bd3c55bb2f927c619ff6e367c4 Mon Sep 17 00:00:00 2001 From: Aaron DeRuvo Date: Thu, 30 Oct 2025 17:02:35 +0100 Subject: [PATCH 6/8] structure exports --- packages/actions/package.json | 21 +++++++++++++++++-- packages/actions/src/index.ts | 4 ---- .../authorize/index.ts | 1 + .../cli/src/commands/account/authorize.ts | 2 +- .../proof-of-possession.compatibility.test.ts | 5 ++++- .../commands/account/proof-of-possession.ts | 2 +- 6 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 packages/actions/src/multicontract-interactions/authorize/index.ts 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/index.ts b/packages/actions/src/index.ts index 85cb75e40..5bc482c87 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -1,9 +1,5 @@ export * from './client.js' export { ContractName } from './contract-name.js' -export { - authorizeValidatorSigner, - authorizeVoteSigner, -} from './contracts/accounts.js' export { resolveAddress } from './contracts/registry.js' export { isValidator } from './contracts/validators.js' export { 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/cli/src/commands/account/authorize.ts b/packages/cli/src/commands/account/authorize.ts index 47a4ada1d..70de33154 100644 --- a/packages/cli/src/commands/account/authorize.ts +++ b/packages/cli/src/commands/account/authorize.ts @@ -1,4 +1,4 @@ -import { authorizeValidatorSigner, authorizeVoteSigner } from '@celo/actions' +import { authorizeValidatorSigner, authorizeVoteSigner } from '@celo/actions/contracts/accounts' import { parseProofOfPossession } from '@celo/core' import { Flags } from '@oclif/core' import { Hex } from 'viem' 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 index 182b0b701..def40f2e0 100644 --- a/packages/cli/src/commands/account/proof-of-possession.compatibility.test.ts +++ b/packages/cli/src/commands/account/proof-of-possession.compatibility.test.ts @@ -1,4 +1,7 @@ -import { generateProofOfKeyPossessionLocally, parseSignatureOfAddress } from '@celo/actions' +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' diff --git a/packages/cli/src/commands/account/proof-of-possession.ts b/packages/cli/src/commands/account/proof-of-possession.ts index 46eccd0da..20e10d525 100644 --- a/packages/cli/src/commands/account/proof-of-possession.ts +++ b/packages/cli/src/commands/account/proof-of-possession.ts @@ -1,4 +1,4 @@ -import { generateProofOfKeyPossession } from '@celo/actions' +import { generateProofOfKeyPossession } from '@celo/actions/authorization' import { serializeSignature } from '@celo/core' import { BaseCommand } from '../../base' import { printValueMap } from '../../utils/cli' From edd5c3580270f78ab96e982b5b23c59191ea0a8b Mon Sep 17 00:00:00 2001 From: Aaron DeRuvo Date: Tue, 4 Nov 2025 16:24:26 +0100 Subject: [PATCH 7/8] handle local signs --- .../authorize/proof-of-possession.ts | 7 +++---- packages/cli/src/base.ts | 2 +- .../commands/account/proof-of-possession.ts | 19 +++++++++++++------ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/actions/src/multicontract-interactions/authorize/proof-of-possession.ts b/packages/actions/src/multicontract-interactions/authorize/proof-of-possession.ts index cabb535f7..d77f00e77 100644 --- a/packages/actions/src/multicontract-interactions/authorize/proof-of-possession.ts +++ b/packages/actions/src/multicontract-interactions/authorize/proof-of-possession.ts @@ -1,5 +1,5 @@ import { parseSignature } from '@celo/core' -import { Address, encodePacked, Hex, keccak256 } from 'viem' +import { Account, Address, encodePacked, Hex, keccak256 } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { WalletCeloClient } from '../../client' @@ -14,7 +14,7 @@ export type ProofOfPossession = { export const generateProofOfKeyPossession = async ( client: WalletCeloClient, account: Address, - signer: Address + signer: Account ): Promise => { // Use the same hash generation as soliditySha3({ type: 'address', value: account }) const hash = keccak256(encodePacked(['address'], [account])) @@ -23,8 +23,7 @@ export const generateProofOfKeyPossession = async ( account: signer, message: { raw: hash }, }) - - return parseSignature(hash, signature, signer) + return parseSignature(hash, signature, signer.address) } export const generateProofOfKeyPossessionLocally = async ( 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/proof-of-possession.ts b/packages/cli/src/commands/account/proof-of-possession.ts index 20e10d525..ba9a32951 100644 --- a/packages/cli/src/commands/account/proof-of-possession.ts +++ b/packages/cli/src/commands/account/proof-of-possession.ts @@ -1,5 +1,7 @@ -import { generateProofOfKeyPossession } from '@celo/actions/authorization' +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' @@ -26,12 +28,17 @@ export default class ProofOfPossession extends BaseCommand { async run() { const res = await this.parse(ProofOfPossession) 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) + } - const pop = await generateProofOfKeyPossession( - walletClient, - res.flags.account, - res.flags.signer - ) + printValueMap({ signature: serializeSignature(pop) }) } From a01019f51bda6596701f0db854c95a3b7c81e5f7 Mon Sep 17 00:00:00 2001 From: Aaron DeRuvo Date: Tue, 4 Nov 2025 16:27:11 +0100 Subject: [PATCH 8/8] lint --- .../src/commands/account/proof-of-possession.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/account/proof-of-possession.ts b/packages/cli/src/commands/account/proof-of-possession.ts index ba9a32951..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,7 @@ -import { generateProofOfKeyPossession, generateProofOfKeyPossessionLocally } from '@celo/actions/authorization' +import { + generateProofOfKeyPossession, + generateProofOfKeyPossessionLocally, +} from '@celo/actions/authorization' import { ensureLeading0x } from '@celo/base' import { serializeSignature } from '@celo/core' import { Hex } from 'viem' @@ -30,16 +33,18 @@ export default class ProofOfPossession extends BaseCommand { 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)) + pop = await generateProofOfKeyPossessionLocally( + ensureLeading0x(res.flags.privateKey), + ensureLeading0x(res.flags.account) + ) } else { - pop = await generateProofOfKeyPossession( + pop = await generateProofOfKeyPossession( walletClient, res.flags.account, - walletClient.account) + walletClient.account + ) } - - printValueMap({ signature: serializeSignature(pop) }) } }