Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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);
}
Expand Down
18 changes: 14 additions & 4 deletions src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
return await provider.getPqPubkey(address) === null;
}

export async function deployContract(options: DeployContractOptions): Promise<DeployContractResult> {
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");

Expand All @@ -453,11 +460,14 @@ export async function deployContract(options: DeployContractOptions): Promise<De
}

export async function writeContract(options: ContractWriteOptions): Promise<ContractWriteResult> {
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");

Expand Down
3 changes: 2 additions & 1 deletion src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 45 additions & 1 deletion tests/contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,18 @@ 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',
async sendTransaction(signed) {
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) => {
Expand Down Expand Up @@ -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',
]);
Expand Down Expand Up @@ -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/);
Expand Down
Loading