Skip to content

Commit

Permalink
feat(tx-builder): implement getExecutionCost and related functions
Browse files Browse the repository at this point in the history
  • Loading branch information
davidyuk committed Feb 15, 2023
1 parent 2360cd2 commit 33085c2
Show file tree
Hide file tree
Showing 7 changed files with 315 additions and 25 deletions.
19 changes: 14 additions & 5 deletions src/account/Memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ import { Tag } from '../tx/builder/constants';

const secretKeys = new WeakMap();

export function getBufferToSign(
transaction: Encoded.Transaction,
networkId: string,
innerTx: boolean,
): Uint8Array {
const prefixes = [networkId];
if (innerTx) prefixes.push('inner_tx');
const rlpBinaryTx = decode(transaction);
return concatBuffers([Buffer.from(prefixes.join('-')), hash(rlpBinaryTx)]);
}

/**
* In-memory account class
*/
Expand Down Expand Up @@ -47,16 +58,14 @@ export default class AccountMemory extends AccountBase {
}

override async signTransaction(
tx: Encoded.Transaction,
transaction: Encoded.Transaction,
{ innerTx, networkId, ...options }: { innerTx?: boolean; networkId?: string } = {},
): Promise<Encoded.Transaction> {
if (networkId == null) {
throw new ArgumentError('networkId', 'provided', networkId);
}
const prefixes = [networkId];
if (innerTx === true) prefixes.push('inner_tx');
const rlpBinaryTx = decode(tx);
const txWithNetworkId = concatBuffers([Buffer.from(prefixes.join('-')), hash(rlpBinaryTx)]);
const rlpBinaryTx = decode(transaction);
const txWithNetworkId = getBufferToSign(transaction, networkId, innerTx === true);

const signatures = [await this.sign(txWithNetworkId, options)];
return buildTx({ tag: Tag.SignedTx, encodedTx: rlpBinaryTx, signatures });
Expand Down
4 changes: 4 additions & 0 deletions src/index-browser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// TODO: avoid `export * from`
export * from './chain';
export * from './utils/crypto';
export * from './utils/keystore';
Expand All @@ -11,6 +12,9 @@ export * from './tx/builder/constants';
export {
ORACLE_TTL_TYPES, ORACLE_TTL, QUERY_TTL, RESPONSE_TTL, DRY_RUN_ACCOUNT, CallReturnType,
} from './tx/builder/schema';
export {
getExecutionCost, getExecutionCostBySignedTx, getExecutionCostUsingNode,
} from './tx/execution-cost';
export { default as getTransactionSignerAddress } from './tx/transaction-signer';
export * from './utils/amount-formatter';
export * from './utils/hd-wallet';
Expand Down
146 changes: 146 additions & 0 deletions src/tx/execution-cost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Encoded } from '../utils/encoder';
import { buildTx, buildTxHash, unpackTx } from './builder';
import { Tag } from './builder/constants';
import { verify } from '../utils/crypto';
import { getBufferToSign } from '../account/Memory';
import { IllegalArgumentError, InternalError, TransactionError } from '../utils/errors';
import Node from '../Node';
import getTransactionSignerAddress from './transaction-signer';

/**
* Calculates the cost of transaction execution
* Provides an upper cost of contract-call-related transactions because of `gasLimit`.
* Also assumes that oracle query fee is 0 unless it is provided in options.
* @param transaction - Transaction to calculate the cost of
* @param innerTx - Should be provided if transaction wrapped with Tag.PayingForTx
* @param gasUsed - Amount of gas actually used to make calculation more accurate
* @param queryFee - Oracle query fee
* @param isInitiator - Is transaction signer an initiator of state channel
*/
export function getExecutionCost(
transaction: Encoded.Transaction,
{
innerTx, gasUsed, queryFee, isInitiator,
}: {
innerTx?: 'fee-payer' | 'freeloader';
gasUsed?: number;
queryFee?: string;
isInitiator?: boolean;
} = {},
): bigint {
const params = unpackTx(transaction);
if (params.tag === Tag.SignedTx) {
throw new IllegalArgumentError('Transaction shouldn\'t be a SignedTx, use `getExecutionCostBySignedTx` instead');
}

let res = 0n;
if ('fee' in params && innerTx !== 'freeloader') {
res += BigInt(params.fee);
}
if (params.tag === Tag.NameClaimTx) {
res += BigInt(params.nameFee);
}
if (params.tag === Tag.OracleQueryTx) {
res += BigInt(params.queryFee);
}
if (params.tag === Tag.OracleResponseTx) {
res -= BigInt(queryFee ?? 0);
}
if (params.tag === Tag.ChannelSettleTx) {
if (isInitiator === true) res -= BigInt(params.initiatorAmountFinal);
if (isInitiator === false) res -= BigInt(params.responderAmountFinal);
}
if (
((params.tag === Tag.SpendTx && params.senderId !== params.recipientId)
|| params.tag === Tag.ContractCreateTx || params.tag === Tag.ContractCallTx
|| params.tag === Tag.ChannelDepositTx) && innerTx !== 'fee-payer'
) {
res += BigInt(params.amount);
}
if (params.tag === Tag.ContractCreateTx) res += BigInt(params.deposit);
if (
(params.tag === Tag.ContractCreateTx || params.tag === Tag.ContractCallTx
|| params.tag === Tag.GaAttachTx || params.tag === Tag.GaMetaTx)
&& innerTx !== 'freeloader'
) {
res += BigInt(params.gasPrice) * BigInt(gasUsed ?? params.gasLimit);
}
if (params.tag === Tag.GaMetaTx || params.tag === Tag.PayingForTx) {
res += getExecutionCost(
buildTx(params.tx.encodedTx),
params.tag === Tag.PayingForTx ? { innerTx: 'fee-payer' } : {},
);
}
return res;
}

/**
* Calculates the cost of signed transaction execution
* @param transaction - Transaction to calculate the cost of
* @param networkId - Network id used to sign the transaction
* @param options - Options
*/
export function getExecutionCostBySignedTx(
transaction: Encoded.Transaction,
networkId: string,
options?: Omit<Parameters<typeof getExecutionCost>[1], 'innerTx'>,
): bigint {
const params = unpackTx(transaction, Tag.SignedTx);
if (params.encodedTx.tag === Tag.GaMetaTx) {
return getExecutionCost(buildTx(params.encodedTx), options);
}

const tx = buildTx(params.encodedTx);
const address = getTransactionSignerAddress(tx);
const [isInnerTx, isNotInnerTx] = [true, false]
.map((f) => verify(getBufferToSign(tx, networkId, f), params.signatures[0], address));
if (!isInnerTx && !isNotInnerTx) throw new TransactionError('Can\'t verify signature');
return getExecutionCost(
buildTx(params.encodedTx),
{ ...isInnerTx && { innerTx: 'freeloader' }, ...options },
);
}

/**
* Calculates the cost of signed and not signed transaction execution using node
* @param transaction - Transaction to calculate the cost of
* @param node - Node to use
* @param isMined - Is transaction already mined or not
* @param options - Options
*/
export async function getExecutionCostUsingNode(
transaction: Encoded.Transaction,
node: Node,
{ isMined, ...options }: { isMined?: boolean } & Parameters<typeof getExecutionCost>[1] = {},
): Promise<bigint> {
let params = unpackTx(transaction);
const isSignedTx = params.tag === Tag.SignedTx;
const txHash = isSignedTx && isMined === true && buildTxHash(transaction);
if (params.tag === Tag.SignedTx) params = params.encodedTx;

// TODO: set gasUsed for PayingForTx after solving https://github.com/aeternity/aeternity/issues/4087
if (
options.gasUsed == null && txHash !== false
&& [Tag.ContractCreateTx, Tag.ContractCallTx, Tag.GaAttachTx, Tag.GaMetaTx].includes(params.tag)
) {
const { callInfo, gaInfo } = await node.getTransactionInfoByHash(txHash);
const combinedInfo = callInfo ?? gaInfo;
if (combinedInfo == null) {
throw new InternalError(`callInfo and gaInfo is not available for transaction ${txHash}`);
}
options.gasUsed = combinedInfo.gasUsed;
}

if (options.queryFee == null && Tag.OracleResponseTx === params.tag) {
options.queryFee = (await node.getOracleByPubkey(params.oracleId)).queryFee.toString();
}

if (options.isInitiator == null && Tag.ChannelSettleTx === params.tag && isMined !== true) {
const { initiatorId } = await node.getChannelByPubkey(params.channelId);
options.isInitiator = params.fromId === initiatorId;
}

return isSignedTx
? getExecutionCostBySignedTx(transaction, await node.getNetworkId(), options)
: getExecutionCost(transaction, options);
}
24 changes: 7 additions & 17 deletions src/tx/validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import BigNumber from 'bignumber.js';
import { hash, verify } from '../utils/crypto';
import { TxUnpacked } from './builder/schema.generated';
import { CtVersion, ProtocolToVmAbi } from './builder/field-types/ct-version';
Expand All @@ -11,6 +10,7 @@ import { Account } from '../apis/node';
import { genAggressiveCacheGetResponsesPolicy } from '../utils/autorest';
import { UnexpectedTsError } from '../utils/errors';
import getTransactionSignerAddress from './transaction-signer';
import { getExecutionCostUsingNode } from './execution-cost';

export interface ValidatorResult {
message: string;
Expand Down Expand Up @@ -121,24 +121,14 @@ validators.push(
checkedKeys: ['ttl'],
}];
},
(tx, { account, parentTxTypes }) => {
let extraFee = '0';
if (tx.tag === Tag.PayingForTx) {
// TODO: calculate nested tx fee more accurate
if ('fee' in tx.tx.encodedTx) {
extraFee = tx.tx.encodedTx.fee;
}
}
const cost = new BigNumber('fee' in tx ? tx.fee : 0)
.plus('nameFee' in tx ? tx.nameFee : 0)
.plus('amount' in tx ? tx.amount : 0)
.plus(extraFee)
.minus(parentTxTypes.includes(Tag.PayingForTx) && 'fee' in tx ? tx.fee : 0);
if (cost.lte(account.balance.toString())) return [];
async (tx, { account, parentTxTypes, node }) => {
if (parentTxTypes.length !== 0) return [];
const cost = await getExecutionCostUsingNode(buildTx(tx), node).catch(() => 0n);
if (cost <= account.balance) return [];
return [{
message: `Account balance ${account.balance.toString()} is not enough to execute the transaction that costs ${cost.toFixed()}`,
message: `Account balance ${account.balance} is not enough to execute the transaction that costs ${cost}`,
key: 'InsufficientBalance',
checkedKeys: ['amount', 'fee', 'nameFee'],
checkedKeys: ['amount', 'fee', 'nameFee', 'gasLimit', 'gasPrice'],
}];
},
(tx, { account }) => {
Expand Down
20 changes: 19 additions & 1 deletion test/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AeSdk, CompilerHttpNode, MemoryAccount, Node,
} from '../../src';
import '..';
import { Encoded } from '../../src/utils/encoder';

export const url = process.env.TEST_URL ?? 'http://localhost:3013';
export const compilerUrl = process.env.COMPILER_URL ?? 'http://localhost:3080';
Expand All @@ -10,11 +11,28 @@ export const networkId = process.env.TEST_NETWORK_ID ?? 'ae_devnet';
export const ignoreVersion = process.env.IGNORE_VERSION === 'true';
const genesisAccount = new MemoryAccount(secretKey);

type TransactionHandler = (tx: Encoded.Transaction) => unknown;
const transactionHandlers: TransactionHandler[] = [];

export function addTransactionHandler(cb: TransactionHandler): void {
transactionHandlers.push(cb);
}

class NodeHandleTx extends Node {
// @ts-expect-error use code generation to create node class?
override async postTransaction(
...args: Parameters<Node['postTransaction']>
): ReturnType<Node['postTransaction']> {
transactionHandlers.forEach((cb) => cb(args[0].tx as Encoded.Transaction));
return super.postTransaction(...args);
}
}

export async function getSdk(accountCount = 1): Promise<AeSdk> {
const accounts = new Array(accountCount).fill(null).map(() => MemoryAccount.generate());
const sdk = new AeSdk({
onCompiler: new CompilerHttpNode(compilerUrl, { ignoreVersion }),
nodes: [{ name: 'test', instance: new Node(url, { ignoreVersion }) }],
nodes: [{ name: 'test', instance: new NodeHandleTx(url, { ignoreVersion }) }],
accounts,
_expectedMineRate: 1000,
_microBlockCycle: 300,
Expand Down
4 changes: 2 additions & 2 deletions test/integration/txVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('Verify Transaction', () => {
const signedTx = await aeSdk.signTransaction(spendTx, { onAccount: MemoryAccount.generate() });
const errors = await verifyTransaction(signedTx, node);
expect(errors.map(({ key }) => key)).to.be.eql([
'InvalidSignature', 'ExpiredTTL', 'InsufficientBalance', 'NonceAlreadyUsed',
'InvalidSignature', 'ExpiredTTL', 'NonceAlreadyUsed',
]);
});

Expand Down Expand Up @@ -79,7 +79,7 @@ describe('Verify Transaction', () => {
});

it('verifies nameFee for nameClaim transaction', async () => {
const tx = 'tx_+KILAfhCuEAtbc38n/FH8jZHO0DkEkiLZZm8ypEzZEhbjyHtaoEYkENOE9tD+Xp6smFMou9X521oI4gkFBQGwSQaQk6Z7XMNuFr4WCACoQHkWpoidhJW2EZEega88I1P9Ktw1DFBUWwrzkr5jC5zUAORc29tZUF1Y3Rpb24uY2hhaW6HDwTrMteR15AJQ0VVyE5TcqKSstgfbGV6hg9HjghAAAAGpIPS';
const tx = 'tx_+KILAfhCuEDpKJambBPcIhemdxhFNwweD8QlInCqNQY2EHyCuP/gQZOyute/X1PxlWpbsOqvEwIqOFRIlr3kgXNhSAaIC9wEuFr4WCACoQE0ePUJYiVSDsuUDUOsQUw2XGWtPSLDefg5djhul3bfqgORc29tZUF1Y3Rpb24uY2hhaW6HDwTrMteR15AJQ0VVyE5TcqKSstgfbGV6hg9HjghAAABn0LtV';
const errors = await verifyTransaction(tx, node);
expect(errors.map(({ key }) => key)).to.include('InsufficientBalance');
});
Expand Down

0 comments on commit 33085c2

Please sign in to comment.