diff --git a/src/fixtures/codec.ts b/src/fixtures/codec.ts index f60a39064..3473cdafe 100644 --- a/src/fixtures/codec.ts +++ b/src/fixtures/codec.ts @@ -2,6 +2,7 @@ import { getAVMManager } from '../serializable/avm/codec'; import { codec as EVMCodec } from '../serializable/evm/codec'; import { Short } from '../serializable/primitives'; import { codec } from '../serializable/pvm/codec'; +import { codec as WarpCodec } from '../serializable/pvm/warp/codec'; // Check for circular imports in the fx type // registries if tests are throwing errors @@ -13,3 +14,5 @@ export const testCodec = () => testManager().getCodecForVersion(new Short(0)); export const testPVMCodec = () => codec; export const testEVMCodec = () => EVMCodec; + +export const testWarpCodec = () => WarpCodec; diff --git a/src/fixtures/warp.ts b/src/fixtures/warp.ts new file mode 100644 index 000000000..497938f77 --- /dev/null +++ b/src/fixtures/warp.ts @@ -0,0 +1,32 @@ +import { + WarpMessage, + WarpSignature, + WarpUnsignedMessage, +} from '../serializable/pvm/warp'; +import { concatBytes } from '../utils'; +import { id, idBytes } from './common'; +import { + blsSignature, + blsSignatureBytes, + bytes, + bytesBytes, + int, + intBytes, +} from './primitives'; + +export const warpUnsignedMessage = () => + new WarpUnsignedMessage(int(), id(), bytes()); + +export const warpUnsignedMessageBytes = () => + concatBytes(intBytes(), idBytes(), bytesBytes()); + +export const warpSignature = () => new WarpSignature(bytes(), blsSignature()); + +export const warpSignatureBytes = () => + concatBytes(bytesBytes(), blsSignatureBytes()); + +export const warpMessage = () => + new WarpMessage(warpUnsignedMessage(), warpSignature()); + +export const warpMessageBytes = () => + concatBytes(warpUnsignedMessageBytes(), warpSignatureBytes()); diff --git a/src/serializable/constants.ts b/src/serializable/constants.ts index 377d44ed4..27680f869 100644 --- a/src/serializable/constants.ts +++ b/src/serializable/constants.ts @@ -95,4 +95,9 @@ export enum TypeSymbols { EvmInput = 'evm.Input', EvmOutput = 'evm.Output', EvmImportTx = 'evm.ImportTx', + + // Warp + WarpMessage = 'warp.Message', + WarpUnsignedMessage = 'warp.UnsignedMessage', + WarpSignature = 'warp.Signature', } diff --git a/src/serializable/primitives/bytes.ts b/src/serializable/primitives/bytes.ts index f528acdd9..4fcceb618 100644 --- a/src/serializable/primitives/bytes.ts +++ b/src/serializable/primitives/bytes.ts @@ -9,7 +9,7 @@ import { TypeSymbols } from '../constants'; @serializable() export class Bytes extends Primitives { _type = TypeSymbols.Bytes; - constructor(private readonly bytes: Uint8Array) { + constructor(public readonly bytes: Uint8Array) { super(); } diff --git a/src/serializable/pvm/warp/codec.ts b/src/serializable/pvm/warp/codec.ts new file mode 100644 index 000000000..39a2a92cf --- /dev/null +++ b/src/serializable/pvm/warp/codec.ts @@ -0,0 +1,15 @@ +import { Codec, Manager } from '../../codec'; +import { WarpSignature } from './signature'; + +/** + * @see https://github.com/ava-labs/avalanchego/blob/master/vms/platformvm/warp/codec.go + */ +export const codec = new Codec([WarpSignature]); + +let manager: Manager; +export const getWarpManager = () => { + if (manager) return manager; + manager = new Manager(); + manager.RegisterCodec(0, codec); + return manager; +}; diff --git a/src/serializable/pvm/warp/index.ts b/src/serializable/pvm/warp/index.ts new file mode 100644 index 000000000..2ccd8278f --- /dev/null +++ b/src/serializable/pvm/warp/index.ts @@ -0,0 +1,4 @@ +export { codec, getWarpManager } from './codec'; +export { WarpMessage } from './message'; +export { WarpSignature } from './signature'; +export { WarpUnsignedMessage } from './unsignedMessage'; diff --git a/src/serializable/pvm/warp/message.test.ts b/src/serializable/pvm/warp/message.test.ts new file mode 100644 index 000000000..dec4153f6 --- /dev/null +++ b/src/serializable/pvm/warp/message.test.ts @@ -0,0 +1,12 @@ +import { testWarpCodec } from '../../../fixtures/codec'; +import { testSerialization } from '../../../fixtures/utils/serializable'; +import { warpMessage, warpMessageBytes } from '../../../fixtures/warp'; +import { WarpMessage } from './message'; + +testSerialization( + 'WarpMessage', + WarpMessage, + warpMessage, + warpMessageBytes, + testWarpCodec, +); diff --git a/src/serializable/pvm/warp/message.ts b/src/serializable/pvm/warp/message.ts new file mode 100644 index 000000000..4d6e82e9b --- /dev/null +++ b/src/serializable/pvm/warp/message.ts @@ -0,0 +1,36 @@ +import { concatBytes } from '../../../utils/buffer'; +import { unpack } from '../../../utils/struct'; +import type { Codec } from '../../codec'; +import { serializable } from '../../common/types'; +import { TypeSymbols } from '../../constants'; +import type { WarpSignature } from './signature'; +import { WarpUnsignedMessage } from './unsignedMessage'; + +@serializable() +export class WarpMessage { + _type = TypeSymbols.WarpMessage; + + constructor( + public readonly unsignedMessage: WarpUnsignedMessage, + public readonly signature: WarpSignature, + ) {} + + static fromBytes(bytes: Uint8Array, codec: Codec): [WarpMessage, Uint8Array] { + const [unsignedMessage, signatureBytes] = unpack( + bytes, + [WarpUnsignedMessage], + codec, + ); + + const [signature, rest] = codec.UnpackPrefix(signatureBytes); + + return [new WarpMessage(unsignedMessage, signature), rest]; + } + + toBytes(codec: Codec) { + return concatBytes( + this.unsignedMessage.toBytes(codec), + codec.PackPrefix(this.signature), + ); + } +} diff --git a/src/serializable/pvm/warp/signature.test.ts b/src/serializable/pvm/warp/signature.test.ts new file mode 100644 index 000000000..68b98011d --- /dev/null +++ b/src/serializable/pvm/warp/signature.test.ts @@ -0,0 +1,12 @@ +import { testWarpCodec } from '../../../fixtures/codec'; +import { testSerialization } from '../../../fixtures/utils/serializable'; +import { warpSignature, warpSignatureBytes } from '../../../fixtures/warp'; +import { WarpSignature } from './signature'; + +testSerialization( + 'WarpSignature', + WarpSignature, + warpSignature, + warpSignatureBytes, + testWarpCodec, +); diff --git a/src/serializable/pvm/warp/signature.ts b/src/serializable/pvm/warp/signature.ts new file mode 100644 index 000000000..2a406665e --- /dev/null +++ b/src/serializable/pvm/warp/signature.ts @@ -0,0 +1,46 @@ +import { hammingWeight } from '../../../utils/buffer'; +import { pack, unpack } from '../../../utils/struct'; +import type { Codec } from '../../codec'; +import { serializable } from '../../common/types'; +import { TypeSymbols } from '../../constants'; +import { BlsSignature } from '../../fxs/common'; +import { Bytes } from '../../primitives'; +import { INT_LEN } from '../../primitives/int'; + +@serializable() +export class WarpSignature { + _type = TypeSymbols.WarpSignature; + + constructor( + public readonly signers: Bytes, + public readonly signature: BlsSignature, + ) {} + + static fromBytes( + bytes: Uint8Array, + codec: Codec, + ): [WarpSignature, Uint8Array] { + const [signers, signature, rest] = unpack( + bytes, + [Bytes, BlsSignature], + codec, + ); + + return [new WarpSignature(signers, signature), rest]; + } + + toBytes(codec: Codec) { + return pack([this.signers, this.signature], codec); + } + + /** + * Number of [bls.PublicKeys] that participated in the + * {@linkcode BlsSignature}. This is exposed because users of the signatures + * typically impose a verification fee that is a function of the number of signers. + * + * This is used to calculate the Warp complexity in transactions. + */ + numOfSigners(): number { + return hammingWeight(this.signers.toBytes().slice(INT_LEN)); + } +} diff --git a/src/serializable/pvm/warp/unsignedMessage.test.ts b/src/serializable/pvm/warp/unsignedMessage.test.ts new file mode 100644 index 000000000..83efed6b2 --- /dev/null +++ b/src/serializable/pvm/warp/unsignedMessage.test.ts @@ -0,0 +1,15 @@ +import { testWarpCodec } from '../../../fixtures/codec'; +import { testSerialization } from '../../../fixtures/utils/serializable'; +import { + warpUnsignedMessage, + warpUnsignedMessageBytes, +} from '../../../fixtures/warp'; +import { WarpUnsignedMessage } from './unsignedMessage'; + +testSerialization( + 'WarpUnsignedMessage', + WarpUnsignedMessage, + warpUnsignedMessage, + warpUnsignedMessageBytes, + testWarpCodec, +); diff --git a/src/serializable/pvm/warp/unsignedMessage.ts b/src/serializable/pvm/warp/unsignedMessage.ts new file mode 100644 index 000000000..cc7c14399 --- /dev/null +++ b/src/serializable/pvm/warp/unsignedMessage.ts @@ -0,0 +1,34 @@ +import { pack, unpack } from '../../../utils/struct'; +import type { Codec } from '../../codec'; +import { serializable } from '../../common/types'; +import { TypeSymbols } from '../../constants'; +import { Id } from '../../fxs/common'; +import { Bytes, Int } from '../../primitives'; + +@serializable() +export class WarpUnsignedMessage { + _type = TypeSymbols.WarpUnsignedMessage; + + constructor( + public readonly networkId: Int, + public readonly sourceChainId: Id, + public readonly payload: Bytes, + ) {} + + static fromBytes( + bytes: Uint8Array, + codec: Codec, + ): [WarpUnsignedMessage, Uint8Array] { + const [networkId, sourceChainId, payload, rest] = unpack( + bytes, + [Int, Id, Bytes], + codec, + ); + + return [new WarpUnsignedMessage(networkId, sourceChainId, payload), rest]; + } + + toBytes(codec: Codec) { + return pack([this.networkId, this.sourceChainId, this.payload], codec); + } +} diff --git a/src/utils/buffer.test.ts b/src/utils/buffer.test.ts index 5b954c7e1..64f225ddc 100644 --- a/src/utils/buffer.test.ts +++ b/src/utils/buffer.test.ts @@ -1,4 +1,9 @@ -import { bufferToBigInt, bufferToNumber, padLeft } from './buffer'; +import { + bufferToBigInt, + bufferToNumber, + hammingWeight, + padLeft, +} from './buffer'; import { describe, it, expect } from 'vitest'; describe('bufferToBigInt', () => { @@ -79,3 +84,31 @@ describe('padLeft', () => { expect(res).toStrictEqual(new Uint8Array([0xaf, 0x72, 0x72])); }); }); + +describe('hammingWeight()', () => { + it('should return expected number of `1` bits from bytes', () => { + expect(hammingWeight(new Uint8Array([0]))).toBe(0); + expect(hammingWeight(new Uint8Array([1]))).toBe(1); + expect(hammingWeight(new Uint8Array([2]))).toBe(1); + expect(hammingWeight(new Uint8Array([3]))).toBe(2); + expect(hammingWeight(new Uint8Array([4]))).toBe(1); + expect(hammingWeight(new Uint8Array([5]))).toBe(2); + expect(hammingWeight(new Uint8Array([6]))).toBe(2); + expect(hammingWeight(new Uint8Array([7]))).toBe(3); + expect(hammingWeight(new Uint8Array([8]))).toBe(1); + expect(hammingWeight(new Uint8Array([9]))).toBe(2); + + expect(hammingWeight(new Uint8Array([0, 0]))).toBe(0); + expect(hammingWeight(new Uint8Array([0, 1]))).toBe(1); + expect(hammingWeight(new Uint8Array([0, 2]))).toBe(1); + expect(hammingWeight(new Uint8Array([0, 3]))).toBe(2); + + expect(hammingWeight(new Uint8Array([1, 1]))).toBe(2); + expect(hammingWeight(new Uint8Array([1, 2]))).toBe(2); + expect(hammingWeight(new Uint8Array([1, 3]))).toBe(3); + + expect(hammingWeight(new Uint8Array([3, 1]))).toBe(3); + expect(hammingWeight(new Uint8Array([3, 2]))).toBe(3); + expect(hammingWeight(new Uint8Array([3, 3]))).toBe(4); + }); +}); diff --git a/src/utils/buffer.ts b/src/utils/buffer.ts index fcaaf1201..c1184a6f6 100644 --- a/src/utils/buffer.ts +++ b/src/utils/buffer.ts @@ -33,4 +33,30 @@ export function padLeft(bytes: Uint8Array, length: number) { return out; } +/** + * Calculates the number of `1`s (set bits) in the binary + * representation a big-endian byte slice. + * + * @param input A Uint8Array + * @returns The number of bits set to 1 in the binary representation of the input + * + * @example + * ```ts + * hammingWeight(new Uint8Array([0, 1, 2, 3, 4, 5])); // 7 + * ``` + */ +export const hammingWeight = (input: Uint8Array): number => { + let count = 0; + + for (let i = 0; i < input.length; i++) { + let num = input[i]; + while (num !== 0) { + count += num & 1; + num >>= 1; + } + } + + return count; +}; + export { concatBytes, strip0x, add0x }; diff --git a/src/utils/getBurnedAmountByTx.test.ts b/src/utils/getBurnedAmountByTx.test.ts index 6de99392c..d80978b5e 100644 --- a/src/utils/getBurnedAmountByTx.test.ts +++ b/src/utils/getBurnedAmountByTx.test.ts @@ -39,8 +39,8 @@ import { feeState, l1Validator } from '../fixtures/pvm'; import { bigIntPr, blsSignatureBytes, - bytesBytes, stringPr, + warpMessageBytes, } from '../fixtures/primitives'; const getUtxoMock = ( @@ -677,10 +677,7 @@ describe('getBurnedAmountByTx', () => { const amounts = getBurnedAmountByTx(tx, testContext); expect(amounts.size).toEqual(1); - expect(amounts.get(testContext.avaxAssetID)).toEqual( - // Magic number. This needs refactored. Is this even correct? - 2_200n, - ); + expect(amounts.get(testContext.avaxAssetID)).toEqual(2_200n); }); }); @@ -695,20 +692,17 @@ describe('getBurnedAmountByTx', () => { utxos: [utxo1, utxo2], balance: bigIntPr().value(), blsSignature: blsSignatureBytes(), - message: bytesBytes(), + message: warpMessageBytes(), }, testContext, ).getTx() as AvaxTx; const amounts = getBurnedAmountByTx(tx, testContext); expect(amounts.size).toEqual(1); - expect(amounts.get(testContext.avaxAssetID)).toEqual( - // Magic number. This needs refactored. Is this even correct? - 2_710n, - ); + expect(amounts.get(testContext.avaxAssetID)).toEqual(3_002n); }); - it('calculates the burned amount of RegisterL1Validator tx correctly', () => { + it('calculates the burned amount of IncreaseL1ValidatorBalance tx correctly', () => { const utxo1 = getUtxoMock(testUTXOID1, 5000000n); const utxo2 = getUtxoMock(testUTXOID2, 6000000n); @@ -725,9 +719,6 @@ describe('getBurnedAmountByTx', () => { const amounts = getBurnedAmountByTx(tx, testContext); expect(amounts.size).toEqual(1); - expect(amounts.get(testContext.avaxAssetID)).toEqual( - // Magic number. This needs refactored. Is this even correct? - 548n, - ); + expect(amounts.get(testContext.avaxAssetID)).toEqual(548n); }); }); diff --git a/src/vms/pvm/txs/fee/complexity.test.ts b/src/vms/pvm/txs/fee/complexity.test.ts index a99256df1..024f482ff 100644 --- a/src/vms/pvm/txs/fee/complexity.test.ts +++ b/src/vms/pvm/txs/fee/complexity.test.ts @@ -294,17 +294,10 @@ describe('Complexity', () => { // Example Warp Message const warpMessage = warpMessageBytes(); - test('complexity from empty warp message', () => { - const result = getWarpComplexity(new Bytes(new Uint8Array(0))); - - expect(result).toEqual( - createDimensions({ - bandwidth: 0, - dbRead: INTRINSIC_WARP_DB_READS, - dbWrite: 0, - compute: INTRINSIC_BLS_VERIFY_COMPUTE, - }), - ); + test('throws "not enough bytes" error from empty warp message', () => { + expect(() => { + getWarpComplexity(new Bytes(new Uint8Array())); + }).toThrow('not enough bytes'); }); test('complexity from warp message', () => { diff --git a/src/vms/pvm/txs/fee/complexity.ts b/src/vms/pvm/txs/fee/complexity.ts index 87ddeb5cc..917ce94e2 100644 --- a/src/vms/pvm/txs/fee/complexity.ts +++ b/src/vms/pvm/txs/fee/complexity.ts @@ -101,7 +101,9 @@ import { INTRINSIC_WARP_DB_READS, } from './constants'; import type { L1Validator } from '../../../../serializable/fxs/pvm/L1Validator'; -import { getWarpMessageNumOfSigners } from './utils'; +import { WarpMessage, getWarpManager } from '../../../../serializable/pvm/warp'; + +const warpManager = getWarpManager(); /** * Returns the complexity outputs add to a transaction. @@ -249,7 +251,9 @@ export const getBytesComplexity = ( }; export const getWarpComplexity = (message: Bytes): Dimensions => { - const numberOfSigners = getWarpMessageNumOfSigners(message); + const numberOfSigners = warpManager + .unpack(message.bytes, WarpMessage) + .signature.numOfSigners(); const aggregationCompute = numberOfSigners * INTRINSIC_BLS_AGGREGATE_COMPUTE; diff --git a/src/vms/pvm/txs/fee/utils.test.ts b/src/vms/pvm/txs/fee/utils.test.ts deleted file mode 100644 index 13b739ae4..000000000 --- a/src/vms/pvm/txs/fee/utils.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { bitsFromBytesLength, getWarpMessageNumOfSigners } from './utils'; -import { warpMessageBytes } from '../../../../fixtures/primitives'; -import { Bytes } from '../../../../serializable'; - -describe('getWarpMessageNumOfSigners()', () => { - it('should return the expected number of signers from warp message bytes', () => { - const result = getWarpMessageNumOfSigners(new Bytes(warpMessageBytes())); - expect(result).toBe(1); - }); -}); - -describe('bitsFromBytesLength()', () => { - it('should return expected number of `1` bits from bytes', () => { - expect(bitsFromBytesLength(new Uint8Array([0]))).toBe(0); - expect(bitsFromBytesLength(new Uint8Array([1]))).toBe(1); - expect(bitsFromBytesLength(new Uint8Array([2]))).toBe(1); - expect(bitsFromBytesLength(new Uint8Array([3]))).toBe(2); - expect(bitsFromBytesLength(new Uint8Array([4]))).toBe(1); - expect(bitsFromBytesLength(new Uint8Array([5]))).toBe(2); - expect(bitsFromBytesLength(new Uint8Array([6]))).toBe(2); - expect(bitsFromBytesLength(new Uint8Array([7]))).toBe(3); - expect(bitsFromBytesLength(new Uint8Array([8]))).toBe(1); - expect(bitsFromBytesLength(new Uint8Array([9]))).toBe(2); - - expect(bitsFromBytesLength(new Uint8Array([0, 0]))).toBe(0); - expect(bitsFromBytesLength(new Uint8Array([0, 1]))).toBe(1); - expect(bitsFromBytesLength(new Uint8Array([0, 2]))).toBe(1); - expect(bitsFromBytesLength(new Uint8Array([0, 3]))).toBe(2); - - expect(bitsFromBytesLength(new Uint8Array([1, 1]))).toBe(2); - expect(bitsFromBytesLength(new Uint8Array([1, 2]))).toBe(2); - expect(bitsFromBytesLength(new Uint8Array([1, 3]))).toBe(3); - - expect(bitsFromBytesLength(new Uint8Array([3, 1]))).toBe(3); - expect(bitsFromBytesLength(new Uint8Array([3, 2]))).toBe(3); - expect(bitsFromBytesLength(new Uint8Array([3, 3]))).toBe(4); - }); -}); diff --git a/src/vms/pvm/txs/fee/utils.ts b/src/vms/pvm/txs/fee/utils.ts deleted file mode 100644 index a959d4110..000000000 --- a/src/vms/pvm/txs/fee/utils.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Bytes } from '../../../../serializable'; -import { ID_LEN } from '../../../../serializable/fxs/common/id'; -import { INT_LEN } from '../../../../serializable/primitives/int'; -import { bufferToNumber } from '../../../../utils'; - -// TODO: Why is there this offset on the message? What are the first two bytes? -// Offset the start of deserialization because of some unknown bytes at the front. -const START_INDEX = 2; -// Offset for the WarpSignature type ID -const SIGNATURE_INDEX_OFFSET = 4; - -/** - * Calculates the number of `1`s (set bits) in the binary representation of a number. - * - * @param num - * @returns The number of bits set to 1 in the binary representation of `num` - */ -const hammingWeight = (num: number): number => { - let count = 0; - while (num !== 0) { - count += num & 1; - num >>= 1; - } - return count; -}; - -/** - * Takes a big-endian byte slice and returns the number of bits set to 1. - * - * This function iterates through each byte in the `Uint8Array` and calculates - * the Hamming weight for each byte, summing up the results. - * - * @param bytes big-endian byte slice - * @returns number of bits set to 1 - */ -export const bitsFromBytesLength = (bytes: Uint8Array): number => { - let count = 0; - - for (let i = 0; i < bytes.length; i++) { - count += hammingWeight(bytes[i]); - } - - return count; -}; - -/** - * This is a very crude implementation of parsing the warp message - * to get the number of signers out of it for calculating transaction - * complexity. The implementation here is not meant to be the final one. - * - * Ideally, if Warp messages are added to AJS, we would want to use the - * built-in deserialization methods to get the number of signers. - * - * @experimental This implementation risks breaking if Warp messages - * change their structure. - * - * @internal - * - * @param message WarpMessage Bytes - * @returns number of signers in the WarpMessage's WarpSignature - */ -export const getWarpMessageNumOfSigners = (messageBytes: Bytes): number => { - const message = messageBytes.toBytes().slice(INT_LEN); - - const unsignedMessagePayloadBytesIndex = - START_INDEX + - INT_LEN + // networkId - ID_LEN; // sourceChainId - - const unsignedMessagePayloadLength = bufferToNumber( - message.slice( - unsignedMessagePayloadBytesIndex, - unsignedMessagePayloadBytesIndex + INT_LEN, // bytes length int - ), - ); - - const signatureSignersBytesIndex = - unsignedMessagePayloadBytesIndex + - INT_LEN + // payload length bytes - unsignedMessagePayloadLength + // payload length - SIGNATURE_INDEX_OFFSET; - - // first 4 bytes are the BitSetSignature typeId - const [signersBytes] = Bytes.fromBytes( - message.slice(signatureSignersBytesIndex), - ); - - // `signers` is a big-endian byte slice - const signers = signersBytes.toBytes().slice(INT_LEN); - - const numberOfSigners = bitsFromBytesLength(signers); - - return numberOfSigners; -};