-
Notifications
You must be signed in to change notification settings - Fork 302
feat(sdk-coin-irys): add commitment transaction builder #8115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
+497
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| require: 'tsx' | ||
| timeout: '60000' | ||
| reporter: 'min' | ||
| reporter-option: | ||
| - 'cdn=true' | ||
| - 'json=false' | ||
| exit: true | ||
| spec: ['test/unit/**/*.ts'] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <sdkteam@bitgo.com>", | ||
| "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" | ||
| ] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './lib'; |
253 changes: 253 additions & 0 deletions
253
modules/sdk-coin-irys/src/lib/commitmentTransactionBuilder.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Uint8Array> { | ||
| 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<CommitmentTransactionBuildResult> { | ||
| 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), | ||
| }; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shall we call this as
noncefor simplicity and then send this field asanchorwhile building?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't prefer we do that since this is not a sequential counter like nonce and it would be better to keep it as per chain semantics