diff --git a/.gitcommitscopes b/.gitcommitscopes index 4e3595991a..4362e2b6c8 100644 --- a/.gitcommitscopes +++ b/.gitcommitscopes @@ -1,3 +1,4 @@ sdk-coin-rune sdk-coin-sui +sdk-core statics diff --git a/modules/abstract-eth/src/lib/index.ts b/modules/abstract-eth/src/lib/index.ts index ae53d5a67b..f685804a50 100644 --- a/modules/abstract-eth/src/lib/index.ts +++ b/modules/abstract-eth/src/lib/index.ts @@ -9,6 +9,7 @@ export * from './transferBuilder'; export * from './types'; export * from './utils'; export * from './walletUtil'; +export * from './messages'; // for Backwards Compatibility import * as Interface from './iface'; diff --git a/modules/abstract-eth/src/lib/messages/eip191/eip191Message.ts b/modules/abstract-eth/src/lib/messages/eip191/eip191Message.ts new file mode 100644 index 0000000000..d6750901ea --- /dev/null +++ b/modules/abstract-eth/src/lib/messages/eip191/eip191Message.ts @@ -0,0 +1,24 @@ +import { BaseMessage, MessageOptions, MessageStandardType } from '@bitgo/sdk-core'; + +/** + * Implementation of Message for EIP191 standard + */ +export class EIP191Message extends BaseMessage { + constructor(options: MessageOptions) { + super({ + ...options, + type: MessageStandardType.EIP191, + }); + } + + /** + * Returns the hash of the EIP-191 prefixed message + */ + async getSignablePayload(): Promise { + if (!this.signablePayload) { + const prefix = `\u0019Ethereum Signed Message:\n${this.payload.length}`; + this.signablePayload = Buffer.from(prefix.concat(this.payload)).toString('hex'); + } + return this.signablePayload; + } +} diff --git a/modules/abstract-eth/src/lib/messages/eip191/eip191MessageBuilder.ts b/modules/abstract-eth/src/lib/messages/eip191/eip191MessageBuilder.ts new file mode 100644 index 0000000000..75b57f2f78 --- /dev/null +++ b/modules/abstract-eth/src/lib/messages/eip191/eip191MessageBuilder.ts @@ -0,0 +1,66 @@ +import { EIP191Message } from './eip191Message'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BaseMessageBuilder, BroadcastableMessage, IMessage, MessageStandardType } from '@bitgo/sdk-core'; + +/** + * Builder for EIP-191 messages + */ +export class Eip191MessageBuilder extends BaseMessageBuilder { + /** + * Base constructor. + * @param _coinConfig BaseCoin from statics library + */ + public constructor(_coinConfig: Readonly) { + super(_coinConfig, MessageStandardType.EIP191); + } + + /** + * Build a signable message using the EIP-191 standard + * with previously set input and metadata + * @returns A signable message + */ + public async build(): Promise { + try { + if (!this.payload) { + throw new Error('Message payload must be set before building the message'); + } + return new EIP191Message({ + coinConfig: this.coinConfig, + payload: this.payload, + signatures: this.signatures, + signers: this.signers, + metadata: { + ...this.metadata, + encoding: 'utf8', + }, + }); + } catch (err) { + if (err instanceof Error) { + throw err; + } + throw new Error('Failed to build EIP-191 message'); + } + } + + /** + * Parse a broadcastable message back into a message + * @param broadcastMessage The broadcastable message to parse + * @returns The parsed message + */ + public async fromBroadcastFormat(broadcastMessage: BroadcastableMessage): Promise { + const { type, payload, signatures, signers, metadata } = broadcastMessage; + if (type !== MessageStandardType.EIP191) { + throw new Error(`Invalid message type, expected ${MessageStandardType.EIP191}`); + } + return new EIP191Message({ + coinConfig: this.coinConfig, + payload, + signatures, + signers, + metadata: { + ...metadata, + encoding: 'utf8', + }, + }); + } +} diff --git a/modules/abstract-eth/src/lib/messages/eip191/index.ts b/modules/abstract-eth/src/lib/messages/eip191/index.ts new file mode 100644 index 0000000000..be3d7fcba4 --- /dev/null +++ b/modules/abstract-eth/src/lib/messages/eip191/index.ts @@ -0,0 +1,2 @@ +export * from './eip191Message'; +export * from './eip191MessageBuilder'; diff --git a/modules/abstract-eth/src/lib/messages/index.ts b/modules/abstract-eth/src/lib/messages/index.ts new file mode 100644 index 0000000000..29a12d9e0a --- /dev/null +++ b/modules/abstract-eth/src/lib/messages/index.ts @@ -0,0 +1,2 @@ +export * from './messageBuilderFactory'; +export * from './eip191'; diff --git a/modules/abstract-eth/src/lib/messages/messageBuilderFactory.ts b/modules/abstract-eth/src/lib/messages/messageBuilderFactory.ts new file mode 100644 index 0000000000..741a39a1a6 --- /dev/null +++ b/modules/abstract-eth/src/lib/messages/messageBuilderFactory.ts @@ -0,0 +1,18 @@ +import { Eip191MessageBuilder } from './eip191/eip191MessageBuilder'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BaseMessageBuilderFactory, IMessageBuilder, MessageStandardType } from '@bitgo/sdk-core'; + +export class MessageBuilderFactory extends BaseMessageBuilderFactory { + constructor(coinConfig: Readonly) { + super(coinConfig); + } + + public getMessageBuilder(type: MessageStandardType): IMessageBuilder { + switch (type) { + case MessageStandardType.EIP191: + return new Eip191MessageBuilder(this.coinConfig); + default: + throw new Error(`Invalid message standard ${type}`); + } + } +} diff --git a/modules/abstract-eth/test/unit/index.ts b/modules/abstract-eth/test/unit/index.ts index a377800cca..5018763948 100644 --- a/modules/abstract-eth/test/unit/index.ts +++ b/modules/abstract-eth/test/unit/index.ts @@ -2,3 +2,4 @@ export * from './transactionBuilder'; export * from './token'; export * from './transaction'; export * from './coin'; +export * from './messages'; diff --git a/modules/abstract-eth/test/unit/messages/eip191/eip191Message.ts b/modules/abstract-eth/test/unit/messages/eip191/eip191Message.ts new file mode 100644 index 0000000000..949a2856f1 --- /dev/null +++ b/modules/abstract-eth/test/unit/messages/eip191/eip191Message.ts @@ -0,0 +1,175 @@ +import 'should'; +import sinon from 'sinon'; +import { MessageStandardType } from '@bitgo/sdk-core'; +import { fixtures } from '../fixtures'; +import { EIP191Message } from '../../../../src'; + +describe('EIP191 Message', () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + }); + + it('should initialize with the correct type', () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.validMessage, + }); + + message.getType().should.equal(MessageStandardType.EIP191); + }); + + it('should generate the correct signable payload with Ethereum prefix', async () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.validMessage, + }); + + const signablePayload = await message.getSignablePayload(); + // Message is prefixed with "\u0019Ethereum Signed Message:\n" + const expectedPrefix = `\u0019Ethereum Signed Message:\n${fixtures.messages.validMessage.length}`; + const expectedPayload = Buffer.from(expectedPrefix.concat(fixtures.messages.validMessage)).toString('hex'); + + signablePayload.should.equal(expectedPayload); + }); + + it('should handle empty messages correctly', async () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.emptyMessage, + }); + + const signablePayload = await message.getSignablePayload(); + // Empty message has length 0 + const expectedPrefix = `\u0019Ethereum Signed Message:\n0`; + const expectedPayload = Buffer.from(expectedPrefix.concat('')).toString('hex'); + + signablePayload.should.equal(expectedPayload); + }); + + it('should handle messages with special characters', async () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.specialCharsMessage, + }); + + const signablePayload = await message.getSignablePayload(); + const expectedPrefix = `\u0019Ethereum Signed Message:\n${fixtures.messages.specialCharsMessage.length}`; + const expectedPayload = Buffer.from(expectedPrefix.concat(fixtures.messages.specialCharsMessage)).toString('hex'); + + signablePayload.should.equal(expectedPayload); + }); + + it('should reuse existing signable payload if already set', async () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.validMessage, + signablePayload: 'predefined-payload', + }); + + const signablePayload = await message.getSignablePayload(); + signablePayload.should.equal('predefined-payload'); + }); + + it('should maintain signatures and signers correctly', () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.validMessage, + signatures: [fixtures.eip191.signature], + signers: [fixtures.eip191.signer], + }); + + message.getSignatures().should.containEql(fixtures.eip191.signature); + message.getSigners().should.containEql(fixtures.eip191.signer); + + // Test adding new ones + message.addSignature('new-signature'); + message.addSigner('new-signer'); + + message.getSignatures().should.containEql('new-signature'); + message.getSigners().should.containEql('new-signer'); + + // Test replacing all + message.setSignatures(['replaced-signature']); + message.setSigners(['replaced-signer']); + + message.getSignatures().should.deepEqual(['replaced-signature']); + message.getSigners().should.deepEqual(['replaced-signer']); + }); + + it('should store and retrieve metadata correctly', () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.validMessage, + metadata: fixtures.eip191.metadata, + }); + + message.getMetadata()!.should.deepEqual(fixtures.eip191.metadata); + }); + + describe('Broadcast Format', () => { + it('should convert to broadcast format correctly', async () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.validMessage, + signatures: [fixtures.eip191.signature], + signers: [fixtures.eip191.signer], + metadata: fixtures.eip191.metadata, + signablePayload: 'test-signable-payload', + }); + + const broadcastFormat = await message.toBroadcastFormat(); + + broadcastFormat.type.should.equal(MessageStandardType.EIP191); + broadcastFormat.payload.should.equal(fixtures.messages.validMessage); + broadcastFormat.signatures.should.deepEqual([fixtures.eip191.signature]); + broadcastFormat.signers.should.deepEqual([fixtures.eip191.signer]); + broadcastFormat.metadata!.should.deepEqual(fixtures.eip191.metadata); + broadcastFormat.signablePayload!.should.equal('test-signable-payload'); + }); + + it('should throw error when broadcasting without signatures', async () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.validMessage, + signers: [fixtures.eip191.signer], + }); + + await message + .toBroadcastFormat() + .should.be.rejectedWith('No signatures available for broadcast. Call setSignatures or addSignature first.'); + }); + + it('should throw error when broadcasting without signers', async () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.validMessage, + signatures: [fixtures.eip191.signature], + }); + + await message + .toBroadcastFormat() + .should.be.rejectedWith('No signers available for broadcast. Call setSigners or addSigner first.'); + }); + + it('should convert to broadcast string correctly', async () => { + const message = new EIP191Message({ + coinConfig: fixtures.coin, + payload: fixtures.messages.validMessage, + signatures: [fixtures.eip191.signature], + signers: [fixtures.eip191.signer], + metadata: fixtures.eip191.metadata, + }); + + const broadcastString = await message.toBroadcastString(); + const parsedBroadcast = JSON.parse(broadcastString); + + parsedBroadcast.type.should.equal(MessageStandardType.EIP191); + parsedBroadcast.payload.should.equal(fixtures.messages.validMessage); + parsedBroadcast.signatures.should.deepEqual([fixtures.eip191.signature]); + parsedBroadcast.signers.should.deepEqual([fixtures.eip191.signer]); + parsedBroadcast.metadata.should.deepEqual(fixtures.eip191.metadata); + }); + }); +}); diff --git a/modules/abstract-eth/test/unit/messages/eip191/eip191MessageBuilder.ts b/modules/abstract-eth/test/unit/messages/eip191/eip191MessageBuilder.ts new file mode 100644 index 0000000000..0b4ede4d7d --- /dev/null +++ b/modules/abstract-eth/test/unit/messages/eip191/eip191MessageBuilder.ts @@ -0,0 +1,117 @@ +import 'should'; +import sinon from 'sinon'; +import { MessageStandardType } from '@bitgo/sdk-core'; +import { fixtures } from '../fixtures'; +import { EIP191Message, Eip191MessageBuilder } from '../../../../src'; + +describe('EIP191 Message Builder', () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + }); + + describe('constructor', () => { + it('should initialize with the correct message type', () => { + const builder = new Eip191MessageBuilder(fixtures.coin); + // Test the builder's private property indirectly through build() + builder.should.be.instanceof(Eip191MessageBuilder); + }); + }); + + describe('build method', () => { + it('should build a valid EIP191 message', async () => { + const builder = new Eip191MessageBuilder(fixtures.coin); + + builder.setPayload(fixtures.messages.validMessage).setMetadata({ customData: 'test data' }); + + const message = await builder.build(); + + message.should.be.instanceof(EIP191Message); + message.getType().should.equal(MessageStandardType.EIP191); + message.getPayload().should.equal(fixtures.messages.validMessage); + message.getMetadata()!.should.have.property('customData', 'test data'); + message.getMetadata()!.should.have.property('encoding', 'utf8'); + }); + + it('should throw an error when building without setting payload', async () => { + const builder = new Eip191MessageBuilder(fixtures.coin); + + await builder.build().should.be.rejectedWith('Message payload must be set before building the message'); + }); + + it('should include signers when building a message', async () => { + const builder = new Eip191MessageBuilder(fixtures.coin); + + builder.setPayload(fixtures.messages.validMessage); + builder.addSigner(fixtures.eip191.signer); + + const message = await builder.build(); + + message.getSigners().should.containEql(fixtures.eip191.signer); + }); + + it('should include signatures when building a message', async () => { + const builder = new Eip191MessageBuilder(fixtures.coin); + + builder.setPayload(fixtures.messages.validMessage); + builder.addSignature(fixtures.eip191.signature); + + const message = await builder.build(); + + message.getSignatures().should.containEql(fixtures.eip191.signature); + }); + + it('should override metadata.encoding with utf8', async () => { + const builder = new Eip191MessageBuilder(fixtures.coin); + + builder.setPayload(fixtures.messages.validMessage); + builder.setMetadata({ encoding: 'hex', customData: 'test data' }); + + const message = await builder.build(); + + message.getMetadata()!.should.have.property('encoding', 'utf8'); + message.getMetadata()!.should.have.property('customData', 'test data'); + }); + }); + + describe('fromBroadcastFormat method', () => { + it('should reconstruct a message from broadcast format', async () => { + const builder = new Eip191MessageBuilder(fixtures.coin); + + const broadcastMessage = { + type: MessageStandardType.EIP191, + payload: fixtures.messages.validMessage, + signatures: [fixtures.eip191.signature], + signers: [fixtures.eip191.signer], + metadata: fixtures.eip191.metadata, + }; + + const message = await builder.fromBroadcastFormat(broadcastMessage); + + message.should.be.instanceof(EIP191Message); + message.getType().should.equal(MessageStandardType.EIP191); + message.getPayload().should.equal(fixtures.messages.validMessage); + message.getSignatures().should.containEql(fixtures.eip191.signature); + message.getSigners().should.containEql(fixtures.eip191.signer); + message.getMetadata()!.should.have.property('encoding', 'utf8'); + message.getMetadata()!.should.have.property('customData', 'test data'); + }); + + it('should throw an error for incorrect message type', async () => { + const builder = new Eip191MessageBuilder(fixtures.coin); + + const broadcastMessage = { + type: MessageStandardType.UNKNOWN, + payload: fixtures.messages.validMessage, + signatures: [fixtures.eip191.signature], + signers: [fixtures.eip191.signer], + metadata: {}, + }; + + await builder + .fromBroadcastFormat(broadcastMessage) + .should.be.rejectedWith(`Invalid message type, expected ${MessageStandardType.EIP191}`); + }); + }); +}); diff --git a/modules/abstract-eth/test/unit/messages/fixtures.ts b/modules/abstract-eth/test/unit/messages/fixtures.ts new file mode 100644 index 0000000000..4cce7ae49f --- /dev/null +++ b/modules/abstract-eth/test/unit/messages/fixtures.ts @@ -0,0 +1,24 @@ +import { coins } from '@bitgo/statics'; + +// Test fixtures for EIP-191 message tests +export const fixtures = { + coin: coins.get('teth'), + messages: { + validMessage: 'Hello, world!', + emptyMessage: '', + specialCharsMessage: '!@#$%^&*()', + longMessage: + 'This is a very long message that contains multiple lines and special characters. ' + + 'It is designed to test the EIP-191 message format with a more complex payload.', + }, + eip191: { + validSignablePayload: '0x19457468657265756d205369676e6564204d6573736167653a0d48656c6c6f2c20776f726c6421', + signature: + '0x5d99b6f7f6d1f73d1a26497f2b1c89b24c0993913f86e9a2d02cd69887d9c94f3c880358579d811b21dd1b7fd9bb01c1d81d10e69f0384e675c32b39643be8921b', + signer: '0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf', + metadata: { + encoding: 'utf8', + customData: 'test data', + }, + }, +}; diff --git a/modules/abstract-eth/test/unit/messages/index.ts b/modules/abstract-eth/test/unit/messages/index.ts new file mode 100644 index 0000000000..f7c1e534f2 --- /dev/null +++ b/modules/abstract-eth/test/unit/messages/index.ts @@ -0,0 +1,3 @@ +export * from './eip191/eip191Message'; +export * from './eip191/eip191MessageBuilder'; +export * from './messageBuilderFactory'; diff --git a/modules/abstract-eth/test/unit/messages/messageBuilderFactory.ts b/modules/abstract-eth/test/unit/messages/messageBuilderFactory.ts new file mode 100644 index 0000000000..1f67e862cc --- /dev/null +++ b/modules/abstract-eth/test/unit/messages/messageBuilderFactory.ts @@ -0,0 +1,54 @@ +import 'should'; +import sinon from 'sinon'; +import { MessageStandardType } from '@bitgo/sdk-core'; +import { fixtures } from './fixtures'; +import { Eip191MessageBuilder, MessageBuilderFactory } from '../../../src'; + +describe('Message Builder Factory', () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + }); + + describe('getMessageBuilder', () => { + it('should return the correct builder for EIP191 message type', () => { + const factory = new MessageBuilderFactory(fixtures.coin); + + const builder = factory.getMessageBuilder(MessageStandardType.EIP191); + + builder.should.be.instanceof(Eip191MessageBuilder); + }); + + it('should throw an error for unsupported message types', () => { + const factory = new MessageBuilderFactory(fixtures.coin); + + // Test with an invalid/unsupported message type + const unsupportedType = 'UNSUPPORTED_TYPE' as MessageStandardType; + + (() => factory.getMessageBuilder(unsupportedType)).should.throw(/Invalid message standard/); + }); + + it('should throw for unknown message standard', () => { + const factory = new MessageBuilderFactory(fixtures.coin); + + (() => factory.getMessageBuilder(MessageStandardType.UNKNOWN)).should.throw( + `Invalid message standard ${MessageStandardType.UNKNOWN}` + ); + }); + }); + + describe('Integration with builder', () => { + it('should create a builder that can build a valid message', async () => { + const factory = new MessageBuilderFactory(fixtures.coin); + + const builder = factory.getMessageBuilder(MessageStandardType.EIP191); + builder.setPayload(fixtures.messages.validMessage); + + const message = await builder.build(); + + message.getType().should.equal(MessageStandardType.EIP191); + message.getPayload().should.equal(fixtures.messages.validMessage); + }); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/index.ts b/modules/sdk-core/src/account-lib/baseCoin/index.ts index a03c90897a..446e0bade7 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/index.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/index.ts @@ -8,3 +8,4 @@ export { TransactionType, StakingOperationTypes, AddressFormat, DotAddressFormat export * from './secp256k1ExtendedKeyPair'; export * from './errors'; export * from './iface'; +export * from './messages'; diff --git a/modules/sdk-core/src/account-lib/baseCoin/messages/baseMessage.ts b/modules/sdk-core/src/account-lib/baseCoin/messages/baseMessage.ts new file mode 100644 index 0000000000..5db5c79369 --- /dev/null +++ b/modules/sdk-core/src/account-lib/baseCoin/messages/baseMessage.ts @@ -0,0 +1,150 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { + BroadcastableMessage, + MessageMetadata, + MessageOptions, + MessagePayload, + MessageStandardType, +} from '../../../bitgo'; +import { IMessage } from './iface'; + +export abstract class BaseMessage implements IMessage { + protected coinConfig: Readonly; + protected type: MessageStandardType; + protected payload: MessagePayload; + protected signatures: string[] = []; + protected signers: string[] = []; + protected signablePayload?: string | Buffer; + protected metadata?: MessageMetadata; + + /** + * Base constructor. + * + * @param options Message options containing type, payload, metadata, etc. + */ + protected constructor(options: MessageOptions) { + this.coinConfig = options.coinConfig; + this.type = options.type || MessageStandardType.UNKNOWN; + this.payload = options.payload || ''; + this.signablePayload = options.signablePayload; + this.metadata = options.metadata || {}; + + if (options.signatures) { + this.signatures = [...options.signatures]; + } + if (options.signers) { + this.signers = [...options.signers]; + } + } + + /** + * Get the message type + */ + getType(): MessageStandardType { + return this.type; + } + + /** + * Get the message payload + */ + getPayload(): MessagePayload { + return this.payload; + } + + /** + * Get the metadata associated with the message + */ + getMetadata(): MessageMetadata | undefined { + return this.metadata; + } + + /** + * Gets all signers addresses or public keys + */ + getSigners(): string[] { + return [...this.signers]; + } + + /** + * Adds a signer address or public key + * @param signer The address or public key of the signer + */ + addSigner(signer: string): void { + if (!this.signers.includes(signer)) { + this.signers.push(signer); + } + } + + /** + * Sets signers addresses or public keys + * @param signers Array of addresses or public keys + */ + setSigners(signers: string[]): void { + this.signers = [...signers]; + } + + /** + * Gets all signatures associated with this message + */ + getSignatures(): string[] { + return [...this.signatures]; + } + + /** + * Sets signatures for this message + * @param signatures Array of signatures to set + */ + setSignatures(signatures: string[]): void { + this.signatures = [...signatures]; + } + + /** + * Adds a signature to this message + * @param signature The signature to add + */ + addSignature(signature: string): void { + this.signatures.push(signature); + } + + /** + * Gets the payload that should be signed + * Each message standard must implement this method + */ + abstract getSignablePayload(): Promise; + + /** + * Converts this message to a broadcastable format + * Uses internal signatures and signers that were previously set + * @returns A broadcastable message + */ + async toBroadcastFormat(): Promise { + if (this.signatures.length === 0) { + throw new Error('No signatures available for broadcast. Call setSignatures or addSignature first.'); + } + + if (this.signers.length === 0) { + throw new Error('No signers available for broadcast. Call setSigners or addSigner first.'); + } + + return { + type: this.type, + payload: this.payload, + signatures: this.signatures, + signers: this.signers, + metadata: { + ...(this.metadata ? JSON.parse(JSON.stringify(this.metadata)) : {}), // deep copy to avoid mutation + }, + signablePayload: this.signablePayload, + }; + } + + /** + * Serializes the broadcastable message to a string + * Uses internal signatures and signers that were previously set + * @returns A JSON string representation of the broadcastable message + */ + async toBroadcastString(): Promise { + const broadcastable = await this.toBroadcastFormat(); + return JSON.stringify(broadcastable); + } +} diff --git a/modules/sdk-core/src/account-lib/baseCoin/messages/baseMessageBuilder.ts b/modules/sdk-core/src/account-lib/baseCoin/messages/baseMessageBuilder.ts new file mode 100644 index 0000000000..7b7c2fb5bf --- /dev/null +++ b/modules/sdk-core/src/account-lib/baseCoin/messages/baseMessageBuilder.ts @@ -0,0 +1,129 @@ +import { BroadcastableMessage, MessagePayload, MessageStandardType } from '../../../bitgo'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { IMessage, IMessageBuilder } from './iface'; + +/** + * Base Message Builder + */ +export abstract class BaseMessageBuilder implements IMessageBuilder { + protected coinConfig: Readonly; + protected payload: MessagePayload = ''; + protected type: MessageStandardType; + protected signatures: string[] = []; + protected signers: string[] = []; + protected metadata?: Record = {}; + protected digest?: string; + + /** + * Base constructor. + * @param coinConfig BaseCoin from statics library + * @param messageType The type of message this builder creates, defaults to UNKNOWN + */ + protected constructor( + coinConfig: Readonly, + messageType: MessageStandardType = MessageStandardType.UNKNOWN + ) { + this.coinConfig = coinConfig; + this.type = messageType; + } + + /** + * Sets the message payload to be used when building the message + * @param payload The message payload (string, JSON, etc.) + * @returns The builder instance for chaining + */ + public setPayload(payload: MessagePayload): IMessageBuilder { + this.payload = payload; + return this; + } + + /** + * Sets metadata for the message + * @param metadata Additional metadata for the message + * @returns The builder instance for chaining + */ + public setMetadata(metadata: Record): IMessageBuilder { + this.metadata = metadata; + return this; + } + + /** + * Gets the current message payload + * @returns The current message payload + */ + public getPayload(): MessagePayload | undefined { + return this.payload; + } + + /** + * Gets the current metadata + * @returns The current metadata + */ + public getMetadata(): Record | undefined { + return this.metadata; + } + + public getType(): MessageStandardType { + return this.type; + } + + public setType(value: MessageStandardType): IMessageBuilder { + this.type = value; + return this; + } + + public getSignatures(): string[] { + return this.signatures; + } + + public setSignatures(value: string[]): IMessageBuilder { + this.signatures = value; + return this; + } + + public addSignature(signature: string): IMessageBuilder { + if (!this.signatures.includes(signature)) { + this.signatures.push(signature); + } + return this; + } + + public getSigners(): string[] { + return this.signers; + } + + public setSigners(value: string[]): IMessageBuilder { + this.signers = value; + return this; + } + + public addSigner(signer: string): IMessageBuilder { + if (!this.signers.includes(signer)) { + this.signers.push(signer); + } + return this; + } + + public getDigest(): string | undefined { + return this.digest; + } + + public setDigest(value: string): IMessageBuilder { + this.digest = value; + return this; + } + + /** + * Builds a message using the previously set payload and metadata + * This abstract method must be implemented by each specific builder + * @returns A Promise resolving to the built IMessage + */ + abstract build(): Promise; + + /** + * Parse a broadcastable message back into a message + * @param broadcastMessage The broadcastable message to parse + * @returns The parsed message + */ + abstract fromBroadcastFormat(broadcastMessage: BroadcastableMessage): Promise; +} diff --git a/modules/sdk-core/src/account-lib/baseCoin/messages/baseMessageBuilderFactory.ts b/modules/sdk-core/src/account-lib/baseCoin/messages/baseMessageBuilderFactory.ts new file mode 100644 index 0000000000..f04c56db87 --- /dev/null +++ b/modules/sdk-core/src/account-lib/baseCoin/messages/baseMessageBuilderFactory.ts @@ -0,0 +1,44 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { IMessageBuilder, IMessageBuilderFactory } from './iface'; +import { BroadcastableMessage, MessageStandardType } from '../../../bitgo'; + +/** + * Base Message Builder + */ +export abstract class BaseMessageBuilderFactory implements IMessageBuilderFactory { + protected coinConfig: Readonly; + + /** + * Base constructor. + * @param coinConfig BaseCoin from statics library + */ + protected constructor(coinConfig: Readonly) { + this.coinConfig = coinConfig; + } + + /** + * Gets a message builder for the specified message type + * @param type The type of message to build + * @returns A message builder instance for the specified type + */ + public abstract getMessageBuilder(type: MessageStandardType): IMessageBuilder; + + /** + * Gets a message builder from a broadcastable message + * @param broadcastMessage The broadcastable message + * @returns A message builder instance for the broadcastable message type + */ + fromBroadcastFormat(broadcastMessage: BroadcastableMessage): IMessageBuilder { + return this.getMessageBuilder(broadcastMessage.type); + } + + /** + * Parses a broadcastable message and gets the message builder based on the message type. + * @param broadcastString The broadcastable message to parse + * @returns A message builder instance for the parsed broadcastable message type + */ + fromBroadcastString(broadcastString: string): IMessageBuilder { + const broadcastMessage = JSON.parse(broadcastString) as BroadcastableMessage; + return this.fromBroadcastFormat(broadcastMessage); + } +} diff --git a/modules/sdk-core/src/account-lib/baseCoin/messages/iface.ts b/modules/sdk-core/src/account-lib/baseCoin/messages/iface.ts new file mode 100644 index 0000000000..714d1a0996 --- /dev/null +++ b/modules/sdk-core/src/account-lib/baseCoin/messages/iface.ts @@ -0,0 +1,181 @@ +import { BroadcastableMessage, MessageMetadata, MessagePayload, MessageStandardType } from '../../../bitgo'; + +/** + * Represents a built message that can be signed + */ +export interface IMessage { + /** + * Get the message type + */ + getType(): MessageStandardType; + + /** + * Get the message payload + */ + getPayload(): MessagePayload; + + /** + * Get the metadata associated with the message + */ + getMetadata(): MessageMetadata | undefined; + + /** + * Gets all signers addresses or public keys + */ + getSigners(): string[]; + + /** + * Adds a signer address or public key + * @param signer The address or public key of the signer + */ + addSigner(signer: string): void; + + /** + * Sets signers addresses or public keys + * @param signers Array of addresses or public keys + */ + setSigners(signers: string[]): void; + + /** + * Gets all signatures associated with this message + */ + getSignatures(): string[]; + + /** + * Sets signatures for this message + * @param signatures Array of signatures to set + */ + setSignatures(signatures: string[]): void; + + /** + * Adds a signature to this message + * @param signature The signature to add + */ + addSignature(signature: string): void; + + /** + * Returns the payload that should be signed + * This might be different from the original payload in some standards + * For example, in EIP-712, this would be the encoded typed data hash + */ + getSignablePayload(): Promise; + + /** + * Creates a broadcastable message format that includes the signatures + * Uses internal signatures and signers that were previously set + * @returns A serializable format for broadcasting + */ + toBroadcastFormat(): Promise; + + /** + * Serializes the broadcastable message to a string + * Uses internal signatures and signers that were previously set + * @returns A JSON string representation of the broadcastable message + */ + toBroadcastString(): Promise; +} + +/** + * Core interface for message building strategies + */ +export interface IMessageBuilder { + /** + * Sets the message payload to be used when building the message + * @param payload The message payload (string, JSON, etc.) + * @returns The builder instance for chaining + */ + setPayload(payload: MessagePayload): IMessageBuilder; + + /** + * Sets metadata for the message + * @param metadata Additional metadata for the message + * @returns The builder instance for chaining + */ + setMetadata(metadata: Record): IMessageBuilder; + + /** + * Sets the signatures to the message + * @param signatures The signatures to add + * @returns The builder instance for chaining + */ + setSignatures(signatures: string[]): IMessageBuilder; + + /** + * Adds a signature to the message + * @param signature The signature to add + * @returns The builder instance for chaining + */ + addSignature(signature: string): IMessageBuilder; + + /** + * Sets the signers for the message + * @param signers Array of addresses or public keys + * @returns The builder instance for chaining + */ + setSigners(signers: string[]): IMessageBuilder; + + /** + * Adds a signer address or public key + * @param signer The address or public key of the signer + * @returns The builder instance for chaining + */ + addSigner(signer: string): IMessageBuilder; + + /** + * Gets the current message payload + * @returns The current message payload + */ + getPayload(): MessagePayload | undefined; + + /** + * Gets the current metadata + * @returns The current metadata + */ + getMetadata(): Record | undefined; + + /** + * Gets the type of message being built + * @returns The type of message standard + */ + getType(): MessageStandardType; + + /** + * Builds a message using the previously set payload and metadata + * @returns A Promise resolving to the built SignableMessage + */ + build(): Promise; + + /** + * Parse a broadcastable message back into a message and signatures + * @param message The broadcastable message to parse + * @returns The parsed message and signature + */ + fromBroadcastFormat(message: BroadcastableMessage): Promise; +} + +/** + * Factory interface for creating message builders + */ +export interface IMessageBuilderFactory { + /** + * Gets a message builder for the specified message type + * @param type The type of message to build + * @returns A message builder instance for the specified type + */ + getMessageBuilder(type: MessageStandardType): IMessageBuilder; + + /** + * Parse a broadcastable message back into a signable message and signature + * This factory method will automatically choose the correct builder based on the message type + * @param broadcastMessage The broadcastable message to parse + * @returns A message builder instance for the parsed specified type + */ + fromBroadcastFormat(broadcastMessage: BroadcastableMessage): IMessageBuilder; + + /** + * Parse a broadcastable message string back into a signable message and signature + * @param broadcastString The JSON string representation of the broadcast message + * @returns A message builder instance for the parsed specified type + */ + fromBroadcastString(broadcastString: string): IMessageBuilder; +} diff --git a/modules/sdk-core/src/account-lib/baseCoin/messages/index.ts b/modules/sdk-core/src/account-lib/baseCoin/messages/index.ts new file mode 100644 index 0000000000..f3940ab64a --- /dev/null +++ b/modules/sdk-core/src/account-lib/baseCoin/messages/index.ts @@ -0,0 +1,4 @@ +export * from './baseMessage'; +export * from './baseMessageBuilder'; +export * from './baseMessageBuilderFactory'; +export * from './iface'; diff --git a/modules/sdk-core/src/bitgo/utils/index.ts b/modules/sdk-core/src/bitgo/utils/index.ts index c3f471375d..5623fd151a 100644 --- a/modules/sdk-core/src/bitgo/utils/index.ts +++ b/modules/sdk-core/src/bitgo/utils/index.ts @@ -10,5 +10,6 @@ export * from './util'; export * from './decode'; export * from './notEmpty'; export * from './wallet'; +export * from './messageTypes'; export { openpgpUtils }; diff --git a/modules/sdk-core/src/bitgo/utils/messageTypes.ts b/modules/sdk-core/src/bitgo/utils/messageTypes.ts new file mode 100644 index 0000000000..a0ca3acb68 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/messageTypes.ts @@ -0,0 +1,37 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; + +/** + * Supported message signing standard types + */ +export enum MessageStandardType { + UNKNOWN = 'UNKNOWN', + EIP191 = 'EIP191', +} + +export type MessagePayload = string; +export type MessageMetadata = Record; + +/** + * Format for broadcasting a signed message + */ +export interface BroadcastableMessage { + type: MessageStandardType; + payload: MessagePayload; + signatures: string[]; + signers: string[]; // list of addresses or public keys of the signers + metadata?: MessageMetadata; + signablePayload?: string | Buffer; +} + +/** + * Options to create a message + */ +export interface MessageOptions { + coinConfig: Readonly; + payload: MessagePayload; + type?: MessageStandardType; + signablePayload?: string | Buffer; + metadata?: MessageMetadata; + signatures?: string[]; + signers?: string[]; +} diff --git a/modules/sdk-core/test/unit/account-lib/baseCoin/messages/baseMessage.ts b/modules/sdk-core/test/unit/account-lib/baseCoin/messages/baseMessage.ts new file mode 100644 index 0000000000..14e69fc940 --- /dev/null +++ b/modules/sdk-core/test/unit/account-lib/baseCoin/messages/baseMessage.ts @@ -0,0 +1,243 @@ +import { BaseCoin } from '@bitgo/statics'; +import sinon from 'sinon'; +import should from 'should'; +import { MessageStandardType } from '../../../../../src'; +import { messageSamples, TestBaseMessage } from './fixtures'; + +describe('Base Message', () => { + let coinConfig: sinon.SinonStubbedInstance; + + beforeEach(() => { + coinConfig = sinon.createStubInstance(BaseCoin); + }); + + it('should initialize with default values', () => { + const message = new TestBaseMessage({ + coinConfig, + payload: '', + }); + + should.equal(message.getType(), MessageStandardType.UNKNOWN); + should.equal(message.getPayload(), ''); + should.deepEqual(message.getMetadata(), {}); + should.deepEqual(message.getSignatures(), []); + should.deepEqual(message.getSigners(), []); + }); + + it('should initialize with provided values', () => { + const { payload, type, metadata, signers, signatures } = messageSamples.eip191; + + const message = new TestBaseMessage({ + coinConfig, + payload, + type, + metadata, + signers, + signatures, + }); + + should.equal(message.getType(), type); + should.equal(message.getPayload(), payload); + should.deepEqual(message.getMetadata(), metadata); + should.deepEqual(message.getSignatures(), signatures); + should.deepEqual(message.getSigners(), signers); + }); + + describe('Getters and Setters', () => { + let message: TestBaseMessage; + + beforeEach(() => { + message = new TestBaseMessage({ + coinConfig, + payload: 'test', + }); + }); + + it('should handle adding and getting signers', () => { + const signer1 = '0xabc123'; + const signer2 = '0xdef456'; + + message.addSigner(signer1); + should.deepEqual(message.getSigners(), [signer1]); + + message.addSigner(signer2); + should.deepEqual(message.getSigners(), [signer1, signer2]); + + // Adding a duplicate signer should not add it again + message.addSigner(signer1); + should.deepEqual(message.getSigners(), [signer1, signer2]); + + // Set signers should replace all existing signers + const newSigners = ['0x111', '0x222']; + message.setSigners(newSigners); + should.deepEqual(message.getSigners(), newSigners); + }); + + it('should handle adding and getting signatures', () => { + const sig1 = 'signature1'; + const sig2 = 'signature2'; + + message.addSignature(sig1); + should.deepEqual(message.getSignatures(), [sig1]); + + message.addSignature(sig2); + should.deepEqual(message.getSignatures(), [sig1, sig2]); + + // Set signatures should replace all existing signatures + const newSignatures = ['sig3', 'sig4']; + message.setSignatures(newSignatures); + should.deepEqual(message.getSignatures(), newSignatures); + }); + + it('should return copies of arrays to prevent mutation', () => { + const signers = ['addr1', 'addr2']; + const signatures = ['sig1', 'sig2']; + + message.setSigners(signers); + message.setSignatures(signatures); + + // Modifying the returned arrays should not affect the internal state + const returnedSigners = message.getSigners(); + const returnedSignatures = message.getSignatures(); + + returnedSigners.push('addr3'); + returnedSignatures.push('sig3'); + + should.deepEqual(message.getSigners(), signers); + should.deepEqual(message.getSignatures(), signatures); + }); + }); + + describe('getSignablePayload', () => { + it('should return the signablePayload if set', async () => { + const customSignablePayload = '0xabcdef123456'; + const message = new TestBaseMessage({ + coinConfig, + payload: 'original payload', + signablePayload: customSignablePayload, + }); + + const result = await message.getSignablePayload(); + should.equal(result, customSignablePayload); + }); + + it('should return the payload as buffer if signablePayload is not set', async () => { + const payload = 'test payload'; + const message = new TestBaseMessage({ + coinConfig, + payload, + }); + + const result = await message.getSignablePayload(); + should.deepEqual(result, Buffer.from(payload)); + }); + }); + + describe('toBroadcastFormat', () => { + it('should throw an error if no signatures are available', async () => { + const message = new TestBaseMessage({ + coinConfig, + payload: 'test', + signers: ['addr1'], + }); + + await message + .toBroadcastFormat() + .should.be.rejectedWith('No signatures available for broadcast. Call setSignatures or addSignature first.'); + }); + + it('should throw an error if no signers are available', async () => { + const message = new TestBaseMessage({ + coinConfig, + payload: 'test', + signatures: ['sig1'], + }); + + await message + .toBroadcastFormat() + .should.be.rejectedWith('No signers available for broadcast. Call setSigners or addSigner first.'); + }); + + it('should create a proper broadcastable format with all fields', async () => { + const { payload, type, metadata, signers, signatures } = messageSamples.eip191; + const customSignablePayload = Buffer.from('custom signable payload'); + + const message = new TestBaseMessage({ + coinConfig, + payload, + type, + metadata, + signers, + signatures, + signablePayload: customSignablePayload, + }); + + const broadcastFormat = await message.toBroadcastFormat(); + + should.deepEqual(broadcastFormat, { + type, + payload, + signatures, + signers, + metadata, + signablePayload: customSignablePayload, + }); + }); + + it('should perform a deep copy of metadata to prevent mutation', async () => { + const nestedMetadata = { + version: '1.0', + settings: { + chainId: 1, + gasLimit: 21000, + }, + }; + + const message = new TestBaseMessage({ + coinConfig, + payload: 'test', + metadata: nestedMetadata, + signers: ['addr1'], + signatures: ['sig1'], + }); + + const broadcastFormat = await message.toBroadcastFormat(); + + // The metadata in the broadcast format should be a deep copy + should.deepEqual(broadcastFormat.metadata, nestedMetadata); + + // But it should not be the same object reference + should.notEqual(broadcastFormat.metadata, nestedMetadata); + + // Modifying the original should not affect the broadcasted version + nestedMetadata.settings.gasLimit = 50000; + should.notEqual((broadcastFormat.metadata as any).settings.gasLimit, 50000); + }); + }); + + describe('toBroadcastString', () => { + it('should serialize the broadcastable format to JSON string', async () => { + const { payload, type, metadata, signers, signatures } = messageSamples.eip191; + + const message = new TestBaseMessage({ + coinConfig, + payload, + type, + metadata, + signers, + signatures, + }); + + const broadcastString = await message.toBroadcastString(); + const parsed = JSON.parse(broadcastString); + + should.deepEqual(parsed, { + type, + payload, + signatures, + signers, + metadata, + }); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/account-lib/baseCoin/messages/baseMessageBuilder.ts b/modules/sdk-core/test/unit/account-lib/baseCoin/messages/baseMessageBuilder.ts new file mode 100644 index 0000000000..58530c0e60 --- /dev/null +++ b/modules/sdk-core/test/unit/account-lib/baseCoin/messages/baseMessageBuilder.ts @@ -0,0 +1,150 @@ +import { BaseCoin } from '@bitgo/statics'; +import sinon from 'sinon'; +import should from 'should'; +import { MessageStandardType } from '../../../../../src'; +import { TestMessageBuilder } from './fixtures'; + +describe('Base Message Builder', () => { + let builder: TestMessageBuilder; + let mockCoinConfig: sinon.SinonStubbedInstance; + + beforeEach(() => { + mockCoinConfig = sinon.createStubInstance(BaseCoin); + builder = new TestMessageBuilder(mockCoinConfig as unknown as BaseCoin, MessageStandardType.EIP191); + }); + + it('should initialize with default values', () => { + should.equal(builder.getPayload(), ''); + should.equal(builder.getType(), MessageStandardType.EIP191); + should.deepEqual(builder.getSignatures(), []); + should.deepEqual(builder.getSigners(), []); + should.deepEqual(builder.getMetadata(), {}); + should.equal(builder.getDigest(), undefined); + }); + + it('should set and get payload', () => { + const payload = 'test payload'; + builder.setPayload(payload); + should.equal(builder.getPayload(), payload); + }); + + it('should set and get metadata', () => { + const metadata = { key: 'value', num: 123 }; + builder.setMetadata(metadata); + should.deepEqual(builder.getMetadata(), metadata); + }); + + it('should set and get type', () => { + const type = MessageStandardType.UNKNOWN; + builder.setType(type); + should.equal(builder.getType(), type); + }); + + it('should set and get signatures', () => { + const signatures = ['sig1', 'sig2', 'sig3']; + builder.setSignatures(signatures); + should.deepEqual(builder.getSignatures(), signatures); + }); + + it('should set and get signers', () => { + const signers = ['address1', 'address2', 'address3']; + builder.setSigners(signers); + should.deepEqual(builder.getSigners(), signers); + }); + + it('should set and get digest', () => { + const digest = '0x1234abcd'; + builder.setDigest(digest); + should.equal(builder.getDigest(), digest); + }); + + it('should build a message with the correct properties', async () => { + const payload = 'test message'; + const metadata = { foo: 'bar' }; + const signatures = ['sig1', 'sig2']; + const signers = ['addr1', 'addr2']; + + builder + .setType(MessageStandardType.EIP191) + .setPayload(payload) + .setMetadata(metadata) + .setSignatures(signatures) + .setSigners(signers); + + const message = await builder.build(); + + should.equal(message.getType(), MessageStandardType.EIP191); + should.equal(message.getPayload(), payload); + should.deepEqual(message.getMetadata(), metadata); + should.deepEqual(message.getSignatures(), signatures); + should.deepEqual(message.getSigners(), signers); + + const signablePayload = await message.getSignablePayload(); + should.deepEqual(signablePayload, Buffer.from(payload)); + }); + + it('should correctly handle toBroadcastFormat', async () => { + const payload = 'hello world'; + const metadata = { version: '1.0' }; + const signatures = ['sig1']; + const signers = ['addr1']; + + builder + .setType(MessageStandardType.EIP191) + .setPayload(payload) + .setMetadata(metadata) + .setSignatures(signatures) + .setSigners(signers); + + const message = await builder.build(); + const broadcastFormat = await message.toBroadcastFormat(); + + should.deepEqual(broadcastFormat, { + type: MessageStandardType.EIP191, + payload: payload, + signatures: signatures, + signers: signers, + metadata: metadata, + signablePayload: undefined, + }); + }); + + it('should correctly handle fromBroadcastFormat', async () => { + const broadcastMessage = { + type: MessageStandardType.EIP191, + payload: 'broadcast test', + signatures: ['sig1', 'sig2'], + signers: ['addr1', 'addr2'], + metadata: { chainId: 1 }, + }; + + const message = await builder.fromBroadcastFormat(broadcastMessage); + + should.equal(message.getType(), broadcastMessage.type); + should.equal(message.getPayload(), broadcastMessage.payload); + should.deepEqual(message.getSignatures(), broadcastMessage.signatures); + should.deepEqual(message.getSigners(), broadcastMessage.signers); + should.deepEqual(message.getMetadata(), broadcastMessage.metadata); + }); + + it('should correctly handle toBroadcastString', async () => { + const payload = 'serialize me'; + const signatures = ['sig1']; + const signers = ['addr1']; + + builder.setType(MessageStandardType.EIP191).setPayload(payload).setSignatures(signatures).setSigners(signers); + + const message = await builder.build(); + const broadcastString = await message.toBroadcastString(); + + const expectedJson = JSON.stringify({ + type: MessageStandardType.EIP191, + payload: payload, + signatures: signatures, + signers: signers, + metadata: {}, + }); + + should.equal(broadcastString, expectedJson); + }); +}); diff --git a/modules/sdk-core/test/unit/account-lib/baseCoin/messages/baseMessageBuilderFactory.ts b/modules/sdk-core/test/unit/account-lib/baseCoin/messages/baseMessageBuilderFactory.ts new file mode 100644 index 0000000000..fa252f9d1f --- /dev/null +++ b/modules/sdk-core/test/unit/account-lib/baseCoin/messages/baseMessageBuilderFactory.ts @@ -0,0 +1,95 @@ +import { BaseCoin } from '@bitgo/statics'; +import sinon from 'sinon'; +import should from 'should'; +import { BroadcastableMessage, MessageStandardType } from '../../../../../src'; +import { TestMessageBuilderFactory } from './fixtures'; + +describe('Base Message Builder Factory', () => { + let factory: TestMessageBuilderFactory; + let mockCoinConfig: sinon.SinonStubbedInstance; + + beforeEach(() => { + mockCoinConfig = sinon.createStubInstance(BaseCoin); + factory = new TestMessageBuilderFactory(mockCoinConfig as unknown as BaseCoin); + }); + + describe('getMessageBuilder', () => { + it('should create a message builder for EIP191 type', () => { + const builder = factory.getMessageBuilder(MessageStandardType.EIP191); + should.exist(builder); + should.equal(builder.getType(), MessageStandardType.EIP191); + }); + + it('should create a message builder for UNKNOWN type', () => { + const builder = factory.getMessageBuilder(MessageStandardType.UNKNOWN); + should.exist(builder); + should.equal(builder.getType(), MessageStandardType.UNKNOWN); + }); + }); + + describe('fromBroadcastFormat', () => { + it('should create a builder from a broadcast message', () => { + const broadcastMessage: BroadcastableMessage = { + type: MessageStandardType.EIP191, + payload: 'hello world', + signatures: ['sig1'], + signers: ['addr1'], + metadata: { version: '1.0' }, + }; + + const builder = factory.fromBroadcastFormat(broadcastMessage); + should.exist(builder); + // Since the TestMessageBuilder always returns the same type that was passed to constructor + should.equal(builder.getType(), MessageStandardType.EIP191); + }); + }); + + describe('fromBroadcastString', () => { + it('should parse a broadcast message string and create the appropriate builder', () => { + const broadcastMessage: BroadcastableMessage = { + type: MessageStandardType.EIP191, + payload: 'test message', + signatures: ['sig1', 'sig2'], + signers: ['addr1', 'addr2'], + metadata: { chainId: 1 }, + }; + + const broadcastString = JSON.stringify(broadcastMessage); + const builder = factory.fromBroadcastString(broadcastString); + + should.exist(builder); + // Since the TestMessageBuilder always returns the same type that was passed to constructor + should.equal(builder.getType(), MessageStandardType.EIP191); + }); + + it('should handle broadcast messages with different types', () => { + const broadcastMessage: BroadcastableMessage = { + type: MessageStandardType.UNKNOWN, + payload: 'unknown message', + signatures: ['sig1'], + signers: ['addr1'], + }; + + const broadcastString = JSON.stringify(broadcastMessage); + const builder = factory.fromBroadcastString(broadcastString); + + should.exist(builder); + should.equal(builder.getType(), MessageStandardType.UNKNOWN); + }); + + it('should handle broadcast messages without optional fields', () => { + const broadcastMessage = { + type: MessageStandardType.EIP191, + payload: 'minimal message', + signatures: [], + signers: [], + }; + + const broadcastString = JSON.stringify(broadcastMessage); + const builder = factory.fromBroadcastString(broadcastString); + + should.exist(builder); + should.equal(builder.getType(), MessageStandardType.EIP191); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/account-lib/baseCoin/messages/fixtures.ts b/modules/sdk-core/test/unit/account-lib/baseCoin/messages/fixtures.ts new file mode 100644 index 0000000000..665f0282ce --- /dev/null +++ b/modules/sdk-core/test/unit/account-lib/baseCoin/messages/fixtures.ts @@ -0,0 +1,80 @@ +import { + BaseMessage, + BaseMessageBuilder, + BaseMessageBuilderFactory, + IMessage, + IMessageBuilder, + MessageOptions, + MessageStandardType, +} from '../../../../../src'; +import { BaseCoin } from '@bitgo/statics'; + +export class TestMessageBuilderFactory extends BaseMessageBuilderFactory { + constructor(coinConfig: Readonly) { + super(coinConfig); + } + + getMessageBuilder(type: MessageStandardType): IMessageBuilder { + return new TestMessageBuilder(this.coinConfig, type); + } +} + +export class TestMessageBuilder extends BaseMessageBuilder { + constructor(coinConfig: Readonly, type: MessageStandardType = MessageStandardType.UNKNOWN) { + super(coinConfig, type); + } + + async build(): Promise { + return new TestBaseMessage({ + coinConfig: this.coinConfig, + payload: this.payload, + type: this.type, + signatures: this.signatures, + signers: this.signers, + metadata: { + ...this.metadata, + }, + }); + } + + async fromBroadcastFormat(broadcastMessage: any): Promise { + this.setType(broadcastMessage.type); + this.setPayload(broadcastMessage.payload); + this.setSignatures(broadcastMessage.signatures || []); + this.setSigners(broadcastMessage.signers || []); + if (broadcastMessage.metadata) { + this.setMetadata(broadcastMessage.metadata); + } + return this.build(); + } +} + +export class TestBaseMessage extends BaseMessage { + constructor(options: MessageOptions) { + super(options); + } + + async getSignablePayload(): Promise { + if (this.signablePayload) { + return this.signablePayload; + } + return Buffer.from(this.payload); + } +} + +export const messageSamples = { + eip191: { + payload: 'Hello BitGo!', + type: MessageStandardType.EIP191, + metadata: { chainId: 1 }, + signers: ['0x1234567890abcdef1234567890abcdef12345678'], + signatures: ['0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + }, + unknown: { + payload: 'Unknown message type', + type: MessageStandardType.UNKNOWN, + metadata: { version: '1.0' }, + signers: ['12345'], + signatures: ['67890'], + }, +}; diff --git a/modules/sdk-core/test/unit/bitgo/utils/messageTypes.ts b/modules/sdk-core/test/unit/bitgo/utils/messageTypes.ts new file mode 100644 index 0000000000..252af46498 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/messageTypes.ts @@ -0,0 +1,46 @@ +import * as should from 'should'; +import { MessageStandardType, BroadcastableMessage } from '../../../../src/bitgo'; + +describe('Message Types', () => { + describe('MessageStandardType', () => { + it('should define supported message standard types', () => { + should.exist(MessageStandardType.UNKNOWN); + should.exist(MessageStandardType.EIP191); + + should.equal(MessageStandardType.UNKNOWN, 'UNKNOWN'); + should.equal(MessageStandardType.EIP191, 'EIP191'); + }); + }); + + describe('BroadcastableMessage', () => { + it('should validate interface structure', () => { + const message: BroadcastableMessage = { + type: MessageStandardType.EIP191, + payload: 'Test payload', + signatures: ['signature1', 'signature2'], + signers: ['signer1', 'signer2'], + metadata: { chainId: 1 }, + signablePayload: Buffer.from('signable payload'), + }; + + should.equal(message.type, MessageStandardType.EIP191); + should.equal(message.payload, 'Test payload'); + should.deepEqual(message.signatures, ['signature1', 'signature2']); + should.deepEqual(message.signers, ['signer1', 'signer2']); + should.deepEqual(message.metadata, { chainId: 1 }); + should.deepEqual(message.signablePayload, Buffer.from('signable payload')); + }); + + it('should allow optional fields to be undefined', () => { + const message: BroadcastableMessage = { + type: MessageStandardType.UNKNOWN, + payload: 'Minimal message', + signatures: [], + signers: [], + }; + + should.not.exist(message.metadata); + should.not.exist(message.signablePayload); + }); + }); +});