Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions NEXT_TASK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`)
Expand Down Expand Up @@ -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
61 changes: 61 additions & 0 deletions packages/connectors/src/sparkSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
},
"dependencies": {
"@scure/base": "^1.1.1",
"arktype": "^2.1.20",
"mitt": "3.0.1",
"zustand": "5.0.0"
},
Expand Down
112 changes: 112 additions & 0 deletions packages/core/src/actions/getTokenBalance.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
})
74 changes: 74 additions & 0 deletions packages/core/src/actions/getTokenBalance.ts
Original file line number Diff line number Diff line change
@@ -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<GetTokenBalanceReturnType> {
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,
})
}
84 changes: 84 additions & 0 deletions packages/core/src/actions/sendToken.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading