From 090780bbde5f1610b370a802b2ee75348256204a Mon Sep 17 00:00:00 2001 From: Abhijit Madhusudan Date: Wed, 25 Feb 2026 11:19:18 +0530 Subject: [PATCH] feat(sdk-coin-irys): add irys coin module with commitment transaction builder - Add statics configuration for irys - Add module scaffolding (package.json, tsconfig, mocharc) - Add IrysCommitmentTransactionBuilder for STAKE and PLEDGE commitment transactions using 7-field RLP encoding with keccak256 prehash for HSM signing - Add shared interfaces, base58/hex address utilities - Add comprehensive unit tests covering RLP encoding, prehash computation, broadcast payload generation, input validation, anchor fetching, and known-good testnet test vectors Ticket: SC-5391 --- modules/sdk-coin-irys/.mocharc.yml | 8 + modules/sdk-coin-irys/.npmignore | 14 + modules/sdk-coin-irys/package.json | 58 +++ modules/sdk-coin-irys/src/index.ts | 1 + .../src/lib/commitmentTransactionBuilder.ts | 253 ++++++++++ modules/sdk-coin-irys/src/lib/iface.ts | 85 ++++ modules/sdk-coin-irys/src/lib/index.ts | 3 + modules/sdk-coin-irys/src/lib/utils.ts | 46 ++ .../test/unit/commitmentTransactionBuilder.ts | 444 ++++++++++++++++++ modules/sdk-coin-irys/tsconfig.json | 13 + modules/statics/src/allCoinsAndTokens.ts | 5 +- tsconfig.packages.json | 3 + 12 files changed, 929 insertions(+), 4 deletions(-) create mode 100644 modules/sdk-coin-irys/.mocharc.yml create mode 100644 modules/sdk-coin-irys/.npmignore create mode 100644 modules/sdk-coin-irys/package.json create mode 100644 modules/sdk-coin-irys/src/index.ts create mode 100644 modules/sdk-coin-irys/src/lib/commitmentTransactionBuilder.ts create mode 100644 modules/sdk-coin-irys/src/lib/iface.ts create mode 100644 modules/sdk-coin-irys/src/lib/index.ts create mode 100644 modules/sdk-coin-irys/src/lib/utils.ts create mode 100644 modules/sdk-coin-irys/test/unit/commitmentTransactionBuilder.ts create mode 100644 modules/sdk-coin-irys/tsconfig.json diff --git a/modules/sdk-coin-irys/.mocharc.yml b/modules/sdk-coin-irys/.mocharc.yml new file mode 100644 index 0000000000..f499ec0a83 --- /dev/null +++ b/modules/sdk-coin-irys/.mocharc.yml @@ -0,0 +1,8 @@ +require: 'tsx' +timeout: '60000' +reporter: 'min' +reporter-option: + - 'cdn=true' + - 'json=false' +exit: true +spec: ['test/unit/**/*.ts'] diff --git a/modules/sdk-coin-irys/.npmignore b/modules/sdk-coin-irys/.npmignore new file mode 100644 index 0000000000..d5fb3a098c --- /dev/null +++ b/modules/sdk-coin-irys/.npmignore @@ -0,0 +1,14 @@ +!dist/ +dist/test/ +dist/tsconfig.tsbuildinfo +.idea/ +.prettierrc.yml +tsconfig.json +src/ +test/ +scripts/ +.nyc_output +CODEOWNERS +node_modules/ +.prettierignore +.mocharc.js diff --git a/modules/sdk-coin-irys/package.json b/modules/sdk-coin-irys/package.json new file mode 100644 index 0000000000..0733b5c318 --- /dev/null +++ b/modules/sdk-coin-irys/package.json @@ -0,0 +1,58 @@ +{ + "name": "@bitgo/sdk-coin-irys", + "version": "1.0.0", + "description": "BitGo SDK coin library for Irys", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "scripts": { + "build": "yarn tsc --build --incremental --verbose .", + "fmt": "prettier --write .", + "check-fmt": "prettier --check '**/*.{ts,js,json}'", + "clean": "rm -r ./dist", + "lint": "eslint --quiet .", + "prepare": "npm run build", + "test": "npm run coverage", + "coverage": "nyc -- npm run unit-test", + "unit-test": "mocha" + }, + "author": "BitGo SDK Team ", + "license": "MIT", + "engines": { + "node": ">=20 <25" + }, + "repository": { + "type": "git", + "url": "https://github.com/BitGo/BitGoJS.git", + "directory": "modules/sdk-coin-irys" + }, + "lint-staged": { + "*.{js,ts}": [ + "yarn prettier --write", + "yarn eslint --fix" + ] + }, + "publishConfig": { + "access": "public" + }, + "nyc": { + "extension": [ + ".ts" + ] + }, + "dependencies": { + "@ethereumjs/rlp": "^4.0.0", + "bs58": "^4.0.1", + "ethers": "^5.1.3", + "superagent": "^9.0.1" + }, + "devDependencies": { + "@types/sinon": "^10.0.11", + "@types/superagent": "^8.1.0", + "nock": "^13.3.1", + "should": "^13.2.3", + "sinon": "^13.0.1" + }, + "files": [ + "dist" + ] +} diff --git a/modules/sdk-coin-irys/src/index.ts b/modules/sdk-coin-irys/src/index.ts new file mode 100644 index 0000000000..f41a696fd2 --- /dev/null +++ b/modules/sdk-coin-irys/src/index.ts @@ -0,0 +1 @@ +export * from './lib'; diff --git a/modules/sdk-coin-irys/src/lib/commitmentTransactionBuilder.ts b/modules/sdk-coin-irys/src/lib/commitmentTransactionBuilder.ts new file mode 100644 index 0000000000..4785117c0b --- /dev/null +++ b/modules/sdk-coin-irys/src/lib/commitmentTransactionBuilder.ts @@ -0,0 +1,253 @@ +import { RLP } from '@ethereumjs/rlp'; +import { arrayify, keccak256 } from 'ethers/lib/utils'; +import request from 'superagent'; +import { + CommitmentType, + CommitmentTypeId, + CommitmentTransactionFields, + CommitmentTransactionBuildResult, + EncodedSignedCommitmentTransaction, + EncodedCommitmentType, + AnchorInfo, + COMMITMENT_TX_VERSION, +} from './iface'; +import { encodeBase58, decodeBase58ToFixed } from './utils'; + +/** + * Builder for Irys commitment transactions (STAKE, PLEDGE). + * + * Commitment transactions are NOT standard EVM transactions. They use a custom + * 7-field RLP encoding with keccak256 prehash and raw ECDSA signing. + * + * Usage (STAKE): + * const builder = new IrysCommitmentTransactionBuilder(apiUrl, chainId); + * builder.setCommitmentType({ type: CommitmentTypeId.STAKE }); + * builder.setFee(fee); + * builder.setValue(value); + * builder.setSigner(signerAddress); + * const result = await builder.build(); // fetches anchor, RLP encodes, returns prehash + * + * Usage (PLEDGE): + * builder.setCommitmentType({ type: CommitmentTypeId.PLEDGE, pledgeCount: 0n }); + */ +export class IrysCommitmentTransactionBuilder { + private _irysApiUrl: string; + private _chainId: bigint; + private _commitmentType?: CommitmentType; + private _fee?: bigint; + private _value?: bigint; + private _signer?: Uint8Array; // 20 bytes + private _anchor?: Uint8Array; // 32 bytes (set during build, or manually for testing) + + constructor(irysApiUrl: string, chainId: bigint) { + this._irysApiUrl = irysApiUrl; + this._chainId = chainId; + } + + /** + * Set the commitment type for this transaction. + * STAKE is a single-operation type. + * PLEDGE requires pledgeCount. + */ + setCommitmentType(type: CommitmentType): this { + this._commitmentType = type; + return this; + } + + /** Set the transaction fee (from Irys price API) */ + setFee(fee: bigint): this { + this._fee = fee; + return this; + } + + /** Set the transaction value (from Irys price API) */ + setValue(value: bigint): this { + this._value = value; + return this; + } + + /** Set the signer address (20-byte Ethereum address as Uint8Array) */ + setSigner(signer: Uint8Array): this { + if (signer.length !== 20) { + throw new Error(`Signer must be 20 bytes, got ${signer.length}`); + } + this._signer = signer; + return this; + } + + /** + * Manually set the anchor (for testing). If not set, build() fetches it from the API. + */ + setAnchor(anchor: Uint8Array): this { + if (anchor.length !== 32) { + throw new Error(`Anchor must be 32 bytes, got ${anchor.length}`); + } + this._anchor = anchor; + return this; + } + + /** + * Fetch the current anchor (block hash) from the Irys API. + * This is the nonce equivalent for commitment transactions. + * Called during build() if anchor hasn't been manually set. + */ + async fetchAnchor(): Promise { + const response = await request.get(`${this._irysApiUrl}/anchor`).accept('json'); + + if (!response.ok) { + throw new Error(`Failed to fetch anchor: ${response.status} ${response.text}`); + } + + const anchorInfo: AnchorInfo = response.body; + return decodeBase58ToFixed(anchorInfo.blockHash, 32); + } + + /** + * Encode the commitment type for RLP signing. + * + * CRITICAL: STAKE (1) MUST be a flat number, NOT an array. + * PLEDGE MUST be a nested array. The Irys Rust decoder + * rejects non-canonical encoding. + * + * Reference: irys-js/src/common/commitmentTransaction.ts lines 180-199 + */ + static encodeCommitmentTypeForSigning( + type: CommitmentType + ): number | bigint | Uint8Array | (number | bigint | Uint8Array)[] { + switch (type.type) { + case CommitmentTypeId.STAKE: + return CommitmentTypeId.STAKE; // flat number + case CommitmentTypeId.PLEDGE: + return [CommitmentTypeId.PLEDGE, type.pledgeCount]; // nested array + default: + throw new Error(`Unknown commitment type`); + } + } + + /** + * Encode the commitment type for the JSON broadcast payload. + */ + static encodeCommitmentTypeForBroadcast(type: CommitmentType): EncodedCommitmentType { + switch (type.type) { + case CommitmentTypeId.STAKE: + return { type: 'stake' }; + case CommitmentTypeId.PLEDGE: + return { type: 'pledge', pledgeCountBeforeExecuting: type.pledgeCount.toString() }; + default: + throw new Error(`Unknown commitment type`); + } + } + + /** + * Validate that all required fields are set before building. + */ + private validateFields(): void { + if (!this._commitmentType) throw new Error('Commitment type is required'); + if (this._fee === undefined) throw new Error('Fee is required'); + if (this._value === undefined) throw new Error('Value is required'); + if (!this._signer) throw new Error('Signer is required'); + } + + /** + * Build the unsigned commitment transaction. + * + * 1. Validates all fields are set + * 2. Fetches anchor from Irys API (if not manually set) -- done LAST to minimize expiration + * 3. RLP encodes the 7 fields in exact order + * 4. Computes keccak256 prehash + * 5. Returns prehash (for HSM) and rlpEncoded (for HSM validation) + */ + async build(): Promise { + this.validateFields(); + + // Fetch anchor LAST -- it expires in ~45 blocks (~9 min) + if (!this._anchor) { + this._anchor = await this.fetchAnchor(); + } + + const fields: CommitmentTransactionFields = { + version: COMMITMENT_TX_VERSION, + anchor: this._anchor, + signer: this._signer!, + commitmentType: this._commitmentType!, + chainId: this._chainId, + fee: this._fee!, + value: this._value!, + }; + + const rlpEncoded = this.rlpEncode(fields); + const prehash = this.computePrehash(rlpEncoded); + + return { prehash, rlpEncoded, fields }; + } + + /** + * RLP encode the 7 commitment transaction fields. + * + * Field order is CRITICAL and must match the Irys protocol exactly: + * [version, anchor, signer, commitmentType, chainId, fee, value] + * + * Reference: irys-js/src/common/commitmentTransaction.ts lines 405-419 + */ + rlpEncode(fields: CommitmentTransactionFields): Uint8Array { + const rlpFields = [ + fields.version, + fields.anchor, + fields.signer, + IrysCommitmentTransactionBuilder.encodeCommitmentTypeForSigning(fields.commitmentType), + fields.chainId, + fields.fee, + fields.value, + ]; + + return RLP.encode(rlpFields as any); + } + + /** + * Compute the prehash: keccak256(rlpEncoded). + * Returns 32 bytes. + */ + computePrehash(rlpEncoded: Uint8Array): Uint8Array { + const hash = keccak256(rlpEncoded); + return arrayify(hash); + } + + /** + * Compute the transaction ID from a signature. + * txId = base58(keccak256(signature)) + * + * @param signature - 65-byte raw ECDSA signature (r || s || v) + */ + static computeTxId(signature: Uint8Array): string { + if (signature.length !== 65) { + throw new Error(`Signature must be 65 bytes, got ${signature.length}`); + } + const idBytes = arrayify(keccak256(signature)); + return encodeBase58(idBytes); + } + + /** + * Create the JSON broadcast payload from a signed transaction. + * + * @param fields - The transaction fields used to build the transaction + * @param signature - 65-byte raw ECDSA signature + * @returns JSON payload ready for POST /v1/commitment-tx + */ + static createBroadcastPayload( + fields: CommitmentTransactionFields, + signature: Uint8Array + ): EncodedSignedCommitmentTransaction { + const txId = IrysCommitmentTransactionBuilder.computeTxId(signature); + return { + version: fields.version, + anchor: encodeBase58(fields.anchor), + signer: encodeBase58(fields.signer), + commitmentType: IrysCommitmentTransactionBuilder.encodeCommitmentTypeForBroadcast(fields.commitmentType), + chainId: fields.chainId.toString(), + fee: fields.fee.toString(), + value: fields.value.toString(), + id: txId, + signature: encodeBase58(signature), + }; + } +} diff --git a/modules/sdk-coin-irys/src/lib/iface.ts b/modules/sdk-coin-irys/src/lib/iface.ts new file mode 100644 index 0000000000..122f01742d --- /dev/null +++ b/modules/sdk-coin-irys/src/lib/iface.ts @@ -0,0 +1,85 @@ +/** + * Commitment type IDs matching the Irys protocol. + * STAKE is a flat value in RLP encoding. + * PLEDGE is encoded as a nested array. + */ +export enum CommitmentTypeId { + STAKE = 1, + PLEDGE = 2, +} + +export type StakeCommitmentType = { type: CommitmentTypeId.STAKE }; +export type PledgeCommitmentType = { type: CommitmentTypeId.PLEDGE; pledgeCount: bigint }; + +export type CommitmentType = StakeCommitmentType | PledgeCommitmentType; + +/** Version 2 is the current commitment transaction version */ +export const COMMITMENT_TX_VERSION = 2; + +/** Irys chain IDs */ +export const IRYS_MAINNET_CHAIN_ID = 3282n; +export const IRYS_TESTNET_CHAIN_ID = 1270n; + +/** + * The 7 fields of an unsigned commitment transaction, + * in the exact order required for RLP encoding. + */ +export interface CommitmentTransactionFields { + version: number; // 1 byte, always 2 (V2) + anchor: Uint8Array; // 32 bytes (block hash from /v1/anchor) + signer: Uint8Array; // 20 bytes (Ethereum address) + commitmentType: CommitmentType; + chainId: bigint; + fee: bigint; + value: bigint; +} + +/** + * JSON payload for broadcasting a signed commitment transaction + * via POST /v1/commitment-tx + */ +export interface EncodedSignedCommitmentTransaction { + version: number; + anchor: string; // base58 + signer: string; // base58 + commitmentType: EncodedCommitmentType; + chainId: string; // decimal string + fee: string; // decimal string + value: string; // decimal string + id: string; // base58(keccak256(signature)) + signature: string; // base58(65-byte raw signature) +} + +export type EncodedCommitmentType = { type: 'stake' } | { type: 'pledge'; pledgeCountBeforeExecuting: string }; + +/** + * Anchor info returned by GET /v1/anchor + */ +export interface AnchorInfo { + blockHash: string; // base58-encoded 32-byte block hash +} + +/** + * Result of building an unsigned commitment transaction. + * Contains the prehash (for HSM signing) and the RLP-encoded bytes (for HSM validation). + */ +export interface CommitmentTransactionBuildResult { + /** keccak256(rlpEncoded) - 32 bytes, used as prehash for signing */ + prehash: Uint8Array; + /** Full RLP-encoded transaction bytes - sent to HSM for validation before signing */ + rlpEncoded: Uint8Array; + /** The transaction fields used to build this result */ + fields: CommitmentTransactionFields; +} + +/** + * Result after signing. Contains everything needed for broadcast. + */ +export interface SignedCommitmentTransactionResult { + /** Transaction ID: base58(keccak256(signature)) */ + txId: string; + /** 65-byte raw ECDSA signature (r || s || v) */ + signature: Uint8Array; + /** JSON payload ready for POST /v1/commitment-tx */ + broadcastPayload: EncodedSignedCommitmentTransaction; +} diff --git a/modules/sdk-coin-irys/src/lib/index.ts b/modules/sdk-coin-irys/src/lib/index.ts new file mode 100644 index 0000000000..f8fa6a5c50 --- /dev/null +++ b/modules/sdk-coin-irys/src/lib/index.ts @@ -0,0 +1,3 @@ +export * from './iface'; +export * from './commitmentTransactionBuilder'; +export * from './utils'; diff --git a/modules/sdk-coin-irys/src/lib/utils.ts b/modules/sdk-coin-irys/src/lib/utils.ts new file mode 100644 index 0000000000..ca7eae5aea --- /dev/null +++ b/modules/sdk-coin-irys/src/lib/utils.ts @@ -0,0 +1,46 @@ +import bs58 from 'bs58'; + +/** + * Encode a byte array to Base58 string. + * Used for encoding addresses, anchors, and signatures for the Irys API. + */ +export function encodeBase58(bytes: Uint8Array): string { + return bs58.encode(Buffer.from(bytes)); +} + +/** + * Decode a Base58 string to a byte array. + */ +export function decodeBase58(str: string): Uint8Array { + return Uint8Array.from(bs58.decode(str)); +} + +/** + * Decode a Base58 string to a fixed-length byte array. + * Throws if decoded length doesn't match expected length. + */ +export function decodeBase58ToFixed(str: string, expectedLength: number): Uint8Array { + const decoded = bs58.decode(str); + if (decoded.length !== expectedLength) { + throw new Error(`Expected ${expectedLength} bytes, got ${decoded.length}`); + } + return Uint8Array.from(decoded); +} + +/** + * Convert a hex address (0x-prefixed or not) to a 20-byte Uint8Array. + */ +export function hexAddressToBytes(hexAddress: string): Uint8Array { + const cleaned = hexAddress.startsWith('0x') ? hexAddress.slice(2) : hexAddress; + if (cleaned.length !== 40) { + throw new Error(`Invalid hex address length: ${cleaned.length}`); + } + return Uint8Array.from(Buffer.from(cleaned, 'hex')); +} + +/** + * Convert a hex address to Base58 (for Irys API calls). + */ +export function hexAddressToBase58(hexAddress: string): string { + return encodeBase58(hexAddressToBytes(hexAddress)); +} diff --git a/modules/sdk-coin-irys/test/unit/commitmentTransactionBuilder.ts b/modules/sdk-coin-irys/test/unit/commitmentTransactionBuilder.ts new file mode 100644 index 0000000000..a55d33dc9b --- /dev/null +++ b/modules/sdk-coin-irys/test/unit/commitmentTransactionBuilder.ts @@ -0,0 +1,444 @@ +import should from 'should'; +import * as sinon from 'sinon'; +import nock from 'nock'; +import { IrysCommitmentTransactionBuilder } from '../../src/lib/commitmentTransactionBuilder'; +import { CommitmentTypeId, COMMITMENT_TX_VERSION, IRYS_TESTNET_CHAIN_ID } from '../../src/lib/iface'; +import { encodeBase58, decodeBase58 } from '../../src/lib/utils'; + +describe('IrysCommitmentTransactionBuilder', function () { + // Common test fixtures + const testAnchor = new Uint8Array(32).fill(1); // 32 bytes of 0x01 + const testSigner = new Uint8Array(20).fill(2); // 20 bytes of 0x02 + const testChainId = IRYS_TESTNET_CHAIN_ID; // 1270n + const testFee = 1000n; + const testValue = 5000n; + const testApiUrl = 'https://testnet-node1.irys.xyz/v1'; + + let builder: IrysCommitmentTransactionBuilder; + + beforeEach(function () { + builder = new IrysCommitmentTransactionBuilder(testApiUrl, testChainId); + }); + + afterEach(function () { + sinon.restore(); + nock.cleanAll(); + }); + + // === Commitment Type Encoding Tests === + + describe('encodeCommitmentTypeForSigning', function () { + it('should encode STAKE as a flat number (not array)', function () { + const result = IrysCommitmentTransactionBuilder.encodeCommitmentTypeForSigning({ + type: CommitmentTypeId.STAKE, + }); + result.should.equal(1); + Array.isArray(result).should.be.false(); + }); + + it('should encode PLEDGE as a nested array [type, pledgeCount]', function () { + const result = IrysCommitmentTransactionBuilder.encodeCommitmentTypeForSigning({ + type: CommitmentTypeId.PLEDGE, + pledgeCount: 42n, + }); + Array.isArray(result).should.be.true(); + (result as any[]).length.should.equal(2); + (result as any[])[0].should.equal(2); + (result as any[])[1].should.equal(42n); + }); + }); + + // === Commitment Type Broadcast Encoding Tests === + + describe('encodeCommitmentTypeForBroadcast', function () { + it('should encode STAKE as { type: "stake" }', function () { + const result = IrysCommitmentTransactionBuilder.encodeCommitmentTypeForBroadcast({ + type: CommitmentTypeId.STAKE, + }); + should.deepEqual(result, { type: 'stake' }); + }); + + it('should encode PLEDGE with pledgeCountBeforeExecuting', function () { + const result = IrysCommitmentTransactionBuilder.encodeCommitmentTypeForBroadcast({ + type: CommitmentTypeId.PLEDGE, + pledgeCount: 42n, + }); + should.deepEqual(result, { type: 'pledge', pledgeCountBeforeExecuting: '42' }); + }); + }); + + // === RLP Encoding Tests === + + describe('rlpEncode', function () { + it('should RLP encode a STAKE transaction with correct field order', function () { + const fields = { + version: COMMITMENT_TX_VERSION, + anchor: testAnchor, + signer: testSigner, + commitmentType: { type: CommitmentTypeId.STAKE as const }, + chainId: testChainId, + fee: testFee, + value: testValue, + }; + + const encoded = builder.rlpEncode(fields); + encoded.should.be.instanceOf(Uint8Array); + encoded.length.should.be.greaterThan(0); + + // The encoded output should be deterministic + const encoded2 = builder.rlpEncode(fields); + Buffer.from(encoded).equals(Buffer.from(encoded2)).should.be.true(); + }); + + it('should RLP encode a PLEDGE transaction with nested array commitment type', function () { + const fields = { + version: COMMITMENT_TX_VERSION, + anchor: testAnchor, + signer: testSigner, + commitmentType: { type: CommitmentTypeId.PLEDGE as const, pledgeCount: 42n }, + chainId: testChainId, + fee: testFee, + value: testValue, + }; + + const encoded = builder.rlpEncode(fields); + encoded.should.be.instanceOf(Uint8Array); + encoded.length.should.be.greaterThan(0); + }); + + it('should produce different encodings for STAKE vs PLEDGE', function () { + const stakeFields = { + version: COMMITMENT_TX_VERSION, + anchor: testAnchor, + signer: testSigner, + commitmentType: { type: CommitmentTypeId.STAKE as const }, + chainId: testChainId, + fee: testFee, + value: testValue, + }; + + const pledgeFields = { + ...stakeFields, + commitmentType: { type: CommitmentTypeId.PLEDGE as const, pledgeCount: 1n }, + }; + + const stakeEncoded = builder.rlpEncode(stakeFields); + const pledgeEncoded = builder.rlpEncode(pledgeFields); + Buffer.from(stakeEncoded).equals(Buffer.from(pledgeEncoded)).should.be.false(); + }); + }); + + // === Prehash Tests === + + describe('computePrehash', function () { + it('should return a 32-byte keccak256 hash', function () { + const rlpEncoded = new Uint8Array([0xc0]); // minimal RLP + const prehash = builder.computePrehash(rlpEncoded); + prehash.should.be.instanceOf(Uint8Array); + prehash.length.should.equal(32); + }); + + it('should produce deterministic output', function () { + const rlpEncoded = new Uint8Array([0xc8, 0x02, 0x01, 0x02, 0x03]); + const hash1 = builder.computePrehash(rlpEncoded); + const hash2 = builder.computePrehash(rlpEncoded); + Buffer.from(hash1).equals(Buffer.from(hash2)).should.be.true(); + }); + + it('should produce different hashes for different inputs', function () { + const input1 = new Uint8Array([0x01]); + const input2 = new Uint8Array([0x02]); + const hash1 = builder.computePrehash(input1); + const hash2 = builder.computePrehash(input2); + Buffer.from(hash1).equals(Buffer.from(hash2)).should.be.false(); + }); + }); + + // === Build Tests === + + describe('build', function () { + it('should build a STAKE transaction with manually set anchor', async function () { + builder + .setCommitmentType({ type: CommitmentTypeId.STAKE }) + .setFee(testFee) + .setValue(testValue) + .setSigner(testSigner) + .setAnchor(testAnchor); + + const result = await builder.build(); + result.prehash.should.be.instanceOf(Uint8Array); + result.prehash.length.should.equal(32); + result.rlpEncoded.should.be.instanceOf(Uint8Array); + result.rlpEncoded.length.should.be.greaterThan(0); + result.fields.version.should.equal(COMMITMENT_TX_VERSION); + result.fields.chainId.should.equal(testChainId); + }); + + it('should build a PLEDGE transaction with manually set anchor', async function () { + builder + .setCommitmentType({ type: CommitmentTypeId.PLEDGE, pledgeCount: 5n }) + .setFee(testFee) + .setValue(testValue) + .setSigner(testSigner) + .setAnchor(testAnchor); + + const result = await builder.build(); + result.prehash.length.should.equal(32); + result.fields.commitmentType.should.deepEqual({ type: CommitmentTypeId.PLEDGE, pledgeCount: 5n }); + }); + + it('should fetch anchor from API when not manually set', async function () { + const mockAnchorBase58 = encodeBase58(testAnchor); + const scope = nock('https://testnet-node1.irys.xyz') + .get('/v1/anchor') + .reply(200, { blockHash: mockAnchorBase58 }); + + builder + .setCommitmentType({ type: CommitmentTypeId.STAKE }) + .setFee(testFee) + .setValue(testValue) + .setSigner(testSigner); + + const result = await builder.build(); + result.prehash.length.should.equal(32); + Buffer.from(result.fields.anchor).equals(Buffer.from(testAnchor)).should.be.true(); + scope.done(); + }); + + it('should throw if commitment type is not set', async function () { + builder.setFee(testFee).setValue(testValue).setSigner(testSigner).setAnchor(testAnchor); + + await builder.build().should.be.rejectedWith('Commitment type is required'); + }); + + it('should throw if fee is not set', async function () { + builder + .setCommitmentType({ type: CommitmentTypeId.STAKE }) + .setValue(testValue) + .setSigner(testSigner) + .setAnchor(testAnchor); + + await builder.build().should.be.rejectedWith('Fee is required'); + }); + + it('should throw if value is not set', async function () { + builder + .setCommitmentType({ type: CommitmentTypeId.STAKE }) + .setFee(testFee) + .setSigner(testSigner) + .setAnchor(testAnchor); + + await builder.build().should.be.rejectedWith('Value is required'); + }); + + it('should throw if signer is not set', async function () { + builder + .setCommitmentType({ type: CommitmentTypeId.STAKE }) + .setFee(testFee) + .setValue(testValue) + .setAnchor(testAnchor); + + await builder.build().should.be.rejectedWith('Signer is required'); + }); + }); + + // === Validation Tests === + + describe('input validation', function () { + it('should reject signer with wrong length', function () { + (() => builder.setSigner(new Uint8Array(19))).should.throw(/Signer must be 20 bytes/); + (() => builder.setSigner(new Uint8Array(21))).should.throw(/Signer must be 20 bytes/); + }); + + it('should reject anchor with wrong length', function () { + (() => builder.setAnchor(new Uint8Array(31))).should.throw(/Anchor must be 32 bytes/); + (() => builder.setAnchor(new Uint8Array(33))).should.throw(/Anchor must be 32 bytes/); + }); + }); + + // === Transaction ID Tests === + + describe('computeTxId', function () { + it('should compute base58(keccak256(signature))', function () { + const fakeSignature = new Uint8Array(65).fill(0xab); + const txId = IrysCommitmentTransactionBuilder.computeTxId(fakeSignature); + txId.should.be.a.String(); + txId.length.should.be.greaterThan(0); + }); + + it('should produce deterministic output', function () { + const sig = new Uint8Array(65).fill(0xcd); + const id1 = IrysCommitmentTransactionBuilder.computeTxId(sig); + const id2 = IrysCommitmentTransactionBuilder.computeTxId(sig); + id1.should.equal(id2); + }); + + it('should reject non-65-byte signatures', function () { + (() => IrysCommitmentTransactionBuilder.computeTxId(new Uint8Array(64))).should.throw( + /Signature must be 65 bytes/ + ); + }); + }); + + // === Broadcast Payload Tests === + + describe('createBroadcastPayload', function () { + it('should create valid JSON payload for STAKE', function () { + const fields = { + version: COMMITMENT_TX_VERSION, + anchor: testAnchor, + signer: testSigner, + commitmentType: { type: CommitmentTypeId.STAKE as const }, + chainId: testChainId, + fee: testFee, + value: testValue, + }; + const signature = new Uint8Array(65).fill(0xab); + + const payload = IrysCommitmentTransactionBuilder.createBroadcastPayload(fields, signature); + + payload.version.should.equal(2); + payload.anchor.should.be.a.String(); + payload.signer.should.be.a.String(); + should.deepEqual(payload.commitmentType, { type: 'stake' }); + payload.chainId.should.equal('1270'); + payload.fee.should.equal('1000'); + payload.value.should.equal('5000'); + payload.id.should.be.a.String(); + payload.signature.should.be.a.String(); + }); + + it('should create valid JSON payload for PLEDGE with pledgeCountBeforeExecuting', function () { + const fields = { + version: COMMITMENT_TX_VERSION, + anchor: testAnchor, + signer: testSigner, + commitmentType: { type: CommitmentTypeId.PLEDGE as const, pledgeCount: 42n }, + chainId: testChainId, + fee: testFee, + value: testValue, + }; + const signature = new Uint8Array(65).fill(0xcd); + + const payload = IrysCommitmentTransactionBuilder.createBroadcastPayload(fields, signature); + + should.deepEqual(payload.commitmentType, { type: 'pledge', pledgeCountBeforeExecuting: '42' }); + }); + }); + + // === Known-Good Test Vectors (from successful testnet transactions) === + // + // These vectors were captured from actual STAKE and PLEDGE transactions + // submitted to the Irys testnet using coins-sandbox/eth/irys/stake.ts. + // They verify our RLP encoding + prehash match the protocol exactly. + + describe('known-good test vectors', function () { + const testnetSigner = '0x22f9C9f1845D9b6C22b96Ef35E46E265aC4Af30c'; + const testnetSignerBytes = Uint8Array.from(Buffer.from(testnetSigner.slice(2), 'hex')); + const testnetChainId = 1270n; + + it('should match known STAKE RLP encoding and prehash', async function () { + // From stake_pledge.txt - successful STAKE transaction + // TX ID: 4XhUTrkhxr1RmUQbXUVRwbNZ6pKEYrAVo5ymdMY41fS5 + const anchorBase58 = '8JR2rD5DejnM2NuVSqqGa68dfye6ZKruT9rdh2Cn4B8y'; + const anchorBytes = decodeBase58(anchorBase58); + + const stakeBuilder = new IrysCommitmentTransactionBuilder(testApiUrl, testnetChainId); + + stakeBuilder + .setCommitmentType({ type: CommitmentTypeId.STAKE }) + .setFee(100n) + .setValue(20000000000000000000000n) // 20000 IRYS + .setSigner(testnetSignerBytes) + .setAnchor(anchorBytes); + + const result = await stakeBuilder.build(); + + const expectedRlp = + '0xf84702a06c77daebc2db4e572e4f296983d1413fc10d4852e0fabfdb8323c9c69a2b85' + + '9e9422f9c9f1845d9b6c22b96ef35e46e265ac4af30c018204f6648a043c33c1937564800000'; + const actualRlp = '0x' + Buffer.from(result.rlpEncoded).toString('hex'); + actualRlp.should.equal(expectedRlp); + + const expectedPrehash = '0xe6fe57810c12785e3ce5fa64e2eb4da120b89ec0e469213715916abf36358d01'; + const actualPrehash = '0x' + Buffer.from(result.prehash).toString('hex'); + actualPrehash.should.equal(expectedPrehash); + }); + + it('should match known PLEDGE RLP encoding and prehash', async function () { + // From stake_pledge.txt - successful PLEDGE transaction + // TX ID: EsdiesC58S8eeY1SHM5jTfy84zYxFMUdKF89Ytr6PyNb + const anchorBase58 = 'jUShJPUACW4bxUSvZji65Q96MaqKDh7AFFALKnkapBn'; + const anchorBytes = decodeBase58(anchorBase58); + + const pledgeBuilder = new IrysCommitmentTransactionBuilder(testApiUrl, testnetChainId); + + pledgeBuilder + .setCommitmentType({ type: CommitmentTypeId.PLEDGE, pledgeCount: 0n }) + .setFee(100n) + .setValue(950000000000000000000n) // 950 IRYS + .setSigner(testnetSignerBytes) + .setAnchor(anchorBytes); + + const result = await pledgeBuilder.build(); + + const expectedRlp = + '0xf84802a00ae16c8476bbde2f28b2e4629d393dfe6fa7affcf0a0c4654f8246a9ba78970594' + + '22f9c9f1845d9b6c22b96ef35e46e265ac4af30cc202808204f66489337fe5feaf2d180000'; + const actualRlp = '0x' + Buffer.from(result.rlpEncoded).toString('hex'); + actualRlp.should.equal(expectedRlp); + + const expectedPrehash = '0xfe07c2f3c6e50d9c9e2cff57f6d7015b4528f425b6132f567e26bba745228102'; + const actualPrehash = '0x' + Buffer.from(result.prehash).toString('hex'); + actualPrehash.should.equal(expectedPrehash); + }); + }); + + // === Edge Case Tests === + + describe('edge cases', function () { + it('should handle zero fee and value', async function () { + builder + .setCommitmentType({ type: CommitmentTypeId.STAKE }) + .setFee(0n) + .setValue(0n) + .setSigner(testSigner) + .setAnchor(testAnchor); + + const result = await builder.build(); + result.prehash.length.should.equal(32); + }); + }); + + // === Anchor Fetch Tests === + + describe('fetchAnchor', function () { + it('should fetch and decode base58 anchor from API', async function () { + const mockAnchorBase58 = encodeBase58(testAnchor); + const scope = nock('https://testnet-node1.irys.xyz') + .get('/v1/anchor') + .reply(200, { blockHash: mockAnchorBase58 }); + + const anchor = await builder.fetchAnchor(); + anchor.should.be.instanceOf(Uint8Array); + anchor.length.should.equal(32); + Buffer.from(anchor).equals(Buffer.from(testAnchor)).should.be.true(); + scope.done(); + }); + + it('should throw on non-200 response', async function () { + const scope = nock('https://testnet-node1.irys.xyz').get('/v1/anchor').reply(500, 'Internal Server Error'); + + await builder.fetchAnchor().should.be.rejectedWith(/Internal Server Error/); + scope.done(); + }); + + it('should throw if anchor decodes to wrong length', async function () { + const shortAnchor = encodeBase58(new Uint8Array(16)); // 16 bytes instead of 32 + const scope = nock('https://testnet-node1.irys.xyz').get('/v1/anchor').reply(200, { blockHash: shortAnchor }); + + await builder.fetchAnchor().should.be.rejectedWith(/Expected 32 bytes/); + scope.done(); + }); + }); +}); diff --git a/modules/sdk-coin-irys/tsconfig.json b/modules/sdk-coin-irys/tsconfig.json new file mode 100644 index 0000000000..ead84fd1ab --- /dev/null +++ b/modules/sdk-coin-irys/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./", + "strictPropertyInitialization": false, + "esModuleInterop": true, + "typeRoots": ["../../types", "./node_modules/@types", "../../node_modules/@types"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules"], + "references": [] +} diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index ef75e92ea9..fafa6894c3 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -1959,8 +1959,6 @@ export const allCoinsAndTokens = [ BaseUnit.ETH, [ ...EVM_FEATURES, - CoinFeature.SHARED_EVM_SIGNING, - CoinFeature.SHARED_EVM_SDK, CoinFeature.EVM_COMPATIBLE_IMS, CoinFeature.EVM_COMPATIBLE_UI, CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, @@ -1977,12 +1975,11 @@ export const allCoinsAndTokens = [ BaseUnit.ETH, [ ...EVM_FEATURES, - CoinFeature.SHARED_EVM_SIGNING, - CoinFeature.SHARED_EVM_SDK, CoinFeature.EVM_COMPATIBLE_IMS, CoinFeature.EVM_COMPATIBLE_UI, CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.STAKING, ] ), account( diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 968aa19595..bae46e2044 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -160,6 +160,9 @@ { "path": "./modules/sdk-coin-iota" }, + { + "path": "./modules/sdk-coin-irys" + }, { "path": "./modules/sdk-coin-islm" },