From dd6ee2bd67403da9054cea5909ffb4c2b26cfdc4 Mon Sep 17 00:00:00 2001 From: Denis Nebytov Date: Mon, 24 Aug 2026 13:25:10 +0300 Subject: [PATCH 1/2] Classify Cardano pool IDs as bech32 or hex. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize pool1… and 56-character hex key hashes in one place before they are turned into CSL pool key hashes. Co-authored-by: Cursor --- src/utils/poolId.js | 20 ++++++++++++++++++++ src/utils/poolId.test.js | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/utils/poolId.js create mode 100644 src/utils/poolId.test.js diff --git a/src/utils/poolId.js b/src/utils/poolId.js new file mode 100644 index 0000000..4703021 --- /dev/null +++ b/src/utils/poolId.js @@ -0,0 +1,20 @@ +export const classifyPoolId = (raw) => { + const value = typeof raw === 'string' ? raw.trim() : '' + if (!value) { + throw new Error('Pool ID is required') + } + + const hexCandidate = value.replace(/^0x/i, '') + if (/^[0-9a-fA-F]+$/.test(hexCandidate)) { + if (hexCandidate.length !== 56) { + throw new Error('Hex pool key hash must be 56 characters') + } + return {format: 'hex', value: hexCandidate.toLowerCase()} + } + + if (/^pool1[0-9a-z]+$/i.test(value)) { + return {format: 'bech32', value: value.toLowerCase()} + } + + throw new Error('Pool ID must be a bech32 pool1… ID or a 56-character hex pool key hash') +} diff --git a/src/utils/poolId.test.js b/src/utils/poolId.test.js new file mode 100644 index 0000000..957b96e --- /dev/null +++ b/src/utils/poolId.test.js @@ -0,0 +1,35 @@ +import {classifyPoolId} from './poolId' + +describe('classifyPoolId', () => { + it('rejects empty and whitespace-only input', () => { + expect(() => classifyPoolId('')).toThrow(/required/) + expect(() => classifyPoolId(' ')).toThrow(/required/) + expect(() => classifyPoolId(null)).toThrow(/required/) + }) + + it('accepts a 56-character hex pool key hash', () => { + const hex = 'deadbeef01234567890abcdef01234567890abcdef01234567890abc' + expect(classifyPoolId(hex)).toEqual({format: 'hex', value: hex}) + }) + + it('strips a 0x prefix and lowercases hex', () => { + expect(classifyPoolId('0xDEADBEEF01234567890ABCDEF01234567890ABCDEF01234567890ABC')).toEqual({ + format: 'hex', + value: 'deadbeef01234567890abcdef01234567890abcdef01234567890abc', + }) + }) + + it('rejects hex of the wrong length', () => { + expect(() => classifyPoolId('deadbeef')).toThrow(/56 characters/) + expect(() => classifyPoolId(`${'ab'.repeat(29)}`)).toThrow(/56 characters/) + }) + + it('accepts a pool1 bech32 ID', () => { + const poolId = 'pool1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq' + expect(classifyPoolId(` ${poolId.toUpperCase()} `)).toEqual({format: 'bech32', value: poolId}) + }) + + it('rejects unrelated strings', () => { + expect(() => classifyPoolId('addr1xyz')).toThrow(/bech32 pool1/) + }) +}) From b461135c665c80349326074faf261a648e7175f0 Mon Sep 17 00:00:00 2001 From: Denis Nebytov Date: Mon, 24 Aug 2026 15:29:12 +0300 Subject: [PATCH 2/2] Reject invalid bech32 pool IDs instead of prefix-only matches. A pool1 string must decode and carry a 28-byte key hash, so truncated, padded, or checksum-invalid IDs never reach stake-delegation as if they were real. Co-authored-by: Cursor --- src/utils/poolId.js | 25 ++++++++++++++++++++++--- src/utils/poolId.test.js | 22 ++++++++++++++++------ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/utils/poolId.js b/src/utils/poolId.js index 4703021..8416c65 100644 --- a/src/utils/poolId.js +++ b/src/utils/poolId.js @@ -1,3 +1,8 @@ +import {bech32} from 'bech32' + +export const POOL_KEY_HASH_BYTES = 28 +export const HEX_POOL_ID_LENGTH = POOL_KEY_HASH_BYTES * 2 + export const classifyPoolId = (raw) => { const value = typeof raw === 'string' ? raw.trim() : '' if (!value) { @@ -6,14 +11,28 @@ export const classifyPoolId = (raw) => { const hexCandidate = value.replace(/^0x/i, '') if (/^[0-9a-fA-F]+$/.test(hexCandidate)) { - if (hexCandidate.length !== 56) { + if (hexCandidate.length !== HEX_POOL_ID_LENGTH) { throw new Error('Hex pool key hash must be 56 characters') } return {format: 'hex', value: hexCandidate.toLowerCase()} } - if (/^pool1[0-9a-z]+$/i.test(value)) { - return {format: 'bech32', value: value.toLowerCase()} + const bech32Candidate = value.toLowerCase() + if (bech32Candidate.startsWith('pool1')) { + let decoded + try { + decoded = bech32.decode(bech32Candidate) + } catch { + throw new Error('Bech32 pool ID is not a valid pool1… encoding') + } + if (decoded.prefix !== 'pool') { + throw new Error('Bech32 pool ID must use the pool prefix') + } + const payloadLength = bech32.fromWords(decoded.words).length + if (payloadLength !== POOL_KEY_HASH_BYTES) { + throw new Error('Bech32 pool ID must encode a 28-byte pool key hash') + } + return {format: 'bech32', value: bech32Candidate} } throw new Error('Pool ID must be a bech32 pool1… ID or a 56-character hex pool key hash') diff --git a/src/utils/poolId.test.js b/src/utils/poolId.test.js index 957b96e..eca52ff 100644 --- a/src/utils/poolId.test.js +++ b/src/utils/poolId.test.js @@ -1,4 +1,9 @@ -import {classifyPoolId} from './poolId' +import {Buffer} from 'buffer' +import {bech32} from 'bech32' +import {classifyPoolId, HEX_POOL_ID_LENGTH} from './poolId' + +const hex = 'deadbeef01234567890abcdef01234567890abcdef01234567890abc' +const validBech32 = bech32.encode('pool', bech32.toWords(Buffer.from(hex, 'hex'))) describe('classifyPoolId', () => { it('rejects empty and whitespace-only input', () => { @@ -8,14 +13,14 @@ describe('classifyPoolId', () => { }) it('accepts a 56-character hex pool key hash', () => { - const hex = 'deadbeef01234567890abcdef01234567890abcdef01234567890abc' + expect(hex).toHaveLength(HEX_POOL_ID_LENGTH) expect(classifyPoolId(hex)).toEqual({format: 'hex', value: hex}) }) it('strips a 0x prefix and lowercases hex', () => { expect(classifyPoolId('0xDEADBEEF01234567890ABCDEF01234567890ABCDEF01234567890ABC')).toEqual({ format: 'hex', - value: 'deadbeef01234567890abcdef01234567890abcdef01234567890abc', + value: hex, }) }) @@ -24,9 +29,14 @@ describe('classifyPoolId', () => { expect(() => classifyPoolId(`${'ab'.repeat(29)}`)).toThrow(/56 characters/) }) - it('accepts a pool1 bech32 ID', () => { - const poolId = 'pool1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq' - expect(classifyPoolId(` ${poolId.toUpperCase()} `)).toEqual({format: 'bech32', value: poolId}) + it('accepts a valid pool1 bech32 ID', () => { + expect(classifyPoolId(` ${validBech32.toUpperCase()} `)).toEqual({format: 'bech32', value: validBech32}) + }) + + it('rejects truncated, padded and checksum-invalid pool1 strings', () => { + expect(() => classifyPoolId('pool1qqqq')).toThrow(/valid pool1/) + expect(() => classifyPoolId(`${validBech32}q`)).toThrow(/valid pool1/) + expect(() => classifyPoolId('pool1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq')).toThrow(/valid pool1/) }) it('rejects unrelated strings', () => {