diff --git a/src/command/stamp/topup.ts b/src/command/stamp/topup.ts index f169a3e0..bf0c6d32 100644 --- a/src/command/stamp/topup.ts +++ b/src/command/stamp/topup.ts @@ -4,6 +4,7 @@ import { stampProperties } from '../../utils/option' import { createSpinner } from '../../utils/spinner' import { VerbosityLevel } from '../root-command/command-log' import { StampCommand } from './stamp-command' +import { calculateAndDisplayCosts, checkBzzBalance, checkXdaiBalance } from '../../utils/bzz-transaction-utils' export class Topup extends StampCommand implements LeafCommand { public readonly name = 'topup' @@ -29,6 +30,66 @@ export class Topup extends StampCommand implements LeafCommand { this.stamp = await pickStamp(this.bee, this.console) } + // Get stamp details to calculate duration extension + const stamp = await this.bee.getPostageBatch(this.stamp) + const chainState = await this.bee.getChainState() + const { bzzBalance } = await this.bee.getWalletBalance() + + // Calculate duration extension (approximate) + const currentPrice = BigInt(chainState.currentPrice) + const blocksPerDay = 17280n // ~5 seconds per block + const additionalDaysNumber = Number(this.amount) / Number(currentPrice * blocksPerDay) + + // Get wallet address + const { ethereum } = await this.bee.getNodeAddresses() + const walletAddress = ethereum.toHex() + + this.console.log(`Topping up stamp ${this.stamp} of depth ${stamp.depth} with ${this.amount} PLUR.\n`) + + // Calculate costs + const { bzzCost, estimatedGasCost } = await calculateAndDisplayCosts( + stamp.depth, + this.amount, + bzzBalance.toPLURBigInt(), + this.console + ) + + this.console.log(`Current price: ${currentPrice.toString()} PLUR per block`) + this.console.log(`Estimated TTL extension: ~${additionalDaysNumber.toFixed(2)} days`) + + // Check BZZ balance + const hasSufficientBzz = await checkBzzBalance( + walletAddress, + bzzCost.toPLURBigInt(), + bzzBalance.toPLURBigInt(), + this.console + ) + + if (!hasSufficientBzz) { + process.exit(1) + } + + // Check xDAI balance + const hasSufficientXdai = await checkXdaiBalance( + walletAddress, + estimatedGasCost, + this.console, + ) + + if (!hasSufficientXdai) { + process.exit(1) + } + + // Ask for confirmation before proceeding + if (!this.yes) { + this.yes = await this.console.confirm('Do you want to proceed with this topup?') + } + + if (!this.yes) { + this.console.log('Topup cancelled by user') + return + } + const spinner = createSpinner('Topup in progress. This may take a few minutes.') if (this.verbosity !== VerbosityLevel.Quiet && !this.curl) { diff --git a/src/command/utility/create-batch.ts b/src/command/utility/create-batch.ts index 9c2bd603..6e21e460 100644 --- a/src/command/utility/create-batch.ts +++ b/src/command/utility/create-batch.ts @@ -1,10 +1,10 @@ -import { Utils } from '@ethersphere/bee-js' import { Numbers, Strings } from 'cafe-utility' -import { Contract, Event, Wallet } from 'ethers' +import { BigNumber, Contract, Event, Wallet } from 'ethers' import { LeafCommand, Option } from 'furious-commander' import { ABI, Contracts } from '../../utils/contracts' import { makeReadySigner } from '../../utils/rpc' import { RootCommand } from '../root-command' +import { calculateAndDisplayCosts, checkBzzBalance, checkXdaiBalance, checkAndApproveAllowance } from '../../utils/bzz-transaction-utils' export class CreateBatch extends RootCommand implements LeafCommand { public readonly name = 'create-batch' @@ -49,6 +49,45 @@ export class CreateBatch extends RootCommand implements LeafCommand { public async run(): Promise { super.init() + const wallet = new Wallet(this.privateKey) + const signer = await makeReadySigner(wallet.privateKey, this.jsonRpcUrl) + + // Get BZZ balance + const bzzContract = new Contract(Contracts.bzz, ABI.bzz, signer) + const balance = await bzzContract.balanceOf(wallet.address) + const bzzBalance = BigNumber.from(balance) + + // Calculate costs + const { bzzCost, estimatedGasCost } = await calculateAndDisplayCosts( + this.depth, + this.amount, + bzzBalance.toBigInt(), + this.console + ) + + // Check BZZ balance + const hasSufficientBzz = await checkBzzBalance( + wallet.address, + bzzCost.toPLURBigInt(), + bzzBalance.toBigInt(), + this.console + ) + + if (!hasSufficientBzz) { + process.exit(1) + } + + // Check xDAI balance + const hasSufficientXdai = await checkXdaiBalance( + wallet.address, + estimatedGasCost, + this.console + ) + + if (!hasSufficientXdai) { + process.exit(1) + } + if (!this.yes) { this.yes = await this.console.confirm( 'This command creates an external batch for advanced usage. Do you want to continue?', @@ -59,20 +98,18 @@ export class CreateBatch extends RootCommand implements LeafCommand { return } - const wallet = new Wallet(this.privateKey) - const cost = Utils.getStampCost(this.depth, this.amount) - const signer = await makeReadySigner(wallet.privateKey, this.jsonRpcUrl) - - this.console.log(`Approving spending of ${cost.toDecimalString()} BZZ to ${wallet.address}`) - const tokenProxyContract = new Contract(Contracts.bzz, ABI.tokenProxy, signer) - const approve = await tokenProxyContract.approve(Contracts.postageStamp, cost.toPLURBigInt().toString(), { - gasLimit: 130_000, - type: 2, - maxFeePerGas: Numbers.make('2gwei'), - maxPriorityFeePerGas: Numbers.make('1gwei'), - }) - this.console.log(`Waiting 3 blocks on approval tx ${approve.hash}`) - await approve.wait(3) + // Check and approve allowance if needed + const requiredAmount = bzzCost.toPLURBigInt().toString() + const approved = await checkAndApproveAllowance( + this.privateKey, + requiredAmount, + this.console + ) + + if (!approved) { + this.console.error('Failed to approve BZZ spending') + process.exit(1) + } this.console.log(`Creating postage batch for ${wallet.address} with depth ${this.depth} and amount ${this.amount}`) const postageStampContract = new Contract(Contracts.postageStamp, ABI.postageStamp, signer) diff --git a/src/utils/bzz-transaction-utils.ts b/src/utils/bzz-transaction-utils.ts new file mode 100644 index 00000000..624c014a --- /dev/null +++ b/src/utils/bzz-transaction-utils.ts @@ -0,0 +1,151 @@ +import { Utils } from '@ethersphere/bee-js' +import { BigNumber, Contract, providers, Wallet, utils as ethersUtils } from 'ethers' +import { NETWORK_ID, Contracts, ABI } from './contracts' +import { eth_getBalance, makeReadySigner } from './rpc' +import { CommandLog } from '../command/root-command/command-log' + +/** + * Checks if a wallet has sufficient BZZ funds for an operation + * @param walletAddress The wallet address to check + * @param requiredAmount The required amount in BZZ + * @param availableAmount The available amount in BZZ + * @param console Console instance for output + * @returns True if sufficient funds, false otherwise + */ +export async function checkBzzBalance( + walletAddress: string, + requiredAmount: bigint, + availableAmount: bigint, + console: CommandLog, +): Promise { + // Convert to string for comparison + const requiredAmountStr = requiredAmount.toString() + const availableAmountStr = availableAmount.toString() + + if (BigNumber.from(availableAmountStr).lt(BigNumber.from(requiredAmountStr))) { + console.error(`\nWallet address: 0x${walletAddress} has insufficient BZZ funds.`) + // Format amounts for display + const requiredFormatted = ethersUtils.formatUnits(requiredAmount, 18) + const availableFormatted = ethersUtils.formatUnits(availableAmount, 18) + + console.error(`Required: ${requiredFormatted} BZZ`) + console.error(`Available: ${availableFormatted} BZZ`) + return false + } + return true +} + +/** + * Checks if a wallet has sufficient xDAI funds for gas + * @param walletAddress The wallet address to check + * @param estimatedGasCost The estimated gas cost + * @param console Console instance for output + * @returns True if sufficient funds, false otherwise + */ +export async function checkXdaiBalance( + walletAddress: string, + estimatedGasCost: BigNumber, + console: CommandLog, +): Promise { + const jsonRpcUrl = 'https://xdai.fairdatasociety.org' + const provider = new providers.JsonRpcProvider(jsonRpcUrl, NETWORK_ID) + const xDAI = await eth_getBalance(walletAddress, provider) + const xDAIValue = BigNumber.from(xDAI) + + if (xDAIValue.lt(estimatedGasCost)) { + console.error(`\nWallet address: 0x${walletAddress} has insufficient xDAI funds for gas fees.`) + console.error( + `Required: ~${ethersUtils.formatEther(estimatedGasCost)} xDAI, Available: ${ethersUtils.formatEther( + xDAIValue + )} xDAI`, + ) + return false + } + return true +} + +/** + * Calculates and displays operation costs + * @param depth The depth of the batch + * @param amount The amount in PLUR + * @param bzzBalance The current BZZ balance (optional) + * @param console Console instance for output + * @returns An object containing cost information + */ +export async function calculateAndDisplayCosts( + depth: number, + amount: bigint, + bzzBalance: bigint, + console: CommandLog, +): Promise<{ + bzzCost: any // Keep as 'any' since it's a Utils.getStampCost return type + estimatedGasCost: BigNumber + provider: providers.JsonRpcProvider +}> { + const bzzCost = Utils.getStampCost(depth, amount) + const jsonRpcUrl = 'https://xdai.fairdatasociety.org' + const provider = new providers.JsonRpcProvider(jsonRpcUrl, NETWORK_ID) + // Estimate gas costs + const gasPrice = await provider.getGasPrice() + const gasLimit = BigNumber.from(1000000) // Conservative estimate + const estimatedGasCost = gasPrice.mul(gasLimit) + + console.log(`Operation will cost ${bzzCost.toDecimalString()} BZZ and ~${ethersUtils.formatEther(estimatedGasCost)} xDAI`) + console.log(`Your current balance is ${ethersUtils.formatUnits(bzzBalance, 16)} BZZ`) + + return { bzzCost, estimatedGasCost, provider } +} + +/** + * Checks if the current allowance is sufficient and approves if needed + * @param privateKey The private key of the wallet + * @param requiredAmount The required amount in BZZ (as a string) + * @param console Console instance for output + * @returns True if approval was successful or not needed + */ +export async function checkAndApproveAllowance( + privateKey: string, + requiredAmount: string, + console: CommandLog, +): Promise { + const jsonRpcUrl = 'https://xdai.fairdatasociety.org' + const wallet = new Wallet(privateKey) + const signer = await makeReadySigner(wallet.privateKey, jsonRpcUrl) + + // Check current allowance + const allowanceAbi = [ + { + type: 'function', + stateMutability: 'view', + payable: false, + outputs: [{ type: 'uint256', name: 'remaining' }], + name: 'allowance', + inputs: [ + { type: 'address', name: '_owner' }, + { type: 'address', name: '_spender' }, + ], + constant: true, + }, + ] + + const bzzAllowanceContract = new Contract(Contracts.bzz, allowanceAbi, signer) + const currentAllowance = await bzzAllowanceContract.allowance(wallet.address, Contracts.postageStamp) + console.log(`Current allowance: ${Number(currentAllowance) / 10 ** 18} BZZ`) + + if (currentAllowance.lt(requiredAmount)) { + console.log(`Approving spending of ${requiredAmount} PLUR to ${Contracts.postageStamp}`) + const tokenProxyContract = new Contract(Contracts.bzz, ABI.tokenProxy, signer) + const approve = await tokenProxyContract.approve(Contracts.postageStamp, requiredAmount, { + gasLimit: 130_000, + type: 2, + maxFeePerGas: BigNumber.from(2000000000), // 2 gwei + maxPriorityFeePerGas: BigNumber.from(1000000000), // 1 gwei + }) + console.log(`Waiting 3 blocks on approval tx ${approve.hash}`) + await approve.wait(3) + return true + } else { + console.log(`Approval not needed. Current allowance: ${Number(currentAllowance) / 10 ** 18} BZZ`) + return true + } +}