From 86eb949afe95ba8d57a7f42109ad366d24491217 Mon Sep 17 00:00:00 2001 From: LucienSong Date: Mon, 13 Jul 2026 01:45:53 +0800 Subject: [PATCH] fix(contracts): detect on-chain pubkey registration --- README.md | 5 +++-- src/contracts.ts | 18 ++++++++++++---- src/provider.ts | 3 ++- tests/contracts.test.mjs | 46 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 69ac104..78c8ef8 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ const signer = new ShellSigner("MlDsa65", MlDsa65Adapter.generate()); |---|---|---| | `tx` | `ShellTransactionRequest` | The unsigned transaction | | `txHash` | `Uint8Array` | RLP-encoded hash to sign | -| `includePublicKey?` | `boolean` | Embed `sender_pubkey` for first-time senders | +| `includePublicKey?` | `boolean` | Embed `sender_pubkey` until the sender key is registered on-chain | #### Helper functions @@ -849,7 +849,8 @@ async function submitTransfer({ signer, to, value, rpcHttpUrl }: { value, }); const txHash = hashTransaction(tx, signer.signatureType); - const signed = await signer.buildSignedTransaction({ tx, txHash, includePublicKey: nonce === 0 }); + const includePublicKey = await provider.getPqPubkey(signer.getAddress()) === null; + const signed = await signer.buildSignedTransaction({ tx, txHash, includePublicKey }); return provider.sendTransaction(signed); } diff --git a/src/contracts.ts b/src/contracts.ts index 76ba9e0..857e4e1 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -423,12 +423,19 @@ export async function waitForTransactionReceipt( throw new Error(`timeout waiting for transaction receipt: ${options.hash}`); } +async function shouldIncludePublicKey(provider: ShellProvider, address: string): Promise { + return await provider.getPqPubkey(address) === null; +} + export async function deployContract(options: DeployContractOptions): Promise { - const nonce = options.nonce ?? await getPendingNonce(options.provider, options.signer.getAddress()); + const sender = options.signer.getAddress(); + const nonce = options.nonce ?? await getPendingNonce(options.provider, sender); + const includePublicKey = options.includePublicKey + ?? await shouldIncludePublicKey(options.provider, sender); const tx = buildDeployTransaction({ ...options, nonce }); const signed = await options.signer.buildSignedTransaction({ tx, - includePublicKey: options.includePublicKey ?? nonce === 0, + includePublicKey, }); const hash = normalizeHexData(await options.provider.sendTransaction(signed), "transaction hash"); @@ -453,11 +460,14 @@ export async function deployContract(options: DeployContractOptions): Promise { - const nonce = options.nonce ?? await getPendingNonce(options.provider, options.signer.getAddress()); + const sender = options.signer.getAddress(); + const nonce = options.nonce ?? await getPendingNonce(options.provider, sender); + const includePublicKey = options.includePublicKey + ?? await shouldIncludePublicKey(options.provider, sender); const tx = buildContractCallTransaction({ ...options, nonce }); const signed = await options.signer.buildSignedTransaction({ tx, - includePublicKey: options.includePublicKey ?? nonce === 0, + includePublicKey, }); const hash = normalizeHexData(await options.provider.sendTransaction(signed), "transaction hash"); diff --git a/src/provider.ts b/src/provider.ts index 7a72ecd..894f654 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -294,7 +294,8 @@ export class ShellProvider { * Retrieve the on-chain public key for an address. * * Calls `shell_getPqPubkey`. Returns `null` if the address has not yet - * submitted a transaction (public key is only recorded on first send). + * had a transaction included on-chain. Pending transactions do not register + * the key persistently. * * @param address - A `0x…` hex address. * @returns Hex-encoded public key string, or `null` if unknown. diff --git a/tests/contracts.test.mjs b/tests/contracts.test.mjs index 1dcc9eb..d4abb85 100644 --- a/tests/contracts.test.mjs +++ b/tests/contracts.test.mjs @@ -50,7 +50,7 @@ function makeReceipt(overrides = {}) { }; } -function makeProvider({ receipt = makeReceipt(), callResult = '0x', nonce = '0x0' } = {}) { +function makeProvider({ receipt = makeReceipt(), callResult = '0x', nonce = '0x0', pqPubkey = null } = {}) { const calls = []; const provider = { rpcHttpUrl: 'http://127.0.0.1:8545', @@ -58,6 +58,10 @@ function makeProvider({ receipt = makeReceipt(), callResult = '0x', nonce = '0x0 calls.push({ method: 'shell_sendTransaction', params: [signed] }); return HASH; }, + async getPqPubkey(address) { + calls.push({ method: 'shell_getPqPubkey', params: [address] }); + return pqPubkey; + }, }; const fetchMock = async (_url, init) => { @@ -164,6 +168,7 @@ test('deployContract sends, waits, and validates 32-byte contract address', asyn assert.deepEqual(calls.map((call) => call.method), [ 'eth_getTransactionCount', + 'shell_getPqPubkey', 'shell_sendTransaction', 'eth_getTransactionReceipt', ]); @@ -231,11 +236,50 @@ test('writeContract sends contract call and waits for receipt', async () => { assert.deepEqual(calls.map((call) => call.method), [ 'eth_getTransactionCount', + 'shell_getPqPubkey', 'shell_sendTransaction', 'eth_getTransactionReceipt', ]); }); +test('writeContract embeds the key for an unregistered sender with a pending nonce', async () => { + const { provider, fetchMock } = makeProvider({ nonce: '0x1' }); + const signer = makeSigner(); + + await withFetchMock(fetchMock, async () => { + await writeContract({ + provider, + signer, + chainId: 1337, + address: CONTRACT, + abi: ABI, + functionName: 'setNumber', + args: [12n], + }); + }); + + assert.equal(signer.signed[0].includePublicKey, true); +}); + +test('writeContract omits the key once the sender is registered on-chain', async () => { + const { provider, fetchMock } = makeProvider({ nonce: '0x1', pqPubkey: '0x1234' }); + const signer = makeSigner(); + + await withFetchMock(fetchMock, async () => { + await writeContract({ + provider, + signer, + chainId: 1337, + address: CONTRACT, + abi: ABI, + functionName: 'setNumber', + args: [12n], + }); + }); + + assert.equal(signer.signed[0].includePublicKey, false); +}); + test('readContract uses eth_call and decodes result', async () => { const encoded = encodeFunctionData({ abi: ABI, functionName: 'getNumber' }); assert.match(encoded, /^0xf2c9ecd8/);