Skip to content

Commit 0d9ba72

Browse files
committed
TXs signing (bypasses HA)
1 parent a4785ac commit 0d9ba72

File tree

9 files changed

+267
-78
lines changed

9 files changed

+267
-78
lines changed

yarn-project/stdlib/src/p2p/block_proposal.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Fr } from '@aztec/foundation/curves/bn254';
66
import type { EthAddress } from '@aztec/foundation/eth-address';
77
import { Signature } from '@aztec/foundation/eth-signature';
88
import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize';
9+
import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
910

1011
import type { L2Block } from '../block/l2_block.js';
1112
import type { L2BlockInfo } from '../block/l2_block_info.js';
@@ -125,7 +126,7 @@ export class BlockProposal extends Gossipable {
125126
archiveRoot: Fr,
126127
txHashes: TxHash[],
127128
txs: Tx[] | undefined,
128-
payloadSigner: (payload: Buffer32) => Promise<Signature>,
129+
payloadSigner: (payload: Buffer32, context: SigningContext) => Promise<Signature>,
129130
): Promise<BlockProposal> {
130131
// Create a temporary proposal to get the payload to sign
131132
const tempProposal = new BlockProposal(
@@ -137,13 +138,23 @@ export class BlockProposal extends Gossipable {
137138
Signature.empty(),
138139
);
139140

141+
// Create the block signing context
142+
const blockContext: SigningContext = {
143+
slot: blockHeader.globalVariables.slotNumber,
144+
blockNumber: blockHeader.globalVariables.blockNumber,
145+
blockIndexWithinCheckpoint: indexWithinCheckpoint,
146+
dutyType: DutyType.BLOCK_PROPOSAL,
147+
};
148+
140149
const hashed = getHashedSignaturePayload(tempProposal, SignatureDomainSeparator.blockProposal);
141-
const sig = await payloadSigner(hashed);
150+
const sig = await payloadSigner(hashed, blockContext);
142151

143152
// If txs are provided, sign them as well
144153
let signedTxs: SignedTxs | undefined;
145154
if (txs) {
146-
signedTxs = await SignedTxs.createFromSigner(txs, payloadSigner);
155+
const txsSigningContext: SigningContext = { dutyType: DutyType.TXS };
156+
const txsSigner = (payload: Buffer32) => payloadSigner(payload, txsSigningContext);
157+
signedTxs = await SignedTxs.createFromSigner(txs, txsSigner);
147158
}
148159

149160
return new BlockProposal(blockHeader, indexWithinCheckpoint, inHash, archiveRoot, txHashes, sig, signedTxs);

yarn-project/stdlib/src/p2p/checkpoint_proposal.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -170,25 +170,14 @@ export class CheckpointProposal extends Gossipable {
170170
return new CheckpointProposal(checkpointHeader, archiveRoot, checkpointSignature);
171171
}
172172

173-
// Create a block-specific signer that uses BLOCK_PROPOSAL duty type
174-
const blockSigner = (payload: Buffer32) => {
175-
const blockContext: SigningContext = {
176-
slot: lastBlockInfo.blockHeader.globalVariables.slotNumber,
177-
blockNumber: lastBlockInfo.blockHeader.globalVariables.blockNumber,
178-
blockIndexWithinCheckpoint: lastBlockInfo.indexWithinCheckpoint,
179-
dutyType: DutyType.BLOCK_PROPOSAL,
180-
};
181-
return payloadSigner(payload, blockContext);
182-
};
183-
184173
const lastBlockProposal = await BlockProposal.createProposalFromSigner(
185174
lastBlockInfo.blockHeader,
186175
lastBlockInfo.indexWithinCheckpoint,
187176
checkpointHeader.inHash,
188177
archiveRoot,
189178
lastBlockInfo.txHashes,
190179
lastBlockInfo.txs,
191-
blockSigner,
180+
payloadSigner,
192181
);
193182

194183
return new CheckpointProposal(checkpointHeader, archiveRoot, checkpointSignature, {

yarn-project/stdlib/src/tests/mocks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ export const makeBlockProposal = (options?: MakeBlockProposalOptions): Promise<B
565565
archiveRoot,
566566
txHashes,
567567
txs,
568-
payload => Promise.resolve(signer.signMessage(payload)),
568+
(_payload, _context) => Promise.resolve(signer.signMessage(_payload)),
569569
);
570570
};
571571

yarn-project/validator-client/src/duties/validation_service.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Fr } from '@aztec/foundation/curves/bn254';
44
import { EthAddress } from '@aztec/foundation/eth-address';
55
import { makeCheckpointProposal, makeL2BlockHeader } from '@aztec/stdlib/testing';
66
import { Tx } from '@aztec/stdlib/tx';
7+
import { DutyType } from '@aztec/validator-ha-signer/types';
78

89
import { generatePrivateKey } from 'viem/accounts';
910

@@ -74,4 +75,76 @@ describe('ValidationService', () => {
7475
expect(attestations[0].getSender()).toEqual(addresses[0]);
7576
expect(attestations[1].getSender()).toEqual(addresses[1]);
7677
});
78+
79+
it('creates checkpoint proposal with different duty types for checkpoint and block', async () => {
80+
// This test verifies the fix for HA double-signing issue where both checkpoint
81+
// and block were incorrectly using the same CHECKPOINT_PROPOSAL duty type.
82+
// Now they should use CHECKPOINT_PROPOSAL and BLOCK_PROPOSAL respectively.
83+
84+
const txs = await Promise.all([Tx.random(), Tx.random()]);
85+
const l2BlockHeader = makeL2BlockHeader(1, 2, 3);
86+
const blockHeader = l2BlockHeader.toBlockHeader();
87+
const indexWithinCheckpoint = 0;
88+
const archive = Fr.random();
89+
90+
// Create a spy keystore to capture signing contexts
91+
const capturedContexts: Array<{ dutyType: DutyType; blockIndexWithinCheckpoint?: number }> = [];
92+
const spyStore = {
93+
...store,
94+
signMessageWithAddress: (address: EthAddress, message: Buffer32, context: any) => {
95+
capturedContexts.push({
96+
dutyType: context.dutyType,
97+
blockIndexWithinCheckpoint: context.blockIndexWithinCheckpoint,
98+
});
99+
return store.signMessageWithAddress(address, message, context);
100+
},
101+
getAddress: (index: number) => store.getAddress(index),
102+
getAddresses: () => store.getAddresses(),
103+
};
104+
const spyService = new ValidationService(spyStore as any);
105+
106+
// Create checkpoint header
107+
const checkpointHeader = l2BlockHeader.toCheckpointHeader();
108+
109+
// Create checkpoint proposal with lastBlock
110+
const proposal = await spyService.createCheckpointProposal(
111+
checkpointHeader,
112+
archive,
113+
{
114+
blockHeader,
115+
indexWithinCheckpoint,
116+
txs,
117+
},
118+
addresses[0],
119+
{ publishFullTxs: true },
120+
);
121+
122+
// Verify proposal was created successfully
123+
expect(proposal.getSender()).toEqual(addresses[0]);
124+
expect(proposal.lastBlock).toBeDefined();
125+
126+
// Verify we captured signing operations:
127+
// 1. CHECKPOINT_PROPOSAL for the checkpoint itself
128+
// 2. BLOCK_PROPOSAL for the block itself
129+
// 3. TXS for the SignedTxs
130+
expect(capturedContexts.length).toBe(3);
131+
132+
// Find the checkpoint and block signatures
133+
const checkpointSigs = capturedContexts.filter(c => c.dutyType === DutyType.CHECKPOINT_PROPOSAL);
134+
const blockSigs = capturedContexts.filter(c => c.dutyType === DutyType.BLOCK_PROPOSAL);
135+
const txsSigs = capturedContexts.filter(c => c.dutyType === DutyType.TXS);
136+
137+
// Should have exactly 1 checkpoint signature (no blockIndexWithinCheckpoint)
138+
expect(checkpointSigs.length).toBe(1);
139+
expect(checkpointSigs[0].blockIndexWithinCheckpoint).toBeUndefined();
140+
141+
// Should have exactly 2 block signatures (both with blockIndexWithinCheckpoint)
142+
// One for the block proposal, one for the SignedTxs
143+
expect(blockSigs.length).toBe(1);
144+
expect(blockSigs[0].blockIndexWithinCheckpoint).toBe(indexWithinCheckpoint);
145+
146+
// Should have exactly 1 txs signature
147+
expect(txsSigs.length).toBe(1);
148+
expect(txsSigs[0].blockIndexWithinCheckpoint).toBeUndefined();
149+
});
77150
});

yarn-project/validator-client/src/duties/validation_service.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,22 +54,16 @@ export class ValidationService {
5454
proposerAttesterAddress: EthAddress | undefined,
5555
options: BlockProposalOptions,
5656
): Promise<BlockProposal> {
57-
const context: SigningContext = {
58-
slot: blockHeader.globalVariables.slotNumber,
59-
blockNumber: blockHeader.globalVariables.blockNumber,
60-
blockIndexWithinCheckpoint,
61-
dutyType: DutyType.BLOCK_PROPOSAL,
62-
};
63-
6457
// For testing: change the new archive to trigger state_mismatch validation failure
6558
if (options.broadcastInvalidBlockProposal) {
6659
archive = Fr.random();
6760
this.log.warn(`Creating INVALID block proposal for slot ${blockHeader.globalVariables.slotNumber}`);
6861
}
6962

70-
// Create a signer that uses the appropriate address and captures the context
63+
// Create a signer that uses the appropriate address
7164
const address = proposerAttesterAddress ?? this.keyStore.getAddress(0);
72-
const payloadSigner = (payload: Buffer32) => this.keyStore.signMessageWithAddress(address, payload, context);
65+
const payloadSigner = (payload: Buffer32, context: SigningContext) =>
66+
this.keyStore.signMessageWithAddress(address, payload, context);
7367

7468
return BlockProposal.createProposalFromSigner(
7569
blockHeader,

yarn-project/validator-client/src/key_store/ha_key_store.test.ts

Lines changed: 141 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { Signature } from '@aztec/foundation/eth-signature';
55
import type { EthRemoteSignerConfig } from '@aztec/node-keystore';
66
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
77
import { DutyAlreadySignedError, SlashingProtectionError } from '@aztec/validator-ha-signer/errors';
8-
import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types';
8+
import { DutyType, type SigningContext, isHAProtectedContext } from '@aztec/validator-ha-signer/types';
99
import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer';
1010

1111
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
@@ -89,9 +89,8 @@ describe('HAKeyStore', () => {
8989
mockHASigner.signWithProtection.mockResolvedValue(mockSignature);
9090
});
9191

92-
describe('ValidatorKeyStore interface delegation (AUTH_REQUEST bypasses HA)', () => {
92+
describe('ValidatorKeyStore interface delegation (non-HA duties bypass HA)', () => {
9393
let haKeyStore: HAKeyStore;
94-
const authRequestContext: SigningContext = { dutyType: DutyType.AUTH_REQUEST };
9594

9695
beforeEach(() => {
9796
haKeyStore = new HAKeyStore(mockBaseKeyStore, mockHASigner);
@@ -109,40 +108,84 @@ describe('HAKeyStore', () => {
109108
expect(mockBaseKeyStore.getAddresses).toHaveBeenCalled();
110109
});
111110

112-
it('should delegate signTypedData to base key store for AUTH_REQUEST (bypasses HA)', async () => {
113-
const result = await haKeyStore.signTypedData(mockTypedData, authRequestContext);
114-
expect(result).toEqual([mockSignature]);
115-
expect(mockBaseKeyStore.signTypedData).toHaveBeenCalledWith(mockTypedData, authRequestContext);
116-
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
117-
});
111+
describe('AUTH_REQUEST duties bypass HA', () => {
112+
const authRequestContext: SigningContext = { dutyType: DutyType.AUTH_REQUEST };
118113

119-
it('should delegate signMessage to base key store for AUTH_REQUEST (bypasses HA)', async () => {
120-
const result = await haKeyStore.signMessage(SIGNING_ROOT, authRequestContext);
121-
expect(result).toEqual([mockSignature]);
122-
expect(mockBaseKeyStore.signMessage).toHaveBeenCalledWith(SIGNING_ROOT, authRequestContext);
123-
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
124-
});
114+
it('should delegate signTypedData to base key store for AUTH_REQUEST', async () => {
115+
const result = await haKeyStore.signTypedData(mockTypedData, authRequestContext);
116+
expect(result).toEqual([mockSignature]);
117+
expect(mockBaseKeyStore.signTypedData).toHaveBeenCalledWith(mockTypedData, authRequestContext);
118+
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
119+
});
125120

126-
it('should delegate signTypedDataWithAddress without HA for AUTH_REQUEST', async () => {
127-
const result = await haKeyStore.signTypedDataWithAddress(VALIDATOR_ADDRESS, mockTypedData, authRequestContext);
128-
expect(result).toBe(mockSignature);
129-
expect(mockBaseKeyStore.signTypedDataWithAddress).toHaveBeenCalledWith(
130-
VALIDATOR_ADDRESS,
131-
mockTypedData,
132-
authRequestContext,
133-
);
134-
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
121+
it('should delegate signMessage to base key store for AUTH_REQUEST', async () => {
122+
const result = await haKeyStore.signMessage(SIGNING_ROOT, authRequestContext);
123+
expect(result).toEqual([mockSignature]);
124+
expect(mockBaseKeyStore.signMessage).toHaveBeenCalledWith(SIGNING_ROOT, authRequestContext);
125+
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
126+
});
127+
128+
it('should delegate signTypedDataWithAddress without HA for AUTH_REQUEST', async () => {
129+
const result = await haKeyStore.signTypedDataWithAddress(VALIDATOR_ADDRESS, mockTypedData, authRequestContext);
130+
expect(result).toBe(mockSignature);
131+
expect(mockBaseKeyStore.signTypedDataWithAddress).toHaveBeenCalledWith(
132+
VALIDATOR_ADDRESS,
133+
mockTypedData,
134+
authRequestContext,
135+
);
136+
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
137+
});
138+
139+
it('should delegate signMessageWithAddress without HA for AUTH_REQUEST', async () => {
140+
const result = await haKeyStore.signMessageWithAddress(VALIDATOR_ADDRESS, SIGNING_ROOT, authRequestContext);
141+
expect(result).toBe(mockSignature);
142+
expect(mockBaseKeyStore.signMessageWithAddress).toHaveBeenCalledWith(
143+
VALIDATOR_ADDRESS,
144+
SIGNING_ROOT,
145+
authRequestContext,
146+
);
147+
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
148+
});
135149
});
136150

137-
it('should delegate signMessageWithAddress without HA for AUTH_REQUEST', async () => {
138-
const result = await haKeyStore.signMessageWithAddress(VALIDATOR_ADDRESS, SIGNING_ROOT, authRequestContext);
139-
expect(result).toBe(mockSignature);
140-
expect(mockBaseKeyStore.signMessageWithAddress).toHaveBeenCalledWith(
141-
VALIDATOR_ADDRESS,
142-
SIGNING_ROOT,
143-
authRequestContext,
144-
);
145-
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
151+
describe('TXS duties bypass HA', () => {
152+
const txsContext: SigningContext = { dutyType: DutyType.TXS };
153+
154+
it('should delegate signTypedData to base key store for TXS', async () => {
155+
const result = await haKeyStore.signTypedData(mockTypedData, txsContext);
156+
expect(result).toEqual([mockSignature]);
157+
expect(mockBaseKeyStore.signTypedData).toHaveBeenCalledWith(mockTypedData, txsContext);
158+
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
159+
});
160+
161+
it('should delegate signMessage to base key store for TXS', async () => {
162+
const result = await haKeyStore.signMessage(SIGNING_ROOT, txsContext);
163+
expect(result).toEqual([mockSignature]);
164+
expect(mockBaseKeyStore.signMessage).toHaveBeenCalledWith(SIGNING_ROOT, txsContext);
165+
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
166+
});
167+
168+
it('should delegate signTypedDataWithAddress without HA for TXS', async () => {
169+
const result = await haKeyStore.signTypedDataWithAddress(VALIDATOR_ADDRESS, mockTypedData, txsContext);
170+
expect(result).toBe(mockSignature);
171+
expect(mockBaseKeyStore.signTypedDataWithAddress).toHaveBeenCalledWith(
172+
VALIDATOR_ADDRESS,
173+
mockTypedData,
174+
txsContext,
175+
);
176+
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
177+
});
178+
179+
it('should delegate signMessageWithAddress without HA for TXS', async () => {
180+
const result = await haKeyStore.signMessageWithAddress(VALIDATOR_ADDRESS, SIGNING_ROOT, txsContext);
181+
expect(result).toBe(mockSignature);
182+
expect(mockBaseKeyStore.signMessageWithAddress).toHaveBeenCalledWith(
183+
VALIDATOR_ADDRESS,
184+
SIGNING_ROOT,
185+
txsContext,
186+
);
187+
expect(mockHASigner.signWithProtection).not.toHaveBeenCalled();
188+
});
146189
});
147190
});
148191

@@ -331,4 +374,69 @@ describe('HAKeyStore', () => {
331374
expect(mockHASigner.stop).toHaveBeenCalledTimes(1);
332375
});
333376
});
377+
378+
describe('isHAProtectedContext type guard', () => {
379+
it('should return false for AUTH_REQUEST', () => {
380+
const context: SigningContext = { dutyType: DutyType.AUTH_REQUEST };
381+
expect(isHAProtectedContext(context)).toBe(false);
382+
});
383+
384+
it('should return false for TXS', () => {
385+
const context: SigningContext = { dutyType: DutyType.TXS };
386+
expect(isHAProtectedContext(context)).toBe(false);
387+
});
388+
389+
it('should return true for BLOCK_PROPOSAL', () => {
390+
const context: SigningContext = {
391+
slot: SlotNumber(100),
392+
blockNumber: BlockNumber(50),
393+
dutyType: DutyType.BLOCK_PROPOSAL,
394+
blockIndexWithinCheckpoint: 0,
395+
};
396+
expect(isHAProtectedContext(context)).toBe(true);
397+
});
398+
399+
it('should return true for ATTESTATION', () => {
400+
const context: SigningContext = {
401+
slot: SlotNumber(100),
402+
blockNumber: BlockNumber(50),
403+
dutyType: DutyType.ATTESTATION,
404+
};
405+
expect(isHAProtectedContext(context)).toBe(true);
406+
});
407+
408+
it('should return true for CHECKPOINT_PROPOSAL', () => {
409+
const context: SigningContext = {
410+
slot: SlotNumber(100),
411+
blockNumber: BlockNumber(50),
412+
dutyType: DutyType.CHECKPOINT_PROPOSAL,
413+
};
414+
expect(isHAProtectedContext(context)).toBe(true);
415+
});
416+
417+
it('should return true for ATTESTATIONS_AND_SIGNERS', () => {
418+
const context: SigningContext = {
419+
slot: SlotNumber(100),
420+
blockNumber: BlockNumber(50),
421+
dutyType: DutyType.ATTESTATIONS_AND_SIGNERS,
422+
};
423+
expect(isHAProtectedContext(context)).toBe(true);
424+
});
425+
426+
it('should return true for GOVERNANCE_VOTE', () => {
427+
const context: SigningContext = {
428+
slot: SlotNumber(100),
429+
dutyType: DutyType.GOVERNANCE_VOTE,
430+
};
431+
expect(isHAProtectedContext(context)).toBe(true);
432+
});
433+
434+
it('should return true for SLASHING_VOTE', () => {
435+
const context: SigningContext = {
436+
slot: SlotNumber(100),
437+
dutyType: DutyType.SLASHING_VOTE,
438+
};
439+
expect(isHAProtectedContext(context)).toBe(true);
440+
});
441+
});
334442
});

0 commit comments

Comments
 (0)