|
1 | 1 | // Serde test for the block proposal type |
| 2 | +import { IndexWithinCheckpoint } from '@aztec/foundation/branded-types'; |
2 | 3 | import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; |
| 4 | +import { Fr } from '@aztec/foundation/curves/bn254'; |
3 | 5 | import { Signature } from '@aztec/foundation/eth-signature'; |
| 6 | +import { bufferToHex, hexToBuffer } from '@aztec/foundation/string'; |
4 | 7 |
|
| 8 | +import { InboxBucketRef } from '../messaging/inbox_bucket.js'; |
5 | 9 | import { TEST_COORDINATION_SIGNATURE_CONTEXT, makeBlockProposal } from '../tests/mocks.js'; |
| 10 | +import { BlockHeader } from '../tx/block_header.js'; |
6 | 11 | import { Tx } from '../tx/tx.js'; |
| 12 | +import { TxHash } from '../tx/tx_hash.js'; |
7 | 13 | import { BlockProposal } from './block_proposal.js'; |
| 14 | +import { EMPTY_COORDINATION_SIGNATURE_CONTEXT } from './signature_utils.js'; |
8 | 15 | import { SignedTxs } from './signed_txs.js'; |
| 16 | +import { LEGACY_BLOCK_PROPOSAL_HEX, LEGACY_BLOCK_PROPOSAL_PAYLOAD_HEX } from './wire_compat_fixtures.js'; |
| 17 | + |
| 18 | +/** |
| 19 | + * Deterministic legacy-shaped proposal (no signedTxs, no bucketRef) matching the golden fixtures in |
| 20 | + * wire_compat_fixtures.ts. Constructed identically to how those bytes were captured on the pre-change code. |
| 21 | + */ |
| 22 | +const makeLegacyFixtureProposal = () => |
| 23 | + new BlockProposal( |
| 24 | + BlockHeader.empty(), |
| 25 | + IndexWithinCheckpoint(3), |
| 26 | + new Fr(42n), |
| 27 | + new Fr(99n), |
| 28 | + [TxHash.fromField(new Fr(7n)), TxHash.fromField(new Fr(8n))], |
| 29 | + Signature.empty(), |
| 30 | + EMPTY_COORDINATION_SIGNATURE_CONTEXT, |
| 31 | + ); |
9 | 32 |
|
10 | 33 | describe('Block Proposal serialization / deserialization', () => { |
11 | 34 | const checkEquivalence = (serialized: BlockProposal, deserialized: BlockProposal) => { |
@@ -92,4 +115,115 @@ describe('Block Proposal serialization / deserialization', () => { |
92 | 115 |
|
93 | 116 | expect(tampered.getSender()).toBeUndefined(); |
94 | 117 | }); |
| 118 | + |
| 119 | + describe('bucket reference (AZIP-22 Fast Inbox)', () => { |
| 120 | + it('round-trips with a bucket reference set', async () => { |
| 121 | + const bucketRef = InboxBucketRef.random(); |
| 122 | + const proposal = await makeBlockProposal({ bucketRef }); |
| 123 | + |
| 124 | + const deserialized = BlockProposal.fromBuffer(proposal.toBuffer()); |
| 125 | + |
| 126 | + expect(deserialized.bucketRef).toBeDefined(); |
| 127 | + expect(deserialized.bucketRef!.equals(bucketRef)).toBe(true); |
| 128 | + expect(deserialized.getSize()).toEqual(proposal.getSize()); |
| 129 | + expect(deserialized).toEqual(proposal); |
| 130 | + }); |
| 131 | + |
| 132 | + it('round-trips with a bucket reference set alongside signed txs', async () => { |
| 133 | + const bucketRef = InboxBucketRef.random(); |
| 134 | + const txs = await Promise.all([Tx.random(), Tx.random()]); |
| 135 | + const proposal = await makeBlockProposal({ txs, bucketRef }); |
| 136 | + |
| 137 | + const deserialized = BlockProposal.fromBuffer(proposal.toBuffer()); |
| 138 | + |
| 139 | + expect(deserialized.bucketRef!.equals(bucketRef)).toBe(true); |
| 140 | + expect(deserialized.txs?.length).toEqual(txs.length); |
| 141 | + expect(deserialized).toEqual(proposal); |
| 142 | + }); |
| 143 | + |
| 144 | + it('serializes byte-identically to the legacy format when unset', () => { |
| 145 | + const proposal = makeLegacyFixtureProposal(); |
| 146 | + expect(proposal.bucketRef).toBeUndefined(); |
| 147 | + expect(bufferToHex(proposal.toBuffer())).toEqual(LEGACY_BLOCK_PROPOSAL_HEX); |
| 148 | + expect(bufferToHex(proposal.getPayloadToSign())).toEqual(LEGACY_BLOCK_PROPOSAL_PAYLOAD_HEX); |
| 149 | + }); |
| 150 | + |
| 151 | + it('decodes a legacy buffer (no tail) as having no bucket reference', () => { |
| 152 | + const deserialized = BlockProposal.fromBuffer(hexToBuffer(LEGACY_BLOCK_PROPOSAL_HEX)); |
| 153 | + expect(deserialized.bucketRef).toBeUndefined(); |
| 154 | + // Re-encoding a legacy buffer yields the same legacy bytes: no phantom tail is introduced. |
| 155 | + expect(bufferToHex(deserialized.toBuffer())).toEqual(LEGACY_BLOCK_PROPOSAL_HEX); |
| 156 | + }); |
| 157 | + |
| 158 | + it('appends the bucket reference only when set, changing the signed payload', async () => { |
| 159 | + const bucketRef = InboxBucketRef.random(); |
| 160 | + const withRef = await makeBlockProposal({ bucketRef }); |
| 161 | + const withoutRef = await makeBlockProposal({ |
| 162 | + blockHeader: withRef.blockHeader, |
| 163 | + indexWithinCheckpoint: withRef.indexWithinCheckpoint, |
| 164 | + inHash: withRef.inHash, |
| 165 | + archiveRoot: withRef.archiveRoot, |
| 166 | + txHashes: withRef.txHashes, |
| 167 | + }); |
| 168 | + |
| 169 | + const withRefPayload = withRef.getPayloadToSign(); |
| 170 | + const withoutRefPayload = withoutRef.getPayloadToSign(); |
| 171 | + |
| 172 | + // The set payload extends the unset payload by exactly the reference bytes (appended tail, no marker). |
| 173 | + expect(withRefPayload.length).toEqual(withoutRefPayload.length + InboxBucketRef.SIZE); |
| 174 | + expect(withRefPayload.subarray(0, withoutRefPayload.length)).toEqual(withoutRefPayload); |
| 175 | + // The payload hashes differ, so the attestation pool treats set-vs-unset as distinct payloads. |
| 176 | + expect(withRef.getPayloadHash().toString()).not.toEqual(withoutRef.getPayloadHash().toString()); |
| 177 | + }); |
| 178 | + |
| 179 | + it('covers the bucket reference under the proposal signature', async () => { |
| 180 | + const signer = Secp256k1Signer.random(); |
| 181 | + const bucketRef = InboxBucketRef.random(); |
| 182 | + const proposal = await makeBlockProposal({ signer, bucketRef }); |
| 183 | + |
| 184 | + const deserialized = BlockProposal.fromBuffer(proposal.toBuffer()); |
| 185 | + expect(deserialized.getSender()).toEqual(signer.address); |
| 186 | + }); |
| 187 | + |
| 188 | + it('breaks sender recovery when the bucket reference is tampered with', async () => { |
| 189 | + const signer = Secp256k1Signer.random(); |
| 190 | + const bucketRef = new InboxBucketRef(5n, 100n, new Fr(7n)); |
| 191 | + const proposal = await makeBlockProposal({ signer, bucketRef }); |
| 192 | + expect(proposal.getSender()).toEqual(signer.address); |
| 193 | + |
| 194 | + // A relay swapping the signed reference for a different one is not covered by the original signature. |
| 195 | + const tampered = new BlockProposal( |
| 196 | + proposal.blockHeader, |
| 197 | + proposal.indexWithinCheckpoint, |
| 198 | + proposal.inHash, |
| 199 | + proposal.archiveRoot, |
| 200 | + proposal.txHashes, |
| 201 | + proposal.signature, |
| 202 | + proposal.signatureContext, |
| 203 | + proposal.signedTxs, |
| 204 | + new InboxBucketRef(6n, 100n, new Fr(7n)), |
| 205 | + ); |
| 206 | + expect(tampered.getSender()).not.toEqual(signer.address); |
| 207 | + }); |
| 208 | + |
| 209 | + it('breaks sender recovery when a bucket reference is injected into an unsigned proposal', async () => { |
| 210 | + const signer = Secp256k1Signer.random(); |
| 211 | + const proposal = await makeBlockProposal({ signer }); |
| 212 | + expect(proposal.getSender()).toEqual(signer.address); |
| 213 | + |
| 214 | + // A relay injecting a reference the proposer never signed over is rejected by signature recovery. |
| 215 | + const injected = new BlockProposal( |
| 216 | + proposal.blockHeader, |
| 217 | + proposal.indexWithinCheckpoint, |
| 218 | + proposal.inHash, |
| 219 | + proposal.archiveRoot, |
| 220 | + proposal.txHashes, |
| 221 | + proposal.signature, |
| 222 | + proposal.signatureContext, |
| 223 | + proposal.signedTxs, |
| 224 | + InboxBucketRef.random(), |
| 225 | + ); |
| 226 | + expect(injected.getSender()).not.toEqual(signer.address); |
| 227 | + }); |
| 228 | + }); |
95 | 229 | }); |
0 commit comments