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

feat!: prevent implicit asset burn #3540

Open
wants to merge 33 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8b58f28
feat: added test for feature
petertonysmith94 Jan 3, 2025
0ea3d8f
feat: added guards against illicit asset burns
petertonysmith94 Jan 3, 2025
a33ec72
chore: docs
petertonysmith94 Jan 3, 2025
69e480f
chore: changeset
petertonysmith94 Jan 3, 2025
5e053b3
chore: removed test.only
petertonysmith94 Jan 6, 2025
61da932
chore: finalize the PR
petertonysmith94 Jan 6, 2025
987221f
Merge branch 'master' into ps/feat/prevent-illicit-burn
petertonysmith94 Jan 6, 2025
1caa00c
Merge branch 'master' of github.com:FuelLabs/fuels-ts into ps/feat/pr…
petertonysmith94 Jan 7, 2025
ac03f64
chore: refactored the burnable assets to a helper
petertonysmith94 Jan 7, 2025
9c4080c
chore: use `autoCost`
petertonysmith94 Jan 7, 2025
16a6b07
chore: breaking change
petertonysmith94 Jan 7, 2025
262bba4
docs: added docs on asset burn
petertonysmith94 Jan 7, 2025
4ff6361
speeling
petertonysmith94 Jan 7, 2025
ad96a1a
chore: changeset
petertonysmith94 Jan 7, 2025
c408eda
lintfix
petertonysmith94 Jan 7, 2025
ad30912
Merge branch 'master' of github.com:FuelLabs/fuels-ts into ps/feat/pr…
petertonysmith94 Jan 7, 2025
4004a0a
chore: allow asset burn via `sendTransaction` options
petertonysmith94 Jan 7, 2025
c5a5b7e
petertonysmith94 Jan 7, 2025
a572ca4
Merge branch 'master' of github.com:FuelLabs/fuels-ts into ps/feat/pr…
petertonysmith94 Jan 7, 2025
6f6a156
Update apps/docs/src/guide/transactions/transaction-request.md
petertonysmith94 Jan 7, 2025
82f472d
chore: added asset burn validation to wallet
petertonysmith94 Jan 8, 2025
3eef294
chore: update wallet-unlocked tx example to be valid
petertonysmith94 Jan 8, 2025
8b2c598
Merge branch 'ps/feat/prevent-illicit-burn' of github.com:FuelLabs/fu…
petertonysmith94 Jan 8, 2025
280db16
Merge branch 'master' of github.com:FuelLabs/fuels-ts into ps/feat/pr…
petertonysmith94 Jan 8, 2025
68e3a6a
chore: update test assertions
petertonysmith94 Jan 8, 2025
4343f71
Merge branch 'master' into ps/feat/prevent-illicit-burn
petertonysmith94 Jan 8, 2025
16985a3
Merge branch 'master' into ps/feat/prevent-illicit-burn
petertonysmith94 Jan 8, 2025
a2e6bc3
Merge branch 'master' into ps/feat/prevent-illicit-burn
nedsalk Jan 8, 2025
43cba99
Merge branch 'master' into ps/feat/prevent-illicit-burn
arboleya Jan 9, 2025
b321ec8
chore: added asset burn validation for `MessageCoin`
petertonysmith94 Jan 9, 2025
0179db4
Merge branch 'ps/feat/prevent-illicit-burn' of github.com:FuelLabs/fu…
petertonysmith94 Jan 9, 2025
62dc663
chore: update validation check
petertonysmith94 Jan 9, 2025
33e5ea9
chore: use transactionRequest for helpers
petertonysmith94 Jan 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/wild-avocados-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@fuel-ts/account": minor
"@fuel-ts/errors": patch
---

feat!: prevent implicit asset burn
arboleya marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 6 additions & 0 deletions apps/docs/src/guide/errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ When an [`Account`](https://fuels-ts-docs-api.vercel.app/classes/_fuel_ts_accoun

It could be caused during the deployments of contracts when an account is required to sign the transaction. This can be resolved by following the deployment guide [here](../contracts/deploying-contracts.md).

### `ASSET_BURN_DETECTED`

When you are trying to send a transaction that will result in an asset burn.

Add relevant coin change outputs to the transaction, or enable asset burn in the transaction request.

### `CONFIG_FILE_NOT_FOUND`

When a configuration file is not found. This could either be a `fuels.config.[ts,js,mjs,cjs]` file or a TOML file.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { InputType, Provider, ScriptTransactionRequest, Wallet } from 'fuels';
import { ASSET_A } from 'fuels/test-utils';

import { LOCAL_NETWORK_URL, WALLET_PVT_KEY } from '../../../../env';

const provider = new Provider(LOCAL_NETWORK_URL);
const sender = Wallet.fromPrivateKey(WALLET_PVT_KEY, provider);

// #region asset-burn
const transactionRequest = new ScriptTransactionRequest();

const {
coins: [coin],
} = await sender.getCoins(ASSET_A);

// Add the coin as an input, without a change output
transactionRequest.inputs.push({
id: coin.id,
type: InputType.Coin,
owner: coin.owner.toB256(),
amount: coin.amount,
assetId: coin.assetId,
txPointer: '0x00000000000000000000000000000000',
witnessIndex:
transactionRequest.getCoinInputWitnessIndexByOwner(coin.owner) ??
transactionRequest.addEmptyWitness(),
});

// Fund the transaction
await transactionRequest.autoCost(sender);

// Send the transaction with asset burn enabled
const tx = await sender.sendTransaction(transactionRequest, {
enableAssetBurn: true,
});
// #endregion asset-burn

const { isStatusSuccess } = await tx.waitForResult();
console.log('Transaction should have been successful', isStatusSuccess);
8 changes: 8 additions & 0 deletions apps/docs/src/guide/transactions/transaction-request.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,11 @@ The transaction ID is a SHA-256 hash of the entire transaction request. This can
<<< @./snippets/transaction-request/add-witness.ts#transaction-request-11{ts:line-numbers}

> **Note**: Any changes made to a transaction request will alter the transaction ID. Therefore, you should only get the transaction ID after all modifications have been made.
### Burning assets

Assets can be burnt as part of a transaction that has inputs without associated output change. The SDK validates against this behavior, so we need to explicitly enable this by sending the transaction with the `enableAssetBurn` option set to `true`.

<<< @./snippets/transaction-request/asset-burn.ts#asset-burn{ts:line-numbers}

> **Note**: Burning assets is permanent and all assets burnt will be lost. Therefore, be mindful of the usage of this functionality.
92 changes: 88 additions & 4 deletions packages/account/src/providers/provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Address } from '@fuel-ts/address';
import { Address, getRandomB256 } from '@fuel-ts/address';
import { ZeroBytes32 } from '@fuel-ts/address/configs';
import { randomBytes, randomUUID } from '@fuel-ts/crypto';
import { FuelError, ErrorCode } from '@fuel-ts/errors';
import { expectToThrowFuelError, safeExec } from '@fuel-ts/errors/test-utils';
import { BN, bn } from '@fuel-ts/math';
import type { Receipt } from '@fuel-ts/transactions';
import { InputType, ReceiptType } from '@fuel-ts/transactions';
import { InputType, OutputType, ReceiptType } from '@fuel-ts/transactions';
import { DateTime, arrayify, sleep } from '@fuel-ts/utils';
import { ASSET_A, ASSET_B } from '@fuel-ts/utils/test-utils';
import { versions } from '@fuel-ts/versions';
Expand Down Expand Up @@ -34,7 +34,10 @@ import Provider, {
} from './provider';
import type { ExcludeResourcesOption } from './resource';
import { isCoin } from './resource';
import type { CoinTransactionRequestInput } from './transaction-request';
import type {
ChangeTransactionRequestOutput,
CoinTransactionRequestInput,
} from './transaction-request';
import { CreateTransactionRequest, ScriptTransactionRequest } from './transaction-request';
import { TransactionResponse } from './transaction-response';
import type { SubmittedStatus } from './transaction-summary/types';
Expand Down Expand Up @@ -325,19 +328,27 @@ describe('Provider', () => {
it('can call()', async () => {
using launched = await setupTestProviderAndWallets();
const { provider } = launched;
const owner = getRandomB256();
const baseAssetId = await provider.getBaseAssetId();

const CoinInputs: CoinTransactionRequestInput[] = [
{
type: InputType.Coin,
id: '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c500',
owner: baseAssetId,
owner,
assetId: baseAssetId,
txPointer: '0x00000000000000000000000000000000',
amount: 500_000,
witnessIndex: 0,
},
];
const ChangeOutputs: ChangeTransactionRequestOutput[] = [
{
type: OutputType.Change,
assetId: baseAssetId,
to: owner,
},
];
const transactionRequest = new ScriptTransactionRequest({
tip: 0,
gasLimit: 100_000,
Expand All @@ -352,6 +363,7 @@ describe('Provider', () => {
arrayify('0x504000ca504400ba3341100024040000'),
scriptData: randomBytes(32),
inputs: CoinInputs,
outputs: ChangeOutputs,
witnesses: ['0x'],
});

Expand Down Expand Up @@ -2263,6 +2275,78 @@ Supported fuel-core version: ${mock.supportedVersion}.`
expect(fetchChainAndNodeInfo).toHaveBeenCalledTimes(2);
});

it('should throw error if asset burn is detected', async () => {
using launched = await setupTestProviderAndWallets();
const {
provider,
wallets: [sender],
} = launched;

const {
coins: [coin],
} = await sender.getCoins(ASSET_A);

const request = new ScriptTransactionRequest();

// Add the coin as an input, without a change output
request.inputs.push({
id: coin.id,
type: InputType.Coin,
owner: coin.owner.toB256(),
amount: coin.amount,
assetId: coin.assetId,
txPointer: '0x00000000000000000000000000000000',
witnessIndex:
request.getCoinInputWitnessIndexByOwner(coin.owner) ?? request.addEmptyWitness(),
});

const expectedErrorMessage = [
'Asset burn detected.',
'Add the relevant change outputs to the transaction to avoid burning assets.',
'Or enable asset burn, upon sending the transaction.',
].join('\n');
await expectToThrowFuelError(
() => provider.sendTransaction(request),
new FuelError(ErrorCode.ASSET_BURN_DETECTED, expectedErrorMessage)
);
});

it('should allow asset burn if enabled', async () => {
using launched = await setupTestProviderAndWallets();
const {
provider,
wallets: [sender],
} = launched;
const {
coins: [coin],
} = await sender.getCoins(ASSET_A);

const request = new ScriptTransactionRequest();

// Add the coin as an input, without a change output
request.inputs.push({
id: coin.id,
type: InputType.Coin,
owner: coin.owner.toB256(),
amount: coin.amount,
assetId: coin.assetId,
txPointer: '0x00000000000000000000000000000000',
witnessIndex: request.getCoinInputWitnessIndexByOwner(sender) ?? request.addEmptyWitness(),
});

// Fund the transaction
await request.autoCost(sender);

const signedTransaction = await sender.signTransaction(request);
request.updateWitnessByOwner(sender.address, signedTransaction);

const response = await provider.sendTransaction(request, {
enableAssetBurn: true,
});
const { isStatusSuccess } = await response.waitForResult();
expect(isStatusSuccess).toBe(true);
});

it('submits transaction and awaits status [success]', async () => {
using launched = await setupTestProviderAndWallets();
const {
Expand Down
12 changes: 10 additions & 2 deletions packages/account/src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
isTransactionTypeCreate,
isTransactionTypeScript,
transactionRequestify,
validateTransactionForAssetBurn,
} from './transaction-request';
import type { TransactionResult, TransactionResultReceipt } from './transaction-response';
import { TransactionResponse, getDecodedLogs } from './transaction-response';
Expand Down Expand Up @@ -363,7 +364,12 @@ export type ProviderCallParams = UTXOValidationParams & EstimateTransactionParam
/**
* Provider Send transaction params
*/
export type ProviderSendTxParams = EstimateTransactionParams;
export type ProviderSendTxParams = EstimateTransactionParams & {
/**
* Whether to enable asset burn for the transaction.
*/
enableAssetBurn?: boolean;
};

/**
* URL - Consensus Params mapping.
Expand Down Expand Up @@ -853,9 +859,11 @@ Supported fuel-core version: ${supportedVersion}.`
*/
async sendTransaction(
transactionRequestLike: TransactionRequestLike,
{ estimateTxDependencies = true }: ProviderSendTxParams = {}
{ estimateTxDependencies = true, enableAssetBurn }: ProviderSendTxParams = {}
): Promise<TransactionResponse> {
const transactionRequest = transactionRequestify(transactionRequestLike);
validateTransactionForAssetBurn(transactionRequest, enableAssetBurn);

if (estimateTxDependencies) {
await this.estimateTxDependencies(transactionRequest);
}
Expand Down
128 changes: 127 additions & 1 deletion packages/account/src/providers/transaction-request/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { getRandomB256, Address } from '@fuel-ts/address';
import { ZeroBytes32 } from '@fuel-ts/address/configs';
import { ErrorCode, FuelError } from '@fuel-ts/errors';
import { expectToThrowFuelError } from '@fuel-ts/errors/test-utils';
import { bn } from '@fuel-ts/math';
import { InputType } from '@fuel-ts/transactions';
import { InputType, OutputType } from '@fuel-ts/transactions';

import { generateFakeCoin, generateFakeMessageCoin } from '../../test-utils/resources';
import {
Expand All @@ -20,7 +22,11 @@ import {
getAssetAmountInRequestInputs,
cacheRequestInputsResources,
cacheRequestInputsResourcesFromOwner,
getBurnableAssetCount,
validateTransactionForAssetBurn,
} from './helpers';
import type { TransactionRequestInput } from './input';
import type { TransactionRequestOutput } from './output';
import { ScriptTransactionRequest } from './script-transaction-request';

/**
Expand Down Expand Up @@ -196,5 +202,125 @@ describe('helpers', () => {
expect(cached.messages).toStrictEqual([input3.nonce]);
});
});

describe('getBurnableAssetCount', () => {
it('should get the number of burnable assets [0]', () => {
const inputs: TransactionRequestInput[] = [
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }),
];
const outputs: TransactionRequestOutput[] = [
{ type: OutputType.Change, assetId: ASSET_A, to: owner.toB256() },
{ type: OutputType.Change, assetId: ASSET_B, to: owner.toB256() },
];
const expectedBurnableAssets = 0;

const burnableAssets = getBurnableAssetCount({ inputs, outputs });

expect(burnableAssets).toBe(expectedBurnableAssets);
});

it('should get the number of burnable assets [1]', () => {
const inputs: TransactionRequestInput[] = [
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }),
];
const outputs: TransactionRequestOutput[] = [
{ type: OutputType.Change, assetId: ASSET_A, to: owner.toB256() },
];
const expectedBurnableAssets = 1;

const burnableAssets = getBurnableAssetCount({ inputs, outputs });

expect(burnableAssets).toBe(expectedBurnableAssets);
});

it('should get the number of burnable assets [2]', () => {
const inputs: TransactionRequestInput[] = [
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }),
];
const outputs: TransactionRequestOutput[] = [];
const expectedBurnableAssets = 2;

const burnableAssets = getBurnableAssetCount({ inputs, outputs });

expect(burnableAssets).toBe(expectedBurnableAssets);
});
});

describe('validateTransactionForAssetBurn', () => {
it('should successfully validate transactions without burnable assets [enableAssetBurn=false]', () => {
const inputs: TransactionRequestInput[] = [
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }),
];
const outputs: TransactionRequestOutput[] = [
{ type: OutputType.Change, assetId: ASSET_A, to: owner.toB256() },
{ type: OutputType.Change, assetId: ASSET_B, to: owner.toB256() },
];
const enableAssetBurn = false;

expect(() =>
validateTransactionForAssetBurn({ inputs, outputs }, enableAssetBurn)
).not.toThrow();
});

it('should throw an error if transaction has burnable assets [enableAssetBurn=false]', async () => {
const inputs: TransactionRequestInput[] = [
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }),
];
const outputs: TransactionRequestOutput[] = [
{ type: OutputType.Change, assetId: ASSET_A, to: owner.toB256() },
];
const enableAssetBurn = false;

await expectToThrowFuelError(
() => validateTransactionForAssetBurn({ inputs, outputs }, enableAssetBurn),
new FuelError(
ErrorCode.ASSET_BURN_DETECTED,
[
`Asset burn detected.`,
`Add the relevant change outputs to the transaction to avoid burning assets.`,
`Or enable asset burn, upon sending the transaction.`,
].join('\n')
)
);
});

it('should successfully validate transactions with burnable assets [enableAssetBurn=true]', () => {
const inputs: TransactionRequestInput[] = [
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }),
];
const outputs: TransactionRequestOutput[] = [];
const enableAssetBurn = true;

expect(() =>
validateTransactionForAssetBurn({ inputs, outputs }, enableAssetBurn)
).not.toThrow();
});

it('should validate asset burn by default [enableAssetBurn=undefined]', async () => {
const inputs: TransactionRequestInput[] = [
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }),
];
const outputs: TransactionRequestOutput[] = [];

await expectToThrowFuelError(
() => validateTransactionForAssetBurn({ inputs, outputs }),
new FuelError(
ErrorCode.ASSET_BURN_DETECTED,
[
`Asset burn detected.`,
`Add the relevant change outputs to the transaction to avoid burning assets.`,
`Or enable asset burn, upon sending the transaction.`,
].join('\n')
)
);
});
});
});
});
Loading
Loading