Skip to content

feat(sdk-core): add message builder and message builder factory #6343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitcommitscopes
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
sdk-coin-rune
sdk-coin-sui
sdk-core
statics
1 change: 1 addition & 0 deletions modules/abstract-eth/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
24 changes: 24 additions & 0 deletions modules/abstract-eth/src/lib/messages/eip191/eip191Message.ts
Original file line number Diff line number Diff line change
@@ -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<string | Buffer> {
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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<IMessage> {
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<IMessage> {
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',
},
});
}
}
2 changes: 2 additions & 0 deletions modules/abstract-eth/src/lib/messages/eip191/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './eip191Message';
export * from './eip191MessageBuilder';
2 changes: 2 additions & 0 deletions modules/abstract-eth/src/lib/messages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './messageBuilderFactory';
export * from './eip191';
18 changes: 18 additions & 0 deletions modules/abstract-eth/src/lib/messages/messageBuilderFactory.ts
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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}`);
}
}
}
1 change: 1 addition & 0 deletions modules/abstract-eth/test/unit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './transactionBuilder';
export * from './token';
export * from './transaction';
export * from './coin';
export * from './messages';
175 changes: 175 additions & 0 deletions modules/abstract-eth/test/unit/messages/eip191/eip191Message.ts
Original file line number Diff line number Diff line change
@@ -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<length><message>"
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);
});
});
});
Loading