Skip to content

Commit

Permalink
feat!: prevent implicit asset burn (#3540)
Browse files Browse the repository at this point in the history
* feat: added test for feature

* feat: added guards against illicit asset burns

* chore: docs

* chore: changeset

* chore: removed test.only

* chore: finalize the PR

* chore: refactored the burnable assets to a helper

* chore: use `autoCost`

* chore: breaking change

* docs: added docs on asset burn

* speeling

* chore: changeset

* lintfix

* chore: allow asset burn via `sendTransaction` options

* Update apps/docs/src/guide/transactions/transaction-request.md

Co-authored-by: Nedim Salkić <[email protected]>

* chore: added asset burn validation to wallet

* chore: update wallet-unlocked tx example to be valid

* chore: update test assertions

Co-authored-by: Nedim Salkić <[email protected]>

* chore: added asset burn validation for `MessageCoin`

* chore: update validation check

Co-authored-by: Sérgio Torres <[email protected]>

* chore: use transactionRequest for helpers

---------

Co-authored-by: Nedim Salkić <[email protected]>
Co-authored-by: Anderson Arboleya <[email protected]>
Co-authored-by: Sérgio Torres <[email protected]>
Co-authored-by: Daniel Bate <[email protected]>
  • Loading branch information
5 people authored Jan 10, 2025
1 parent 0f138cd commit 08a31d8
Show file tree
Hide file tree
Showing 13 changed files with 584 additions and 36 deletions.
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
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
16 changes: 14 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,15 @@ Supported fuel-core version: ${supportedVersion}.`
*/
async sendTransaction(
transactionRequestLike: TransactionRequestLike,
{ estimateTxDependencies = true }: ProviderSendTxParams = {}
{ estimateTxDependencies = true, enableAssetBurn }: ProviderSendTxParams = {}
): Promise<TransactionResponse> {
const transactionRequest = transactionRequestify(transactionRequestLike);
validateTransactionForAssetBurn(
await this.getBaseAssetId(),
transactionRequest,
enableAssetBurn
);

if (estimateTxDependencies) {
await this.estimateTxDependencies(transactionRequest);
}
Expand Down
123 changes: 122 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,6 +22,8 @@ import {
getAssetAmountInRequestInputs,
cacheRequestInputsResources,
cacheRequestInputsResourcesFromOwner,
getBurnableAssetCount,
validateTransactionForAssetBurn,
} from './helpers';
import { ScriptTransactionRequest } from './script-transaction-request';

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

describe('getBurnableAssetCount', () => {
it('should get the number of burnable assets [0]', () => {
const request = new ScriptTransactionRequest();
request.inputs = [
generateFakeRequestInputMessage(), // Will be a `baseAssetId`
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }),
];
request.outputs = [
{ type: OutputType.Change, assetId: baseAssetId, to: owner.toB256() },
{ type: OutputType.Change, assetId: ASSET_A, to: owner.toB256() },
{ type: OutputType.Change, assetId: ASSET_B, to: owner.toB256() },
];
const expectedBurnableAssets = 0;

const burnableAssets = getBurnableAssetCount(baseAssetId, request);

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

it('should get the number of burnable coins [2]', () => {
const request = new ScriptTransactionRequest();
request.inputs = [
generateFakeRequestInputMessage(), // Burnable asset - will be a `baseAssetId`
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }), // Burnable asset
];
request.outputs = [{ type: OutputType.Change, assetId: ASSET_A, to: owner.toB256() }];
const expectedBurnableAssets = 2;

const burnableAssets = getBurnableAssetCount(baseAssetId, request);

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

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

expect(() =>
validateTransactionForAssetBurn(baseAssetId, request, enableAssetBurn)
).not.toThrow();
});

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

await expectToThrowFuelError(
() => validateTransactionForAssetBurn(baseAssetId, request, 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 request = new ScriptTransactionRequest();
request.inputs = [
generateFakeRequestInputMessage(),
generateFakeRequestInputCoin({ assetId: ASSET_A, owner: owner.toB256() }),
generateFakeRequestInputCoin({ assetId: ASSET_B, owner: owner.toB256() }),
];
request.outputs = [];
const enableAssetBurn = true;

expect(() =>
validateTransactionForAssetBurn(baseAssetId, request, enableAssetBurn)
).not.toThrow();
});

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

await expectToThrowFuelError(
() => validateTransactionForAssetBurn(baseAssetId, request),
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

0 comments on commit 08a31d8

Please sign in to comment.