Skip to content

Commit 50c53ec

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

File tree

15 files changed

+802
-2
lines changed

15 files changed

+802
-2
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/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export { StakingWithdrawBuilder } from './stakingWithdrawBuilder';
1212
export { StakingPledgeBuilder } from './stakingPledgeBuilder';
1313
export { VoteDelegationBuilder } from './voteDelegationBuilder';
1414
export { Utils };
15+
export * from './messages';
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
5+
/**
6+
* Implementation of Message for CIP8 standard
7+
*/
8+
export class Cip8Message extends BaseMessage {
9+
constructor(options: MessageOptions) {
10+
super({
11+
...options,
12+
type: MessageStandardType.CIP8,
13+
});
14+
}
15+
16+
/**
17+
* Validates required fields and returns common setup objects
18+
* @private
19+
*/
20+
private validateAndGetCommonSetup() {
21+
if (!this.payload) {
22+
throw new Error('Payload is required to build a CIP8 message');
23+
}
24+
if (!this.signers || this.signers.length === 0) {
25+
throw new Error('A signer address is required to build a CIP8 message');
26+
}
27+
28+
let cslAddress: CardanoSL.Address;
29+
try {
30+
cslAddress = CardanoSL.Address.from_bech32(this.signers[0]);
31+
} catch (error) {
32+
// Convert string errors to proper Error objects
33+
if (typeof error === 'string') {
34+
throw new Error(`Invalid signer address: ${error}`);
35+
}
36+
throw error;
37+
}
38+
39+
const addressCborBytes = cslAddress.to_bytes();
40+
41+
return { addressCborBytes };
42+
}
43+
44+
/**
45+
* Returns the hash of the CIP-8 prefixed message
46+
*/
47+
async getSignablePayload(): Promise<string | Buffer> {
48+
if (!this.signablePayload) {
49+
this.signablePayload = this.buildSignablePayload();
50+
}
51+
return this.signablePayload;
52+
}
53+
54+
/**
55+
* Builds the signable payload for a CIP8 message
56+
* @returns The signable payload as a Buffer
57+
*/
58+
buildSignablePayload(): string | Buffer {
59+
const { addressCborBytes } = this.validateAndGetCommonSetup();
60+
const { sigStructureCborBytes } = createCSLSigStructure(addressCborBytes, this.payload);
61+
return Buffer.from(sigStructureCborBytes);
62+
}
63+
64+
getBroadcastableSignatures(): Signature[] {
65+
if (!this.signatures.length) {
66+
return [];
67+
}
68+
69+
const signature = this.signatures[0].signature;
70+
const publicKeyHex = this.signatures[0].publicKey.pub;
71+
72+
const { addressCborBytes } = this.validateAndGetCommonSetup();
73+
const { protectedHeaderCborBytes, payloadBytes } = createCSLSigStructure(addressCborBytes, this.payload);
74+
75+
const coseObjectsOutput = constructCSLCoseObjects(
76+
protectedHeaderCborBytes,
77+
payloadBytes,
78+
signature,
79+
CardanoSL.PublicKey.from_bytes(Buffer.from(publicKeyHex, 'hex'))
80+
);
81+
const coseObjectsBuffer = coseObjectsOutputToBuffer(coseObjectsOutput);
82+
return [
83+
{
84+
signature: coseObjectsBuffer,
85+
publicKey: {
86+
pub: publicKeyHex,
87+
},
88+
},
89+
];
90+
}
91+
}
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 CIP-8 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 CIP-8 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 CIP-8 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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Buffer } from 'buffer';
2+
import * as CSL from '@emurgo/cardano-serialization-lib-nodejs';
3+
import { Decoder, Encoder } from 'cbor';
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+
* @returns An object containing the signature structure CBOR bytes, protected header CBOR bytes, and payload bytes.
27+
*/
28+
export function createCSLSigStructure(addressCborBytes: Uint8Array, message: string): CSLSigStructureOutput {
29+
// Payload
30+
const payloadBytes = Buffer.from(message, 'utf-8');
31+
32+
// Protected Header
33+
const protectedHeaderMap = new Map<number | string, any>();
34+
protectedHeaderMap.set(1, -8); // Algorithm ID: EdDSA
35+
protectedHeaderMap.set('address', Buffer.from(addressCborBytes));
36+
const protectedHeaderCborBytes = Encoder.encode(protectedHeaderMap);
37+
38+
// Sig_structure
39+
const sigStructureArray: any[] = [
40+
'Signature1',
41+
Buffer.from(protectedHeaderCborBytes),
42+
Buffer.from([]), // Empty external_aad
43+
Buffer.from(payloadBytes),
44+
];
45+
const sigStructureCborBytes = Encoder.encode(sigStructureArray);
46+
47+
return { sigStructureCborBytes, protectedHeaderCborBytes, payloadBytes };
48+
}
49+
50+
// COSE objects construction function
51+
export function constructCSLCoseObjects(
52+
protectedHeaderCborBytes: Uint8Array,
53+
payloadBytes: Buffer,
54+
cslSignatureBytes: Uint8Array,
55+
paymentPubKey: CSL.PublicKey
56+
): CSLCoseObjectsOutput {
57+
// COSE_Sign1 Construction
58+
const unprotectedHeadersMap = new Map<string, any>();
59+
unprotectedHeadersMap.set('hashed', false);
60+
const coseSign1Array: any[] = [
61+
Buffer.from(protectedHeaderCborBytes),
62+
unprotectedHeadersMap,
63+
Buffer.from(payloadBytes),
64+
Buffer.from(cslSignatureBytes),
65+
];
66+
const finalCoseSign1CborBytes = Encoder.encode(coseSign1Array);
67+
/* // directly encoding the coseSign1Array without prepending the 0xD2 tag.
68+
* const coseSign1PayloadBytes = Encoder.encode(coseSign1Array);
69+
* const coseSign1Tag = Buffer.from([0xD2]); // Tag 18 for COSE_Sign1
70+
* const finalCoseSign1CborBytes = Buffer.concat([coseSign1Tag, coseSign1PayloadBytes]);
71+
*/
72+
const manualCoseSign1Hex = bytesToHex(finalCoseSign1CborBytes);
73+
74+
// COSE_Key Construction
75+
const coseKeyMap = new Map<number, any>();
76+
coseKeyMap.set(1, 1); // kty: OKP (Octet Key Pair)
77+
coseKeyMap.set(3, -8); // alg: EdDSA
78+
coseKeyMap.set(-1, 6); // crv: Ed25519
79+
coseKeyMap.set(-2, Buffer.from(paymentPubKey.as_bytes())); // x: public_key_bytes (Ed25519 public key)
80+
const finalCoseKeyCborBytes = Encoder.encode(coseKeyMap);
81+
const manualCoseKeyHex = bytesToHex(finalCoseKeyCborBytes);
82+
83+
return { manualCoseSign1Hex, manualCoseKeyHex };
84+
}
85+
86+
export function coseObjectsOutputToBuffer(output: CSLCoseObjectsOutput): Buffer {
87+
return Buffer.from(Encoder.encode(output));
88+
}
89+
90+
export async function bufferToCoseObjectsOutput(buffer: Buffer): Promise<CSLCoseObjectsOutput> {
91+
return await Decoder.decodeFirst(buffer);
92+
}
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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as CardanoSL from '@emurgo/cardano-serialization-lib-nodejs';
2+
import { Buffer } from 'buffer';
3+
4+
export const cip8TestResources = {
5+
// Test address and key pair
6+
address: {
7+
bech32:
8+
'addr_test1qpxecfjurjtcnalwy6gxcqzp09je55gvfv79hghqst8p7p6dnsn9c8yh38m7uf5sdsqyz7t9nfgscjeutw3wpqkwrursutfm7h',
9+
paymentKeyHash: '5a0bf45a9f8214d9d44e20f806116bda59e10e706574b877501391b14',
10+
},
11+
keyPair: {
12+
prv: '38e3bf2573ebbc35b65b5bc91275e0ef05cc3ebd5bb913ede29c19fe0edacc8a',
13+
pub: 'c082eb504ec79dbdaecbf9c69745f88bb7973b02db8c4c73e4faeef349e21447',
14+
},
15+
16+
// Test messages
17+
messages: {
18+
simple: 'Hello, Cardano!',
19+
utf8: 'こんにちは, Cardano!', // Test UTF-8 characters
20+
longer:
21+
'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.',
22+
},
23+
24+
// Pre-computed signatures for tests
25+
signatures: {
26+
simpleMessageSignature:
27+
'8458208458208a582000000000000000000000000000000000000000000000000000000000000000001a40158205a0bf45a9f8214d9d44e20f806116bda59e10e706574b877501391b14a1686866616c7365584073884144eb54ddc9a92cc5a5fff4bb38536c0489e75e84244c454419ebc5e636528d6c68e939a9c15d7f6d57e4da5ba68bca9b94f17ac0652d25470fac1207',
28+
},
29+
30+
// Helper function to create a test signature
31+
createTestSignature: function (payload: string): Uint8Array {
32+
// This is a dummy function that returns a fixed signature
33+
// In real tests, we'd use actual cryptographic libraries to sign
34+
const buffer = Buffer.alloc(64, 0);
35+
buffer.write(payload.slice(0, 64), 'utf8');
36+
return buffer;
37+
},
38+
39+
// Helper function to create a CSL public key from the test key pair
40+
createTestPublicKey: function (): CardanoSL.PublicKey {
41+
return CardanoSL.PublicKey.from_bytes(Buffer.from(this.keyPair.pub, 'hex'));
42+
},
43+
44+
// Pre-computed signable payloads for verification
45+
signablePayloads: {
46+
simple: 'a0', // Example CBOR hex for simple message (will be replaced with actual values)
47+
},
48+
};

0 commit comments

Comments
 (0)