import assert from 'assert'; import { ChainTypes, hash, ops, PublicKey, Signature } from 'gxbjs'; const expire_in_secs = 120; const expire_in_secs_proposal = 24 * 60 * 60; const review_in_secs_committee = 24 * 60 * 60; let head_block_time_string; let committee_min_review; export class TransactionBuilder { signProvider; rpc; chain_id; ref_block_num; ref_block_prefix; expiration; operations; signatures; signer_private_keys; tr_buffer; signed; _broadcast; constructor(signProvider = null, rpc, chain_id) { if (!!signProvider) { // a function,first param is transaction instance,second is chain_id, must return array buffer like [buffer,buffer] this.signProvider = signProvider; } if (!!rpc) { this.rpc = rpc; } if (!!chain_id) { this.chain_id = chain_id; } this.ref_block_num = 0; this.ref_block_prefix = 0; this.expiration = 0; this.operations = []; this.signatures = []; this.signer_private_keys = []; // semi-private method bindings this._broadcast = _broadcast.bind(this); } /** * @arg {string} name - like "transfer" * @arg {object} operation - JSON matchching the operation's format */ add_type_operation(name, operation) { this.add_operation(this.get_type_operation(name, operation)); return; } /** Typically this is called automatically just prior to signing. Once finalized this transaction can not be changed. */ finalize(useRemoteSerializer = false) { return new Promise((resolve) => { if (this.tr_buffer) { throw new Error('already finalized'); } resolve( this.rpc.query('get_objects', [['2.1.0']]).then((r) => { head_block_time_string = r[0].time; if (this.expiration === 0) { this.expiration = base_expiration_sec() + expire_in_secs; } const last_irreversible_block_num = r[0].last_irreversible_block_num; return this.rpc.query('get_block', [last_irreversible_block_num]).then((block) => { this.ref_block_num = last_irreversible_block_num & 0xffff; this.ref_block_prefix = Buffer.from(block.block_id, 'hex').readUInt32LE(4); const iterable = this.operations; for (let i = 0, op; i < iterable.length; i++) { op = iterable[i]; if (op[1].finalize) { op[1].finalize(); } } if (useRemoteSerializer) { console.log('object to serialize', this.toObject()); return this.rpc.query('get_transaction_hex', [this.toObject()]).then((hex) => { this.tr_buffer = Buffer.from(hex.substring(0, hex.length - 2), 'hex'); }); } this.tr_buffer = ops.transaction.toBuffer(this); return this.tr_buffer; }); }) ); }); } /** @return {string} hex transaction ID */ id() { if (!this.tr_buffer) { throw new Error('not finalized'); } return hash.sha256(this.tr_buffer).toString('hex').substring(0, 40); } /** * Typically one will use {@link this.add_type_operation} instead. * @arg {array} operation - [operation_id, operation] */ add_operation(operation) { if (this.tr_buffer) { throw new Error('already finalized'); } assert(operation, 'operation'); if (!Array.isArray(operation)) { throw new Error('Expecting array [operation_id, operation]'); } this.operations.push(operation); return; } get_type_operation(name, operation) { if (this.tr_buffer) { throw new Error('already finalized'); } assert(name, 'name'); assert(operation, 'operation'); const _type = ops[name]; assert(_type, `Unknown operation ${name}`); const operation_id = ChainTypes.operations[_type.operation_name]; if (operation_id === undefined) { throw new Error(`unknown operation: ${_type.operation_name}`); } if (!operation.fee) { operation.fee = { amount: 0, asset_id: 1 }; } if (name === 'proposal_create') { /* * Proposals involving the committee account require a review * period to be set, look for them here */ let requiresReview = false; let extraReview = 0; operation.proposed_ops.forEach((op) => { const COMMITTE_ACCOUNT = 0; let key; switch (op.op[0]) { case 0: // transfer key = 'from'; break; case 6: // account_update case 17: // asset_settle key = 'account'; break; case 10: // asset_create case 11: // asset_update case 12: // asset_update_bitasset case 13: // asset_update_feed_producers case 14: // asset_issue case 18: // asset_global_settle case 43: // asset_claim_fees key = 'issuer'; break; case 15: // asset_reserve key = 'payer'; break; case 16: // asset_fund_fee_pool key = 'from_account'; break; case 22: // proposal_create case 23: // proposal_update case 24: // proposal_delete key = 'fee_paying_account'; break; case 31: // committee_member_update_global_parameters requiresReview = true; extraReview = 60 * 60 * 24 * 13; // Make the review period 2 weeks total break; } if (key in op.op[1] && op.op[1][key] === COMMITTE_ACCOUNT) { requiresReview = true; } }); // tslint:disable-next-line: no-unused-expression operation.expiration_time || (operation.expiration_time = base_expiration_sec() + expire_in_secs_proposal); if (requiresReview) { operation.review_period_seconds = extraReview + Math.max(committee_min_review, review_in_secs_committee); /* * Expiration time must be at least equal to * now + review_period_seconds, so we add one hour to make sure */ operation.expiration_time += 60 * 60 + extraReview; } } const operation_instance = _type.fromObject(operation); return [operation_id, operation_instance]; } /* optional: fetch the current head block */ update_head_block() { return Promise.all([this.rpc.query('get_objects', [['2.0.0']]), this.rpc.query('get_objects', [['2.1.0']])]).then((res) => { const [g, r] = res; head_block_time_string = r[0].time; committee_min_review = g[0].parameters.committee_proposal_review_period; }); } /** optional: there is a deafult expiration */ set_expire_seconds(sec) { if (this.tr_buffer) { throw new Error('already finalized'); } return (this.expiration = base_expiration_sec() + sec); } /* Wraps this transaction in a proposal_create transaction */ propose(proposal_create_options) { if (this.tr_buffer) { throw new Error('already finalized'); } if (!this.operations.length) { throw new Error('add operation first'); } assert(proposal_create_options, 'proposal_create_options'); assert(proposal_create_options.fee_paying_account, 'proposal_create_options.fee_paying_account'); const proposed_ops = this.operations.map((op) => { return { op }; }); this.operations = []; this.signatures = []; this.signer_private_keys = []; proposal_create_options.proposed_ops = proposed_ops; this.add_type_operation('proposal_create', proposal_create_options); return this; } /** optional: the fees can be obtained from the witness node */ set_required_fees(asset_id) { let fee_pool; if (this.tr_buffer) { throw new Error('already finalized'); } if (!this.operations.length) { throw new Error('add operations first'); } const operations = []; for (let i = 0, op; i < this.operations.length; i++) { op = this.operations[i]; operations.push(ops.operation.toObject(op)); } if (!asset_id) { const op1_fee = operations[0][1].fee; if (op1_fee && op1_fee.asset_id !== null) { asset_id = op1_fee.asset_id; } else { asset_id = '1.3.1'; } } const promises = [this.rpc.query('get_required_fees', [operations, asset_id])]; // let feeAssetPromise = null; if (asset_id !== '1.3.1') { // This handles the fallback to paying fees in BTS if the fee pool is empty. promises.push(this.rpc.query('get_required_fees', [operations, '1.3.1'])); promises.push(this.rpc.query('get_objects', [[asset_id]])); } return Promise.all(promises).then((results) => { // tslint:disable-next-line: prefer-const let [fees, coreFees, asset] = results; asset = asset ? asset[0] : null; const dynamicPromise = asset_id !== '1.3.1' && asset ? this.rpc.query('get_objects', [[asset.dynamic_asset_data_id]]) : new Promise((resolve) => { resolve(); }); return dynamicPromise.then((dynamicObject) => { if (asset_id !== '1.3.1') { fee_pool = dynamicObject ? dynamicObject[0].fee_pool : 0; let totalFees = 0; for (let j = 0, fee; j < coreFees.length; j++) { fee = coreFees[j]; totalFees += fee.amount; } if (totalFees > parseInt(fee_pool, 10)) { fees = coreFees; asset_id = '1.3.1'; } } // Proposed transactions need to be flattened const flat_assets = []; const flatten = (obj) => { if (Array.isArray(obj)) { for (let k = 0, item; k < obj.length; k++) { item = obj[k]; flatten(item); } } else { flat_assets.push(obj); } return; }; flatten(fees); let asset_index = 0; const set_fee = (operation) => { if ( !operation.fee || operation.fee.amount === 0 || (operation.fee.amount.toString && operation.fee.amount.toString() === '0') // Long ) { operation.fee = flat_assets[asset_index]; // console.log("new operation.fee", operation.fee) } else { // console.log("old operation.fee", operation.fee) } asset_index++; if (operation.proposed_ops) { const result = []; // tslint:disable-next-line: prefer-for-of for (let y = 0; y < operation.proposed_ops.length; y++) result.push(set_fee(operation.proposed_ops[y].op[1])); return result; } }; // tslint:disable-next-line: prefer-for-of for (let i = 0; i < this.operations.length; i++) { set_fee(this.operations[i][1]); } }); }); } add_signer(private_key, public_key = private_key.toPublicKey()) { assert(private_key.d, 'required PrivateKey object'); if (this.signed) { throw new Error('already signed'); } if (!public_key.Q) { public_key = PublicKey.fromPublicKeyString(public_key); } // prevent duplicates const spHex = private_key.toHex(); for (const sp of this.signer_private_keys) { if (sp[0].toHex() === spHex) return; } this.signer_private_keys.push([private_key, public_key]); } sign() { return new Promise(async (resolve, reject) => { if (!this.tr_buffer) { throw new Error('not finalized'); } if (this.signed) { throw new Error('already signed'); } if (!this.signProvider) { if (!this.signer_private_keys.length) { throw new Error('Transaction was not signed. Do you have a private key? [no_signers]'); } const end = this.signer_private_keys.length; for (let i = 0; 0 < end ? i < end : i > end; 0 < end ? i++ : i++) { const [private_key, public_key] = this.signer_private_keys[i]; const sig = Signature.signBuffer(Buffer.concat([Buffer.from(this.chain_id, 'hex'), this.tr_buffer]), private_key, public_key); this.signatures.push(sig.toBuffer()); } } else { try { this.signatures = await this.signProvider(this, this.chain_id); } catch (err) { reject(err); return; } } this.signer_private_keys = []; this.signed = true; resolve(); }); } serialize() { return ops.signed_transaction.toObject(this); } toObject() { return ops.signed_transaction.toObject(this); } broadcast() { if (this.tr_buffer) { return this._broadcast(); } else { return this.finalize().then(() => { return this._broadcast(); }); } } } const base_expiration_sec = () => { const head_block_sec = Math.ceil(getHeadBlockDate().getTime() / 1000); const now_sec = Math.ceil(Date.now() / 1000); // The head block time should be updated every 3 seconds. If it isn't // then help the transaction to expire (use head_block_sec) if (now_sec - head_block_sec > 30) { return head_block_sec; } // If the user's clock is very far behind, use the head block time. return Math.max(now_sec, head_block_sec); }; function _broadcast() { return new Promise(async (resolve, reject) => { try { if (!this.signed) { await this.sign(); } } catch (err) { reject(err); return; } if (!this.tr_buffer) { throw new Error('not finalized'); } if (!this.signatures.length) { throw new Error('not signed'); } if (!this.operations.length) { throw new Error('no operations'); } const tr_object = ops.signed_transaction.toObject(this); resolve( this.rpc.broadcast(tr_object).catch((error) => { let message = error.message; if (!message) { message = ''; } throw new Error(message); // throw new Error(( // message + "\n" + // "gxb-crypto " + // " digest " + hash.sha256(this.tr_buffer).toString("hex") + // " transaction " + this.tr_buffer.toString("hex") + // " " + JSON.stringify(tr_object))); }) ); }); } function getHeadBlockDate() { return timeStringToDate(head_block_time_string); } function timeStringToDate(time_string) { if (!time_string) return new Date('1970-01-01T00:00:00.000Z'); if (!/Z$/.test(time_string)) // does not end in Z // https://github.com/cryptonomex/graphene/issues/368 time_string = time_string + 'Z'; return new Date(time_string); }