diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc8e4db3..c02d985df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- added: `EdgeCurrencyWallet.makeMaxSpend` and `EdgeMemoryWallet.makeMaxSpend`, which atomically build a transaction that spends the maximum amount. The core falls back to `getMaxSpendable` + `makeSpend` for engines that don't implement `EdgeCurrencyEngine.makeMaxSpend`. +- deprecated: `EdgeCurrencyWallet.getMaxSpendable` and `EdgeMemoryWallet.getMaxSpendable`. Use `makeMaxSpend` to build a max-spend transaction. + ## 2.46.0 (2026-06-18) - added: `EdgeCurrencyWallet.walletSettings` and `EdgeCurrencyWallet.changeWalletSettings`, plus matching engine plumbing. diff --git a/src/core/account/memory-wallet.ts b/src/core/account/memory-wallet.ts index b6114f894..2bafdd7e2 100644 --- a/src/core/account/memory-wallet.ts +++ b/src/core/account/memory-wallet.ts @@ -203,6 +203,34 @@ export const makeMemoryWalletInner = async ( unsafeMakeSpend ? privateKeys : undefined ) }, + async makeMaxSpend(spendInfo: EdgeSpendInfo) { + if (engine.makeMaxSpend != null) { + return await engine.makeMaxSpend( + spendInfo, + unsafeMakeSpend ? privateKeys : undefined + ) + } + + // Fallback shim for engines without a native `makeMaxSpend`: compute the + // maximum spendable amount, then build a normal spend for that amount. + const maxNativeAmount = await getMaxSpendableInner( + spendInfo, + plugin, + engine, + config.allTokens, + walletInfo + ) + const maxSpendInfo: EdgeSpendInfo = { + ...spendInfo, + spendTargets: spendInfo.spendTargets.map((target, index) => + index === 0 ? { ...target, nativeAmount: maxNativeAmount } : target + ) + } + return await engine.makeSpend( + maxSpendInfo, + unsafeMakeSpend ? privateKeys : undefined + ) + }, async signTx(tx: EdgeTransaction) { return await engine.signTx(tx, privateKeys) }, diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index 094d8ce1a..94c50a145 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -1,4 +1,4 @@ -import { abs, div, lt, mul } from 'biggystring' +import { abs, div, lt, mul, sub } from 'biggystring' import { Disklet } from 'disklet' import { base64 } from 'rfc4648' import { bridgifyObject, emit, onMethod, watchMethod } from 'yaob' @@ -23,6 +23,7 @@ import { EdgeCurrencyWallet, EdgeDataDump, EdgeEncodeUri, + EdgeEnginePrivateKeyOptions, EdgeGetReceiveAddressOptions, EdgeGetTransactionsOptions, EdgeParsedUri, @@ -123,6 +124,124 @@ export function makeCurrencyWalletApi( } bridgifyObject(otherMethods) + /** + * Shared implementation for `makeSpend` and `makeMaxSpend`. Performs all the + * common spend-info preparation and transaction post-processing, delegating + * the actual transaction construction to `makeEngineTx`. + */ + const makeSpendInner = async ( + spendInfo: EdgeSpendInfo, + makeEngineTx: ( + engineSpendInfo: EdgeSpendInfo, + opts: EdgeEnginePrivateKeyOptions + ) => Promise, + isMaxSpend: boolean = false + ): Promise => { + spendInfo = upgradeMemos(spendInfo, plugin.currencyInfo) + const { + assetAction, + customNetworkFee, + enableRbf, + memos, + metadata, + networkFeeOption = 'standard', + noUnconfirmed = false, + otherParams, + pendingTxs, + rbfTxid, + savedAction, + skipChecks, + spendTargets = [], + swapData + } = spendInfo + + // Figure out which asset this is: + const upgradedCurrency = upgradeCurrencyCode({ + allTokens: input.props.state.accounts[accountId].allTokens[pluginId], + currencyInfo: plugin.currencyInfo, + tokenId: spendInfo.tokenId + }) + + // Check the spend targets: + const cleanTargets: EdgeSpendTarget[] = [] + const savedTargets: SavedSpendTargets = [] + for (const target of spendTargets) { + const { + memo, + publicAddress, + nativeAmount = '0', + otherParams = {} + } = target + if (publicAddress == null) continue + + cleanTargets.push({ + memo, + nativeAmount, + otherParams, + publicAddress, + uniqueIdentifier: memo + }) + savedTargets.push({ + currencyCode: upgradedCurrency.currencyCode, + memo, + nativeAmount, + publicAddress, + uniqueIdentifier: memo + }) + } + + if (spendInfo.privateKeys != null) { + throw new TypeError('Only sweepPrivateKeys takes private keys') + } + + // Only provide wallet info if currency requires it: + const privateKeys = unsafeMakeSpend ? walletInfo.keys : undefined + + const tx: EdgeTransaction = await makeEngineTx( + { + ...upgradedCurrency, + customNetworkFee, + enableRbf, + memos, + metadata, + networkFeeOption, + noUnconfirmed, + otherParams, + pendingTxs, + rbfTxid, + skipChecks, + spendTargets: cleanTargets + }, + { privateKeys } + ) + + // For a max spend the engine owns the final amount, so backfill the saved + // target from the resulting transaction (the caller passed `0`): + if (isMaxSpend && savedTargets.length === 1) { + const sentNativeAmount = + tx.tokenId == null + ? sub(abs(tx.nativeAmount), tx.networkFee) + : abs(tx.nativeAmount) + savedTargets[0] = { ...savedTargets[0], nativeAmount: sentNativeAmount } + } + + upgradeTxNetworkFees(tx) + tx.networkFeeOption = networkFeeOption + tx.requestedCustomFee = customNetworkFee + tx.spendTargets = savedTargets + tx.currencyCode = upgradedCurrency.currencyCode + tx.tokenId = upgradedCurrency.tokenId + if (metadata != null) tx.metadata = metadata + if (swapData != null) tx.swapData = asEdgeTxSwap(swapData) + if (savedAction != null) tx.savedAction = asEdgeTxAction(savedAction) + if (assetAction != null) tx.assetAction = asEdgeAssetAction(assetAction) + if (input.props.state.login.deviceInfo.deviceDescription != null) + tx.deviceDescription = + input.props.state.login.deviceInfo.deviceDescription + + return tx + } + const out: EdgeCurrencyWallet & InternalWalletMethods = { on: onMethod, watch: watchMethod, @@ -540,98 +659,44 @@ export function makeCurrencyWalletApi( return await engine.getPaymentProtocolInfo(paymentProtocolUrl) }, async makeSpend(spendInfo: EdgeSpendInfo): Promise { - spendInfo = upgradeMemos(spendInfo, plugin.currencyInfo) - const { - assetAction, - customNetworkFee, - enableRbf, - memos, - metadata, - networkFeeOption = 'standard', - noUnconfirmed = false, - otherParams, - pendingTxs, - rbfTxid, - savedAction, - skipChecks, - spendTargets = [], - swapData - } = spendInfo - - // Figure out which asset this is: - const upgradedCurrency = upgradeCurrencyCode({ - allTokens: input.props.state.accounts[accountId].allTokens[pluginId], - currencyInfo: plugin.currencyInfo, - tokenId: spendInfo.tokenId - }) - - // Check the spend targets: - const cleanTargets: EdgeSpendTarget[] = [] - const savedTargets: SavedSpendTargets = [] - for (const target of spendTargets) { - const { - memo, - publicAddress, - nativeAmount = '0', - otherParams = {} - } = target - if (publicAddress == null) continue - - cleanTargets.push({ - memo, - nativeAmount, - otherParams, - publicAddress, - uniqueIdentifier: memo - }) - savedTargets.push({ - currencyCode: upgradedCurrency.currencyCode, - memo, - nativeAmount, - publicAddress, - uniqueIdentifier: memo - }) + return await makeSpendInner( + spendInfo, + async (engineSpendInfo, opts) => + await engine.makeSpend(engineSpendInfo, opts) + ) + }, + async makeMaxSpend(spendInfo: EdgeSpendInfo): Promise { + const { makeMaxSpend } = engine + if (makeMaxSpend != null) { + // The engine builds the max-spend transaction atomically: + return await makeSpendInner( + spendInfo, + async (engineSpendInfo, opts) => + await makeMaxSpend(engineSpendInfo, opts), + true + ) } - if (spendInfo.privateKeys != null) { - throw new TypeError('Only sweepPrivateKeys takes private keys') + // Fallback shim for engines without a native `makeMaxSpend`: compute the + // maximum spendable amount, then build a normal spend for that amount. + const maxNativeAmount = await getMaxSpendableInner( + spendInfo, + plugin, + engine, + input.props.state.accounts[accountId].allTokens[pluginId], + walletInfo + ) + const maxSpendInfo: EdgeSpendInfo = { + ...spendInfo, + spendTargets: spendInfo.spendTargets.map((target, index) => + index === 0 ? { ...target, nativeAmount: maxNativeAmount } : target + ) } - - // Only provide wallet info if currency requires it: - const privateKeys = unsafeMakeSpend ? walletInfo.keys : undefined - - const tx: EdgeTransaction = await engine.makeSpend( - { - ...upgradedCurrency, - customNetworkFee, - enableRbf, - memos, - metadata, - networkFeeOption, - noUnconfirmed, - otherParams, - pendingTxs, - rbfTxid, - skipChecks, - spendTargets: cleanTargets - }, - { privateKeys } + return await makeSpendInner( + maxSpendInfo, + async (engineSpendInfo, opts) => + await engine.makeSpend(engineSpendInfo, opts) ) - upgradeTxNetworkFees(tx) - tx.networkFeeOption = networkFeeOption - tx.requestedCustomFee = customNetworkFee - tx.spendTargets = savedTargets - tx.currencyCode = upgradedCurrency.currencyCode - tx.tokenId = upgradedCurrency.tokenId - if (metadata != null) tx.metadata = metadata - if (swapData != null) tx.swapData = asEdgeTxSwap(swapData) - if (savedAction != null) tx.savedAction = asEdgeTxAction(savedAction) - if (assetAction != null) tx.assetAction = asEdgeAssetAction(assetAction) - if (input.props.state.login.deviceInfo.deviceDescription != null) - tx.deviceDescription = - input.props.state.login.deviceInfo.deviceDescription - - return tx }, async saveTx(transaction: EdgeTransaction): Promise { if (input.props.walletState.txs[transaction.txid] == null) { diff --git a/src/types/types.ts b/src/types/types.ts index d7fe8fec5..37ce5db7b 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1121,6 +1121,15 @@ export interface EdgeCurrencyEngine { spendInfo: EdgeSpendInfo, opts?: EdgeEnginePrivateKeyOptions ) => Promise + /** + * Atomically builds a transaction that spends the maximum amount. + * Engines that don't implement this get a core fallback that combines + * `getMaxSpendable` and `makeSpend`. + */ + readonly makeMaxSpend?: ( + spendInfo: EdgeSpendInfo, + opts?: EdgeEnginePrivateKeyOptions + ) => Promise readonly signTx: ( transaction: EdgeTransaction, privateKeys: JsonObject @@ -1390,11 +1399,18 @@ export interface EdgeCurrencyWallet { // Sending: readonly broadcastTx: (tx: EdgeTransaction) => Promise + /** @deprecated Use `makeMaxSpend` to build a max-spend transaction. */ readonly getMaxSpendable: (spendInfo: EdgeSpendInfo) => Promise readonly getPaymentProtocolInfo: ( paymentProtocolUrl: string ) => Promise readonly makeSpend: (spendInfo: EdgeSpendInfo) => Promise + /** + * Atomically builds a transaction that spends the maximum amount. + * Same signature as `makeSpend`. Always available: the core provides a + * fallback for engines that don't implement it natively. + */ + readonly makeMaxSpend: (spendInfo: EdgeSpendInfo) => Promise readonly saveTx: (tx: EdgeTransaction) => Promise readonly saveTxAction: (opts: EdgeSaveTxActionOptions) => Promise readonly saveTxMetadata: (opts: EdgeSaveTxMetadataOptions) => Promise @@ -1476,8 +1492,10 @@ export interface EdgeMemoryWallet { readonly syncStatus: EdgeSyncStatus readonly changeEnabledTokenIds: (tokenIds: string[]) => Promise readonly startEngine: () => Promise + /** @deprecated Use `makeMaxSpend` to build a max-spend transaction. */ readonly getMaxSpendable: (spendInfo: EdgeSpendInfo) => Promise readonly makeSpend: (spendInfo: EdgeSpendInfo) => Promise + readonly makeMaxSpend: (spendInfo: EdgeSpendInfo) => Promise readonly signTx: (tx: EdgeTransaction) => Promise readonly broadcastTx: (tx: EdgeTransaction) => Promise readonly saveTx: (tx: EdgeTransaction) => Promise diff --git a/test/core/currency/wallet/currency-wallet.test.ts b/test/core/currency/wallet/currency-wallet.test.ts index dfd14db20..d3263fc16 100644 --- a/test/core/currency/wallet/currency-wallet.test.ts +++ b/test/core/currency/wallet/currency-wallet.test.ts @@ -457,6 +457,28 @@ describe('currency wallets', function () { ) }) + it('can make max spend', async function () { + const { wallet, config } = await makeFakeCurrencyWallet() + await config.changeUserSettings({ balance: 50 }) + + // The fake engine does not implement `makeMaxSpend`, so this exercises the + // core fallback shim (getMaxSpendable + makeSpend): + const tx = await wallet.makeMaxSpend({ + tokenId: null, + spendTargets: [{ publicAddress: 'somewhere' }] + }) + expect(tx.nativeAmount).equals('50') + expect(tx.spendTargets).deep.equals([ + { + currencyCode: 'FAKE', + memo: undefined, + nativeAmount: '50', + publicAddress: 'somewhere', + uniqueIdentifier: undefined + } + ]) + }) + it('converts number formats', async function () { const { wallet } = await makeFakeCurrencyWallet() expect(await wallet.denominationToNative('0.1', 'SMALL')).equals('1')