Skip to content

Commit

Permalink
fix: TransactionRequest funding (#1531)
Browse files Browse the repository at this point in the history
  • Loading branch information
Torres-ssf authored Dec 20, 2023
1 parent 137ed8a commit 53dafb1
Show file tree
Hide file tree
Showing 7 changed files with 306 additions and 111 deletions.
6 changes: 6 additions & 0 deletions .changeset/sour-apples-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@fuel-ts/providers": minor
"@fuel-ts/wallet": minor
---

fix Account fund and Provider fundWithFakeUtxos
187 changes: 187 additions & 0 deletions packages/fuel-gauge/src/funding-transaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { seedTestWallet } from '@fuel-ts/wallet/test-utils';
import type { Account, CoinTransactionRequestInput } from 'fuels';
import {
FUEL_NETWORK_URL,
Provider,
BaseAssetId,
ScriptTransactionRequest,
Wallet,
bn,
} from 'fuels';

describe(__filename, () => {
let mainWallet: Account;
let provider: Provider;
beforeAll(async () => {
provider = await Provider.create(FUEL_NETWORK_URL);
mainWallet = Wallet.generate({ provider });
await seedTestWallet(mainWallet, [[500_000, BaseAssetId]]);
});

const fundingTxWithMultipleUTXOs = async ({
account,
totalAmount,
splitIn,
}: {
account: Account;
totalAmount: number;
splitIn: number;
}) => {
const request = new ScriptTransactionRequest({
gasLimit: 1_000,
gasPrice: bn(10),
});

for (let i = 0; i < splitIn; i++) {
request.addCoinOutput(account.address, totalAmount / splitIn, BaseAssetId);
}

const resources = await mainWallet.getResourcesToSpend([[totalAmount + 2_000, BaseAssetId]]);
request.addResources(resources);

const tx = await mainWallet.sendTransaction(request);
await tx.waitForResult();
};

it('should successfully fund a transaction request when it is not fully funded', async () => {
const sender = Wallet.generate({ provider });
const receiver = Wallet.generate({ provider });

// 1500 splitted in 5 = 5 UTXOs of 300 each
await fundingTxWithMultipleUTXOs({
account: sender,
totalAmount: 1500,
splitIn: 5,
});

// this will return one UTXO of 300, not enought to pay for the TX fees
const lowResources = await sender.getResourcesToSpend([[100, BaseAssetId]]);

// confirm we only fetched 1 UTXO from the expected amount
expect(lowResources.length).toBe(1);
expect(lowResources[0].amount.toNumber()).toBe(300);

const request = new ScriptTransactionRequest({
gasLimit: 1_000,
gasPrice: bn(10),
});

const amountToTransfer = 300;
request.addCoinOutput(receiver.address, amountToTransfer, BaseAssetId);

request.addResources(lowResources);

const { maxFee, requiredQuantities } = await provider.getTransactionCost(request);

// TX request already does NOT carries enough resources, it needs to be funded
expect(request.inputs.length).toBe(1);
expect(bn((<CoinTransactionRequestInput>request.inputs[0]).amount).toNumber()).toBe(300);
expect(maxFee.gt(300)).toBeTruthy();

const getResourcesToSpendSpy = jest.spyOn(sender, 'getResourcesToSpend');

await sender.fund(request, requiredQuantities, maxFee);

const tx = await sender.sendTransaction(request);

await tx.waitForResult();

// fund method should have been called to fetch the remaining UTXOs
expect(getResourcesToSpendSpy).toHaveBeenCalledTimes(1);

const receiverBalance = await receiver.getBalance(BaseAssetId);

expect(receiverBalance.toNumber()).toBe(amountToTransfer);
});

it('should not fund a transaction request when it is already funded', async () => {
const sender = Wallet.generate({ provider });
const receiver = Wallet.generate({ provider });

// 2000 splitted in 2 = 2 UTXOs of 1000 each
await fundingTxWithMultipleUTXOs({
account: sender,
totalAmount: 2000,
splitIn: 2,
});

// sender has 2 UTXOs for 1000 each, so it has enough resources to spend 1000 of BaseAssetId
const enoughtResources = await sender.getResourcesToSpend([[100, BaseAssetId]]);

// confirm we only fetched 1 UTXO from the expected amount
expect(enoughtResources.length).toBe(1);
expect(enoughtResources[0].amount.toNumber()).toBe(1000);

const request = new ScriptTransactionRequest({
gasLimit: 1_000,
gasPrice: bn(10),
});

const amountToTransfer = 100;

request.addCoinOutput(receiver.address, amountToTransfer, BaseAssetId);
request.addResources(enoughtResources);

const { maxFee, requiredQuantities } = await provider.getTransactionCost(request);

// TX request already carries enough resources, it does not need to be funded
expect(request.inputs.length).toBe(1);
expect(bn((<CoinTransactionRequestInput>request.inputs[0]).amount).toNumber()).toBe(1000);
expect(maxFee.lt(1000)).toBeTruthy();

const getResourcesToSpendSpy = jest.spyOn(sender, 'getResourcesToSpend');

await sender.fund(request, requiredQuantities, maxFee);

const tx = await sender.sendTransaction(request);

await tx.waitForResult();

// fund should not have been called since the TX request was already funded
expect(getResourcesToSpendSpy).toHaveBeenCalledTimes(0);

const receiverBalance = await receiver.getBalance(BaseAssetId);

expect(receiverBalance.toNumber()).toBe(amountToTransfer);
});

it('should fully fund a transaction when it is has no funds yet', async () => {
const sender = Wallet.generate({ provider });
const receiver = Wallet.generate({ provider });

// 5000 splitted in 10 = 10 UTXOs of 500 each
await fundingTxWithMultipleUTXOs({
account: sender,
totalAmount: 5000,
splitIn: 10,
});

const request = new ScriptTransactionRequest({
gasLimit: 1_000,
gasPrice: bn(10),
});

const amountToTransfer = 1000;
request.addCoinOutput(receiver.address, amountToTransfer, BaseAssetId);

const { maxFee, requiredQuantities } = await provider.getTransactionCost(request);

// TX request does NOT carry any resources, it needs to be funded
expect(request.inputs.length).toBe(0);

const getResourcesToSpendSpy = jest.spyOn(sender, 'getResourcesToSpend');

await sender.fund(request, requiredQuantities, maxFee);

const tx = await sender.sendTransaction(request);

await tx.waitForResult();

// fund method should have been called to fetch UTXOs
expect(getResourcesToSpendSpy).toHaveBeenCalledTimes(1);

const receiverBalance = await receiver.getBalance(BaseAssetId);

expect(receiverBalance.toNumber()).toBe(amountToTransfer);
});
});
2 changes: 1 addition & 1 deletion packages/providers/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,7 @@ export default class Provider {
excludedIds?: ExcludeResourcesOption
): Promise<Resource[]> {
const excludeInput = {
messages: excludedIds?.messages?.map((id) => hexlify(id)) || [],
messages: excludedIds?.messages?.map((nonce) => hexlify(nonce)) || [],
utxos: excludedIds?.utxos?.map((id) => hexlify(id)) || [],
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import { Address } from '@fuel-ts/address';
import { BaseAssetId } from '@fuel-ts/address/configs';
import { bn, toNumber } from '@fuel-ts/math';
import { InputType, OutputType, TransactionType } from '@fuel-ts/transactions';

import {
MOCK_REQUEST_CHANGE_OUTPUT,
MOCK_REQUEST_COIN_INPUT,
MOCK_REQUEST_COIN_OUTPUT,
MOCK_REQUEST_CONTRACT_INPUT,
MOCK_REQUEST_CONTRACT_OUTPUT,
MOCK_REQUEST_MESSAGE_INPUT,
} from '../../test/fixtures/inputs-and-outputs';
import { TransactionType } from '@fuel-ts/transactions';

import type { CoinQuantity } from '../coin-quantity';

import type { CoinTransactionRequestInput } from './input';
Expand Down Expand Up @@ -128,65 +120,5 @@ describe('TransactionRequest', () => {
expect(inputB?.amount).toEqual(bn(300));
expect(inputBase?.amount).toEqual(bn(500));
});

it('should add BaseAssetId with amount bn(1) if not present in quantities', () => {
const transactionRequest = new ScriptTransactionRequest();

const quantities: CoinQuantity[] = [{ assetId: assetIdB, amount: bn(10) }];

transactionRequest.fundWithFakeUtxos(quantities);

const baseAssetEntry = quantities.find((q) => q.assetId === BaseAssetId);
expect(baseAssetEntry).not.toBeNull();
expect(baseAssetEntry?.amount).toEqual(bn(1));
});

it('should not add BaseAssetId if it is already present in quantities', () => {
const transactionRequest = new ScriptTransactionRequest();

const quantities = [{ assetId: BaseAssetId, amount: bn(10) }];
transactionRequest.fundWithFakeUtxos(quantities);
const baseAssetEntries = quantities.filter((q) => q.assetId === BaseAssetId);
expect(baseAssetEntries.length).toBe(1);
});

it('should filter inputs and outputs accordingly', () => {
const transactionRequest = new ScriptTransactionRequest();

transactionRequest.inputs = [
MOCK_REQUEST_COIN_INPUT,
MOCK_REQUEST_MESSAGE_INPUT,
MOCK_REQUEST_CONTRACT_INPUT,
];
transactionRequest.outputs = [
MOCK_REQUEST_COIN_OUTPUT,
MOCK_REQUEST_CONTRACT_OUTPUT,
MOCK_REQUEST_CHANGE_OUTPUT,
];

transactionRequest.fundWithFakeUtxos([]);

const contractInput = transactionRequest.inputs.find((i) => i.type === InputType.Contract);
const coinInput = transactionRequest.inputs.find((i) => i.type === InputType.Coin);
const messageInput = transactionRequest.inputs.find((i) => i.type === InputType.Message);

// Contract inputs should not be filtered out
expect(contractInput).toBe(MOCK_REQUEST_CONTRACT_INPUT);

// Coin and Message inputs should be filtered out
expect(coinInput).not.toBe(MOCK_REQUEST_COIN_INPUT);
expect(messageInput).not.toBe(MOCK_REQUEST_MESSAGE_INPUT);

const coinOutput = transactionRequest.outputs.find((o) => o.type === OutputType.Coin);
const contractOutput = transactionRequest.outputs.find((o) => o.type === OutputType.Contract);
const changeOutput = transactionRequest.outputs.find((o) => o.type === OutputType.Change);

// Coin and Contract outputs should not be filtered out
expect(coinOutput).toBe(MOCK_REQUEST_COIN_OUTPUT);
expect(contractOutput).toBe(MOCK_REQUEST_CONTRACT_OUTPUT);

// Change output should be filtered out
expect(changeOutput).not.toBe(MOCK_REQUEST_CHANGE_OUTPUT);
});
});
});
75 changes: 40 additions & 35 deletions packages/providers/src/transaction-request/transaction-request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Address, addressify, getRandomB256 } from '@fuel-ts/address';
import { Address, addressify } from '@fuel-ts/address';
import { BaseAssetId, ZeroBytes32 } from '@fuel-ts/address/configs';
import type { AddressLike, AbstractAddress, AbstractPredicate } from '@fuel-ts/interfaces';
import type { BN, BigNumberish } from '@fuel-ts/math';
Expand All @@ -25,15 +25,18 @@ import { isCoin } from '../resource';
import { normalizeJSON } from '../utils';
import { getMaxGas, getMinGas } from '../utils/gas';

import type { CoinTransactionRequestOutput } from '.';
import { NoWitnessAtIndexError } from './errors';
import type {
TransactionRequestInput,
CoinTransactionRequestInput,
MessageTransactionRequestInput,
} from './input';
import { inputify } from './input';
import type { TransactionRequestOutput, ChangeTransactionRequestOutput } from './output';
import type {
TransactionRequestOutput,
ChangeTransactionRequestOutput,
CoinTransactionRequestOutput,
} from './output';
import { outputify } from './output';
import type { TransactionRequestWitness } from './witness';
import { witnessify } from './witness';
Expand Down Expand Up @@ -559,42 +562,44 @@ export abstract class BaseTransactionRequest implements BaseTransactionRequestLi
* @param quantities - CoinQuantity Array.
*/
fundWithFakeUtxos(quantities: CoinQuantity[]) {
const hasBaseAssetId = quantities.some(({ assetId }) => assetId === BaseAssetId);

if (!hasBaseAssetId) {
quantities.push({ assetId: BaseAssetId, amount: bn(1) });
}

const owner = getRandomB256();
let idCounter = 0;
const generateId = (): string => {
const counterString = String(idCounter++);
const id = ZeroBytes32.slice(0, -counterString.length).concat(counterString);
return id;
};

const witnessToRemove = this.inputs.reduce(
(acc, input) => {
if (input.type === InputType.Coin || input.type === InputType.Message) {
if (!acc[input.witnessIndex]) {
acc[input.witnessIndex] = true;
}
const findAssetInput = (assetId: string) =>
this.inputs.find((input) => {
if ('assetId' in input) {
return input.assetId === assetId;
}
return false;
});

return acc;
},
{} as Record<number, boolean>
);

this.witnesses = this.witnesses.filter((_, idx) => !witnessToRemove[idx]);
this.inputs = this.inputs.filter((input) => input.type === InputType.Contract);
this.outputs = this.outputs.filter((output) => output.type !== OutputType.Change);

const fakeResources = quantities.map(({ assetId, amount }, idx) => ({
id: `${ZeroBytes32}0${idx}`,
amount,
assetId,
owner: Address.fromB256(owner),
maturity: 0,
blockCreated: bn(1),
txCreatedIdx: bn(1),
}));
const updateAssetInput = (assetId: string, quantity: BN) => {
const assetInput = findAssetInput(assetId);

if (assetInput && 'assetId' in assetInput) {
assetInput.id = generateId();
assetInput.amount = quantity;
} else {
this.addResources([
{
id: generateId(),
amount: quantity,
assetId,
owner: Address.fromRandom(),
maturity: 0,
blockCreated: bn(1),
txCreatedIdx: bn(1),
},
]);
}
};

this.addResources(fakeResources);
updateAssetInput(BaseAssetId, bn(100_000_000_000));
quantities.forEach((q) => updateAssetInput(q.assetId, q.amount));
}

/**
Expand Down
Loading

0 comments on commit 53dafb1

Please sign in to comment.