diff --git a/.changeset/hot-vans-battle.md b/.changeset/hot-vans-battle.md new file mode 100644 index 0000000000..ff1cb006ad --- /dev/null +++ b/.changeset/hot-vans-battle.md @@ -0,0 +1,6 @@ +--- +"@fuel-ts/account": patch +"@fuel-ts/program": patch +--- + +fix: avoid overriding user `gasLimit` and `maxFee` inputs diff --git a/packages/account/src/account.test.ts b/packages/account/src/account.test.ts index fd93147ea3..54b3a2995a 100644 --- a/packages/account/src/account.test.ts +++ b/packages/account/src/account.test.ts @@ -3,6 +3,7 @@ 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 { PolicyType } from '@fuel-ts/transactions'; import { ASSET_A, ASSET_B } from '@fuel-ts/utils/test-utils'; import { Account } from './account'; @@ -251,6 +252,8 @@ describe('Account', () => { const request = new ScriptTransactionRequest(); + request.maxFee = fee; + const resourcesToSpend: Resource[] = []; const getResourcesToSpendSpy = vi .spyOn(Account.prototype, 'getResourcesToSpend') @@ -267,7 +270,6 @@ describe('Account', () => { await account.fund(request, { requiredQuantities: quantities, - maxFee: fee, estimatedPredicates: [], addedSignatures: 0, }); @@ -408,6 +410,28 @@ describe('Account', () => { expect(receiverBalances).toEqual([{ assetId: baseAssetId, amount: bn(1) }]); }); + it('can set "gasLimit" and "maxFee" when transferring amounts', async () => { + const sender = await generateTestWallet(provider, [[500_000, baseAssetId]]); + const receiver = Address.fromRandom(); + + const gasLimit = 30_000; + const maxFee = 15_000; + + const request = await sender.createTransfer(receiver, 1, baseAssetId, { + gasLimit, + maxFee, + }); + + const response = await sender.sendTransaction(request); + const { transaction } = await response.wait(); + + const { scriptGasLimit, policies } = transaction; + const maxFeePolicy = policies?.find((policy) => policy.type === PolicyType.MaxFee); + + expect(scriptGasLimit?.toNumber()).toBe(gasLimit); + expect(bn(maxFeePolicy?.data).toNumber()).toBe(maxFee); + }); + it('can transfer with custom TX Params', async () => { const sender = await generateTestWallet(provider, [[50_000, baseAssetId]]); const receiver = Wallet.generate({ provider }); @@ -590,6 +614,29 @@ describe('Account', () => { expect(senderBalances).toEqual([{ assetId: baseAssetId, amount: bn(expectedRemaining) }]); }); + it('can set "gasLimit" and "maxFee" when withdrawing to base layer', async () => { + const sender = Wallet.generate({ + provider, + }); + + await seedTestWallet(sender, [[500_000, baseAssetId]]); + + const recipient = Address.fromRandom(); + const amount = 110; + + const gasLimit = 100_000; + const maxFee = 50_000; + + const tx = await sender.withdrawToBaseLayer(recipient, amount, { gasLimit, maxFee }); + const { transaction } = await tx.wait(); + + const { scriptGasLimit, policies } = transaction; + const maxFeePolicy = policies?.find((policy) => policy.type === PolicyType.MaxFee); + + expect(scriptGasLimit?.toNumber()).toBe(gasLimit); + expect(bn(maxFeePolicy?.data).toNumber()).toBe(maxFee); + }); + it('should ensure gas price and gas limit are validated when transfering amounts', async () => { const sender = await generateTestWallet(provider, [[1000, baseAssetId]]); const receiver = Wallet.generate({ provider }); diff --git a/packages/account/src/account.ts b/packages/account/src/account.ts index a7e8e0bc63..d8a7254123 100644 --- a/packages/account/src/account.ts +++ b/packages/account/src/account.ts @@ -46,7 +46,7 @@ export type TxParamsType = Pick< export type EstimatedTxParams = Pick< TransactionCost, - 'maxFee' | 'estimatedPredicates' | 'addedSignatures' | 'requiredQuantities' + 'estimatedPredicates' | 'addedSignatures' | 'requiredQuantities' | 'updateMaxFee' >; const MAX_FUNDING_ATTEMPTS = 2; @@ -244,21 +244,21 @@ export class Account extends AbstractAccount { } /** - * Adds resources to the transaction enough to fund it. + * Funds a transaction request by adding the necessary resources. * - * @param request - The transaction request. - * @param requiredQuantities - The coin quantities required to execute the transaction. - * @param fee - The estimated transaction fee. - * @returns A promise that resolves when the resources are added to the transaction. + * @typeParam T - The type of the TransactionRequest. + * @param request - The transaction request to fund. + * @param params - The estimated transaction parameters. + * @returns The funded transaction request. */ async fund(request: T, params: EstimatedTxParams): Promise { - const { addedSignatures, estimatedPredicates, maxFee: fee, requiredQuantities } = params; + const { addedSignatures, estimatedPredicates, requiredQuantities, updateMaxFee } = params; + const fee = request.maxFee; const baseAssetId = this.provider.getBaseAssetId(); const requiredInBaseAsset = requiredQuantities.find((quantity) => quantity.assetId === baseAssetId)?.amount || bn(0); - const txRequest = request as T; const requiredQuantitiesWithFee = addAmountToCoinQuantities({ amount: bn(fee), assetId: baseAssetId, @@ -301,17 +301,19 @@ export class Account extends AbstractAccount { ); request.addResources(resources); + request.shiftPredicateData(); + request.updatePredicateGasUsed(estimatedPredicates); - txRequest.shiftPredicateData(); - txRequest.updatePredicateGasUsed(estimatedPredicates); - - const requestToReestimate = clone(txRequest); + const requestToReestimate = clone(request); if (addedSignatures) { Array.from({ length: addedSignatures }).forEach(() => requestToReestimate.addEmptyWitness() ); } + if (!updateMaxFee) { + break; + } const { maxFee: newFee } = await this.provider.estimateTxGasAndFee({ transactionRequest: requestToReestimate, }); @@ -338,20 +340,25 @@ export class Account extends AbstractAccount { fundingAttempts += 1; } - txRequest.shiftPredicateData(); - txRequest.updatePredicateGasUsed(estimatedPredicates); + request.shiftPredicateData(); + request.updatePredicateGasUsed(estimatedPredicates); - const requestToReestimate = clone(txRequest); + const requestToReestimate = clone(request); if (addedSignatures) { Array.from({ length: addedSignatures }).forEach(() => requestToReestimate.addEmptyWitness()); } + + if (!updateMaxFee) { + return request; + } + const { maxFee } = await this.provider.estimateTxGasAndFee({ transactionRequest: requestToReestimate, }); - txRequest.maxFee = maxFee; + request.maxFee = maxFee; - return txRequest; + return request; } /** @@ -373,7 +380,7 @@ export class Account extends AbstractAccount { /** Tx Params */ txParams: TxParamsType = {} ): Promise { - const request = new ScriptTransactionRequest(txParams); + let request = new ScriptTransactionRequest(txParams); const assetIdToTransfer = assetId ?? this.provider.getBaseAssetId(); request.addCoinOutput(Address.fromAddressOrString(destination), amount, assetIdToTransfer); const txCost = await this.provider.getTransactionCost(request, { @@ -381,15 +388,13 @@ export class Account extends AbstractAccount { resourcesOwner: this, }); - this.validateGasLimitAndMaxFee({ + request = this.validateGasLimitAndMaxFee({ + transactionRequest: request, gasUsed: txCost.gasUsed, maxFee: txCost.maxFee, txParams, }); - request.gasLimit = txCost.gasUsed; - request.maxFee = txCost.maxFee; - await this.fund(request, txCost); return request; @@ -459,7 +464,7 @@ export class Account extends AbstractAccount { assetId: assetIdToTransfer, }); - const request = new ScriptTransactionRequest({ + let request = new ScriptTransactionRequest({ ...txParams, script, scriptData, @@ -472,15 +477,13 @@ export class Account extends AbstractAccount { quantitiesToContract: [{ amount: bn(amount), assetId: String(assetIdToTransfer) }], }); - this.validateGasLimitAndMaxFee({ + request = this.validateGasLimitAndMaxFee({ + transactionRequest: request, gasUsed: txCost.gasUsed, maxFee: txCost.maxFee, txParams, }); - request.gasLimit = txCost.gasUsed; - request.maxFee = txCost.maxFee; - await this.fund(request, txCost); return this.sendTransaction(request); @@ -519,20 +522,18 @@ export class Account extends AbstractAccount { const params: ScriptTransactionRequestLike = { script, ...txParams }; const baseAssetId = this.provider.getBaseAssetId(); - const request = new ScriptTransactionRequest(params); + let request = new ScriptTransactionRequest(params); const quantitiesToContract = [{ amount: bn(amount), assetId: baseAssetId }]; const txCost = await this.provider.getTransactionCost(request, { quantitiesToContract }); - this.validateGasLimitAndMaxFee({ + request = this.validateGasLimitAndMaxFee({ + transactionRequest: request, gasUsed: txCost.gasUsed, maxFee: txCost.maxFee, txParams, }); - request.maxFee = txCost.maxFee; - request.gasLimit = txCost.gasUsed; - await this.fund(request, txCost); return this.sendTransaction(request); @@ -604,26 +605,36 @@ export class Account extends AbstractAccount { } private validateGasLimitAndMaxFee({ - txParams: { gasLimit: setGasLimit, maxFee: setMaxFee }, gasUsed, maxFee, + transactionRequest, + txParams: { gasLimit: setGasLimit, maxFee: setMaxFee }, }: { gasUsed: BN; maxFee: BN; + transactionRequest: ScriptTransactionRequest; txParams: Pick; }) { - if (isDefined(setGasLimit) && gasUsed.gt(setGasLimit)) { + const request = transactionRequestify(transactionRequest) as ScriptTransactionRequest; + + if (!isDefined(setGasLimit)) { + request.gasLimit = gasUsed; + } else if (gasUsed.gt(setGasLimit)) { throw new FuelError( ErrorCode.GAS_LIMIT_TOO_LOW, `Gas limit '${setGasLimit}' is lower than the required: '${gasUsed}'.` ); } - if (isDefined(setMaxFee) && maxFee.gt(setMaxFee)) { + if (!isDefined(setMaxFee)) { + request.maxFee = maxFee; + } else if (maxFee.gt(setMaxFee)) { throw new FuelError( ErrorCode.MAX_FEE_TOO_LOW, `Max fee '${setMaxFee}' is lower than the required: '${maxFee}'.` ); } + + return request; } } diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts index 71a696462d..f49cb9781a 100644 --- a/packages/account/src/providers/provider.test.ts +++ b/packages/account/src/providers/provider.test.ts @@ -882,8 +882,8 @@ describe('Provider', () => { expect(consoleWarnSpy).toHaveBeenCalledOnce(); expect(consoleWarnSpy).toHaveBeenCalledWith( - `The Fuel Node that you are trying to connect to is using fuel-core version ${FUEL_CORE}, -which is not supported by the version of the TS SDK that you are using. + `The Fuel Node that you are trying to connect to is using fuel-core version ${FUEL_CORE}, +which is not supported by the version of the TS SDK that you are using. Things may not work as expected. Supported fuel-core version: ${mock.supportedVersion}.` ); @@ -914,8 +914,8 @@ Supported fuel-core version: ${mock.supportedVersion}.` expect(consoleWarnSpy).toHaveBeenCalledOnce(); expect(consoleWarnSpy).toHaveBeenCalledWith( - `The Fuel Node that you are trying to connect to is using fuel-core version ${FUEL_CORE}, -which is not supported by the version of the TS SDK that you are using. + `The Fuel Node that you are trying to connect to is using fuel-core version ${FUEL_CORE}, +which is not supported by the version of the TS SDK that you are using. Things may not work as expected. Supported fuel-core version: ${mock.supportedVersion}.` ); diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index ca263cf0b8..317d975b72 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -164,6 +164,7 @@ export type TransactionCost = { requiredQuantities: CoinQuantity[]; addedSignatures: number; dryRunStatus?: DryRunStatus; + updateMaxFee?: boolean; }; // #endregion cost-estimation-1 @@ -483,8 +484,8 @@ export default class Provider { if (!isMajorSupported || !isMinorSupported) { // eslint-disable-next-line no-console console.warn( - `The Fuel Node that you are trying to connect to is using fuel-core version ${nodeInfo.nodeVersion}, -which is not supported by the version of the TS SDK that you are using. + `The Fuel Node that you are trying to connect to is using fuel-core version ${nodeInfo.nodeVersion}, +which is not supported by the version of the TS SDK that you are using. Things may not work as expected. Supported fuel-core version: ${supportedVersion}.` ); @@ -1059,7 +1060,7 @@ Supported fuel-core version: ${supportedVersion}.` const txRequestClone = clone(transactionRequestify(transactionRequestLike)); const isScriptTransaction = txRequestClone.type === TransactionType.Script; const baseAssetId = this.getBaseAssetId(); - + const updateMaxFee = txRequestClone.maxFee.eq(0); // Fund with fake UTXOs to avoid not enough funds error // Getting coin quantities from amounts being transferred const coinOutputsQuantities = txRequestClone.getCoinOutputsQuantities(); @@ -1072,7 +1073,6 @@ Supported fuel-core version: ${supportedVersion}.` * Estimate predicates gasUsed */ // Remove gasLimit to avoid gasLimit when estimating predicates - txRequestClone.maxFee = bn(0); if (isScriptTransaction) { txRequestClone.gasLimit = bn(0); } @@ -1097,6 +1097,7 @@ Supported fuel-core version: ${supportedVersion}.` } await this.estimatePredicates(signedRequest); + txRequestClone.updatePredicateGasUsed(signedRequest.inputs); /** * Calculate minGas and maxGas based on the real transaction @@ -1112,8 +1113,6 @@ Supported fuel-core version: ${supportedVersion}.` let outputVariables = 0; let gasUsed = bn(0); - txRequestClone.updatePredicateGasUsed(signedRequest.inputs); - txRequestClone.maxFee = maxFee; if (isScriptTransaction) { txRequestClone.gasLimit = gasLimit; @@ -1148,6 +1147,7 @@ Supported fuel-core version: ${supportedVersion}.` addedSignatures, estimatedPredicates: txRequestClone.inputs, dryRunStatus, + updateMaxFee, }; } diff --git a/packages/fuel-gauge/src/contract.test.ts b/packages/fuel-gauge/src/contract.test.ts index 30ab12e0dc..2dbb929bb1 100644 --- a/packages/fuel-gauge/src/contract.test.ts +++ b/packages/fuel-gauge/src/contract.test.ts @@ -25,6 +25,7 @@ import { ZeroBytes32, FUEL_NETWORK_URL, Predicate, + PolicyType, } from 'fuels'; import { FuelGaugeProjectsEnum, getFuelGaugeForcProject } from '../test/fixtures'; @@ -478,7 +479,6 @@ describe('Contract', () => { ]) .txParams({ gasLimit: 4_000_000, - optimizeGas: false, }) .call<[BN, BN]>(); @@ -510,7 +510,6 @@ describe('Contract', () => { const { value } = await invocationScope .txParams({ gasLimit: transactionCost.gasUsed, - optimizeGas: false, }) .call<[string, string]>(); @@ -646,9 +645,7 @@ describe('Contract', () => { const struct = { a: true, b: 1337 }; const invocationScopes = [contract.functions.foo(num), contract.functions.boo(struct)]; const multiCallScope = contract.multiCall(invocationScopes); - await multiCallScope.fundWithRequiredCoins(); - - const transactionRequest = await multiCallScope.getTransactionRequest(); + const transactionRequest = await multiCallScope.fundWithRequiredCoins(); const txRequest = JSON.stringify(transactionRequest); const txRequestParsed = JSON.parse(txRequest); @@ -821,13 +818,13 @@ describe('Contract', () => { * to move them to another test suite when addressing https://github.com/FuelLabs/fuels-ts/issues/1043. */ it('should tranfer asset to a deployed contract just fine (NATIVE ASSET)', async () => { - const wallet = await generateTestWallet(provider, [[10_000_000_000, baseAssetId]]); + const wallet = await generateTestWallet(provider, [[10_000_000, baseAssetId]]); const contract = await setupContract(); const initialBalance = new BN(await contract.getBalance(baseAssetId)).toNumber(); - const u64Amount = bn(5_000_000_000); + const u64Amount = bn(10_000); const amountToContract = u64Amount; const tx = await wallet.transferToContract(contract.id, amountToContract, baseAssetId); @@ -839,6 +836,28 @@ describe('Contract', () => { expect(finalBalance).toBe(initialBalance + amountToContract.toNumber()); }); + it('should set "gasLimit" and "maxFee" when transferring amounts to contract just fine', async () => { + const wallet = await generateTestWallet(provider, [[10_000_000, baseAssetId]]); + const contract = await setupContract(); + const amountToContract = 5_000; + + const gasLimit = 80_000; + const maxFee = 40_000; + + const tx = await wallet.transferToContract(contract.id, amountToContract, baseAssetId, { + gasLimit, + maxFee, + }); + + const { transaction } = await tx.waitForResult(); + + const { scriptGasLimit, policies } = transaction; + const maxFeePolicy = policies?.find((policy) => policy.type === PolicyType.MaxFee); + + expect(scriptGasLimit?.toNumber()).toBe(gasLimit); + expect(bn(maxFeePolicy?.data).toNumber()).toBe(maxFee); + }); + it('should ensure gas price and gas limit are validated when transfering to contract', async () => { const wallet = await generateTestWallet(provider, [[1000, baseAssetId]]); @@ -1148,4 +1167,66 @@ describe('Contract', () => { expect(value.toNumber()).toBe(initialCounterValue); }); + + it('should ensure "maxFee" and "gasLimit" can be set for a contract call', async () => { + const { abiContents, binHexlified } = getFuelGaugeForcProject( + FuelGaugeProjectsEnum.STORAGE_TEST_CONTRACT + ); + + const wallet = await generateTestWallet(provider, [[150_000, baseAssetId]]); + const factory = new ContractFactory(binHexlified, abiContents, wallet); + + const storageContract = await factory.deployContract(); + + const gasLimit = 200_000; + const maxFee = 100_000; + + const { + transactionResult: { transaction }, + } = await storageContract.functions + .counter() + .txParams({ + gasLimit, + maxFee, + }) + .call(); + + const maxFeePolicy = transaction.policies?.find((policy) => policy.type === PolicyType.MaxFee); + const scriptGasLimit = transaction.scriptGasLimit; + + expect(scriptGasLimit?.toNumber()).toBe(gasLimit); + expect(bn(maxFeePolicy?.data).toNumber()).toBe(maxFee); + }); + + it('should ensure "maxFee" and "gasLimit" can be set on a multicall', async () => { + const contract = await setupContract({ + cache: false, + }); + + const gasLimit = 500_000; + const maxFee = 250_000; + + const { + transactionResult: { transaction }, + } = await contract + .multiCall([ + contract.functions.foo(1336), + contract.functions.foo(1336), + contract.functions.foo(1336), + contract.functions.foo(1336), + contract.functions.foo(1336), + contract.functions.foo(1336), + contract.functions.foo(1336), + contract.functions.foo(1336), + ]) + .txParams({ gasLimit, maxFee }) + .call(); + + const { scriptGasLimit, policies } = transaction; + + const maxFeePolicy = policies?.find((policy) => policy.type === PolicyType.MaxFee); + + expect(scriptGasLimit?.toNumber()).toBe(gasLimit); + expect(bn(maxFeePolicy?.data).toNumber()).toBe(maxFee); + }); }); diff --git a/packages/program/package.json b/packages/program/package.json index a73981eea5..8b2bde12ac 100644 --- a/packages/program/package.json +++ b/packages/program/package.json @@ -25,6 +25,7 @@ }, "license": "Apache-2.0", "dependencies": { + "ramda": "^0.29.0", "@fuel-ts/abi-coder": "workspace:*", "@fuel-ts/account": "workspace:*", "@fuel-ts/address": "workspace:*", @@ -34,5 +35,8 @@ "@fuel-ts/transactions": "workspace:*", "@fuel-ts/utils": "workspace:*", "@fuels/vm-asm": "0.49.0" + }, + "devDependencies": { + "@types/ramda": "^0.29.3" } } diff --git a/packages/program/src/functions/base-invocation-scope.ts b/packages/program/src/functions/base-invocation-scope.ts index 22e714079d..8b94e63077 100644 --- a/packages/program/src/functions/base-invocation-scope.ts +++ b/packages/program/src/functions/base-invocation-scope.ts @@ -16,6 +16,7 @@ import { bn } from '@fuel-ts/math'; import { InputType, TransactionType } from '@fuel-ts/transactions'; import { isDefined } from '@fuel-ts/utils'; import * as asm from '@fuels/vm-asm'; +import { clone } from 'ramda'; import { getContractCallScript } from '../contract-call-script'; import { POINTER_DATA_OFFSET } from '../script-request'; @@ -257,12 +258,12 @@ export class BaseInvocationScope { * @returns The current instance of the class. */ async fundWithRequiredCoins() { - const transactionRequest = await this.getTransactionRequest(); + let transactionRequest = await this.getTransactionRequest(); + transactionRequest = clone(transactionRequest); const txCost = await this.getTransactionCost(); const { gasUsed, missingContractIds, outputVariables, maxFee } = txCost; this.setDefaultTxParams(transactionRequest, gasUsed, maxFee); - // Clean coin inputs before add new coins to the request transactionRequest.inputs = transactionRequest.inputs.filter((i) => i.type !== InputType.Coin); @@ -274,21 +275,11 @@ export class BaseInvocationScope { // Adding required number of OutputVariables transactionRequest.addVariableOutputs(outputVariables); - const optimizeGas = this.txParameters?.optimizeGas ?? true; - if (this.txParameters?.gasLimit && !optimizeGas) { - transactionRequest.gasLimit = bn(this.txParameters.gasLimit); - const { maxFee: maxFeeForGasLimit } = await this.getProvider().estimateTxGasAndFee({ - transactionRequest, - }); - transactionRequest.maxFee = maxFeeForGasLimit; - } - await this.program.account?.fund(transactionRequest, txCost); if (this.addSignersCallback) { await this.addSignersCallback(transactionRequest); } - return transactionRequest; } @@ -471,23 +462,24 @@ export class BaseInvocationScope { const gasLimitSpecified = isDefined(this.txParameters?.gasLimit) || this.hasCallParamsGasLimit; const maxFeeSpecified = isDefined(this.txParameters?.maxFee); - const { gasLimit, maxFee: setMaxFee } = transactionRequest; + const { gasLimit: setGasLimit, maxFee: setMaxFee } = transactionRequest; - if (gasLimitSpecified && gasLimit.lt(gasUsed)) { + if (!gasLimitSpecified) { + transactionRequest.gasLimit = gasUsed; + } else if (setGasLimit.lt(gasUsed)) { throw new FuelError( ErrorCode.GAS_LIMIT_TOO_LOW, - `Gas limit '${gasLimit}' is lower than the required: '${gasUsed}'.` + `Gas limit '${setGasLimit}' is lower than the required: '${gasUsed}'.` ); } - if (maxFeeSpecified && maxFee.gt(setMaxFee)) { + if (!maxFeeSpecified) { + transactionRequest.maxFee = maxFee; + } else if (maxFee.gt(setMaxFee)) { throw new FuelError( ErrorCode.MAX_FEE_TOO_LOW, `Max fee '${setMaxFee}' is lower than the required: '${maxFee}'.` ); } - - transactionRequest.gasLimit = gasUsed; - transactionRequest.maxFee = maxFee; } } diff --git a/packages/program/src/types.ts b/packages/program/src/types.ts index 9662cb4dca..17d6a32338 100644 --- a/packages/program/src/types.ts +++ b/packages/program/src/types.ts @@ -41,7 +41,6 @@ export type TxParams = Partial<{ maxFee?: BigNumberish; witnessLimit?: BigNumberish; variableOutputs: number; - optimizeGas?: boolean; }>; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7db304181d..57f4b9737c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1033,6 +1033,13 @@ importers: '@fuels/vm-asm': specifier: 0.49.0 version: 0.49.0 + ramda: + specifier: ^0.29.0 + version: 0.29.0 + devDependencies: + '@types/ramda': + specifier: ^0.29.3 + version: 0.29.3 packages/script: dependencies: