|
| 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