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..f3c2c12c97 --- /dev/null +++ b/modules/sdk-coin-irys/package.json @@ -0,0 +1,61 @@ +{ + "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": { + "@bitgo/abstract-eth": "^24.20.0", + "@bitgo/sdk-core": "^36.30.0", + "@bitgo/statics": "^58.24.0", + "@ethereumjs/rlp": "^4.0.0", + "bs58": "^4.0.1", + "ethers": "^5.1.3", + "keccak": "^3.0.3", + "superagent": "^9.0.1" + }, + "devDependencies": { + "@bitgo/sdk-api": "^1.73.4", + "@bitgo/sdk-test": "^9.1.25", + "@types/keccak": "^3.0.5", + "@types/superagent": "^8.1.0" + }, + "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/tsconfig.json b/modules/sdk-coin-irys/tsconfig.json new file mode 100644 index 0000000000..8cc7ae1497 --- /dev/null +++ b/modules/sdk-coin-irys/tsconfig.json @@ -0,0 +1,26 @@ +{ + "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": [ + { + "path": "../abstract-eth" + }, + { + "path": "../sdk-core" + }, + { + "path": "../statics" + }, + { + "path": "../sdk-test" + } + ] +} diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index ca2279669c..89aa7e0a05 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -1891,8 +1891,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, @@ -1909,12 +1907,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(