Skip to content
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

fix: TransactionRequest funding #1531

Merged
merged 14 commits into from
Dec 20, 2023
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
Loading