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/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/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/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/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/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 e82c827..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, @@ -194,6 +207,12 @@ export { //////////////////////////////////////////////////////////////////////////////// export type { Register, ResolvedRegister } from '../types/register' +export type { + Token, + TokenList, + TokenListTag, + TokenListVersion, +} from '../types/token' //////////////////////////////////////////////////////////////////////////////// // Utilities @@ -211,8 +230,9 @@ 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' //////////////////////////////////////////////////////////////////////////////// // 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/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 +} 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/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, + }) +} 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' 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) 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: {}