Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions src/core/account/memory-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
243 changes: 154 additions & 89 deletions src/core/currency/wallet/currency-wallet-api.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -23,6 +23,7 @@ import {
EdgeCurrencyWallet,
EdgeDataDump,
EdgeEncodeUri,
EdgeEnginePrivateKeyOptions,
EdgeGetReceiveAddressOptions,
EdgeGetTransactionsOptions,
EdgeParsedUri,
Expand Down Expand Up @@ -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<EdgeTransaction>,
isMaxSpend: boolean = false
): Promise<EdgeTransaction> => {
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,
Expand Down Expand Up @@ -540,98 +659,44 @@ export function makeCurrencyWalletApi(
return await engine.getPaymentProtocolInfo(paymentProtocolUrl)
},
async makeSpend(spendInfo: EdgeSpendInfo): Promise<EdgeTransaction> {
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<EdgeTransaction> {
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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbound makeMaxSpend breaks class engines

High Severity

When a native EdgeCurrencyEngine.makeMaxSpend exists, the wallet copies it off the engine and invokes the bare function. Class-based engines (the usual pattern here) rely on this; that call drops the engine instance and can throw or misbehave at runtime.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 40b8361. Configure here.

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<void> {
if (input.props.walletState.txs[transaction.txid] == null) {
Expand Down
18 changes: 18 additions & 0 deletions src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,15 @@ export interface EdgeCurrencyEngine {
spendInfo: EdgeSpendInfo,
opts?: EdgeEnginePrivateKeyOptions
) => Promise<EdgeTransaction>
/**
* 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<EdgeTransaction>
readonly signTx: (
transaction: EdgeTransaction,
privateKeys: JsonObject
Expand Down Expand Up @@ -1390,11 +1399,18 @@ export interface EdgeCurrencyWallet {

// Sending:
readonly broadcastTx: (tx: EdgeTransaction) => Promise<EdgeTransaction>
/** @deprecated Use `makeMaxSpend` to build a max-spend transaction. */
readonly getMaxSpendable: (spendInfo: EdgeSpendInfo) => Promise<string>
readonly getPaymentProtocolInfo: (
paymentProtocolUrl: string
) => Promise<EdgePaymentProtocolInfo>
readonly makeSpend: (spendInfo: EdgeSpendInfo) => Promise<EdgeTransaction>
/**
* 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<EdgeTransaction>
readonly saveTx: (tx: EdgeTransaction) => Promise<void>
readonly saveTxAction: (opts: EdgeSaveTxActionOptions) => Promise<void>
readonly saveTxMetadata: (opts: EdgeSaveTxMetadataOptions) => Promise<void>
Expand Down Expand Up @@ -1476,8 +1492,10 @@ export interface EdgeMemoryWallet {
readonly syncStatus: EdgeSyncStatus
readonly changeEnabledTokenIds: (tokenIds: string[]) => Promise<void>
readonly startEngine: () => Promise<void>
/** @deprecated Use `makeMaxSpend` to build a max-spend transaction. */
readonly getMaxSpendable: (spendInfo: EdgeSpendInfo) => Promise<string>
readonly makeSpend: (spendInfo: EdgeSpendInfo) => Promise<EdgeTransaction>
readonly makeMaxSpend: (spendInfo: EdgeSpendInfo) => Promise<EdgeTransaction>
readonly signTx: (tx: EdgeTransaction) => Promise<EdgeTransaction>
readonly broadcastTx: (tx: EdgeTransaction) => Promise<EdgeTransaction>
readonly saveTx: (tx: EdgeTransaction) => Promise<void>
Expand Down
22 changes: 22 additions & 0 deletions test/core/currency/wallet/currency-wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading