diff --git a/src/utils/poolId.js b/src/utils/poolId.js new file mode 100644 index 0000000..8416c65 --- /dev/null +++ b/src/utils/poolId.js @@ -0,0 +1,39 @@ +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) { + throw new Error('Pool ID is required') + } + + const hexCandidate = value.replace(/^0x/i, '') + if (/^[0-9a-fA-F]+$/.test(hexCandidate)) { + if (hexCandidate.length !== HEX_POOL_ID_LENGTH) { + throw new Error('Hex pool key hash must be 56 characters') + } + return {format: 'hex', value: hexCandidate.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 new file mode 100644 index 0000000..eca52ff --- /dev/null +++ b/src/utils/poolId.test.js @@ -0,0 +1,45 @@ +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', () => { + expect(() => classifyPoolId('')).toThrow(/required/) + expect(() => classifyPoolId(' ')).toThrow(/required/) + expect(() => classifyPoolId(null)).toThrow(/required/) + }) + + it('accepts a 56-character hex pool key hash', () => { + 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: hex, + }) + }) + + it('rejects hex of the wrong length', () => { + expect(() => classifyPoolId('deadbeef')).toThrow(/56 characters/) + expect(() => classifyPoolId(`${'ab'.repeat(29)}`)).toThrow(/56 characters/) + }) + + 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', () => { + expect(() => classifyPoolId('addr1xyz')).toThrow(/bech32 pool1/) + }) +})