Skip to content

Commit bc374d6

Browse files
authored
Merge pull request #8116 from BitGo/SC-5391
feat(sdk-coin-irys): Irys commitment transaction support
2 parents f31db64 + 1e6f529 commit bc374d6

File tree

12 files changed

+927
-5
lines changed

12 files changed

+927
-5
lines changed

modules/sdk-coin-irys/.mocharc.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
require: 'tsx'
2+
timeout: '60000'
3+
reporter: 'min'
4+
reporter-option:
5+
- 'cdn=true'
6+
- 'json=false'
7+
exit: true
8+
spec: ['test/unit/**/*.ts']

modules/sdk-coin-irys/.npmignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
!dist/
2+
dist/test/
3+
dist/tsconfig.tsbuildinfo
4+
.idea/
5+
.prettierrc.yml
6+
tsconfig.json
7+
src/
8+
test/
9+
scripts/
10+
.nyc_output
11+
CODEOWNERS
12+
node_modules/
13+
.prettierignore
14+
.mocharc.js

modules/sdk-coin-irys/package.json

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"name": "@bitgo/sdk-coin-irys",
3+
"version": "1.0.0",
4+
"description": "BitGo SDK coin library for Irys",
5+
"main": "./dist/src/index.js",
6+
"types": "./dist/src/index.d.ts",
7+
"scripts": {
8+
"build": "yarn tsc --build --incremental --verbose .",
9+
"fmt": "prettier --write .",
10+
"check-fmt": "prettier --check '**/*.{ts,js,json}'",
11+
"clean": "rm -r ./dist",
12+
"lint": "eslint --quiet .",
13+
"prepare": "npm run build",
14+
"test": "npm run coverage",
15+
"coverage": "nyc -- npm run unit-test",
16+
"unit-test": "mocha"
17+
},
18+
"author": "BitGo SDK Team <sdkteam@bitgo.com>",
19+
"license": "MIT",
20+
"engines": {
21+
"node": ">=20 <25"
22+
},
23+
"repository": {
24+
"type": "git",
25+
"url": "https://github.com/BitGo/BitGoJS.git",
26+
"directory": "modules/sdk-coin-irys"
27+
},
28+
"lint-staged": {
29+
"*.{js,ts}": [
30+
"yarn prettier --write",
31+
"yarn eslint --fix"
32+
]
33+
},
34+
"publishConfig": {
35+
"access": "public"
36+
},
37+
"nyc": {
38+
"extension": [
39+
".ts"
40+
]
41+
},
42+
"dependencies": {
43+
"@ethereumjs/rlp": "^4.0.0",
44+
"bs58": "^4.0.1",
45+
"ethers": "^5.1.3",
46+
"superagent": "^9.0.1"
47+
},
48+
"devDependencies": {
49+
"@types/sinon": "^10.0.11",
50+
"@types/superagent": "^8.1.0",
51+
"nock": "^13.3.1",
52+
"should": "^13.2.3",
53+
"sinon": "^13.0.1"
54+
},
55+
"files": [
56+
"dist"
57+
]
58+
}

modules/sdk-coin-irys/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './lib';
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { RLP } from '@ethereumjs/rlp';
2+
import { arrayify, keccak256 } from 'ethers/lib/utils';
3+
import request from 'superagent';
4+
import {
5+
CommitmentType,
6+
CommitmentTypeId,
7+
CommitmentTransactionFields,
8+
CommitmentTransactionBuildResult,
9+
EncodedSignedCommitmentTransaction,
10+
EncodedCommitmentType,
11+
AnchorInfo,
12+
COMMITMENT_TX_VERSION,
13+
} from './iface';
14+
import { encodeBase58, decodeBase58ToFixed } from './utils';
15+
16+
/**
17+
* Builder for Irys commitment transactions (STAKE, PLEDGE).
18+
*
19+
* Commitment transactions are NOT standard EVM transactions. They use a custom
20+
* 7-field RLP encoding with keccak256 prehash and raw ECDSA signing.
21+
*
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
29+
*
30+
* Usage (PLEDGE):
31+
* builder.setCommitmentType({ type: CommitmentTypeId.PLEDGE, pledgeCount: 0n });
32+
*/
33+
export class IrysCommitmentTransactionBuilder {
34+
private _irysApiUrl: string;
35+
private _chainId: bigint;
36+
private _commitmentType?: CommitmentType;
37+
private _fee?: bigint;
38+
private _value?: bigint;
39+
private _signer?: Uint8Array; // 20 bytes
40+
private _anchor?: Uint8Array; // 32 bytes (set during build, or manually for testing)
41+
42+
constructor(irysApiUrl: string, chainId: bigint) {
43+
this._irysApiUrl = irysApiUrl;
44+
this._chainId = chainId;
45+
}
46+
47+
/**
48+
* Set the commitment type for this transaction.
49+
* STAKE is a single-operation type.
50+
* PLEDGE requires pledgeCount.
51+
*/
52+
setCommitmentType(type: CommitmentType): this {
53+
this._commitmentType = type;
54+
return this;
55+
}
56+
57+
/** Set the transaction fee (from Irys price API) */
58+
setFee(fee: bigint): this {
59+
this._fee = fee;
60+
return this;
61+
}
62+
63+
/** Set the transaction value (from Irys price API) */
64+
setValue(value: bigint): this {
65+
this._value = value;
66+
return this;
67+
}
68+
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}`);
73+
}
74+
this._signer = signer;
75+
return this;
76+
}
77+
78+
/**
79+
* Manually set the anchor (for testing). If not set, build() fetches it from the API.
80+
*/
81+
setAnchor(anchor: Uint8Array): this {
82+
if (anchor.length !== 32) {
83+
throw new Error(`Anchor must be 32 bytes, got ${anchor.length}`);
84+
}
85+
this._anchor = anchor;
86+
return this;
87+
}
88+
89+
/**
90+
* Fetch the current anchor (block hash) from the Irys API.
91+
* This is the nonce equivalent for commitment transactions.
92+
* Called during build() if anchor hasn't been manually set.
93+
*/
94+
async fetchAnchor(): Promise<Uint8Array> {
95+
const response = await request.get(`${this._irysApiUrl}/anchor`).accept('json');
96+
97+
if (!response.ok) {
98+
throw new Error(`Failed to fetch anchor: ${response.status} ${response.text}`);
99+
}
100+
101+
const anchorInfo: AnchorInfo = response.body;
102+
return decodeBase58ToFixed(anchorInfo.blockHash, 32);
103+
}
104+
105+
/**
106+
* Encode the commitment type for RLP signing.
107+
*
108+
* CRITICAL: STAKE (1) MUST be a flat number, NOT an array.
109+
* PLEDGE MUST be a nested array. The Irys Rust decoder
110+
* rejects non-canonical encoding.
111+
*
112+
* Reference: irys-js/src/common/commitmentTransaction.ts lines 180-199
113+
*/
114+
static encodeCommitmentTypeForSigning(
115+
type: CommitmentType
116+
): number | bigint | Uint8Array | (number | bigint | Uint8Array)[] {
117+
switch (type.type) {
118+
case CommitmentTypeId.STAKE:
119+
return CommitmentTypeId.STAKE; // flat number
120+
case CommitmentTypeId.PLEDGE:
121+
return [CommitmentTypeId.PLEDGE, type.pledgeCount]; // nested array
122+
default:
123+
throw new Error(`Unknown commitment type`);
124+
}
125+
}
126+
127+
/**
128+
* Encode the commitment type for the JSON broadcast payload.
129+
*/
130+
static encodeCommitmentTypeForBroadcast(type: CommitmentType): EncodedCommitmentType {
131+
switch (type.type) {
132+
case CommitmentTypeId.STAKE:
133+
return { type: 'stake' };
134+
case CommitmentTypeId.PLEDGE:
135+
return { type: 'pledge', pledgeCountBeforeExecuting: type.pledgeCount.toString() };
136+
default:
137+
throw new Error(`Unknown commitment type`);
138+
}
139+
}
140+
141+
/**
142+
* Validate that all required fields are set before building.
143+
*/
144+
private validateFields(): void {
145+
if (!this._commitmentType) throw new Error('Commitment type is required');
146+
if (this._fee === undefined) throw new Error('Fee is required');
147+
if (this._value === undefined) throw new Error('Value is required');
148+
if (!this._signer) throw new Error('Signer is required');
149+
}
150+
151+
/**
152+
* 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)
159+
*/
160+
async build(): Promise<CommitmentTransactionBuildResult> {
161+
this.validateFields();
162+
163+
// Fetch anchor LAST -- it expires in ~45 blocks (~9 min)
164+
if (!this._anchor) {
165+
this._anchor = await this.fetchAnchor();
166+
}
167+
168+
const fields: CommitmentTransactionFields = {
169+
version: COMMITMENT_TX_VERSION,
170+
anchor: this._anchor,
171+
signer: this._signer!,
172+
commitmentType: this._commitmentType!,
173+
chainId: this._chainId,
174+
fee: this._fee!,
175+
value: this._value!,
176+
};
177+
178+
const rlpEncoded = this.rlpEncode(fields);
179+
const prehash = this.computePrehash(rlpEncoded);
180+
181+
return { prehash, rlpEncoded, fields };
182+
}
183+
184+
/**
185+
* RLP encode the 7 commitment transaction fields.
186+
*
187+
* Field order is CRITICAL and must match the Irys protocol exactly:
188+
* [version, anchor, signer, commitmentType, chainId, fee, value]
189+
*
190+
* Reference: irys-js/src/common/commitmentTransaction.ts lines 405-419
191+
*/
192+
rlpEncode(fields: CommitmentTransactionFields): Uint8Array {
193+
const rlpFields = [
194+
fields.version,
195+
fields.anchor,
196+
fields.signer,
197+
IrysCommitmentTransactionBuilder.encodeCommitmentTypeForSigning(fields.commitmentType),
198+
fields.chainId,
199+
fields.fee,
200+
fields.value,
201+
];
202+
203+
return RLP.encode(rlpFields as any);
204+
}
205+
206+
/**
207+
* Compute the prehash: keccak256(rlpEncoded).
208+
* Returns 32 bytes.
209+
*/
210+
computePrehash(rlpEncoded: Uint8Array): Uint8Array {
211+
const hash = keccak256(rlpEncoded);
212+
return arrayify(hash);
213+
}
214+
215+
/**
216+
* Compute the transaction ID from a signature.
217+
* txId = base58(keccak256(signature))
218+
*
219+
* @param signature - 65-byte raw ECDSA signature (r || s || v)
220+
*/
221+
static computeTxId(signature: Uint8Array): string {
222+
if (signature.length !== 65) {
223+
throw new Error(`Signature must be 65 bytes, got ${signature.length}`);
224+
}
225+
const idBytes = arrayify(keccak256(signature));
226+
return encodeBase58(idBytes);
227+
}
228+
229+
/**
230+
* Create the JSON broadcast payload from a signed transaction.
231+
*
232+
* @param fields - The transaction fields used to build the transaction
233+
* @param signature - 65-byte raw ECDSA signature
234+
* @returns JSON payload ready for POST /v1/commitment-tx
235+
*/
236+
static createBroadcastPayload(
237+
fields: CommitmentTransactionFields,
238+
signature: Uint8Array
239+
): EncodedSignedCommitmentTransaction {
240+
const txId = IrysCommitmentTransactionBuilder.computeTxId(signature);
241+
return {
242+
version: fields.version,
243+
anchor: encodeBase58(fields.anchor),
244+
signer: encodeBase58(fields.signer),
245+
commitmentType: IrysCommitmentTransactionBuilder.encodeCommitmentTypeForBroadcast(fields.commitmentType),
246+
chainId: fields.chainId.toString(),
247+
fee: fields.fee.toString(),
248+
value: fields.value.toString(),
249+
id: txId,
250+
signature: encodeBase58(signature),
251+
};
252+
}
253+
}

0 commit comments

Comments
 (0)