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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

All notable changes to Q-Wallets will be documented in this file.

## [1.3.3]

### Added

- `calculateMaxSendable` utility that computes the "SEND MAX" amount in integer satoshi math and holds back a small safety buffer, avoiding floating-point boundary errors that triggered false "Insufficient funds" rejections from the host
- `SEND_MAX_SAFETY_BUFFER_SATS` constant (1000 sats) used as the SEND MAX safety margin

### Fixed

- BTC and DGB Bech32 address validation: corrected the regex charset to the bech32 alphabet (excludes `1`, `b`, `i`, `o`) and allowed variable address length (39–59 chars) so longer Bech32 addresses (e.g. P2WSH) validate correctly
- SEND MAX no longer prefills an amount that lands on or just above the spendable cutoff

### Changed

- All coin send pages (ARRR, BTC, DGB, DOGE, LTC, QORT, RVN) refactored to use the shared `calculateMaxSendable` utility

### Tests

- Added tests for `calculateMaxSendable`
- Expanded address validation tests covering BTC/DGB Bech32 charset and length cases

## [1.3.2] - 2026-03-06

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "q-wallets",
"private": true,
"version": "1.3.2",
"version": "1.3.3",
"type": "module",
"scripts": {
"build": "tsc -b && vite build && cp CHANGELOG.md dist/",
Expand Down
6 changes: 6 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export const DECIMAL_ROUND_UP: number = 8;
// QORT section
export const QORT_1_UNIT: number = 100000000;

// Satoshis held back by "SEND MAX" so the prefilled amount stays strictly
// within the spendable balance (absorbs fee-estimate / serialization slack).
// 1000 sats = 0.00001 coin — negligible to the user, enough to clear the
// host's "Insufficient funds" boundary check.
export const SEND_MAX_SAFETY_BUFFER_SATS: number = 1000;

// Fees
export const ARRR_FEE: number = 0.0001;
export const BTC_FEE: number = 500;
Expand Down
35 changes: 21 additions & 14 deletions src/pages/arrr/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
ARRR_FEE,
DECIMAL_ROUND_UP,
EMPTY_STRING,
SEND_MAX_SAFETY_BUFFER_SATS,
TIME_MINUTES_2,
TIME_MINUTES_3,
TIME_MINUTES_5,
Expand All @@ -88,6 +89,7 @@ import {
} from '../../styles/page-styles';
import { Coin } from 'qapp-core';
import { validateArrrAddress } from '../../utils/addressValidation';
import { calculateMaxSendable } from '../../utils/maxSendable';

interface TablePaginationActionsProps {
count: number;
Expand Down Expand Up @@ -204,18 +206,14 @@ export default function PirateWallet() {
const [openArrrAddressBook, setOpenArrrAddressBook] = useState(false);
const [_retry, setRetry] = useState(false);

const maxSendableArrrCoin = () => {
// manage the correct round up
const value = (walletBalanceArrr - ARRR_FEE).toString();
const [integer, decimal = ''] = value.split('.');
const truncated = decimal
.substring(0, DECIMAL_ROUND_UP)
.padEnd(DECIMAL_ROUND_UP, '0');
let truncatedMaxSendableArrrCoin: number = parseFloat(
`${integer}.${truncated}`
// Safely-spendable max: integer-satoshi math plus a small safety buffer so
// the prefilled amount never lands on/above the host's spendable cutoff.
const maxSendableArrrCoin = () =>
calculateMaxSendable(
walletBalanceArrr,
ARRR_FEE,
SEND_MAX_SAFETY_BUFFER_SATS
);
return truncatedMaxSendableArrrCoin;
};

const emptyRows =
page > 0
Expand Down Expand Up @@ -258,7 +256,10 @@ export default function PirateWallet() {
};

const disableCanSendArrr = () =>
arrrAmount <= 0 || arrrRecipient === EMPTY_STRING || addressFormatError;
arrrAmount <= 0 ||
arrrRecipient === EMPTY_STRING ||
addressFormatError ||
arrrAmount > maxSendableArrrCoin();

const handleRecipientChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim();
Expand Down Expand Up @@ -329,6 +330,12 @@ export default function PirateWallet() {
};

const sendArrrRequest = async () => {
// Conservative revalidation: never submit more than the safely-spendable
// max, even if state changed (e.g. balance refresh) after input.
if (arrrAmount <= 0 || arrrAmount > maxSendableArrrCoin()) {
setOpenSendArrrError(true);
return;
}
setOpenTxArrrSubmit(true);
try {
const sendRequest = await qortalRequest({
Expand Down Expand Up @@ -1158,7 +1165,7 @@ export default function PirateWallet() {
align="center"
sx={{ color: 'text.primary', fontWeight: 700 }}
>
{(walletBalanceArrr - ARRR_FEE).toFixed(DECIMAL_ROUND_UP) + ' ARRR'}
{maxSendableArrrCoin() + ' ARRR'}
</Typography>
<Box style={{ marginInlineStart: '15px' }}>
<Button
Expand Down Expand Up @@ -1197,7 +1204,7 @@ export default function PirateWallet() {
{...({ label: 'Amount (ARRR)' } as any)}
fullWidth
isAllowed={(values) => {
const maxArrrCoin = walletBalanceArrr - ARRR_FEE;
const maxArrrCoin = maxSendableArrrCoin();
const { formattedValue, floatValue } = values;
return (
formattedValue === EMPTY_STRING ||
Expand Down
43 changes: 21 additions & 22 deletions src/pages/btc/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
BTC_FEE,
DECIMAL_ROUND_UP,
EMPTY_STRING,
SEND_MAX_SAFETY_BUFFER_SATS,
TIME_MINUTES_3,
TIME_MINUTES_5,
TIME_SECONDS_2,
Expand All @@ -79,6 +80,7 @@ import {
} from '../../styles/page-styles';
import { Coin } from 'qapp-core';
import { validateBtcAddress } from '../../utils/addressValidation';
import { calculateMaxSendable } from '../../utils/maxSendable';
import { AddressBookDialog } from '../../components/AddressBook/AddressBookDialog';

interface TablePaginationActionsProps {
Expand Down Expand Up @@ -192,18 +194,14 @@ export default function BitcoinWallet() {
const btcFeeCalculated = +(+inputFee / 1000 / 1e8).toFixed(DECIMAL_ROUND_UP);
const estimatedFeeCalculated = +btcFeeCalculated * BTC_FEE;

const maxSendableBtcCoin = () => {
// manage the correct round up
const value = (walletBalanceBtc - estimatedFeeCalculated).toString();
const [integer, decimal = ''] = value.split('.');
const truncated = decimal
.substring(0, DECIMAL_ROUND_UP)
.padEnd(DECIMAL_ROUND_UP, '0');
let truncatedMaxSendableBtcCoin: number = parseFloat(
`${integer}.${truncated}`
// Safely-spendable max: integer-satoshi math plus a small safety buffer so
// the prefilled amount never lands on/above the host's spendable cutoff.
const maxSendableBtcCoin = () =>
calculateMaxSendable(
walletBalanceBtc,
estimatedFeeCalculated,
SEND_MAX_SAFETY_BUFFER_SATS
);
return truncatedMaxSendableBtcCoin;
};

const emptyRows =
page > 0
Expand Down Expand Up @@ -238,7 +236,10 @@ export default function BitcoinWallet() {
};

const disableCanSendBtc = () =>
btcAmount <= 0 || btcRecipient === EMPTY_STRING || addressFormatError;
btcAmount <= 0 ||
btcRecipient === EMPTY_STRING ||
addressFormatError ||
btcAmount > maxSendableBtcCoin();

const handleRecipientChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
Expand Down Expand Up @@ -417,6 +418,12 @@ export default function BitcoinWallet() {

const sendBtcRequest = async () => {
if (!btcFeeCalculated) return;
// Conservative revalidation: never submit more than the safely-spendable
// max, even if state changed (e.g. balance refresh) after input.
if (btcAmount <= 0 || btcAmount > maxSendableBtcCoin()) {
setOpenSendBtcError(true);
return;
}
setOpenTxBtcSubmit(true);
try {
const sendRequest = await qortalRequest({
Expand Down Expand Up @@ -911,15 +918,7 @@ export default function BitcoinWallet() {
align="center"
sx={{ color: 'text.primary', fontWeight: 700 }}
>
{(() => {
const newMaxBtcAmount =
+walletBalanceBtc - estimatedFeeCalculated;
if (newMaxBtcAmount < 0) {
return Number(0.0) + ' BTC';
} else {
return newMaxBtcAmount + ' BTC';
}
})()}
{maxSendableBtcCoin() + ' BTC'}
</Typography>
<Box style={{ marginInlineStart: '15px' }}>
<Button
Expand Down Expand Up @@ -958,7 +957,7 @@ export default function BitcoinWallet() {
label="Amount (BTC)"
fullWidth
isAllowed={(values) => {
const maxBtcCoin = +walletBalanceBtc - estimatedFeeCalculated;
const maxBtcCoin = maxSendableBtcCoin();
const { formattedValue, floatValue } = values;
return (
formattedValue === EMPTY_STRING ||
Expand Down
48 changes: 24 additions & 24 deletions src/pages/dgb/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
DECIMAL_ROUND_UP,
DGB_FEE,
EMPTY_STRING,
SEND_MAX_SAFETY_BUFFER_SATS,
TIME_MINUTES_3,
TIME_MINUTES_5,
TIME_SECONDS_2,
Expand All @@ -75,6 +76,7 @@ import {
} from '../../styles/page-styles';
import { Coin } from 'qapp-core';
import { validateDgbAddress } from '../../utils/addressValidation';
import { calculateMaxSendable } from '../../utils/maxSendable';

interface TablePaginationActionsProps {
count: number;
Expand Down Expand Up @@ -204,19 +206,17 @@ export default function DigibyteWallet() {
const [openSendDgbError, setOpenSendDgbError] = useState(false);
const [openDgbAddressBook, setOpenDgbAddressBook] = useState(false);

const maxSendableDbgCoin = () => {
// manage the correct round up
const value = (walletBalanceDgb - (dgbFee * 1000) / 1e8)
.toFixed(DECIMAL_ROUND_UP);
const [integer, decimal = ''] = value.split('.');
const truncated = decimal
.substring(0, DECIMAL_ROUND_UP)
.padEnd(DECIMAL_ROUND_UP, '0');
let truncatedMaxSendableDgbCoin: number = parseFloat(
`${integer}.${truncated}`
// Estimated fee in whole coins (fee rate applied to a ~1000-byte tx).
const estimatedDgbFee = (dgbFee * 1000) / 1e8;

// Safely-spendable max: integer-satoshi math plus a small safety buffer so
// the prefilled amount never lands on/above the host's spendable cutoff.
const maxSendableDbgCoin = () =>
calculateMaxSendable(
walletBalanceDgb,
estimatedDgbFee,
SEND_MAX_SAFETY_BUFFER_SATS
);
return truncatedMaxSendableDgbCoin;
};

const emptyRows =
page > 0
Expand Down Expand Up @@ -251,7 +251,10 @@ export default function DigibyteWallet() {
};

const disableCanSendDgb = () =>
dgbAmount <= 0 || dgbRecipient === EMPTY_STRING || addressFormatError;
dgbAmount <= 0 ||
dgbRecipient === EMPTY_STRING ||
addressFormatError ||
dgbAmount > maxSendableDbgCoin();

const handleRecipientChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
Expand Down Expand Up @@ -433,6 +436,12 @@ export default function DigibyteWallet() {
};

const sendDgbRequest = async () => {
// Conservative revalidation: never submit more than the safely-spendable
// max, even if state changed (e.g. balance refresh) after input.
if (dgbAmount <= 0 || dgbAmount > maxSendableDbgCoin()) {
setOpenSendDgbError(true);
return;
}
setOpenTxDgbSubmit(true);
const dgbFeeCalculated = Number(dgbFee / 1e8).toFixed(DECIMAL_ROUND_UP);
try {
Expand Down Expand Up @@ -915,16 +924,7 @@ export default function DigibyteWallet() {
align="center"
sx={{ color: 'text.primary', fontWeight: 700 }}
>
{(() => {
const newMaxDgbAmount = parseFloat(
(walletBalanceDgb - (dgbFee * 1000) / 1e8).toFixed(DECIMAL_ROUND_UP)
);
if (newMaxDgbAmount < 0) {
return Number(0.0) + ' DGB';
} else {
return newMaxDgbAmount + ' DGB';
}
})()}
{maxSendableDbgCoin() + ' DGB'}
</Typography>
<Box style={{ marginInlineStart: '15px' }}>
<Button
Expand Down Expand Up @@ -963,7 +963,7 @@ export default function DigibyteWallet() {
label="Amount (DGB)"
fullWidth
isAllowed={(values) => {
const maxDgbCoin = walletBalanceDgb - (dgbFee * 1000) / 1e8;
const maxDgbCoin = maxSendableDbgCoin();
const { formattedValue, floatValue } = values;
return (
formattedValue === EMPTY_STRING ||
Expand Down
Loading
Loading