From 26274cc8b7749aafba5fbdb4fd7f2902c42599e5 Mon Sep 17 00:00:00 2001 From: Nic Date: Tue, 2 Jun 2026 00:41:07 +0200 Subject: [PATCH 1/7] Improve validation --- src/utils/__tests__/addressValidation.test.ts | 93 ++++++++++++++++++- src/utils/addressValidation.ts | 6 +- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/addressValidation.test.ts b/src/utils/__tests__/addressValidation.test.ts index 408e0f2..1c4ee49 100644 --- a/src/utils/__tests__/addressValidation.test.ts +++ b/src/utils/__tests__/addressValidation.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { validateLtcAddress } from '../addressValidation'; +import { + validateBtcAddress, + validateDgbAddress, + validateLtcAddress, +} from '../addressValidation'; describe('addressValidation', () => { describe('validateLtcAddress', () => { @@ -80,4 +84,91 @@ describe('addressValidation', () => { }); }); }); + + describe('validateBtcAddress', () => { + describe('valid addresses', () => { + it('should accept P2PKH addresses starting with 1', () => { + expect(validateBtcAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2')).toBe(true); + }); + + it('should accept P2SH addresses starting with 3', () => { + expect(validateBtcAddress('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy')).toBe(true); + }); + + it('should accept Bech32 P2WPKH addresses (bc1q...)', () => { + expect(validateBtcAddress('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(true); + }); + + it('should accept Bech32 addresses containing 0 and l (valid Bech32 charset)', () => { + // '0' and 'l' are valid Bech32 characters and must not be rejected + expect(validateBtcAddress('bc1q9kk0zvwglnw6twj7xj2j5qt94afc42l5h6kdsm')).toBe(true); + }); + + it('should accept Bech32 P2WSH addresses (bc1q... longer)', () => { + expect( + validateBtcAddress('bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3') + ).toBe(true); + }); + }); + + describe('invalid addresses', () => { + it('should reject empty strings', () => { + expect(validateBtcAddress('')).toBe(false); + expect(validateBtcAddress(' ')).toBe(false); + }); + + it('should reject Bech32 addresses with invalid characters (1, b, i, o)', () => { + expect(validateBtcAddress('bc1q9kk1zvwglnw6twj7xj2j5qt94afc42l5h6kdsm')).toBe(false); // '1' + expect(validateBtcAddress('bc1qbkk0zvwglnw6twj7xj2j5qt94afc42l5h6kdsm')).toBe(false); // 'b' + expect(validateBtcAddress('bc1qikk0zvwglnw6twj7xj2j5qt94afc42l5h6kdsm')).toBe(false); // 'i' + expect(validateBtcAddress('bc1qokk0zvwglnw6twj7xj2j5qt94afc42l5h6kdsm')).toBe(false); // 'o' + }); + + it('should reject addresses with uppercase in Bech32 part', () => { + expect(validateBtcAddress('bc1Qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(false); + }); + + it('should reject LTC Bech32 addresses', () => { + expect(validateBtcAddress('ltc1q9kk0zvwglnw6twj7xj2j5qt94afc42l5pm7aj9')).toBe(false); + }); + }); + }); + + describe('validateDgbAddress', () => { + describe('valid addresses', () => { + it('should accept P2PKH addresses starting with D', () => { + expect(validateDgbAddress('DMguqG5busEUvCg85sQBxUqUBmE7huM6xy')).toBe(true); + }); + + it('should accept P2SH addresses starting with S', () => { + expect(validateDgbAddress('SUngTA1vaC2E62mbnc81Mdos3TcKXMHkcy')).toBe(true); + }); + + it('should accept Bech32 P2WPKH addresses (dgb1q...)', () => { + expect(validateDgbAddress('dgb1qar0srrr7xfkvy5l643lydnw9re59gtzz9zw2cf')).toBe(true); + }); + + it('should accept Bech32 addresses containing 0 and l (regression)', () => { + // Regression: '0' was previously excluded from the dgb1 charset, rejecting valid addresses + expect(validateDgbAddress('dgb1q9kk0zvwglnw6twj7xj2j5qt94afc42l5h6kdsm')).toBe(true); + }); + }); + + describe('invalid addresses', () => { + it('should reject empty strings', () => { + expect(validateDgbAddress('')).toBe(false); + expect(validateDgbAddress(' ')).toBe(false); + }); + + it('should reject Bech32 addresses with invalid characters (1, b, i, o)', () => { + expect(validateDgbAddress('dgb1q9kk1zvwglnw6twj7xj2j5qt94afc42l5h6kdsm')).toBe(false); // '1' + expect(validateDgbAddress('dgb1qbkk0zvwglnw6twj7xj2j5qt94afc42l5h6kdsm')).toBe(false); // 'b' + expect(validateDgbAddress('dgb1qokk0zvwglnw6twj7xj2j5qt94afc42l5h6kdsm')).toBe(false); // 'o' + }); + + it('should reject addresses with uppercase in Bech32 part', () => { + expect(validateDgbAddress('dgb1Qar0srrr7xfkvy5l643lydnw9re59gtzz9zw2cf')).toBe(false); + }); + }); + }); }); diff --git a/src/utils/addressValidation.ts b/src/utils/addressValidation.ts index 010eb8a..8352b32 100644 --- a/src/utils/addressValidation.ts +++ b/src/utils/addressValidation.ts @@ -4,10 +4,11 @@ import { EMPTY_STRING } from '../common/constants'; /** * Validate Bitcoin (BTC) address format * Supports P2PKH (1...), P2SH (3...), and Bech32 (bc1...) addresses + * Bech32 uses charset: qpzry9x8gf2tvdw0s3jn54khce6mua7l (excludes 1, b, i, o) */ export const validateBtcAddress = (address: string): boolean => { const pattern = - /^(1[1-9A-HJ-NP-Za-km-z]{33}|3[1-9A-HJ-NP-Za-km-z]{33}|bc1[02-9A-HJ-NP-Za-z]{39})$/; + /^(1[1-9A-HJ-NP-Za-km-z]{33}|3[1-9A-HJ-NP-Za-km-z]{33}|bc1[ac-hj-np-z02-9]{39,59})$/; return pattern.test(address.trim()); }; @@ -43,10 +44,11 @@ export const validateRvnAddress = (address: string): boolean => { /** * Validate Digibyte (DGB) address format * Supports P2PKH (D...), P2SH (S...), and Bech32 (dgb1...) addresses + * Bech32 uses charset: qpzry9x8gf2tvdw0s3jn54khce6mua7l (excludes 1, b, i, o) */ export const validateDgbAddress = (address: string): boolean => { const pattern = - /^(D[1-9A-HJ-NP-Za-km-z]{33}|S[1-9A-HJ-NP-Za-km-z]{33}|dgb1[2-9A-HJ-NP-Za-z]{39})$/; + /^(D[1-9A-HJ-NP-Za-km-z]{33}|S[1-9A-HJ-NP-Za-km-z]{33}|dgb1[ac-hj-np-z02-9]{39,59})$/; return pattern.test(address.trim()); }; From 18a6e39ad2b5c2204d6a42c38fdb718377576584 Mon Sep 17 00:00:00 2001 From: Nic Date: Tue, 2 Jun 2026 00:49:25 +0200 Subject: [PATCH 2/7] Add validation tests --- src/utils/__tests__/addressValidation.test.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/utils/__tests__/addressValidation.test.ts b/src/utils/__tests__/addressValidation.test.ts index 1c4ee49..5988963 100644 --- a/src/utils/__tests__/addressValidation.test.ts +++ b/src/utils/__tests__/addressValidation.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect } from 'vitest'; import { + validateArrrAddress, validateBtcAddress, validateDgbAddress, + validateDogeAddress, validateLtcAddress, + validateQortAddress, + validateRvnAddress, } from '../addressValidation'; describe('addressValidation', () => { @@ -171,4 +175,139 @@ describe('addressValidation', () => { }); }); }); + + describe('validateDogeAddress', () => { + describe('valid addresses', () => { + it('should accept P2PKH addresses starting with D', () => { + expect(validateDogeAddress('DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L')).toBe(true); + expect(validateDogeAddress('DBXu2kgc3xtvCUWFcxFE3r9hEYgmuaaCyD')).toBe(true); + }); + + it('should accept addresses with leading/trailing whitespace', () => { + expect(validateDogeAddress(' DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L ')).toBe(true); + }); + }); + + describe('invalid addresses', () => { + it('should reject empty strings', () => { + expect(validateDogeAddress('')).toBe(false); + expect(validateDogeAddress(' ')).toBe(false); + }); + + it('should reject addresses with an invalid prefix', () => { + expect(validateDogeAddress('AH5yaieqoZN36fDVciNyRueRGvGLR3mr7L')).toBe(false); + expect(validateDogeAddress('RXissCG2jZTNd6Mp8x3Jpvw8Gqgj87enQ8')).toBe(false); // RVN + }); + + it('should reject base58-ambiguous characters (0, O, I, l)', () => { + expect(validateDogeAddress('DH5yaieqoZN36fDVciNyRueRGvGLR3mr0L')).toBe(false); // '0' + expect(validateDogeAddress('DH5yaieqoZN36fDVciNyRueRGvGLR3mrlL')).toBe(false); // 'l' + }); + + it('should reject addresses that are too short or too long', () => { + expect(validateDogeAddress('DH5yaieqoZN36fDVciNyRueRGvGLR3mr')).toBe(false); + expect(validateDogeAddress('DH5yaieqoZN36fDVciNyRueRGvGLR3mr7Lextra')).toBe(false); + }); + }); + }); + + describe('validateRvnAddress', () => { + describe('valid addresses', () => { + it('should accept P2PKH addresses starting with R', () => { + expect(validateRvnAddress('RXissCG2jZTNd6Mp8x3Jpvw8Gqgj87enQ8')).toBe(true); + }); + + it('should accept addresses with leading/trailing whitespace', () => { + expect(validateRvnAddress('\tRXissCG2jZTNd6Mp8x3Jpvw8Gqgj87enQ8\n')).toBe(true); + }); + }); + + describe('invalid addresses', () => { + it('should reject empty strings', () => { + expect(validateRvnAddress('')).toBe(false); + expect(validateRvnAddress(' ')).toBe(false); + }); + + it('should reject addresses with an invalid prefix', () => { + expect(validateRvnAddress('DXissCG2jZTNd6Mp8x3Jpvw8Gqgj87enQ8')).toBe(false); // DOGE/DGB + }); + + it('should reject base58-ambiguous characters (0, O, I, l)', () => { + expect(validateRvnAddress('RXissCG2jZTNd6Mp8x3Jpvw8Gqgj87en0Q')).toBe(false); // '0' + expect(validateRvnAddress('RXissCG2jZTNd6Mp8x3Jpvw8Gqgj87enlQ')).toBe(false); // 'l' + }); + + it('should reject addresses that are too short or too long', () => { + expect(validateRvnAddress('RXissCG2jZTNd6Mp8x3Jpvw8Gqgj87en')).toBe(false); + expect(validateRvnAddress('RXissCG2jZTNd6Mp8x3Jpvw8Gqgj87enQ8extra')).toBe(false); + }); + }); + }); + + describe('validateArrrAddress', () => { + describe('valid addresses', () => { + it('should accept Sapling shielded addresses (zs1...)', () => { + expect( + validateArrrAddress('zs1z7rejlpsa98s2rrrfkwmaxu53e4ue0ulcrw0h4x5g8jl04tak0d3mm47vdtahatqrlkngh9sla5') + ).toBe(true); + }); + + it('should accept addresses with leading/trailing whitespace', () => { + expect( + validateArrrAddress(' zs1z7rejlpsa98s2rrrfkwmaxu53e4ue0ulcrw0h4x5g8jl04tak0d3mm47vdtahatqrlkngh9sla5 ') + ).toBe(true); + }); + }); + + describe('invalid addresses', () => { + it('should reject empty strings', () => { + expect(validateArrrAddress('')).toBe(false); + expect(validateArrrAddress(' ')).toBe(false); + }); + + it('should reject addresses with an invalid prefix', () => { + expect( + validateArrrAddress('zt1z7rejlpsa98s2rrrfkwmaxu53e4ue0ulcrw0h4x5g8jl04tak0d3mm47vdtahatqrlkngh9sla5') + ).toBe(false); + }); + + it('should reject addresses that are too short or too long', () => { + expect( + validateArrrAddress('zs1z7rejlpsa98s2rrrfkwmaxu53e4ue0ulcrw0h4x5g8jl04tak0d3mm47vdtahatqrlkngh9sla') + ).toBe(false); + expect( + validateArrrAddress('zs1z7rejlpsa98s2rrrfkwmaxu53e4ue0ulcrw0h4x5g8jl04tak0d3mm47vdtahatqrlkngh9sla5gg') + ).toBe(false); + }); + }); + }); + + describe('validateQortAddress', () => { + describe('valid addresses', () => { + it('should accept base58 addresses', () => { + expect(validateQortAddress('QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v')).toBe(true); + }); + + it('should accept names (minimum 3 characters)', () => { + expect(validateQortAddress('Bob')).toBe(true); + expect(validateQortAddress('alice')).toBe(true); + }); + + it('should accept addresses with leading/trailing whitespace', () => { + expect(validateQortAddress(' Bob ')).toBe(true); + }); + }); + + describe('invalid addresses', () => { + it('should reject empty strings', () => { + expect(validateQortAddress('')).toBe(false); + expect(validateQortAddress(' ')).toBe(false); + }); + + it('should reject values shorter than 3 characters', () => { + expect(validateQortAddress('ab')).toBe(false); + expect(validateQortAddress(' a ')).toBe(false); + }); + }); + }); }); From 4148485524f74f6e2fb574fc76f1eb1c4055c9e6 Mon Sep 17 00:00:00 2001 From: Nic Date: Tue, 2 Jun 2026 01:11:13 +0200 Subject: [PATCH 3/7] Improve maxSendable feature --- src/common/constants.ts | 6 +++ src/pages/arrr/index.tsx | 35 ++++++++------ src/pages/btc/index.tsx | 43 ++++++++--------- src/pages/dgb/index.tsx | 48 +++++++++---------- src/pages/doge/index.tsx | 41 ++++++++-------- src/pages/ltc/index.tsx | 43 ++++++++--------- src/pages/qort/index.tsx | 17 +++---- src/pages/rvn/index.tsx | 50 ++++++++++--------- src/utils/__tests__/maxSendable.test.ts | 64 +++++++++++++++++++++++++ src/utils/maxSendable.ts | 50 +++++++++++++++++++ 10 files changed, 260 insertions(+), 137 deletions(-) create mode 100644 src/utils/__tests__/maxSendable.test.ts create mode 100644 src/utils/maxSendable.ts diff --git a/src/common/constants.ts b/src/common/constants.ts index bc9d706..e37fe88 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -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; diff --git a/src/pages/arrr/index.tsx b/src/pages/arrr/index.tsx index a6305c2..28107d1 100644 --- a/src/pages/arrr/index.tsx +++ b/src/pages/arrr/index.tsx @@ -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, @@ -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; @@ -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 @@ -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) => { const value = e.target.value.trim(); @@ -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({ @@ -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'}