diff --git a/.changeset/famous-pans-press.md b/.changeset/famous-pans-press.md new file mode 100644 index 00000000000..c167bbc6c04 --- /dev/null +++ b/.changeset/famous-pans-press.md @@ -0,0 +1,5 @@ +--- +"@fuel-ts/account": minor +--- + +feat!: read malleable fields from transaction status on subscription diff --git a/apps/docs-snippets/src/guide/provider/querying-the-chain.test.ts b/apps/docs-snippets/src/guide/provider/querying-the-chain.test.ts index cc8ba8c574d..c2fa4904346 100644 --- a/apps/docs-snippets/src/guide/provider/querying-the-chain.test.ts +++ b/apps/docs-snippets/src/guide/provider/querying-the-chain.test.ts @@ -237,11 +237,7 @@ describe('querying the chain', () => { const { nonce } = result.receipts[0] as TransactionResultMessageOutReceipt; // Retrieves the message proof for the transaction ID and nonce using the next block Id - const messageProof = await provider.getMessageProof( - result.gqlTransaction.id, - nonce, - latestBlock?.id - ); + const messageProof = await provider.getMessageProof(result.id, nonce, latestBlock?.id); // #endregion Message-getMessageProof-blockId expect(messageProof?.amount.toNumber()).toEqual(100); @@ -283,7 +279,7 @@ describe('querying the chain', () => { // Retrieves the message proof for the transaction ID and nonce using the block height const messageProof = await provider.getMessageProof( - result.gqlTransaction.id, + result.id, nonce, undefined, latestBlock?.height diff --git a/apps/docs/src/guide/transactions/transaction-response.md b/apps/docs/src/guide/transactions/transaction-response.md index 9a4eaf5fd3e..1394bddd23b 100644 --- a/apps/docs/src/guide/transactions/transaction-response.md +++ b/apps/docs/src/guide/transactions/transaction-response.md @@ -6,7 +6,6 @@ Once a transaction has been submitted, you may want to extract information regar - The status (submitted, success, squeezed out, or failure) - Receipts (return data, logs, mints/burns, transfers and panic/reverts) - Gas fees and usages -- The raw payload of the transaction including inputs and outputs - Date and time of the transaction - The block the transaction was included in diff --git a/packages/account/src/account.test.ts b/packages/account/src/account.test.ts index ba590193fb1..b7290db6fed 100644 --- a/packages/account/src/account.test.ts +++ b/packages/account/src/account.test.ts @@ -637,7 +637,7 @@ describe('Account', () => { const messageOutReceipt = result.receipts[0]; const messageProof = await provider.getMessageProof( - result.gqlTransaction.id, + result.id, messageOutReceipt.nonce, nextBlock.blockId ); @@ -763,7 +763,7 @@ describe('Account', () => { const messageOutReceipt = result.receipts[0]; expect(result.isStatusSuccess).toBeTruthy(); - expect(result.gqlTransaction.id).toEqual(messageOutReceipt.sender); + expect(result.id).toEqual(messageOutReceipt.sender); expect(recipient.toHexString()).toEqual(messageOutReceipt.recipient); expect(amount.toString()).toEqual(messageOutReceipt.amount.toString()); }); diff --git a/packages/account/src/providers/operations.graphql b/packages/account/src/providers/operations.graphql index 0419221e412..c40006dbef1 100644 --- a/packages/account/src/providers/operations.graphql +++ b/packages/account/src/providers/operations.graphql @@ -61,10 +61,64 @@ fragment transactionStatusFragment on TransactionStatus { } } +fragment malleableTransactionFieldsFragment on Transaction { + receiptsRoot + inputs { + type: __typename + ... on InputCoin { + txPointer + } + ... on InputContract { + txPointer + } + } + outputs { + type: __typename + ... on CoinOutput { + to + amount + assetId + } + ... on ContractOutput { + inputIndex + balanceRoot + stateRoot + } + ... on ChangeOutput { + to + amount + assetId + } + ... on VariableOutput { + to + amount + assetId + } + ... on ContractCreated { + contract + stateRoot + } + } +} + fragment transactionStatusSubscriptionFragment on TransactionStatus { - type: __typename + ... on SubmittedStatus { + ...SubmittedStatusFragment + } + ... on SuccessStatus { + ...SuccessStatusFragment + transaction { + ...malleableTransactionFieldsFragment + } + } + ... on FailureStatus { + ...FailureStatusFragment + transaction { + ...malleableTransactionFieldsFragment + } + } ... on SqueezedOutStatus { - reason + ...SqueezedOutStatusFragment } } diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index 5d8dbd92538..7a7da36ec95 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -768,7 +768,7 @@ Supported fuel-core version: ${supportedVersion}.` } = await this.operations.submit({ encodedTransaction }); this.#cacheInputs(transactionRequest.inputs, transactionId); - return new TransactionResponse(transactionId, this, abis); + return new TransactionResponse(transactionRequest, this, abis); } /** diff --git a/packages/account/src/providers/transaction-response/transaction-response.ts b/packages/account/src/providers/transaction-response/transaction-response.ts index a9b88335fab..93878735add 100644 --- a/packages/account/src/providers/transaction-response/transaction-response.ts +++ b/packages/account/src/providers/transaction-response/transaction-response.ts @@ -16,21 +16,26 @@ import type { Transaction, ReceiptMint, ReceiptBurn, + OutputCoin, + OutputContract, + OutputChange, + OutputVariable, + OutputContractCreated, + Output, + TransactionType, } from '@fuel-ts/transactions'; -import { TransactionCoder } from '@fuel-ts/transactions'; -import { arrayify, sleep } from '@fuel-ts/utils'; +import { OutputType, TransactionCoder, TxPointerCoder } from '@fuel-ts/transactions'; +import { arrayify, assertUnreachable } from '@fuel-ts/utils'; -import type { GqlReceiptFragment } from '../__generated__/operations'; +import type { + GqlMalleableTransactionFieldsFragment, + GqlStatusChangeSubscription, +} from '../__generated__/operations'; import type Provider from '../provider'; -import type { JsonAbisFromAllCalls } from '../transaction-request'; +import type { JsonAbisFromAllCalls, TransactionRequest } from '../transaction-request'; import { assembleTransactionSummary } from '../transaction-summary/assemble-transaction-summary'; import { processGqlReceipt } from '../transaction-summary/receipt'; -import type { - TransactionSummary, - GqlTransaction, - AbiMap, - GqlTransactionStatusesNames, -} from '../transaction-summary/types'; +import type { TransactionSummary, GqlTransaction, AbiMap } from '../transaction-summary/types'; import { extractTxError } from '../utils'; import { getDecodedLogs } from './getDecodedLogs'; @@ -78,10 +83,42 @@ export type TransactionResultReceipt = /** @hidden */ export type TransactionResult = TransactionSummary & { - gqlTransaction: GqlTransaction; logs?: Array; }; +function mapGqlOutputsToTxOutputs( + outputs: GqlMalleableTransactionFieldsFragment['outputs'] +): Output[] { + return outputs.map((o) => { + const obj = 'amount' in o ? { ...o, amount: bn(o.amount) } : o; + switch (obj.type) { + case 'CoinOutput': + return { ...obj, type: OutputType.Coin } satisfies OutputCoin; + case 'ContractOutput': + return { + ...obj, + type: OutputType.Contract, + inputIndex: parseInt(obj.inputIndex, 10), + } satisfies OutputContract; + case 'ChangeOutput': + return { + ...obj, + type: OutputType.Change, + } satisfies OutputChange; + case 'VariableOutput': + return { ...obj, type: OutputType.Variable } satisfies OutputVariable; + case 'ContractCreated': + return { + ...obj, + type: OutputType.ContractCreated, + contractId: obj.contract, + } satisfies OutputContractCreated; + default: + return assertUnreachable(obj); + } + }); +} + /** * Represents a response for a transaction. */ @@ -93,22 +130,23 @@ export class TransactionResponse { /** Gas used on the transaction */ gasUsed: BN = bn(0); /** The graphql Transaction with receipts object. */ - gqlTransaction?: GqlTransaction; - + private gqlTransaction?: GqlTransaction; + private request?: TransactionRequest; + private status?: GqlStatusChangeSubscription['statusChange']; abis?: JsonAbisFromAllCalls; - /** The expected status from the getTransactionWithReceipts response */ - private expectedStatus?: GqlTransactionStatusesNames; /** * Constructor for `TransactionResponse`. * - * @param id - The transaction ID. + * @param tx - The transaction ID or TransactionRequest. * @param provider - The provider. */ - constructor(id: string, provider: Provider, abis?: JsonAbisFromAllCalls) { - this.id = id; + constructor(tx: string | TransactionRequest, provider: Provider, abis?: JsonAbisFromAllCalls) { + this.id = typeof tx === 'string' ? tx : tx.getTransactionId(provider.getChainId()); + this.provider = provider; this.abis = abis; + this.request = typeof tx === 'string' ? undefined : tx; } /** @@ -129,6 +167,72 @@ export class TransactionResponse { return response; } + private applyMalleableSubscriptionFields( + transaction: Transaction + ) { + const status = this.status; + if (!status) { + return; + } + + // The SDK currently submits only these + const tx = transaction as Transaction< + TransactionType.Script | TransactionType.Create | TransactionType.Blob + >; + + if (status.type === 'SuccessStatus' || status.type === 'FailureStatus') { + tx.inputs = tx.inputs.map((input, idx) => { + if ('txPointer' in input) { + const correspondingInput = status.transaction.inputs?.[idx] as { txPointer: string }; + return { + ...input, + txPointer: TxPointerCoder.decodeFromGqlScalar(correspondingInput.txPointer), + }; + } + return input; + }); + + tx.outputs = mapGqlOutputsToTxOutputs(status.transaction.outputs); + + if ('receiptsRoot' in status.transaction) { + (tx as Transaction).receiptsRoot = status.transaction + .receiptsRoot as string; + } + } + } + + private async getTransaction(): Promise<{ + tx: Transaction; + bytes: Uint8Array; + }> { + if (this.request) { + const tx = this.request.toTransaction() as Transaction; + this.applyMalleableSubscriptionFields(tx); + return { + tx, + bytes: this.request.toTransactionBytes(), + }; + } + + const gqlTransaction = this.gqlTransaction ?? (await this.fetch()); + return { + tx: this.decodeTransaction(gqlTransaction) as Transaction, + bytes: arrayify(gqlTransaction.rawPayload), + }; + } + + private getReceipts(): TransactionResultReceipt[] { + const status = this.status ?? this.gqlTransaction?.status; + + switch (status?.type) { + case 'SuccessStatus': + case 'FailureStatus': + return status.receipts.map(processGqlReceipt); + default: + return []; + } + } + /** * Fetch the transaction with receipts from the provider. * @@ -146,7 +250,7 @@ export class TransactionResponse { for await (const { statusChange } of subscription) { if (statusChange) { - this.expectedStatus = statusChange.type; + this.status = statusChange; break; } } @@ -154,12 +258,6 @@ export class TransactionResponse { return this.fetch(); } - // Refetch if the expected status is not the same as the response status - if (this.expectedStatus && response.transaction.status?.type !== this.expectedStatus) { - await sleep(100); - return this.fetch(); - } - this.gqlTransaction = response.transaction; return response.transaction; @@ -188,23 +286,8 @@ export class TransactionResponse { async getTransactionSummary( contractsAbiMap?: AbiMap ): Promise> { - let transaction = this.gqlTransaction; - - if (!transaction) { - transaction = await this.fetch(); - } - - const decodedTransaction = this.decodeTransaction( - transaction - ) as Transaction; - - let txReceipts: GqlReceiptFragment[] = []; - - if (transaction?.status && 'receipts' in transaction.status) { - txReceipts = transaction.status.receipts; - } - - const receipts = txReceipts.map(processGqlReceipt) || []; + const { tx: transaction, bytes: transactionBytes } = + await this.getTransaction(); const { gasPerByte, gasPriceFactor, gasCosts, maxGasPerTx } = this.provider.getGasConfig(); const gasPrice = await this.provider.getLatestGasPrice(); @@ -213,10 +296,10 @@ export class TransactionResponse { const transactionSummary = assembleTransactionSummary({ id: this.id, - receipts, - transaction: decodedTransaction, - transactionBytes: arrayify(transaction.rawPayload), - gqlTransactionStatus: transaction.status, + receipts: this.getReceipts(), + transaction, + transactionBytes, + gqlTransactionStatus: this.status ?? this.gqlTransaction?.status, gasPerByte, gasPriceFactor, abiMap: contractsAbiMap, @@ -241,6 +324,7 @@ export class TransactionResponse { }); for await (const { statusChange } of subscription) { + this.status = statusChange; if (statusChange.type === 'SqueezedOutStatus') { this.unsetResourceCache(); throw new FuelError( @@ -249,12 +333,9 @@ export class TransactionResponse { ); } if (statusChange.type !== 'SubmittedStatus') { - this.expectedStatus = statusChange.type; break; } } - - await this.fetch(); } /** @@ -275,7 +356,6 @@ export class TransactionResponse { const transactionSummary = await this.getTransactionSummary(contractsAbiMap); const transactionResult: TransactionResult = { - gqlTransaction: this.gqlTransaction as GqlTransaction, ...transactionSummary, }; @@ -291,11 +371,12 @@ export class TransactionResponse { transactionResult.logs = logs; } - const { gqlTransaction, receipts } = transactionResult; + const { receipts } = transactionResult; - if (gqlTransaction.status?.type === 'FailureStatus') { + const status = this.status ?? this.gqlTransaction?.status; + if (status?.type === 'FailureStatus') { this.unsetResourceCache(); - const { reason } = gqlTransaction.status; + const { reason } = status; throw extractTxError({ receipts, statusReason: reason, diff --git a/packages/account/src/providers/transaction-summary/assemble-transaction-summary.ts b/packages/account/src/providers/transaction-summary/assemble-transaction-summary.ts index 0b003eb5c28..a4c34921117 100644 --- a/packages/account/src/providers/transaction-summary/assemble-transaction-summary.ts +++ b/packages/account/src/providers/transaction-summary/assemble-transaction-summary.ts @@ -25,7 +25,7 @@ export interface AssembleTransactionSummaryParams { gasPerByte: BN; gasPriceFactor: BN; transaction: Transaction; - id?: string; + id: string; transactionBytes: Uint8Array; gqlTransactionStatus?: GraphqlTransactionStatus; receipts: TransactionResultReceipt[]; diff --git a/packages/account/src/providers/transaction-summary/get-transaction-summary.ts b/packages/account/src/providers/transaction-summary/get-transaction-summary.ts index b183d21ab31..579d816b403 100644 --- a/packages/account/src/providers/transaction-summary/get-transaction-summary.ts +++ b/packages/account/src/providers/transaction-summary/get-transaction-summary.ts @@ -80,7 +80,6 @@ export async function getTransactionSummary( }); return { - gqlTransaction, ...transactionInfo, }; } @@ -109,6 +108,7 @@ export async function getTransactionSummaryFromRequest( const baseAssetId = provider.getBaseAssetId(); const transactionSummary = assembleTransactionSummary({ + id: transactionRequest.getTransactionId(provider.getChainId()), receipts, transaction, transactionBytes, @@ -189,7 +189,6 @@ export async function getTransactionsSummaries( }); const output: TransactionResult = { - gqlTransaction, ...transactionSummary, }; diff --git a/packages/account/src/providers/transaction-summary/types.ts b/packages/account/src/providers/transaction-summary/types.ts index 6f9f0f0ed04..012d1982491 100644 --- a/packages/account/src/providers/transaction-summary/types.ts +++ b/packages/account/src/providers/transaction-summary/types.ts @@ -158,7 +158,7 @@ export interface MintedAsset { export type BurnedAsset = MintedAsset; export type TransactionSummary = { - id?: string; + id: string; time?: string; operations: Operation[]; gasUsed: BN; diff --git a/packages/fuel-gauge/src/await-execution.test.ts b/packages/fuel-gauge/src/await-execution.test.ts index f1025b703e1..e0e27761218 100644 --- a/packages/fuel-gauge/src/await-execution.test.ts +++ b/packages/fuel-gauge/src/await-execution.test.ts @@ -35,8 +35,8 @@ describe('await-execution', () => { ); const response = await provider.sendTransaction(transfer); - await response.waitForResult(); + const { isStatusSuccess } = await response.waitForResult(); - expect(response.gqlTransaction?.status?.type).toBe('SuccessStatus'); + expect(isStatusSuccess).toBe(true); }); }); diff --git a/packages/fuel-gauge/src/contract-factory.test.ts b/packages/fuel-gauge/src/contract-factory.test.ts index 57d36b14a1a..c2834ed30c5 100644 --- a/packages/fuel-gauge/src/contract-factory.test.ts +++ b/packages/fuel-gauge/src/contract-factory.test.ts @@ -57,7 +57,6 @@ describe('Contract Factory', () => { receipts: expect.arrayContaining([expect.any(Object)]), status: expect.any(String), type: expect.any(String), - gqlTransaction: expect.any(Object), operations: expect.any(Array), isStatusFailure: expect.any(Boolean), isStatusPending: expect.any(Boolean), diff --git a/packages/fuel-gauge/src/transaction-summary.test.ts b/packages/fuel-gauge/src/transaction-summary.test.ts index 56178846e84..94320a18a44 100644 --- a/packages/fuel-gauge/src/transaction-summary.test.ts +++ b/packages/fuel-gauge/src/transaction-summary.test.ts @@ -22,6 +22,27 @@ import { ASSET_A, ASSET_B, launchTestNode, TestMessage } from 'fuels/test-utils' import { MultiTokenContractFactory, TokenContractFactory } from '../test/typegen'; +function convertBnsToHex(value: unknown): unknown { + if (value instanceof BN) { + return value.toHex(); + } + + if (Array.isArray(value)) { + return value.map((v) => convertBnsToHex(v)); + } + + if (typeof value === 'object') { + // imagine, typeof null returns 'object'... + if (value === null) { + return value; + } + const entries = Object.entries(value).map(([key, v]) => [key, convertBnsToHex(v)]); + return Object.fromEntries(entries); + } + + return value; +} + /** * @group node * @group browser @@ -46,7 +67,6 @@ describe('TransactionSummary', () => { expect(transaction.isStatusSuccess).toBe(!isRequest); expect(transaction.isStatusPending).toBe(false); if (!isRequest) { - expect((transaction).gqlTransaction).toStrictEqual(expect.any(Object)); expect(transaction.blockId).toEqual(expect.any(String)); expect(transaction.time).toEqual(expect.any(String)); expect(transaction.status).toEqual(expect.any(String)); @@ -94,8 +114,7 @@ describe('TransactionSummary', () => { transaction: transactionSummary, }); - expect(transactionResponse).toStrictEqual(transactionSummary); - expect(transactionSummary.transaction).toStrictEqual(transactionResponse.transaction); + expect(convertBnsToHex(transactionResponse)).toStrictEqual(convertBnsToHex(transactionSummary)); }); it('should ensure getTransactionsSummaries executes just fine', async () => { @@ -147,8 +166,8 @@ describe('TransactionSummary', () => { }); }); - expect(transactions[0]).toStrictEqual(transactionResponse1); - expect(transactions[1]).toStrictEqual(transactionResponse2); + expect(convertBnsToHex(transactions[0])).toStrictEqual(convertBnsToHex(transactionResponse1)); + expect(convertBnsToHex(transactions[1])).toStrictEqual(convertBnsToHex(transactionResponse2)); }); it('should ensure getTransactionSummaryFromRequest executes just fine', async () => { diff --git a/packages/transactions/src/coders/tx-pointer.test.ts b/packages/transactions/src/coders/tx-pointer.test.ts index c55b2de1ad0..1fabf50d81d 100644 --- a/packages/transactions/src/coders/tx-pointer.test.ts +++ b/packages/transactions/src/coders/tx-pointer.test.ts @@ -1,3 +1,5 @@ +import { ErrorCode, FuelError } from '@fuel-ts/errors'; +import { expectToThrowFuelError } from '@fuel-ts/errors/test-utils'; import { arrayify, hexlify } from '@fuel-ts/utils'; import type { TxPointer } from './tx-pointer'; @@ -37,4 +39,26 @@ describe('TxPointerCoder', () => { new TxPointerCoder().encode(txPointer); }).toThrow(); }); + + it('decodes gql scalar', () => { + const result = TxPointerCoder.decodeFromGqlScalar('000000010001'); + expect(result).toStrictEqual({ blockHeight: 1, txIndex: 1 }); + }); + it('throws if invalid length of gql scalar', async () => { + await expectToThrowFuelError( + () => TxPointerCoder.decodeFromGqlScalar('0'.repeat(11)), + new FuelError( + ErrorCode.DECODE_ERROR, + 'Invalid TxPointer scalar string length 11. It must have length 12.' + ) + ); + + await expectToThrowFuelError( + () => TxPointerCoder.decodeFromGqlScalar('0'.repeat(13)), + new FuelError( + ErrorCode.DECODE_ERROR, + 'Invalid TxPointer scalar string length 13. It must have length 12.' + ) + ); + }); }); diff --git a/packages/transactions/src/coders/tx-pointer.ts b/packages/transactions/src/coders/tx-pointer.ts index a4cd880f5cc..6ac2087cd86 100644 --- a/packages/transactions/src/coders/tx-pointer.ts +++ b/packages/transactions/src/coders/tx-pointer.ts @@ -1,4 +1,5 @@ import { NumberCoder, StructCoder } from '@fuel-ts/abi-coder'; +import { ErrorCode, FuelError } from '@fuel-ts/errors'; export type TxPointer = { /** Block height (u32) */ @@ -18,4 +19,19 @@ export class TxPointerCoder extends StructCoder<{ txIndex: new NumberCoder('u16', { padToWordSize: true }), }); } + + public static decodeFromGqlScalar(value: string) { + // taken from https://github.com/FuelLabs/fuel-vm/blob/7366db6955589cb3444c9b2bb46e45c8539f19f5/fuel-tx/src/tx_pointer.rs#L87 + if (value.length !== 12) { + throw new FuelError( + ErrorCode.DECODE_ERROR, + `Invalid TxPointer scalar string length ${value.length}. It must have length 12.` + ); + } + const [blockHeight, txIndex] = [value.substring(0, 8), value.substring(8)]; + return { + blockHeight: parseInt(blockHeight, 16), + txIndex: parseInt(txIndex, 16), + }; + } } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index deba8f20bc2..4f21266f23c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -14,3 +14,10 @@ export * from './utils/dataSlice'; export * from './utils/toUtf8Bytes'; export * from './utils/toUtf8String'; export * from './utils/bytecode'; + +/** + * Used to verify that a switch statement exhausts all variants. + */ +export function assertUnreachable(_x: never): never { + throw new Error("Didn't expect to get here"); +}