From ee0ec430be53b0879c2e627c72ac7109f3adfd44 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sat, 28 Mar 2026 06:05:06 +0000 Subject: [PATCH 1/6] feat(core): add token types, ArkType validation, and token list fetching Add Token/TokenList types matching the btkn-info schema used by btknlist.org registry. Implement ArkType schema validation for token lists and a fetchTokenList utility that fetches and validates token list JSON from URLs. Co-authored-by: Claude Signed-off-by: Claude --- packages/core/package.json | 1 + packages/core/src/exports/index.ts | 7 ++ packages/core/src/types/token.ts | 48 ++++++++ packages/core/src/utils/tokenList.test.ts | 136 ++++++++++++++++++++++ packages/core/src/utils/tokenList.ts | 82 +++++++++++++ pnpm-lock.yaml | 31 +++++ 6 files changed, 305 insertions(+) create mode 100644 packages/core/src/types/token.ts create mode 100644 packages/core/src/utils/tokenList.test.ts create mode 100644 packages/core/src/utils/tokenList.ts diff --git a/packages/core/package.json b/packages/core/package.json index 8d115d8..1e82cf2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -58,6 +58,7 @@ }, "dependencies": { "@scure/base": "^1.1.1", + "arktype": "^2.1.20", "mitt": "3.0.1", "zustand": "5.0.0" }, diff --git a/packages/core/src/exports/index.ts b/packages/core/src/exports/index.ts index e82c827..f892e3b 100644 --- a/packages/core/src/exports/index.ts +++ b/packages/core/src/exports/index.ts @@ -194,6 +194,12 @@ export { //////////////////////////////////////////////////////////////////////////////// export type { Register, ResolvedRegister } from '../types/register' +export type { + Token, + TokenList, + TokenListTag, + TokenListVersion, +} from '../types/token' //////////////////////////////////////////////////////////////////////////////// // Utilities @@ -213,6 +219,7 @@ export { deserialize } from '../utils/deserialize' export { formatSats, parseSats } from '../utils/sats' export { serialize } from '../utils/serialize' +export { fetchTokenList, validateTokenList } from '../utils/tokenList' //////////////////////////////////////////////////////////////////////////////// // Version diff --git a/packages/core/src/types/token.ts b/packages/core/src/types/token.ts new file mode 100644 index 0000000..c870c00 --- /dev/null +++ b/packages/core/src/types/token.ts @@ -0,0 +1,48 @@ +/** A single token entry in a token list, matching the btkn-info schema. */ +export type Token = { + /** Raw hex token identifier. */ + identifier?: string | undefined + /** Bech32m-encoded token address (e.g. `btkn1...`). */ + address: string + /** Human-readable token name. */ + name: string + /** Short ticker symbol. */ + symbol: string + /** Number of decimal places for token amounts. */ + decimals: number + /** URI or IPFS hash for the token logo. */ + logoURI?: string | undefined + /** Tag IDs referencing the parent list's `tags` definitions. */ + tags?: string[] | undefined +} + +/** Semantic version of a token list. */ +export type TokenListVersion = { + major: number + minor: number + patch: number +} + +/** A tag definition within a token list. */ +export type TokenListTag = { + name: string + description: string +} + +/** A token list conforming to the btkn-info schema, as served by btknlist.org registrants. */ +export type TokenList = { + /** Human-readable list name. */ + name: string + /** ISO 8601 UTC timestamp of the last change. */ + timestamp: string + /** Semantic version of this list. */ + version: TokenListVersion + /** URI or IPFS hash for the list logo. */ + logoURI?: string | undefined + /** Free-form search keywords. */ + keywords?: string[] | undefined + /** Tag definitions keyed by tag ID. */ + tags?: Record | undefined + /** Token entries in this list. */ + tokens: Token[] +} diff --git a/packages/core/src/utils/tokenList.test.ts b/packages/core/src/utils/tokenList.test.ts new file mode 100644 index 0000000..56adcbf --- /dev/null +++ b/packages/core/src/utils/tokenList.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchTokenList, validateTokenList } from './tokenList' + +const validTokenList = { + name: 'Test Token List', + timestamp: '2025-07-28T12:00:00Z', + version: { major: 1, minor: 0, patch: 0 }, + logoURI: 'https://example.com/logo.png', + keywords: ['spark', 'test'], + tags: { + stablecoin: { + name: 'Stablecoin', + description: 'Tokens fixed to an external asset', + }, + }, + tokens: [ + { + identifier: 'abc123', + address: + 'btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87', + name: 'USD Bitcoin', + symbol: 'USDB', + decimals: 6, + logoURI: 'https://example.com/usdb.svg', + tags: ['stablecoin'], + }, + ], +} + +describe('validateTokenList', () => { + it('validates a complete token list', () => { + const result = validateTokenList(validTokenList) + expect(result.name).toBe('Test Token List') + expect(result.tokens).toHaveLength(1) + expect(result.tokens[0]!.symbol).toBe('USDB') + expect(result.tokens[0]!.decimals).toBe(6) + expect(result.version).toEqual({ major: 1, minor: 0, patch: 0 }) + expect(result.tags?.stablecoin?.name).toBe('Stablecoin') + }) + + it('validates a minimal token list (no optional fields)', () => { + const minimal = { + name: 'Minimal', + timestamp: '2025-01-01T00:00:00Z', + version: { major: 1, minor: 0, patch: 0 }, + tokens: [ + { + address: 'btkn1test', + name: 'Test', + symbol: 'TST', + decimals: 8, + }, + ], + } + const result = validateTokenList(minimal) + expect(result.name).toBe('Minimal') + expect(result.tokens[0]!.identifier).toBeUndefined() + expect(result.logoURI).toBeUndefined() + }) + + it('throws on missing required fields', () => { + expect(() => validateTokenList({})).toThrow('Invalid token list') + }) + + it('throws on invalid token decimals', () => { + const bad = { + ...validTokenList, + tokens: [{ address: 'btkn1x', name: 'X', symbol: 'X', decimals: 1.5 }], + } + expect(() => validateTokenList(bad)).toThrow('Invalid token list') + }) + + it('throws on invalid tag structure', () => { + const bad = { + ...validTokenList, + tags: { broken: { name: 123 } }, + } + expect(() => validateTokenList(bad)).toThrow('Invalid tag') + }) + + it('throws on non-object input', () => { + expect(() => validateTokenList('not an object')).toThrow( + 'Invalid token list', + ) + expect(() => validateTokenList(null)).toThrow('Invalid token list') + }) +}) + +describe('fetchTokenList', () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + globalThis.fetch = vi.fn() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('fetches and validates a token list', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: true, + json: async () => validTokenList, + } as Response) + + const result = await fetchTokenList('https://example.com/list.json') + expect(result.name).toBe('Test Token List') + expect(result.tokens).toHaveLength(1) + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://example.com/list.json', + ) + }) + + it('throws on HTTP error', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + } as Response) + + await expect( + fetchTokenList('https://example.com/missing.json'), + ).rejects.toThrow('Failed to fetch token list') + }) + + it('throws on invalid response body', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ invalid: true }), + } as Response) + + await expect( + fetchTokenList('https://example.com/bad.json'), + ).rejects.toThrow('Invalid token list') + }) +}) diff --git a/packages/core/src/utils/tokenList.ts b/packages/core/src/utils/tokenList.ts new file mode 100644 index 0000000..1748280 --- /dev/null +++ b/packages/core/src/utils/tokenList.ts @@ -0,0 +1,82 @@ +import { type } from 'arktype' +import type { TokenList } from '../types/token' + +const tokenSchema = type({ + 'identifier?': 'string', + address: 'string', + name: 'string', + symbol: 'string', + decimals: 'number.integer', + 'logoURI?': 'string', + 'tags?': 'string[]', +}) + +const tokenListVersionSchema = type({ + major: 'number.integer', + minor: 'number.integer', + patch: 'number.integer', +}) + +const tokenListTagSchema = type({ + name: 'string', + description: 'string', +}) + +const tokenListSchema = type({ + name: 'string', + timestamp: 'string', + version: tokenListVersionSchema, + 'logoURI?': 'string', + 'keywords?': 'string[]', + 'tags?': type('Record').pipe((tags) => { + const result: Record = {} + for (const [key, value] of Object.entries(tags)) { + const parsed = tokenListTagSchema(value) + if (parsed instanceof type.errors) { + throw new Error(`Invalid tag "${key}": ${parsed.summary}`) + } + result[key] = parsed + } + return result + }), + tokens: tokenSchema.array(), +}) + +/** + * Validates a raw JSON value against the btkn-info token list schema. + * + * @param data - The raw parsed JSON to validate. + * @returns The validated {@link TokenList}. + * @throws If the data does not conform to the schema. + */ +export function validateTokenList(data: unknown): TokenList { + const result = tokenListSchema(data) + if (result instanceof type.errors) { + throw new Error(`Invalid token list: ${result.summary}`) + } + return result as unknown as TokenList +} + +/** + * Fetches and validates a token list from a URL. + * + * @param url - The URL to fetch the token list JSON from. + * @returns The validated {@link TokenList}. + * @throws If the fetch fails or the response does not conform to the schema. + * + * @example + * ```ts + * const list = await fetchTokenList('https://flashnet.xyz/api/tokenlist') + * console.log(list.tokens[0].symbol) // "USDB" + * ``` + */ +export async function fetchTokenList(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error( + `Failed to fetch token list from ${url}: ${response.status} ${response.statusText}`, + ) + } + const data: unknown = await response.json() + return validateTokenList(data) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1aad5ed..6dcc66d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@scure/base': specifier: ^1.1.1 version: 1.2.6 + arktype: + specifier: ^2.1.20 + version: 2.2.0 mitt: specifier: 3.0.1 version: 3.0.1 @@ -325,6 +328,12 @@ packages: resolution: {integrity: sha512-GiwTmBFOU1/+UVNqqCGzFJYfBXEytUkiI+iRZ6Qx7KmUVtLm00sYySkfe203C9QtPG11yOz1ZaMek8dT/xnlgg==} engines: {node: '>=20'} + '@ark/schema@0.56.0': + resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} + + '@ark/util@0.56.0': + resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -2259,6 +2268,12 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arkregex@0.0.5: + resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} + + arktype@2.2.0: + resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -5115,6 +5130,12 @@ snapshots: typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 + '@ark/schema@0.56.0': + dependencies: + '@ark/util': 0.56.0 + + '@ark/util@0.56.0': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -7031,6 +7052,16 @@ snapshots: dependencies: dequal: 2.0.3 + arkregex@0.0.5: + dependencies: + '@ark/util': 0.56.0 + + arktype@2.2.0: + dependencies: + '@ark/schema': 0.56.0 + '@ark/util': 0.56.0 + arkregex: 0.0.5 + array-union@2.1.0: {} assertion-error@2.0.1: {} From dd722c085300c60e082232caf8d3760005bf22ab Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sat, 28 Mar 2026 06:06:56 +0000 Subject: [PATCH 2/6] feat(core): add formatTokenAmount and parseTokenAmount helpers Generic token amount formatting/parsing that respects per-token decimals, analogous to existing formatSats/parseSats but parameterized by decimals. Co-authored-by: Claude Signed-off-by: Claude --- packages/core/src/exports/index.ts | 2 +- packages/core/src/utils/token.test.ts | 93 +++++++++++++++++++++++++++ packages/core/src/utils/token.ts | 74 +++++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/utils/token.test.ts create mode 100644 packages/core/src/utils/token.ts diff --git a/packages/core/src/exports/index.ts b/packages/core/src/exports/index.ts index f892e3b..8486c9b 100644 --- a/packages/core/src/exports/index.ts +++ b/packages/core/src/exports/index.ts @@ -217,8 +217,8 @@ export { export { deepEqual } from '../utils/deepEqual' export { deserialize } from '../utils/deserialize' export { formatSats, parseSats } from '../utils/sats' - export { serialize } from '../utils/serialize' +export { formatTokenAmount, parseTokenAmount } from '../utils/token' export { fetchTokenList, validateTokenList } from '../utils/tokenList' //////////////////////////////////////////////////////////////////////////////// diff --git a/packages/core/src/utils/token.test.ts b/packages/core/src/utils/token.test.ts new file mode 100644 index 0000000..999d156 --- /dev/null +++ b/packages/core/src/utils/token.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { formatTokenAmount, parseTokenAmount } from './token' + +describe('formatTokenAmount', () => { + it('formats with 6 decimals', () => { + expect(formatTokenAmount(1500000n, 6)).toBe('1.5') + }) + + it('formats with 8 decimals', () => { + expect(formatTokenAmount(100000000n, 8)).toBe('1') + expect(formatTokenAmount(50000n, 8)).toBe('0.0005') + }) + + it('formats zero', () => { + expect(formatTokenAmount(0n, 6)).toBe('0') + expect(formatTokenAmount(0n, 0)).toBe('0') + }) + + it('formats with 0 decimals', () => { + expect(formatTokenAmount(100n, 0)).toBe('100') + }) + + it('formats negative amounts', () => { + expect(formatTokenAmount(-1500000n, 6)).toBe('-1.5') + expect(formatTokenAmount(-100n, 0)).toBe('-100') + }) + + it('strips trailing zeros', () => { + expect(formatTokenAmount(1000000n, 6)).toBe('1') + expect(formatTokenAmount(1100000n, 6)).toBe('1.1') + }) + + it('formats with 18 decimals (large precision)', () => { + expect(formatTokenAmount(1000000000000000000n, 18)).toBe('1') + expect(formatTokenAmount(1500000000000000000n, 18)).toBe('1.5') + }) +}) + +describe('parseTokenAmount', () => { + it('parses with 6 decimals', () => { + expect(parseTokenAmount('1.5', 6)).toBe(1500000n) + }) + + it('parses with 8 decimals', () => { + expect(parseTokenAmount('1', 8)).toBe(100000000n) + expect(parseTokenAmount('0.0005', 8)).toBe(50000n) + }) + + it('parses zero', () => { + expect(parseTokenAmount('0', 6)).toBe(0n) + expect(parseTokenAmount('0', 0)).toBe(0n) + }) + + it('parses with 0 decimals', () => { + expect(parseTokenAmount('100', 0)).toBe(100n) + }) + + it('parses negative amounts', () => { + expect(parseTokenAmount('-1.5', 6)).toBe(-1500000n) + }) + + it('throws on empty string', () => { + expect(() => parseTokenAmount('', 6)).toThrow('empty string') + }) + + it('throws on invalid format', () => { + expect(() => parseTokenAmount('abc', 6)).toThrow('Invalid token amount') + }) + + it('throws on too many decimals', () => { + expect(() => parseTokenAmount('1.1234567', 6)).toThrow( + 'Too many decimal places', + ) + }) + + it('handles whitespace', () => { + expect(parseTokenAmount(' 1.5 ', 6)).toBe(1500000n) + }) + + it('round-trips with formatTokenAmount', () => { + const cases = [ + { value: 1500000n, decimals: 6 }, + { value: 100000000n, decimals: 8 }, + { value: 0n, decimals: 6 }, + { value: 50000n, decimals: 8 }, + ] + for (const { value, decimals } of cases) { + expect( + parseTokenAmount(formatTokenAmount(value, decimals), decimals), + ).toBe(value) + } + }) +}) diff --git a/packages/core/src/utils/token.ts b/packages/core/src/utils/token.ts new file mode 100644 index 0000000..78f9614 --- /dev/null +++ b/packages/core/src/utils/token.ts @@ -0,0 +1,74 @@ +/** + * Converts a raw token amount to a human-readable decimal string, + * respecting the token's decimal places. + * + * @param value - Raw token amount as a `bigint`. + * @param decimals - Number of decimal places for this token. + * @returns Formatted string (e.g. `"1.5"` for `1500000n` with `decimals: 6`). + * + * @example + * ```ts + * formatTokenAmount(1500000n, 6) // "1.5" + * formatTokenAmount(100n, 0) // "100" + * formatTokenAmount(0n, 8) // "0" + * ``` + */ +export function formatTokenAmount(value: bigint, decimals: number): string { + if (decimals === 0) return value.toString() + + const negative = value < 0n + const abs = negative ? -value : value + const divisor = 10n ** BigInt(decimals) + const whole = abs / divisor + const remainder = abs % divisor + + if (remainder === 0n) { + return `${negative ? '-' : ''}${whole}` + } + + const fractional = remainder + .toString() + .padStart(decimals, '0') + .replace(/0+$/, '') + return `${negative ? '-' : ''}${whole}.${fractional}` +} + +/** + * Parses a human-readable decimal string into a raw token amount, + * respecting the token's decimal places. + * + * @param value - Decimal amount as a string (e.g. `"1.5"`). + * @param decimals - Number of decimal places for this token. + * @returns Raw token amount as a `bigint`. + * @throws If the string is not a valid decimal number or exceeds the allowed decimal places. + * + * @example + * ```ts + * parseTokenAmount("1.5", 6) // 1500000n + * parseTokenAmount("100", 0) // 100n + * parseTokenAmount("0", 8) // 0n + * ``` + */ +export function parseTokenAmount(value: string, decimals: number): bigint { + const trimmed = value.trim() + if (trimmed === '') throw new Error('Invalid token amount: empty string') + + const negative = trimmed.startsWith('-') + const unsigned = negative ? trimmed.slice(1) : trimmed + + const match = unsigned.match(/^(\d+)(?:\.(\d+))?$/) + if (!match) throw new Error(`Invalid token amount: "${value}"`) + + const wholePart = match[1]! + const fracPart = match[2] ?? '' + + if (fracPart.length > decimals) { + throw new Error(`Too many decimal places (max ${decimals}): "${value}"`) + } + + const paddedFrac = fracPart.padEnd(decimals, '0') + const divisor = 10n ** BigInt(decimals) + const raw = BigInt(wholePart) * divisor + BigInt(paddedFrac || '0') + + return negative ? -raw : raw +} From 7c10e43322597085196e8210638f3931194da2fa Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sat, 28 Mar 2026 06:08:53 +0000 Subject: [PATCH 3/6] feat(connectors): add getTokenBalance and sendToken to connector interface Add optional getTokenBalance and sendToken methods to the connector interface. Implement them in the sparkSdk connector using the Spark SDK's getBalance (tokenBalances) and transferTokens APIs. Add mock implementations in the test connector. Co-authored-by: Claude Signed-off-by: Claude --- packages/connectors/src/sparkSdk.ts | 61 +++++++++++++++++++ .../core/src/connectors/createConnector.ts | 18 ++++++ packages/test/src/connector.ts | 46 ++++++++++++++ packages/test/src/exports/index.ts | 2 +- 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/packages/connectors/src/sparkSdk.ts b/packages/connectors/src/sparkSdk.ts index 1fca108..656b24c 100644 --- a/packages/connectors/src/sparkSdk.ts +++ b/packages/connectors/src/sparkSdk.ts @@ -305,6 +305,67 @@ export function sparkSdk(parameters: SparkSdkParameters) { } }, + async getTokenBalance({ + tokenIdentifier, + }: { + tokenIdentifier?: string + } = {}) { + if (!wallet) { + throw new Error('Spark SDK wallet not initialized') + } + + const { tokenBalances } = await wallet.getBalance() + + const tokens: Array<{ + identifier: string + name: string + symbol: string + decimals: number + balance: bigint + availableBalance: bigint + }> = [] + + for (const [id, entry] of tokenBalances) { + if (tokenIdentifier && id !== tokenIdentifier) continue + tokens.push({ + identifier: id, + name: entry.tokenMetadata.tokenName, + symbol: entry.tokenMetadata.tokenTicker, + decimals: entry.tokenMetadata.decimals, + balance: entry.ownedBalance, + availableBalance: entry.availableToSendBalance, + }) + } + + return { tokens } + }, + + async sendToken({ + tokenIdentifier, + amount, + to, + }: { + tokenIdentifier: string + amount: bigint + to: string + }) { + if (!wallet) { + throw new Error('Spark SDK wallet not initialized') + } + + type BtknId = Parameters< + typeof wallet.transferTokens + >[0]['tokenIdentifier'] + + const id = await wallet.transferTokens({ + tokenIdentifier: tokenIdentifier as BtknId, + tokenAmount: amount, + receiverSparkAddress: to, + }) + + return { id } + }, + async signMessage({ message }: { message: string; address?: string }) { if (!wallet) { throw new Error('Spark SDK wallet not initialized') diff --git a/packages/core/src/connectors/createConnector.ts b/packages/core/src/connectors/createConnector.ts index b18b1cc..deeeefe 100644 --- a/packages/core/src/connectors/createConnector.ts +++ b/packages/core/src/connectors/createConnector.ts @@ -100,6 +100,24 @@ export type CreateConnectorFn< timeout?: number | undefined }): Promise<{ id: string; amount: bigint }> + getTokenBalance?(params: { + tokenIdentifier?: string | undefined + }): Promise<{ + tokens: Array<{ + identifier: string + name: string + symbol: string + decimals: number + balance: bigint + availableBalance: bigint + }> + }> + sendToken?(params: { + tokenIdentifier: string + amount: bigint + to: string + }): Promise<{ id: string }> + onAccountsChanged(accounts: string[]): void onConnect?(connectInfo: { accounts: readonly string[] }): void onDisconnect(error?: Error): void diff --git a/packages/test/src/connector.ts b/packages/test/src/connector.ts index 687af2b..7168dd1 100644 --- a/packages/test/src/connector.ts +++ b/packages/test/src/connector.ts @@ -1,11 +1,23 @@ import { createConnector } from '@mbga/core' +/** A mock token balance entry. */ +export type MockTokenBalance = { + identifier: string + name: string + symbol: string + decimals: number + balance: bigint + availableBalance: bigint +} + /** Parameters for the {@link mock} connector. */ export type MockParameters = { /** Account addresses to expose. @default ['bc1qmock...address1'] */ accounts?: string[] /** Balance to return from `getBalance`. @default 100000n */ balance?: bigint + /** Token balances to return from `getTokenBalance`. */ + tokenBalances?: MockTokenBalance[] } /** @@ -32,6 +44,16 @@ export function mock(parameters: MockParameters = {}) { const { accounts: accounts_ = ['bc1qmock...address1'], balance: balance_ = 100000n, + tokenBalances: tokenBalances_ = [ + { + identifier: 'btkn1mock', + name: 'Mock Token', + symbol: 'MOCK', + decimals: 8, + balance: 1000000n, + availableBalance: 1000000n, + }, + ], } = parameters let connected = false @@ -135,6 +157,30 @@ export function mock(parameters: MockParameters = {}) { return { transactions: all.slice(offset, offset + limit) } }, + async getTokenBalance({ + tokenIdentifier, + }: { + tokenIdentifier?: string + } = {}) { + const tokens = tokenIdentifier + ? tokenBalances_.filter((t) => t.identifier === tokenIdentifier) + : tokenBalances_ + return { tokens } + }, + + async sendToken({ + tokenIdentifier, + to, + }: { + tokenIdentifier: string + amount: bigint + to: string + }) { + return { + id: `mock-token-tx-${tokenIdentifier.slice(0, 8)}-${to.slice(0, 8)}`, + } + }, + async waitForPayment({ invoice, invoiceId, diff --git a/packages/test/src/exports/index.ts b/packages/test/src/exports/index.ts index e069441..389537b 100644 --- a/packages/test/src/exports/index.ts +++ b/packages/test/src/exports/index.ts @@ -1,2 +1,2 @@ // biome-ignore lint/performance/noBarrelFile: entrypoint module -export { type MockParameters, mock } from '../connector' +export { type MockParameters, type MockTokenBalance, mock } from '../connector' From d2f3578194dc430c9ebe640c4d863aa95ae233f9 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sat, 28 Mar 2026 06:11:14 +0000 Subject: [PATCH 4/6] feat(core): add getTokenBalance and sendToken actions Core actions for querying token balances and sending tokens on Spark. Follow the same connector-delegation pattern as getBalance/sendPayment. Co-authored-by: Claude Signed-off-by: Claude --- .../core/src/actions/getTokenBalance.test.ts | 112 ++++++++++++++++++ packages/core/src/actions/getTokenBalance.ts | 74 ++++++++++++ packages/core/src/actions/sendToken.test.ts | 84 +++++++++++++ packages/core/src/actions/sendToken.ts | 64 ++++++++++ packages/core/src/exports/actions.ts | 15 ++- packages/core/src/exports/index.ts | 13 ++ 6 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/actions/getTokenBalance.test.ts create mode 100644 packages/core/src/actions/getTokenBalance.ts create mode 100644 packages/core/src/actions/sendToken.test.ts create mode 100644 packages/core/src/actions/sendToken.ts diff --git a/packages/core/src/actions/getTokenBalance.test.ts b/packages/core/src/actions/getTokenBalance.test.ts new file mode 100644 index 0000000..e7d4f53 --- /dev/null +++ b/packages/core/src/actions/getTokenBalance.test.ts @@ -0,0 +1,112 @@ +import { mock } from '@mbga/test' +import { describe, expect, it } from 'vitest' +import { createConfig } from '../createConfig' +import { ConnectorActionNotSupportedError } from '../errors/connector' +import { sparkMainnet } from '../types/network' +import { connect } from './connect' +import { getTokenBalance } from './getTokenBalance' + +describe('getTokenBalance', () => { + it('returns token balances from the connector', async () => { + const config = createConfig({ + network: sparkMainnet, + connectors: [mock()], + }) + + await connect(config, { connector: config.connectors[0]! }) + + const result = await getTokenBalance(config) + + expect(result.tokens).toHaveLength(1) + expect(result.tokens[0]!.identifier).toBe('btkn1mock') + expect(result.tokens[0]!.symbol).toBe('MOCK') + expect(result.tokens[0]!.balance).toBe(1000000n) + }) + + it('filters by tokenIdentifier', async () => { + const config = createConfig({ + network: sparkMainnet, + connectors: [ + mock({ + tokenBalances: [ + { + identifier: 'btkn1a', + name: 'A', + symbol: 'A', + decimals: 6, + balance: 100n, + availableBalance: 100n, + }, + { + identifier: 'btkn1b', + name: 'B', + symbol: 'B', + decimals: 8, + balance: 200n, + availableBalance: 200n, + }, + ], + }), + ], + }) + + await connect(config, { connector: config.connectors[0]! }) + + const result = await getTokenBalance(config, { + tokenIdentifier: 'btkn1a', + }) + + expect(result.tokens).toHaveLength(1) + expect(result.tokens[0]!.identifier).toBe('btkn1a') + }) + + it('throws ConnectorNotConnectedError when not connected', async () => { + const config = createConfig({ + network: sparkMainnet, + connectors: [mock()], + }) + + await expect(getTokenBalance(config)).rejects.toThrow( + 'Connector is not connected.', + ) + }) + + it('throws ConnectorActionNotSupportedError when connector lacks getTokenBalance', async () => { + const { createConnector } = await import('../connectors/createConnector') + + const minimal = createConnector((_config) => ({ + id: 'minimal', + name: 'Minimal', + type: 'minimal', + async connect() { + _config.emitter.emit('connect', { accounts: ['bc1q123'] }) + return { accounts: ['bc1q123'] } + }, + async disconnect() { + _config.emitter.emit('disconnect') + }, + async getAccounts() { + return ['bc1q123'] + }, + async getProvider() { + return null + }, + async isAuthorized() { + return true + }, + onAccountsChanged() {}, + onDisconnect() {}, + })) + + const config = createConfig({ + network: sparkMainnet, + connectors: [minimal], + }) + + await connect(config, { connector: config.connectors[0]! }) + + await expect(getTokenBalance(config)).rejects.toThrow( + ConnectorActionNotSupportedError, + ) + }) +}) diff --git a/packages/core/src/actions/getTokenBalance.ts b/packages/core/src/actions/getTokenBalance.ts new file mode 100644 index 0000000..8d88419 --- /dev/null +++ b/packages/core/src/actions/getTokenBalance.ts @@ -0,0 +1,74 @@ +import type { Config, Connector } from '../createConfig' +import { ConnectorNotConnectedError } from '../errors/config' +import { ConnectorActionNotSupportedError } from '../errors/connector' + +/** Parameters for {@link getTokenBalance}. */ +export type GetTokenBalanceParameters = { + /** Filter to a specific token by its bech32m identifier. Returns all tokens if omitted. */ + tokenIdentifier?: string | undefined + /** Connector to use. Defaults to current connection. */ + connector?: Connector | undefined +} + +/** A single token balance entry. */ +export type TokenBalanceEntry = { + /** Bech32m-encoded token identifier. */ + identifier: string + /** Human-readable token name. */ + name: string + /** Token ticker symbol. */ + symbol: string + /** Number of decimal places. */ + decimals: number + /** Total owned balance (raw units). */ + balance: bigint + /** Balance available to send (raw units). */ + availableBalance: bigint +} + +/** Return type of {@link getTokenBalance}. */ +export type GetTokenBalanceReturnType = { + /** Token balance entries. */ + tokens: TokenBalanceEntry[] +} + +/** Error types that {@link getTokenBalance} may throw. */ +export type GetTokenBalanceErrorType = + | ConnectorNotConnectedError + | ConnectorActionNotSupportedError + | Error + +/** + * Fetches token balances for the current connection. + * + * @param config - The MBGA config instance. + * @param parameters - Optional token identifier filter and connector override. + * @returns An object containing an array of token balance entries. + * @throws {ConnectorNotConnectedError} If no wallet is connected. + * @throws {ConnectorActionNotSupportedError} If the connector does not support token balance queries. + */ +export async function getTokenBalance( + config: Config, + parameters: GetTokenBalanceParameters = {}, +): Promise { + const { current, connections } = config.state + + const uid = parameters.connector?.uid ?? current + if (!uid) throw new ConnectorNotConnectedError() + + const connection = connections.get(uid) + if (!connection) throw new ConnectorNotConnectedError() + + const { connector } = connection + + if (!connector.getTokenBalance) { + throw new ConnectorActionNotSupportedError({ + action: 'getTokenBalance', + connector: connector.name, + }) + } + + return connector.getTokenBalance({ + tokenIdentifier: parameters.tokenIdentifier, + }) +} diff --git a/packages/core/src/actions/sendToken.test.ts b/packages/core/src/actions/sendToken.test.ts new file mode 100644 index 0000000..29afa3d --- /dev/null +++ b/packages/core/src/actions/sendToken.test.ts @@ -0,0 +1,84 @@ +import { mock } from '@mbga/test' +import { describe, expect, it } from 'vitest' +import { createConfig } from '../createConfig' +import { ConnectorActionNotSupportedError } from '../errors/connector' +import { sparkMainnet } from '../types/network' +import { connect } from './connect' +import { sendToken } from './sendToken' + +describe('sendToken', () => { + it('delegates to connector and returns transaction ID', async () => { + const config = createConfig({ + network: sparkMainnet, + connectors: [mock()], + }) + + await connect(config, { connector: config.connectors[0]! }) + + const result = await sendToken(config, { + tokenIdentifier: 'btkn1abc123', + amount: 1000n, + to: 'sp1recipient', + }) + + expect(result.id).toBe('mock-token-tx-btkn1abc-sp1recip') + }) + + it('throws ConnectorNotConnectedError when not connected', async () => { + const config = createConfig({ + network: sparkMainnet, + connectors: [mock()], + }) + + await expect( + sendToken(config, { + tokenIdentifier: 'btkn1abc', + amount: 1000n, + to: 'sp1recipient', + }), + ).rejects.toThrow('Connector is not connected.') + }) + + it('throws ConnectorActionNotSupportedError when connector lacks sendToken', async () => { + const { createConnector } = await import('../connectors/createConnector') + + const minimal = createConnector((_config) => ({ + id: 'minimal', + name: 'Minimal', + type: 'minimal', + async connect() { + _config.emitter.emit('connect', { accounts: ['bc1q123'] }) + return { accounts: ['bc1q123'] } + }, + async disconnect() { + _config.emitter.emit('disconnect') + }, + async getAccounts() { + return ['bc1q123'] + }, + async getProvider() { + return null + }, + async isAuthorized() { + return true + }, + onAccountsChanged() {}, + onDisconnect() {}, + })) + + const config = createConfig({ + network: sparkMainnet, + connectors: [minimal], + }) + + await connect(config, { connector: config.connectors[0]! }) + + await expect( + sendToken(config, { + tokenIdentifier: 'btkn1abc', + amount: 1000n, + to: 'sp1recipient', + }), + ).rejects.toThrow(ConnectorActionNotSupportedError) + }) +}) diff --git a/packages/core/src/actions/sendToken.ts b/packages/core/src/actions/sendToken.ts new file mode 100644 index 0000000..993b7b0 --- /dev/null +++ b/packages/core/src/actions/sendToken.ts @@ -0,0 +1,64 @@ +import type { Config, Connector } from '../createConfig' +import { ConnectorNotConnectedError } from '../errors/config' +import { ConnectorActionNotSupportedError } from '../errors/connector' + +/** Parameters for {@link sendToken}. */ +export type SendTokenParameters = { + /** Bech32m-encoded token identifier (e.g. `btkn1...`). */ + tokenIdentifier: string + /** Amount to send in raw token units. */ + amount: bigint + /** Recipient Spark address. */ + to: string + /** Connector to use. Defaults to current connection. */ + connector?: Connector | undefined +} + +/** Return type of {@link sendToken}. */ +export type SendTokenReturnType = { + /** Transaction ID of the token transfer. */ + id: string +} + +/** Error types that {@link sendToken} may throw. */ +export type SendTokenErrorType = + | ConnectorNotConnectedError + | ConnectorActionNotSupportedError + | Error + +/** + * Sends tokens to a Spark address. + * + * @param config - The MBGA config instance. + * @param parameters - Token identifier, amount, recipient, and optional connector. + * @returns The transaction ID. + * @throws {ConnectorNotConnectedError} If no wallet is connected. + * @throws {ConnectorActionNotSupportedError} If the connector does not support token transfers. + */ +export async function sendToken( + config: Config, + parameters: SendTokenParameters, +): Promise { + const { current, connections } = config.state + + const uid = parameters.connector?.uid ?? current + if (!uid) throw new ConnectorNotConnectedError() + + const connection = connections.get(uid) + if (!connection) throw new ConnectorNotConnectedError() + + const { connector } = connection + + if (!connector.sendToken) { + throw new ConnectorActionNotSupportedError({ + action: 'sendToken', + connector: connector.name, + }) + } + + return connector.sendToken({ + tokenIdentifier: parameters.tokenIdentifier, + amount: parameters.amount, + to: parameters.to, + }) +} diff --git a/packages/core/src/exports/actions.ts b/packages/core/src/exports/actions.ts index c44201c..6a8d310 100644 --- a/packages/core/src/exports/actions.ts +++ b/packages/core/src/exports/actions.ts @@ -29,16 +29,21 @@ export { type GetConnectionReturnType, getConnection, } from '../actions/getConnection' - export { type GetConnectionsReturnType, getConnections, } from '../actions/getConnections' - export { type GetConnectorsReturnType, getConnectors, } from '../actions/getConnectors' +export { + type GetTokenBalanceErrorType, + type GetTokenBalanceParameters, + type GetTokenBalanceReturnType, + getTokenBalance, + type TokenBalanceEntry, +} from '../actions/getTokenBalance' export { type GetTransactionsErrorType, type GetTransactionsParameters, @@ -57,6 +62,12 @@ export { type SendPaymentReturnType, sendPayment, } from '../actions/sendPayment' +export { + type SendTokenErrorType, + type SendTokenParameters, + type SendTokenReturnType, + sendToken, +} from '../actions/sendToken' export { type SignMessageErrorType, type SignMessageParameters, diff --git a/packages/core/src/exports/index.ts b/packages/core/src/exports/index.ts index 8486c9b..56ad73b 100644 --- a/packages/core/src/exports/index.ts +++ b/packages/core/src/exports/index.ts @@ -47,6 +47,13 @@ export { type GetConnectorsReturnType, getConnectors, } from '../actions/getConnectors' +export { + type GetTokenBalanceErrorType, + type GetTokenBalanceParameters, + type GetTokenBalanceReturnType, + getTokenBalance, + type TokenBalanceEntry, +} from '../actions/getTokenBalance' export { type GetTransactionErrorType, type GetTransactionParameters, @@ -74,6 +81,12 @@ export { type SendPaymentReturnType, sendPayment, } from '../actions/sendPayment' +export { + type SendTokenErrorType, + type SendTokenParameters, + type SendTokenReturnType, + sendToken, +} from '../actions/sendToken' export { type SignMessageErrorType, type SignMessageParameters, From 5a503d41cf5d44570a51e68a1a25a2bb656c7a6d Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sat, 28 Mar 2026 06:14:28 +0000 Subject: [PATCH 5/6] feat(react): add useTokenList, useTokenBalance, and useSendToken hooks React hooks for token operations: - useTokenList: fetches and validates a token list from a URL (query) - useTokenBalance: queries token balances via the connector (query) - useSendToken: sends tokens to a Spark address (mutation) Also re-exports new token types and utilities from @mbga/core. Co-authored-by: Claude Signed-off-by: Claude --- packages/react/src/exports/index.ts | 24 ++++ .../react/src/hooks/useSendToken.test.tsx | 119 ++++++++++++++++++ packages/react/src/hooks/useSendToken.ts | 67 ++++++++++ .../react/src/hooks/useTokenBalance.test.tsx | 82 ++++++++++++ packages/react/src/hooks/useTokenBalance.ts | 50 ++++++++ .../react/src/hooks/useTokenList.test.tsx | 107 ++++++++++++++++ packages/react/src/hooks/useTokenList.ts | 41 ++++++ 7 files changed, 490 insertions(+) create mode 100644 packages/react/src/hooks/useSendToken.test.tsx create mode 100644 packages/react/src/hooks/useSendToken.ts create mode 100644 packages/react/src/hooks/useTokenBalance.test.tsx create mode 100644 packages/react/src/hooks/useTokenBalance.ts create mode 100644 packages/react/src/hooks/useTokenList.test.tsx create mode 100644 packages/react/src/hooks/useTokenList.ts diff --git a/packages/react/src/exports/index.ts b/packages/react/src/exports/index.ts index e86ac23..f703576 100644 --- a/packages/react/src/exports/index.ts +++ b/packages/react/src/exports/index.ts @@ -80,6 +80,11 @@ export { type UseSendPaymentReturnType, useSendPayment, } from '../hooks/useSendPayment' +export { + type UseSendTokenParameters, + type UseSendTokenReturnType, + useSendToken, +} from '../hooks/useSendToken' export { type UseSignMessageParameters, type UseSignMessageReturnType, @@ -90,6 +95,16 @@ export { type UseSwitchConnectionReturnType, useSwitchConnection, } from '../hooks/useSwitchConnection' +export { + type UseTokenBalanceParameters, + type UseTokenBalanceReturnType, + useTokenBalance, +} from '../hooks/useTokenBalance' +export { + type UseTokenListParameters, + type UseTokenListReturnType, + useTokenList, +} from '../hooks/useTokenList' export { type UseTransactionHistoryParameters, type UseTransactionHistoryReturnType, @@ -123,18 +138,27 @@ export { createConnector, createStorage, detectPaymentDestination, + fetchTokenList, formatSats, + formatTokenAmount, getNetworkFromSparkAddress, isValidBitcoinAddress, isValidLightningInvoice, isValidSparkAddress, noopStorage, parseSats, + parseTokenAmount, type SparkNetwork, type SparkNetworkType, type State, type Storage, sparkMainnet, sparkTestnet, + type Token, + type TokenBalanceEntry, + type TokenList, + type TokenListTag, + type TokenListVersion, + validateTokenList, version, } from '@mbga/core' diff --git a/packages/react/src/hooks/useSendToken.test.tsx b/packages/react/src/hooks/useSendToken.test.tsx new file mode 100644 index 0000000..fcf3f60 --- /dev/null +++ b/packages/react/src/hooks/useSendToken.test.tsx @@ -0,0 +1,119 @@ +import { connect, createConfig, sparkMainnet } from '@mbga/core' +import { mock } from '@mbga/test' +import { renderHook, waitFor } from '@testing-library/react' +import { createElement } from 'react' +import { describe, expect, it } from 'vitest' +import { MbgaProvider } from '../context' +import { useSendToken } from './useSendToken' + +function createTestConfig() { + return createConfig({ + network: sparkMainnet, + connectors: [mock()], + storage: null, + }) +} + +function createWrapper(config: ReturnType) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return createElement(MbgaProvider, { config }, children) + } +} + +describe('useSendToken', () => { + it('starts in idle state', () => { + const config = createTestConfig() + + const { result } = renderHook(() => useSendToken(), { + wrapper: createWrapper(config), + }) + + expect(result.current.isIdle).toBe(true) + expect(result.current.data).toBeUndefined() + }) + + it('sends a token successfully', async () => { + const config = createTestConfig() + + await connect(config, { connector: config.connectors[0]! }) + + const { result } = renderHook(() => useSendToken(), { + wrapper: createWrapper(config), + }) + + result.current.sendToken({ + tokenIdentifier: 'btkn1abc123', + amount: 1000n, + to: 'sp1recipient', + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.id).toContain('mock-token-tx') + }) + + it('sendTokenAsync returns the result', async () => { + const config = createTestConfig() + + await connect(config, { connector: config.connectors[0]! }) + + const { result } = renderHook(() => useSendToken(), { + wrapper: createWrapper(config), + }) + + const response = await result.current.sendTokenAsync({ + tokenIdentifier: 'btkn1abc123', + amount: 1000n, + to: 'sp1recipient', + }) + + expect(response.id).toContain('mock-token-tx') + }) + + it('errors when not connected', async () => { + const config = createTestConfig() + + const { result } = renderHook(() => useSendToken(), { + wrapper: createWrapper(config), + }) + + result.current.sendToken({ + tokenIdentifier: 'btkn1abc123', + amount: 1000n, + to: 'sp1recipient', + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error).toBeDefined() + }) + + it('can reset after error', async () => { + const config = createTestConfig() + + const { result } = renderHook(() => useSendToken(), { + wrapper: createWrapper(config), + }) + + result.current.sendToken({ + tokenIdentifier: 'btkn1abc', + amount: 1000n, + to: 'sp1recipient', + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + result.current.reset() + + await waitFor(() => { + expect(result.current.isIdle).toBe(true) + }) + expect(result.current.error).toBeNull() + }) +}) diff --git a/packages/react/src/hooks/useSendToken.ts b/packages/react/src/hooks/useSendToken.ts new file mode 100644 index 0000000..5ef588f --- /dev/null +++ b/packages/react/src/hooks/useSendToken.ts @@ -0,0 +1,67 @@ +'use client' + +import type { + Config, + ResolvedRegister, + SendTokenErrorType, + SendTokenParameters, + SendTokenReturnType, +} from '@mbga/core' +import { sendToken } from '@mbga/core' +import { useMutation } from '@tanstack/react-query' +import { useConfig } from './useConfig' + +/** Parameters for {@link useSendToken}. */ +export type UseSendTokenParameters = { + config?: Config | config | undefined +} + +/** Return type of {@link useSendToken}. */ +export type UseSendTokenReturnType = { + sendToken: (variables: SendTokenParameters) => void + sendTokenAsync: ( + variables: SendTokenParameters, + ) => Promise + data: SendTokenReturnType | undefined + error: SendTokenErrorType | null + isError: boolean + isIdle: boolean + isPending: boolean + isSuccess: boolean + reset: () => void + status: 'error' | 'idle' | 'pending' | 'success' +} + +/** + * Hook for sending tokens on Spark. Wraps the core `sendToken` action. + * + * @example + * ```tsx + * const { sendToken } = useSendToken() + * sendToken({ tokenIdentifier: 'btkn1...', amount: 1000n, to: 'sp1...' }) + * ``` + */ +export function useSendToken< + config extends Config = ResolvedRegister['config'], +>(parameters: UseSendTokenParameters = {}): UseSendTokenReturnType { + const config = useConfig(parameters) + + const mutation = useMutation({ + mutationKey: ['sendToken'], + mutationFn: (variables: SendTokenParameters) => + sendToken(config, variables), + }) + + return { + sendToken: mutation.mutate, + sendTokenAsync: mutation.mutateAsync, + data: mutation.data, + error: mutation.error, + isError: mutation.isError, + isIdle: mutation.isIdle, + isPending: mutation.isPending, + isSuccess: mutation.isSuccess, + reset: mutation.reset, + status: mutation.status, + } +} diff --git a/packages/react/src/hooks/useTokenBalance.test.tsx b/packages/react/src/hooks/useTokenBalance.test.tsx new file mode 100644 index 0000000..3e7fdb9 --- /dev/null +++ b/packages/react/src/hooks/useTokenBalance.test.tsx @@ -0,0 +1,82 @@ +import { connect, createConfig, sparkMainnet } from '@mbga/core' +import { mock } from '@mbga/test' +import { renderHook, waitFor } from '@testing-library/react' +import { createElement } from 'react' +import { describe, expect, it } from 'vitest' +import { MbgaProvider } from '../context' +import { useTokenBalance } from './useTokenBalance' + +function createTestConfig() { + return createConfig({ + network: sparkMainnet, + connectors: [mock()], + storage: null, + }) +} + +function createWrapper(config: ReturnType) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return createElement(MbgaProvider, { config }, children) + } +} + +describe('useTokenBalance', () => { + it('fetches token balances after connect', async () => { + const config = createTestConfig() + + await connect(config, { connector: config.connectors[0]! }) + + const { result } = renderHook(() => useTokenBalance(), { + wrapper: createWrapper(config), + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.tokens).toHaveLength(1) + expect(result.current.data?.tokens[0]?.symbol).toBe('MOCK') + expect(result.current.data?.tokens[0]?.balance).toBe(1000000n) + }) + + it('skips fetch when enabled is false', async () => { + const config = createTestConfig() + + await connect(config, { connector: config.connectors[0]! }) + + const { result } = renderHook(() => useTokenBalance({ enabled: false }), { + wrapper: createWrapper(config), + }) + + expect(result.current.isSuccess).toBe(false) + expect(result.current.data).toBeUndefined() + }) + + it('errors when not connected', async () => { + const config = createTestConfig() + + const { result } = renderHook(() => useTokenBalance(), { + wrapper: createWrapper(config), + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error).toBeDefined() + }) + + it('returns loading state initially', async () => { + const config = createTestConfig() + + await connect(config, { connector: config.connectors[0]! }) + + const { result } = renderHook(() => useTokenBalance(), { + wrapper: createWrapper(config), + }) + + expect(result.current).toHaveProperty('data') + expect(result.current).toHaveProperty('isLoading') + expect(result.current).toHaveProperty('isError') + }) +}) diff --git a/packages/react/src/hooks/useTokenBalance.ts b/packages/react/src/hooks/useTokenBalance.ts new file mode 100644 index 0000000..09b77bb --- /dev/null +++ b/packages/react/src/hooks/useTokenBalance.ts @@ -0,0 +1,50 @@ +'use client' + +import type { + Config, + GetTokenBalanceErrorType, + GetTokenBalanceParameters, + GetTokenBalanceReturnType, + ResolvedRegister, +} from '@mbga/core' +import { getTokenBalance } from '@mbga/core' +import { useQuery } from '@tanstack/react-query' +import type { UseQueryReturnType } from '../utils/query' +import { useConfig } from './useConfig' + +/** Parameters for {@link useTokenBalance}. */ +export type UseTokenBalanceParameters = + GetTokenBalanceParameters & { + config?: Config | config | undefined + enabled?: boolean | undefined + } + +/** Return type of {@link useTokenBalance}. */ +export type UseTokenBalanceReturnType = UseQueryReturnType< + GetTokenBalanceReturnType, + GetTokenBalanceErrorType +> + +/** + * Hook that fetches token balances for the current connection. + * + * @example + * ```tsx + * const { data } = useTokenBalance() + * data?.tokens.map(t => `${t.symbol}: ${t.balance}`) + * ``` + */ +export function useTokenBalance< + config extends Config = ResolvedRegister['config'], +>( + parameters: UseTokenBalanceParameters = {}, +): UseTokenBalanceReturnType { + const { enabled = true, ...rest } = parameters + const config = useConfig(rest) + + return useQuery({ + queryKey: ['tokenBalance', parameters.tokenIdentifier, rest.connector?.uid], + queryFn: () => getTokenBalance(config, rest), + enabled, + }) +} diff --git a/packages/react/src/hooks/useTokenList.test.tsx b/packages/react/src/hooks/useTokenList.test.tsx new file mode 100644 index 0000000..f189027 --- /dev/null +++ b/packages/react/src/hooks/useTokenList.test.tsx @@ -0,0 +1,107 @@ +import { createConfig, sparkMainnet } from '@mbga/core' +import { mock } from '@mbga/test' +import { renderHook, waitFor } from '@testing-library/react' +import { createElement } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MbgaProvider } from '../context' +import { useTokenList } from './useTokenList' + +const validTokenList = { + name: 'Test List', + timestamp: '2025-01-01T00:00:00Z', + version: { major: 1, minor: 0, patch: 0 }, + tokens: [ + { + address: 'btkn1test', + name: 'Test Token', + symbol: 'TST', + decimals: 8, + }, + ], +} + +function createTestConfig() { + return createConfig({ + network: sparkMainnet, + connectors: [mock()], + storage: null, + }) +} + +function createWrapper(config: ReturnType) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return createElement(MbgaProvider, { config }, children) + } +} + +describe('useTokenList', () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + globalThis.fetch = vi.fn() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('fetches and returns a token list', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: true, + json: async () => validTokenList, + } as Response) + + const config = createTestConfig() + + const { result } = renderHook( + () => useTokenList({ url: 'https://example.com/list.json' }), + { wrapper: createWrapper(config) }, + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.name).toBe('Test List') + expect(result.current.data?.tokens).toHaveLength(1) + expect(result.current.data?.tokens[0]?.symbol).toBe('TST') + }) + + it('skips fetch when enabled is false', async () => { + const config = createTestConfig() + + const { result } = renderHook( + () => + useTokenList({ + url: 'https://example.com/list.json', + enabled: false, + }), + { wrapper: createWrapper(config) }, + ) + + expect(result.current.isSuccess).toBe(false) + expect(result.current.data).toBeUndefined() + expect(globalThis.fetch).not.toHaveBeenCalled() + }) + + it('returns error on fetch failure', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + } as Response) + + const config = createTestConfig() + + const { result } = renderHook( + () => useTokenList({ url: 'https://example.com/missing.json' }), + { wrapper: createWrapper(config) }, + ) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error).toBeDefined() + }) +}) diff --git a/packages/react/src/hooks/useTokenList.ts b/packages/react/src/hooks/useTokenList.ts new file mode 100644 index 0000000..139d587 --- /dev/null +++ b/packages/react/src/hooks/useTokenList.ts @@ -0,0 +1,41 @@ +'use client' + +import type { Config, ResolvedRegister, TokenList } from '@mbga/core' +import { fetchTokenList } from '@mbga/core' +import { useQuery } from '@tanstack/react-query' +import type { UseQueryReturnType } from '../utils/query' +import { useConfig } from './useConfig' + +/** Parameters for {@link useTokenList}. */ +export type UseTokenListParameters = { + /** URL of the token list JSON to fetch. */ + url: string + config?: Config | config | undefined + enabled?: boolean | undefined +} + +/** Return type of {@link useTokenList}. */ +export type UseTokenListReturnType = UseQueryReturnType + +/** + * Hook that fetches and validates a token list from a URL. + * + * @example + * ```tsx + * const { data: tokenList } = useTokenList({ + * url: 'https://flashnet.xyz/api/tokenlist', + * }) + * ``` + */ +export function useTokenList< + config extends Config = ResolvedRegister['config'], +>(parameters: UseTokenListParameters): UseTokenListReturnType { + const { url, enabled = true, ...rest } = parameters + useConfig(rest) + + return useQuery({ + queryKey: ['tokenList', url], + queryFn: () => fetchTokenList(url), + enabled, + }) +} From ff8fc4975c1969a32591c035b1b83dbb9f6f9325 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sat, 28 Mar 2026 06:15:26 +0000 Subject: [PATCH 6/6] chore: mark Spark token operations done, update NEXT_TASK.md Co-authored-by: Claude Signed-off-by: Claude --- NEXT_TASK.md | 19 +++++++------------ plans/todos.md | 2 +- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/NEXT_TASK.md b/NEXT_TASK.md index 8cca431..4971b85 100644 --- a/NEXT_TASK.md +++ b/NEXT_TASK.md @@ -7,26 +7,21 @@ After the task is done, Claude will update this file with the next task in line. ## Current Task -**Spark token operations** +**Flashnet authentication** -Fetch token lists from btknlist.org registry, validate with ArkType schema parser, `Token`/`TokenList` types matching the btkn-info schema, `getTokenBalance`/`sendToken` actions, `useTokenList`/`useTokenBalance`/`useSendToken` hooks, `formatTokenAmount`/`parseTokenAmount` helpers respecting per-token decimals. +Simple, effortless auth flow — OAuth or API-key based — so apps can authenticate with Flashnet before any swap/trade operations. ## Prompt ``` Work on the quantumlyy/mbga repo. -Your current task: **Spark token operations** +Your current task: **Flashnet authentication** This includes: -### Spark token operations -- Fetch token lists from btknlist.org registry -- Validate with ArkType schema parser -- `Token`/`TokenList` types matching the btkn-info schema -- `getTokenBalance`/`sendToken` actions -- `useTokenList`/`useTokenBalance`/`useSendToken` hooks -- `formatTokenAmount`/`parseTokenAmount` helpers respecting per-token decimals +### Flashnet authentication +- Simple, effortless auth flow — OAuth or API-key based — so apps can authenticate with Flashnet before any swap/trade operations After completing the task: 1. Mark it done in plans/todos.md (change `- [ ]` to `- [x]`) @@ -60,7 +55,7 @@ Split work into logical commits. Run tests before pushing. 3. ~~Build @mbga/kit UI component library~~ (DONE) 4. ~~Build documentation site with VitePress~~ (DONE) 5. ~~Multi-wallet simultaneous connections~~ (DONE) -6. Spark token operations **(CURRENT)** -7. Flashnet authentication +6. ~~Spark token operations~~ (DONE) +7. Flashnet authentication **(CURRENT)** 8. Flashnet swaps and clawbacks 9. Configure npm publishing workflow diff --git a/plans/todos.md b/plans/todos.md index 3293319..fca055f 100644 --- a/plans/todos.md +++ b/plans/todos.md @@ -84,7 +84,7 @@ ### Features - [x] Multi-wallet simultaneous connections (allow multiple connectors connected at once, switch active connection, update actions to accept optional connector parameter) -- [ ] Spark token operations (fetch token lists from btknlist.org registry, validate with ArkType schema parser, `Token`/`TokenList` types matching the btkn-info schema, `getTokenBalance`/`sendToken` actions, `useTokenList`/`useTokenBalance`/`useSendToken` hooks, `formatTokenAmount`/`parseTokenAmount` helpers respecting per-token decimals) +- [x] Spark token operations (fetch token lists from btknlist.org registry, validate with ArkType schema parser, `Token`/`TokenList` types matching the btkn-info schema, `getTokenBalance`/`sendToken` actions, `useTokenList`/`useTokenBalance`/`useSendToken` hooks, `formatTokenAmount`/`parseTokenAmount` helpers respecting per-token decimals) ### Flashnet Integration - [ ] Flashnet authentication (simple, effortless auth flow — OAuth or API-key based — so apps can authenticate with Flashnet before any swap/trade operations)