Skip to content

Commit 55b8778

Browse files
committed
feat(sdk-coin-ada): add CIP-8 message builder
TICKET: COIN-4724
1 parent 21eaed1 commit 55b8778

File tree

17 files changed

+846
-3
lines changed

17 files changed

+846
-3
lines changed

.gitcommitscopes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
account-lib
2+
sdk-coin-ada
23
sdk-coin-rune
34
sdk-coin-sui
45
sdk-core

modules/abstract-eth/test/unit/messages/eip191/eip191Message.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ describe('EIP191 Message', () => {
143143
broadcastFormat.serializedSignatures.should.deepEqual(expectedSerializedSignatures);
144144
broadcastFormat.signers.should.deepEqual([fixtures.eip191.signer]);
145145
broadcastFormat.metadata!.should.deepEqual(fixtures.eip191.metadata);
146-
broadcastFormat.signablePayload!.should.equal('test-signable-payload');
146+
broadcastFormat.signablePayload!.should.equal('dGVzdC1zaWduYWJsZS1wYXlsb2Fk');
147147
});
148148

149149
it('should throw error when broadcasting without signatures', async () => {

modules/sdk-coin-ada/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"cbor": "^10.0.3",
5252
"lodash": "^4.17.21",
5353
"superagent": "^9.0.1",
54-
"tweetnacl": "^1.0.3"
54+
"tweetnacl": "^1.0.3",
55+
"cbor-x": "^1.5.9"
5556
},
5657
"devDependencies": {
5758
"@bitgo/sdk-api": "^1.64.1",
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { BaseMessage, MessageOptions, MessageStandardType, Signature } from '@bitgo/sdk-core';
2+
import * as CardanoSL from '@emurgo/cardano-serialization-lib-nodejs';
3+
import { constructCSLCoseObjects, coseObjectsOutputToBuffer, createCSLSigStructure } from './utils';
4+
import { Encoder } from 'cbor-x';
5+
6+
/**
7+
* Implementation of Message for CIP8 standard
8+
*/
9+
export class Cip8Message extends BaseMessage {
10+
private readonly cborEncoder: Encoder = new Encoder({ mapsAsObjects: false });
11+
12+
constructor(options: MessageOptions) {
13+
super({
14+
...options,
15+
type: MessageStandardType.CIP8,
16+
});
17+
}
18+
19+
/**
20+
* Validates required fields and returns common setup objects
21+
* @private
22+
*/
23+
private validateAndGetCommonSetup() {
24+
if (!this.payload) {
25+
throw new Error('Payload is required to build a CIP8 message');
26+
}
27+
if (!this.signers || this.signers.length === 0) {
28+
throw new Error('A signer address is required to build a CIP8 message');
29+
}
30+
31+
let cslAddress: CardanoSL.Address;
32+
try {
33+
cslAddress = CardanoSL.Address.from_bech32(this.signers[0]);
34+
} catch (error) {
35+
// Convert string errors to proper Error objects
36+
if (typeof error === 'string') {
37+
throw new Error(`Invalid signer address: ${error}`);
38+
}
39+
throw error;
40+
}
41+
42+
const addressCborBytes = cslAddress.to_bytes();
43+
44+
return { addressCborBytes };
45+
}
46+
47+
/**
48+
* Returns the hash of the EIP-191 prefixed message
49+
*/
50+
async getSignablePayload(): Promise<string | Buffer> {
51+
if (!this.signablePayload) {
52+
this.signablePayload = this.buildSignablePayload();
53+
}
54+
return this.signablePayload;
55+
}
56+
57+
/**
58+
* Builds the signable payload for a CIP8 message
59+
* @returns The signable payload as a Buffer
60+
*/
61+
buildSignablePayload(): string | Buffer {
62+
const { addressCborBytes } = this.validateAndGetCommonSetup();
63+
const { sigStructureCborBytes } = createCSLSigStructure(addressCborBytes, this.payload, this.cborEncoder);
64+
return Buffer.from(sigStructureCborBytes);
65+
}
66+
67+
getBroadcastableSignatures(): Signature[] {
68+
if (!this.signatures.length) {
69+
return [];
70+
}
71+
72+
const signature = this.signatures[0].signature;
73+
const publicKeyHex = this.signatures[0].publicKey.pub;
74+
75+
const { addressCborBytes } = this.validateAndGetCommonSetup();
76+
const { protectedHeaderCborBytes, payloadBytes } = createCSLSigStructure(
77+
addressCborBytes,
78+
this.payload,
79+
this.cborEncoder
80+
);
81+
82+
const coseObjectsOutput = constructCSLCoseObjects(
83+
protectedHeaderCborBytes,
84+
payloadBytes,
85+
signature,
86+
CardanoSL.PublicKey.from_bytes(Buffer.from(publicKeyHex, 'hex')),
87+
this.cborEncoder
88+
);
89+
const coseObjectsBuffer = coseObjectsOutputToBuffer(coseObjectsOutput, this.cborEncoder);
90+
return [
91+
{
92+
signature: coseObjectsBuffer,
93+
publicKey: {
94+
pub: publicKeyHex,
95+
},
96+
},
97+
];
98+
}
99+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Cip8Message } from './cip8Message';
2+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3+
import {
4+
BaseMessageBuilder,
5+
BroadcastableMessage,
6+
deserializeSignatures,
7+
IMessage,
8+
MessageStandardType,
9+
} from '@bitgo/sdk-core';
10+
11+
/**
12+
* Builder for EIP-191 messages
13+
*/
14+
export class Cip8MessageBuilder extends BaseMessageBuilder {
15+
/**
16+
* Base constructor.
17+
* @param _coinConfig BaseCoin from statics library
18+
*/
19+
public constructor(_coinConfig: Readonly<CoinConfig>) {
20+
super(_coinConfig, MessageStandardType.CIP8);
21+
}
22+
23+
/**
24+
* Build a signable message using the EIP-191 standard
25+
* with previously set input and metadata
26+
* @returns A signable message
27+
*/
28+
public async build(): Promise<IMessage> {
29+
try {
30+
if (!this.payload) {
31+
throw new Error('Message payload must be set before building the message');
32+
}
33+
return new Cip8Message({
34+
coinConfig: this.coinConfig,
35+
payload: this.payload,
36+
signatures: this.signatures,
37+
signers: this.signers,
38+
metadata: {
39+
...this.metadata,
40+
encoding: 'utf8',
41+
},
42+
});
43+
} catch (err) {
44+
if (err instanceof Error) {
45+
throw err;
46+
}
47+
throw new Error('Failed to build EIP-191 message');
48+
}
49+
}
50+
51+
/**
52+
* Parse a broadcastable message back into a message
53+
* @param broadcastMessage The broadcastable message to parse
54+
* @returns The parsed message
55+
*/
56+
public async fromBroadcastFormat(broadcastMessage: BroadcastableMessage): Promise<IMessage> {
57+
const { type, payload, serializedSignatures, signers, metadata } = broadcastMessage;
58+
if (type !== MessageStandardType.CIP8) {
59+
throw new Error(`Invalid message type, expected ${MessageStandardType.CIP8}`);
60+
}
61+
return new Cip8Message({
62+
coinConfig: this.coinConfig,
63+
payload,
64+
signatures: deserializeSignatures(serializedSignatures),
65+
signers,
66+
metadata: {
67+
...metadata,
68+
encoding: 'utf8',
69+
},
70+
});
71+
}
72+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './cip8Message';
2+
export * from './cip8MessageBuilder';
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Buffer } from 'buffer';
2+
import * as CSL from '@emurgo/cardano-serialization-lib-nodejs';
3+
import { Encoder } from 'cbor-x';
4+
5+
// Helper function to convert a Uint8Array or Buffer to a hex string
6+
export function bytesToHex(bytes: Uint8Array | Buffer): string {
7+
return Buffer.from(bytes).toString('hex');
8+
}
9+
10+
export interface CSLSigStructureOutput {
11+
sigStructureCborBytes: Uint8Array;
12+
protectedHeaderCborBytes: Uint8Array;
13+
payloadBytes: Buffer;
14+
}
15+
16+
export interface CSLCoseObjectsOutput {
17+
manualCoseSign1Hex: string;
18+
manualCoseKeyHex: string;
19+
}
20+
21+
/**
22+
* Creates the CSL signature structure for off-chain message signing.
23+
*
24+
* @param addressCborBytes - The CBOR bytes of the CSL address.
25+
* @param message - The message to be signed.
26+
* @param cborEncoder - The CBOR encoder instance.
27+
* @returns An object containing the signature structure CBOR bytes, protected header CBOR bytes, and payload bytes.
28+
*/
29+
export function createCSLSigStructure(
30+
addressCborBytes: Uint8Array,
31+
message: string,
32+
cborEncoder: Encoder
33+
): CSLSigStructureOutput {
34+
// Payload
35+
const payloadBytes = Buffer.from(message, 'utf-8');
36+
37+
// Protected Header
38+
const protectedHeaderMap = new Map<number | string, any>();
39+
protectedHeaderMap.set(1, -8); // Algorithm ID: EdDSA
40+
protectedHeaderMap.set('address', Buffer.from(addressCborBytes));
41+
const protectedHeaderCborBytes = cborEncoder.encode(protectedHeaderMap);
42+
43+
// Sig_structure
44+
const sigStructureArray: any[] = [
45+
'Signature1',
46+
Buffer.from(protectedHeaderCborBytes),
47+
Buffer.from([]), // Empty external_aad
48+
Buffer.from(payloadBytes),
49+
];
50+
const sigStructureCborBytes = cborEncoder.encode(sigStructureArray);
51+
52+
return { sigStructureCborBytes, protectedHeaderCborBytes, payloadBytes };
53+
}
54+
55+
// COSE objects construction function
56+
export function constructCSLCoseObjects(
57+
protectedHeaderCborBytes: Uint8Array,
58+
payloadBytes: Buffer,
59+
cslSignatureBytes: Uint8Array,
60+
paymentPubKey: CSL.PublicKey,
61+
cborEncoder: Encoder
62+
): CSLCoseObjectsOutput {
63+
// COSE_Sign1 Construction
64+
const unprotectedHeadersMap = new Map<string, any>();
65+
unprotectedHeadersMap.set('hashed', false);
66+
const coseSign1Array: any[] = [
67+
Buffer.from(protectedHeaderCborBytes),
68+
unprotectedHeadersMap,
69+
Buffer.from(payloadBytes),
70+
Buffer.from(cslSignatureBytes),
71+
];
72+
const finalCoseSign1CborBytes = cborEncoder.encode(coseSign1Array);
73+
/* // directly encoding the coseSign1Array without prepending the 0xD2 tag.
74+
* const coseSign1PayloadBytes = cborEncoder.encode(coseSign1Array);
75+
* const coseSign1Tag = Buffer.from([0xD2]); // Tag 18 for COSE_Sign1
76+
* const finalCoseSign1CborBytes = Buffer.concat([coseSign1Tag, coseSign1PayloadBytes]);
77+
*/
78+
const manualCoseSign1Hex = bytesToHex(finalCoseSign1CborBytes);
79+
80+
// COSE_Key Construction
81+
const coseKeyMap = new Map<number, any>();
82+
coseKeyMap.set(1, 1); // kty: OKP (Octet Key Pair)
83+
coseKeyMap.set(3, -8); // alg: EdDSA
84+
coseKeyMap.set(-1, 6); // crv: Ed25519
85+
coseKeyMap.set(-2, Buffer.from(paymentPubKey.as_bytes())); // x: public_key_bytes (Ed25519 public key)
86+
const finalCoseKeyCborBytes = cborEncoder.encode(coseKeyMap);
87+
const manualCoseKeyHex = bytesToHex(finalCoseKeyCborBytes);
88+
89+
return { manualCoseSign1Hex, manualCoseKeyHex };
90+
}
91+
92+
export function coseObjectsOutputToBuffer(output: CSLCoseObjectsOutput, cborEncoder: Encoder): Buffer {
93+
return Buffer.from(cborEncoder.encode(output));
94+
}
95+
96+
export function bufferToCoseObjectsOutput(buffer: Buffer, cborEncoder: Encoder): CSLCoseObjectsOutput {
97+
return cborEncoder.decode(buffer);
98+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './messageBuilderFactory';
2+
export * from './cip8';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Cip8MessageBuilder } from './cip8';
2+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3+
import { BaseMessageBuilderFactory, IMessageBuilder, MessageStandardType } from '@bitgo/sdk-core';
4+
5+
export class MessageBuilderFactory extends BaseMessageBuilderFactory {
6+
constructor(coinConfig: Readonly<CoinConfig>) {
7+
super(coinConfig);
8+
}
9+
10+
public getMessageBuilder(type: MessageStandardType): IMessageBuilder {
11+
switch (type) {
12+
case MessageStandardType.CIP8:
13+
return new Cip8MessageBuilder(this.coinConfig);
14+
default:
15+
throw new Error(`Invalid message standard ${type}`);
16+
}
17+
}
18+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as CardanoSL from '@emurgo/cardano-serialization-lib-nodejs';
2+
import { Encoder } from 'cbor-x';
3+
import { Buffer } from 'buffer';
4+
5+
export const cip8TestResources = {
6+
// Test address and key pair
7+
address: {
8+
bech32:
9+
'addr_test1qpxecfjurjtcnalwy6gxcqzp09je55gvfv79hghqst8p7p6dnsn9c8yh38m7uf5sdsqyz7t9nfgscjeutw3wpqkwrursutfm7h',
10+
paymentKeyHash: '5a0bf45a9f8214d9d44e20f806116bda59e10e706574b877501391b14',
11+
},
12+
keyPair: {
13+
prv: '38e3bf2573ebbc35b65b5bc91275e0ef05cc3ebd5bb913ede29c19fe0edacc8a',
14+
pub: 'c082eb504ec79dbdaecbf9c69745f88bb7973b02db8c4c73e4faeef349e21447',
15+
},
16+
17+
// Test messages
18+
messages: {
19+
simple: 'Hello, Cardano!',
20+
utf8: 'こんにちは, Cardano!', // Test UTF-8 characters
21+
longer:
22+
'This is a longer message for testing the CIP8 message implementation. It contains multiple sentences and is intended to test how the implementation handles messages of varying lengths.',
23+
},
24+
25+
// Pre-computed signatures for tests
26+
signatures: {
27+
simpleMessageSignature:
28+
'8458208458208a582000000000000000000000000000000000000000000000000000000000000000001a40158205a0bf45a9f8214d9d44e20f806116bda59e10e706574b877501391b14a1686866616c7365584073884144eb54ddc9a92cc5a5fff4bb38536c0489e75e84244c454419ebc5e636528d6c68e939a9c15d7f6d57e4da5ba68bca9b94f17ac0652d25470fac1207',
29+
},
30+
31+
// CBOR encoder instance for testing
32+
getCborEncoder: function (): Encoder {
33+
return new Encoder({ mapsAsObjects: false });
34+
},
35+
36+
// Helper function to create a test signature
37+
createTestSignature: function (payload: string): Uint8Array {
38+
// This is a dummy function that returns a fixed signature
39+
// In real tests, we'd use actual cryptographic libraries to sign
40+
const buffer = Buffer.alloc(64, 0);
41+
buffer.write(payload.slice(0, 64), 'utf8');
42+
return buffer;
43+
},
44+
45+
// Helper function to create a CSL public key from the test key pair
46+
createTestPublicKey: function (): CardanoSL.PublicKey {
47+
return CardanoSL.PublicKey.from_bytes(Buffer.from(this.keyPair.pub, 'hex'));
48+
},
49+
50+
// Pre-computed signable payloads for verification
51+
signablePayloads: {
52+
simple: 'a0', // Example CBOR hex for simple message (will be replaced with actual values)
53+
},
54+
};

0 commit comments

Comments
 (0)