Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/lucky-otters-wander.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/coin-tezos": minor
Comment thread
amaslakov marked this conversation as resolved.
---

Fix Tezos send-max reporting insufficient funds when the minFees floor exceeds the estimated fee
46 changes: 43 additions & 3 deletions libs/coin-modules/coin-tezos/src/logic/estimateFees.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,11 +812,51 @@ describe("estimateFees", () => {
});

expect(result.fees).toBe(BigInt(minFees));
// max = spendable(10000) - suggestedFee(100) - (DUST_MARGIN(500) - (DUST_MARGIN*0.1 + opSize(200)))
// = 9900 - (500 - 250) = 9650
expect(result.amount).toBe(9650n);
// max = spendable(10000) - mainOpFee(500) - (DUST_MARGIN(500) - (DUST_MARGIN*0.1 + opSize(200)))
// = 9500 - (500 - 250) = 9250
expect(result.amount).toBe(9250n);
});

// Regression for LIVE-28506: the max must reserve the fee that is actually reported, so that
// `amount + estimatedFees` always fits the spendable balance. Reserving the raw taquito
// suggestion instead made every send-max fail validation once `minFees` exceeded it by more
// than the dust margin — at any balance.
it.each([
// balance, minFees, suggestedFeeMutez, burnFeeMutez, expected amount
[10000n, 2000, 100, 0, 7750n],
[10000n, 100, 2000, 0, 7750n],
// recipient allocation burn (277 bytes * COST_PER_BYTE), minus taquito's 20-byte pad
[100000n, 2000, 100, 69250, 33500n],
])(
"useAllAmount send reserves the reported fee (balance=%i, minFees=%i, suggested=%i, burn=%i)",
Comment thread
amaslakov marked this conversation as resolved.
async (balance, minFees, suggestedFeeMutez, burnFeeMutez, expectedAmount) => {
mockTezosToolkit.estimate.transfer.mockResolvedValue({
suggestedFeeMutez,
gasLimit: 1500,
storageLimit: 0,
burnFeeMutez,
opSize: 200,
});
(coinConfig.getCoinConfig as jest.Mock).mockReturnValue({
fees: { ...defaultFeesConfig, minFees },
});

const result = await estimateFees({
account: { ...revealedAccount, balance },
transaction: {
mode: "send",
recipient: "tz1VSUr8wwNhLAzempoch5d6nLRSNtxK8LBr",
amount: 0n,
useAllAmount: true,
},
});

expect(result.amount).toBe(expectedAmount);
// the invariant validateIntent's balance-coverage check relies on
expect(result.amount! + result.estimatedFees).toBeLessThanOrEqual(balance);
},
);

it("useAllAmount send excludes staked and unstaked funds from the max", async () => {
const balance = 10000n;
const stakedBalance = 3000n;
Expand Down
16 changes: 14 additions & 2 deletions libs/coin-modules/coin-tezos/src/logic/estimateFees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,15 @@ export async function estimateFees({

// NOTE: send-max only applies to native XTZ transfer, not FA2
if (transaction.useAllAmount && transaction.mode === "send") {
// Reserve `mainOpFee`, not the raw taquito suggestion: `estimatedFees` below reports the
// `minFees` floor, so subtracting the (lower) suggestion here yields an amount whose
// `amount + estimatedFees` exceeds the spendable balance, and validateIntent then rejects
// the very max it was handed (LIVE-28506).
// NOTE: from https://github.com/ecadlabs/taquito/blob/master/integration-tests/__tests__/contract/empty-implicit-account-into-new-implicit-account.spec.ts#L37
const totalFees =
estimate.burnFeeMutez > 0
? estimate.suggestedFeeMutez + estimate.burnFeeMutez - 20 * COST_PER_BYTE // 20 is storage buffer
: estimate.suggestedFeeMutez;
? mainOpFee + estimate.burnFeeMutez - 20 * COST_PER_BYTE // 20 is storage buffer
: mainOpFee;
const maxAmount = spendableForMax - (totalFees + Number(revealFee));
// NOTE: from https://github.com/ecadlabs/taquito/blob/a70c64c4b105381bb9f1d04c9c70e8ef26e9241c/integration-tests/contract-empty-implicit-account-into-new-implicit-account.spec.ts#L33
// Temporary fix, see https://gitlab.com/tezos/tezos/-/issues/1754
Expand All @@ -196,6 +200,14 @@ export async function estimateFees({
const incr = DUST_MARGIN_MUTEZ * MINIMAL_FEE_PER_GAS_MUTEZ + Number(estimate.opSize);
const maxMinusBuff = maxAmount - (DUST_MARGIN_MUTEZ - incr);
estimation.amount = maxMinusBuff > 0 ? BigInt(maxMinusBuff) : 0n;
log("tezos-send-max", "send-max fee inputs", {
minFees,
mainOpFee,
suggestedFeeMutez: estimate.suggestedFeeMutez,
burnFeeMutez: estimate.burnFeeMutez,
opSize: Number(estimate.opSize),
revealFee: Number(revealFee),
});
} else if (transaction.useAllAmount && transaction.mode === "stake") {
estimation.amount = computeMaxStakeAmount(
BigInt(account.balance),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ export const scenarioTezosTz1: Scenario<GenericTransaction, Account> = {
const undelegateOps = account.operations.filter(op => op.type === "UNDELEGATE");
expect(undelegateOps.length).toBeGreaterThanOrEqual(1);

expect(account.balance.toNumber()).toBeLessThanOrEqual(300);
// Send-max leaves the dust margin behind (DUST_MARGIN_MUTEZ, less the fee-per-gas/op-size
// increment), independently of how the `minFees` floor compares to the suggested fee.
expect(account.balance.toNumber()).toBeLessThanOrEqual(500);
},

teardown: async () => {
Expand Down Expand Up @@ -284,7 +286,9 @@ export const scenarioTezosTz2: Scenario<GenericTransaction, Account> = {
const undelegateOps = account.operations.filter(op => op.type === "UNDELEGATE");
expect(undelegateOps.length).toBe(1);

expect(account.balance.toNumber()).toBeLessThanOrEqual(300);
// Send-max leaves the dust margin behind (DUST_MARGIN_MUTEZ, less the fee-per-gas/op-size
// increment), independently of how the `minFees` floor compares to the suggested fee.
expect(account.balance.toNumber()).toBeLessThanOrEqual(500);
},

teardown: async () => {
Expand Down
Loading