Skip to content

Commit b7d5bc3

Browse files
authored
Merge pull request #8216 from BitGo/SC-5947
feat(irys): implement transaction builder factory for stake and pledge
2 parents c6e22fb + 45832ab commit b7d5bc3

6 files changed

Lines changed: 273 additions & 52 deletions

File tree

modules/sdk-coin-irys/src/lib/commitmentTransactionBuilder.ts

Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,24 @@ import {
1111
AnchorInfo,
1212
COMMITMENT_TX_VERSION,
1313
} from './iface';
14-
import { encodeBase58, decodeBase58ToFixed } from './utils';
14+
import { encodeBase58, decodeBase58ToFixed, hexAddressToBytes } from './utils';
15+
16+
/** String commitment type for the standard coin API (e.g. wallet-platform) */
17+
export type CommitmentTypeString = 'STAKE' | 'PLEDGE';
1518

1619
/**
1720
* Builder for Irys commitment transactions (STAKE, PLEDGE).
1821
*
1922
* Commitment transactions are NOT standard EVM transactions. They use a custom
2023
* 7-field RLP encoding with keccak256 prehash and raw ECDSA signing.
2124
*
22-
* Usage (STAKE):
23-
* const builder = new IrysCommitmentTransactionBuilder(apiUrl, chainId);
24-
* builder.setCommitmentType({ type: CommitmentTypeId.STAKE });
25-
* builder.setFee(fee);
26-
* builder.setValue(value);
27-
* builder.setSigner(signerAddress);
28-
* const result = await builder.build(); // fetches anchor, RLP encodes, returns prehash
25+
* Usage (standard coin pattern, e.g. from TransactionBuilderFactory):
26+
* const builder = factory.getCommitmentTransactionBuilder();
27+
* builder.setCommitmentType('STAKE').setSigner(hexAddress).setFee('1000').setValue('5000');
28+
* const result = await builder.build(); // { serializedTxHex, signableHex, fields, coinSpecific }
2929
*
30-
* Usage (PLEDGE):
31-
* builder.setCommitmentType({ type: CommitmentTypeId.PLEDGE, pledgeCount: 0n });
30+
* Usage (low-level, for tests or advanced use):
31+
* builder.setCommitmentType({ type: CommitmentTypeId.STAKE }).setSigner(bytes).setFee(1000n)...
3232
*/
3333
export class IrysCommitmentTransactionBuilder {
3434
private _irysApiUrl: string;
@@ -38,40 +38,62 @@ export class IrysCommitmentTransactionBuilder {
3838
private _value?: bigint;
3939
private _signer?: Uint8Array; // 20 bytes
4040
private _anchor?: Uint8Array; // 32 bytes (set during build, or manually for testing)
41+
private _pledgeCount = 0;
4142

4243
constructor(irysApiUrl: string, chainId: bigint) {
4344
this._irysApiUrl = irysApiUrl;
4445
this._chainId = chainId;
4546
}
4647

4748
/**
48-
* Set the commitment type for this transaction.
49-
* STAKE is a single-operation type.
50-
* PLEDGE requires pledgeCount.
49+
* Set the commitment type. Accepts string ('STAKE' | 'PLEDGE') for the standard API
50+
* or CommitmentType for low-level use. For PLEDGE string form, use setPledgeCount() before or after.
5151
*/
52-
setCommitmentType(type: CommitmentType): this {
53-
this._commitmentType = type;
52+
setCommitmentType(type: CommitmentType | CommitmentTypeString): this {
53+
if (type === 'STAKE') {
54+
this._commitmentType = { type: CommitmentTypeId.STAKE };
55+
} else if (type === 'PLEDGE') {
56+
this._commitmentType = { type: CommitmentTypeId.PLEDGE, pledgeCount: BigInt(this._pledgeCount) };
57+
} else {
58+
this._commitmentType = type;
59+
}
60+
return this;
61+
}
62+
63+
/** Set the transaction fee (bigint or string from Irys price API) */
64+
setFee(fee: bigint | string): this {
65+
this._fee = typeof fee === 'string' ? BigInt(fee) : fee;
66+
return this;
67+
}
68+
69+
/** Set the transaction value (bigint or string from Irys price API) */
70+
setValue(value: bigint | string): this {
71+
this._value = typeof value === 'string' ? BigInt(value) : value;
5472
return this;
5573
}
5674

57-
/** Set the transaction fee (from Irys price API) */
58-
setFee(fee: bigint): this {
59-
this._fee = fee;
75+
/** Set the signer address (hex string with or without 0x, or 20-byte Uint8Array) */
76+
setSigner(signer: Uint8Array | string): this {
77+
const bytes = typeof signer === 'string' ? hexAddressToBytes(signer) : signer;
78+
if (bytes.length !== 20) {
79+
throw new Error(`Signer must be 20 bytes, got ${bytes.length}`);
80+
}
81+
this._signer = bytes;
6082
return this;
6183
}
6284

63-
/** Set the transaction value (from Irys price API) */
64-
setValue(value: bigint): this {
65-
this._value = value;
85+
/** Set the chain ID (number or bigint, e.g. 3282 mainnet, 1270 testnet) */
86+
setChainId(chainId: bigint | number): this {
87+
this._chainId = typeof chainId === 'number' ? BigInt(chainId) : chainId;
6688
return this;
6789
}
6890

69-
/** Set the signer address (20-byte Ethereum address as Uint8Array) */
70-
setSigner(signer: Uint8Array): this {
71-
if (signer.length !== 20) {
72-
throw new Error(`Signer must be 20 bytes, got ${signer.length}`);
91+
/** Set the pledge count for PLEDGE. Call before or after setCommitmentType('PLEDGE'). */
92+
setPledgeCount(n: number): this {
93+
this._pledgeCount = n;
94+
if (this._commitmentType?.type === CommitmentTypeId.PLEDGE) {
95+
this._commitmentType = { type: CommitmentTypeId.PLEDGE, pledgeCount: BigInt(n) };
7396
}
74-
this._signer = signer;
7597
return this;
7698
}
7799

@@ -150,17 +172,11 @@ export class IrysCommitmentTransactionBuilder {
150172

151173
/**
152174
* Build the unsigned commitment transaction.
153-
*
154-
* 1. Validates all fields are set
155-
* 2. Fetches anchor from Irys API (if not manually set) -- done LAST to minimize expiration
156-
* 3. RLP encodes the 7 fields in exact order
157-
* 4. Computes keccak256 prehash
158-
* 5. Returns prehash (for HSM) and rlpEncoded (for HSM validation)
175+
* Returns the standard build result with serializedTxHex, signableHex, fields, and coinSpecific.
159176
*/
160177
async build(): Promise<CommitmentTransactionBuildResult> {
161178
this.validateFields();
162179

163-
// Fetch anchor LAST -- it expires in ~45 blocks (~9 min)
164180
if (!this._anchor) {
165181
this._anchor = await this.fetchAnchor();
166182
}
@@ -178,7 +194,12 @@ export class IrysCommitmentTransactionBuilder {
178194
const rlpEncoded = this.rlpEncode(fields);
179195
const prehash = this.computePrehash(rlpEncoded);
180196

181-
return { prehash, rlpEncoded, fields };
197+
return {
198+
serializedTxHex: Buffer.from(rlpEncoded).toString('hex'),
199+
signableHex: Buffer.from(prehash).toString('hex'),
200+
fields,
201+
coinSpecific: { keyServerPathPrefix: 'irys' },
202+
};
182203
}
183204

184205
/**

modules/sdk-coin-irys/src/lib/iface.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,20 @@ export interface AnchorInfo {
6060
}
6161

6262
/**
63-
* Result of building an unsigned commitment transaction.
64-
* Contains the prehash (for HSM signing) and the RLP-encoded bytes (for HSM validation).
63+
* Build result from the commitment transaction builder (wallet-platform / standard coin pattern).
64+
* Exposes serializedTxHex and signableHex for the signing pipeline and coinSpecific for HSM routing.
6565
*/
6666
export interface CommitmentTransactionBuildResult {
67-
/** keccak256(rlpEncoded) - 32 bytes, used as prehash for signing */
68-
prehash: Uint8Array;
69-
/** Full RLP-encoded transaction bytes - sent to HSM for validation before signing */
70-
rlpEncoded: Uint8Array;
71-
/** The transaction fields used to build this result */
67+
/** Hex string of the RLP-encoded commitment transaction */
68+
serializedTxHex: string;
69+
/** Hex string of the prehash (keccak256 of RLP-encoded tx) for signing */
70+
signableHex: string;
71+
/** The transaction fields (for broadcast payload creation if needed) */
7272
fields: CommitmentTransactionFields;
73+
/** Coin-specific data for signing pipeline (e.g. keyServerPathPrefix for HSM routing) */
74+
coinSpecific: {
75+
keyServerPathPrefix: 'irys';
76+
};
7377
}
7478

7579
/**
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './iface';
22
export * from './commitmentTransactionBuilder';
3+
export * from './transactionBuilderFactory';
34
export * from './utils';
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
2+
import { IrysCommitmentTransactionBuilder } from './commitmentTransactionBuilder';
3+
import { IRYS_MAINNET_CHAIN_ID, IRYS_TESTNET_CHAIN_ID } from './iface';
4+
5+
/** Irys API base URLs for anchor fetch (mainnet and testnet) */
6+
const IRYS_MAINNET_API_URL = 'https://mainnet.irys.xyz/v1';
7+
const IRYS_TESTNET_API_URL = 'https://testnet.irys.xyz/v1';
8+
9+
/** Coin names for mainnet vs testnet */
10+
const IRYS_MAINNET_NAME = 'irys';
11+
const IRYS_TESTNET_NAME = 'tirys';
12+
13+
/**
14+
* Minimal coin config shape used by the factory to derive API URL and chain ID.
15+
* Compatible with coins.get(coinName) from @bitgo/statics.
16+
*/
17+
export interface IrysCoinConfigLike {
18+
name: string;
19+
network: { chainId: number };
20+
}
21+
22+
function getIrysApiUrlAndChainId(coinConfig: IrysCoinConfigLike): { irysApiUrl: string; chainId: bigint } {
23+
const name = coinConfig.name;
24+
const chainIdFromNetwork = coinConfig.network?.chainId;
25+
if (name === IRYS_MAINNET_NAME) {
26+
return {
27+
irysApiUrl: IRYS_MAINNET_API_URL,
28+
chainId: chainIdFromNetwork !== undefined ? BigInt(chainIdFromNetwork) : IRYS_MAINNET_CHAIN_ID,
29+
};
30+
}
31+
if (name === IRYS_TESTNET_NAME) {
32+
return {
33+
irysApiUrl: IRYS_TESTNET_API_URL,
34+
chainId: chainIdFromNetwork !== undefined ? BigInt(chainIdFromNetwork) : IRYS_TESTNET_CHAIN_ID,
35+
};
36+
}
37+
// Fallback: use chainId from network if present, otherwise default to testnet
38+
const chainId = chainIdFromNetwork !== undefined ? BigInt(chainIdFromNetwork) : IRYS_TESTNET_CHAIN_ID;
39+
const irysApiUrl = chainId === IRYS_MAINNET_CHAIN_ID ? IRYS_MAINNET_API_URL : IRYS_TESTNET_API_URL;
40+
return { irysApiUrl, chainId };
41+
}
42+
43+
/**
44+
* Factory for Irys commitment transaction builders.
45+
* Accepts coin config (e.g. from coins.get('irys') or coins.get('tirys')) and provides
46+
* getCommitmentTransactionBuilder() for building STAKE/PLEDGE transactions with the
47+
* wallet-platform–friendly API (serializedTxHex, signableHex).
48+
*/
49+
export class TransactionBuilderFactory {
50+
private readonly _coinConfig: Readonly<CoinConfig>;
51+
private readonly _irysApiUrl: string;
52+
private readonly _chainId: bigint;
53+
54+
constructor(coinConfig: Readonly<CoinConfig>) {
55+
this._coinConfig = coinConfig;
56+
const configLike = coinConfig as unknown as IrysCoinConfigLike;
57+
const { irysApiUrl, chainId } = getIrysApiUrlAndChainId(configLike);
58+
this._irysApiUrl = irysApiUrl;
59+
this._chainId = chainId;
60+
}
61+
62+
/**
63+
* Returns a commitment transaction builder configured with the factory's
64+
* Irys API URL and chain ID. Same pattern as other coins (e.g. getTransferBuilder()).
65+
*/
66+
getCommitmentTransactionBuilder(): IrysCommitmentTransactionBuilder {
67+
return new IrysCommitmentTransactionBuilder(this._irysApiUrl, this._chainId);
68+
}
69+
}

modules/sdk-coin-irys/test/unit/commitmentTransactionBuilder.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,14 @@ describe('IrysCommitmentTransactionBuilder', function () {
166166
.setAnchor(testAnchor);
167167

168168
const result = await builder.build();
169-
result.prehash.should.be.instanceOf(Uint8Array);
170-
result.prehash.length.should.equal(32);
171-
result.rlpEncoded.should.be.instanceOf(Uint8Array);
172-
result.rlpEncoded.length.should.be.greaterThan(0);
169+
result.signableHex.should.be.a.String();
170+
result.signableHex.should.have.length(64);
171+
Buffer.from(result.signableHex, 'hex').length.should.equal(32);
172+
result.serializedTxHex.should.be.a.String();
173+
result.serializedTxHex.length.should.be.greaterThan(0);
173174
result.fields.version.should.equal(COMMITMENT_TX_VERSION);
174175
result.fields.chainId.should.equal(testChainId);
176+
result.coinSpecific.should.deepEqual({ keyServerPathPrefix: 'irys' });
175177
});
176178

177179
it('should build a PLEDGE transaction with manually set anchor', async function () {
@@ -183,7 +185,7 @@ describe('IrysCommitmentTransactionBuilder', function () {
183185
.setAnchor(testAnchor);
184186

185187
const result = await builder.build();
186-
result.prehash.length.should.equal(32);
188+
result.signableHex.should.have.length(64);
187189
result.fields.commitmentType.should.deepEqual({ type: CommitmentTypeId.PLEDGE, pledgeCount: 5n });
188190
});
189191

@@ -200,7 +202,7 @@ describe('IrysCommitmentTransactionBuilder', function () {
200202
.setSigner(testSigner);
201203

202204
const result = await builder.build();
203-
result.prehash.length.should.equal(32);
205+
result.signableHex.should.have.length(64);
204206
Buffer.from(result.fields.anchor).equals(Buffer.from(testAnchor)).should.be.true();
205207
scope.done();
206208
});
@@ -357,11 +359,11 @@ describe('IrysCommitmentTransactionBuilder', function () {
357359
const expectedRlp =
358360
'0xf84702a06c77daebc2db4e572e4f296983d1413fc10d4852e0fabfdb8323c9c69a2b85' +
359361
'9e9422f9c9f1845d9b6c22b96ef35e46e265ac4af30c018204f6648a043c33c1937564800000';
360-
const actualRlp = '0x' + Buffer.from(result.rlpEncoded).toString('hex');
362+
const actualRlp = '0x' + result.serializedTxHex;
361363
actualRlp.should.equal(expectedRlp);
362364

363365
const expectedPrehash = '0xe6fe57810c12785e3ce5fa64e2eb4da120b89ec0e469213715916abf36358d01';
364-
const actualPrehash = '0x' + Buffer.from(result.prehash).toString('hex');
366+
const actualPrehash = '0x' + result.signableHex;
365367
actualPrehash.should.equal(expectedPrehash);
366368
});
367369

@@ -385,11 +387,11 @@ describe('IrysCommitmentTransactionBuilder', function () {
385387
const expectedRlp =
386388
'0xf84802a00ae16c8476bbde2f28b2e4629d393dfe6fa7affcf0a0c4654f8246a9ba78970594' +
387389
'22f9c9f1845d9b6c22b96ef35e46e265ac4af30cc202808204f66489337fe5feaf2d180000';
388-
const actualRlp = '0x' + Buffer.from(result.rlpEncoded).toString('hex');
390+
const actualRlp = '0x' + result.serializedTxHex;
389391
actualRlp.should.equal(expectedRlp);
390392

391393
const expectedPrehash = '0xfe07c2f3c6e50d9c9e2cff57f6d7015b4528f425b6132f567e26bba745228102';
392-
const actualPrehash = '0x' + Buffer.from(result.prehash).toString('hex');
394+
const actualPrehash = '0x' + result.signableHex;
393395
actualPrehash.should.equal(expectedPrehash);
394396
});
395397
});
@@ -406,7 +408,7 @@ describe('IrysCommitmentTransactionBuilder', function () {
406408
.setAnchor(testAnchor);
407409

408410
const result = await builder.build();
409-
result.prehash.length.should.equal(32);
411+
result.signableHex.should.have.length(64);
410412
});
411413
});
412414

0 commit comments

Comments
 (0)