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
21 changes: 12 additions & 9 deletions NEXT_TASK.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,26 @@ After the task is done, Claude will update this file with the next task in line.

## Current Task

**Multi-wallet simultaneous connections**
**Spark token operations**

Allow multiple connectors connected at once, switch active connection, update actions to accept optional connector parameter.
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.

## Prompt

```
Work on the quantumlyy/mbga repo.

Your current task: **Multi-wallet simultaneous connections**
Your current task: **Spark token operations**

This includes:

### 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

After completing the task:
1. Mark it done in plans/todos.md (change `- [ ]` to `- [x]`)
Expand Down Expand Up @@ -56,8 +59,8 @@ Split work into logical commits. Run tests before pushing.
2. ~~Improve example apps and build interactive playground~~ (DONE)
3. ~~Build @mbga/kit UI component library~~ (DONE)
4. ~~Build documentation site with VitePress~~ (DONE)
5. Multi-wallet simultaneous connections **(CURRENT)**
6. Spark token operations
5. ~~Multi-wallet simultaneous connections~~ (DONE)
6. Spark token operations **(CURRENT)**
7. Flashnet authentication
8. Flashnet swaps and clawbacks
9. Configure npm publishing workflow
79 changes: 78 additions & 1 deletion packages/core/src/actions/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('connect', () => {
expect(config.state.current).toBeDefined()
})

it('throws if already connected', async () => {
it('throws if same connector is already connected', async () => {
const mockConnector = mock({
accounts: ['bc1qtest123'],
})
Expand All @@ -44,6 +44,83 @@ describe('connect', () => {
).rejects.toThrow('Connector already connected')
})

it('allows connecting multiple different connectors', async () => {
const mockA = mock({ accounts: ['addr-a'] })
const mockB = createConnector((_config) => ({
id: 'mock-b',
name: 'Mock B',
type: 'mock-b',
async connect() {
return { accounts: ['addr-b'] }
},
async disconnect() {},
async getAccounts() {
return ['addr-b']
},
async getProvider() {
return {}
},
async isAuthorized() {
return true
},
onAccountsChanged() {},
onDisconnect() {},
}))

const config = createConfig({
network: sparkMainnet,
connectors: [mockA, mockB],
})

await connect(config, { connector: config.connectors[0]! })
expect(config.state.connections.size).toBe(1)
const firstUid = config.state.current

await connect(config, { connector: config.connectors[1]! })
expect(config.state.connections.size).toBe(2)
expect(config.state.current).toBe(config.connectors[1]!.uid)
expect(config.state.current).not.toBe(firstUid)
expect(config.state.status).toBe('connected')
})

it('keeps connected status when second connector fails but first is still connected', async () => {
const mockA = mock({ accounts: ['addr-a'] })
const failingConnector = createConnector((_config) => ({
id: 'failing',
name: 'Failing Connector',
type: 'failing',
async connect() {
throw new Error('Connection failed')
},
async disconnect() {},
async getAccounts() {
return []
},
async getProvider() {
return {}
},
async isAuthorized() {
return false
},
onAccountsChanged() {},
onDisconnect() {},
}))

const config = createConfig({
network: sparkMainnet,
connectors: [mockA, failingConnector],
})

await connect(config, { connector: config.connectors[0]! })
expect(config.state.status).toBe('connected')

await expect(
connect(config, { connector: config.connectors[1]! }),
).rejects.toThrow('Connection failed')

expect(config.state.status).toBe('connected')
})

it('resets status to disconnected when connector.connect() throws', async () => {
const failingConnector = createConnector((_config) => ({
id: 'failing',
Expand Down
21 changes: 15 additions & 6 deletions packages/core/src/actions/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ export type ConnectErrorType = ConnectorAlreadyConnectedError | Error

/**
* Connects to a wallet via the specified connector.
* Multiple wallets can be connected simultaneously — the newly connected wallet becomes the active one.
*
* @param config - The MBGA config instance.
* @param parameters - Connection parameters including the target connector.
* @returns The accounts exposed by the wallet.
* @throws {ConnectorAlreadyConnectedError} If a wallet is already connected.
* @throws {ConnectorAlreadyConnectedError} If this specific connector is already connected.
*
* @example
* ```ts
Expand All @@ -38,8 +39,8 @@ export async function connect(
): Promise<ConnectReturnType> {
const { connector } = parameters

// If already connected, throw
if (config.state.status === 'connected') {
// Throw only if this specific connector is already connected
if (config.state.connections.has(connector.uid)) {
throw new ConnectorAlreadyConnectedError()
}

Expand All @@ -65,14 +66,22 @@ export async function connect(
status: 'connected',
}))

// Store recent connector id
await config.storage?.setItem('recentConnectorId', connector.id)
// Store recent connector ids
const existing =
(await config.storage?.getItem('recentConnectorIds', null)) ?? []
const updated = [
connector.id,
...existing.filter((id: string) => id !== connector.id),
]
await config.storage?.setItem('recentConnectorIds', updated)

return { accounts }
} catch (error) {
// Only reset to disconnected if no other connections remain
const hasOtherConnections = config._internal.connections.size > 0
config.setState((x) => ({
...x,
status: 'disconnected',
status: hasOtherConnections ? 'connected' : 'disconnected',
}))
throw error
}
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/actions/createInvoice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Config } from '../createConfig'
import type { Config, Connector } from '../createConfig'
import { ConnectorNotConnectedError } from '../errors/config'
import { ConnectorActionNotSupportedError } from '../errors/connector'

Expand All @@ -8,6 +8,8 @@ export type CreateInvoiceParameters = {
amount: bigint
/** Optional memo / description for the invoice */
memo?: string | undefined
/** Connector to use. Defaults to current connection. */
connector?: Connector | undefined
}

/** Return type of {@link createInvoice}. */
Expand Down Expand Up @@ -39,9 +41,10 @@ export async function createInvoice(
): Promise<CreateInvoiceReturnType> {
const { current, connections } = config.state

if (!current) throw new ConnectorNotConnectedError()
const uid = parameters.connector?.uid ?? current
if (!uid) throw new ConnectorNotConnectedError()

const connection = connections.get(current)
const connection = connections.get(uid)
if (!connection) throw new ConnectorNotConnectedError()

const { connector } = connection
Expand Down
83 changes: 80 additions & 3 deletions packages/core/src/actions/disconnect.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mock } from '@mbga/test'
import { describe, expect, it } from 'vitest'
import { createConnector } from '../connectors/createConnector'
import { createConfig } from '../createConfig'
import { sparkMainnet } from '../types/network'
import { connect } from './connect'
Expand Down Expand Up @@ -38,12 +39,11 @@ describe('disconnect', () => {
it('handles disconnect when not connected', async () => {
const config = createConfig({ network: sparkMainnet })

// Should not throw
await disconnect(config)
expect(config.state.status).toBe('disconnected')
})

it('removes recentConnectorId from storage', async () => {
it('removes recentConnectorIds from storage', async () => {
const mockConnector = mock({ accounts: ['bc1qtest'] })
const config = createConfig({
network: sparkMainnet,
Expand All @@ -53,7 +53,84 @@ describe('disconnect', () => {
await connect(config, { connector: config.connectors[0]! })
await disconnect(config)

const stored = await config.storage?.getItem('recentConnectorId', null)
const stored = await config.storage?.getItem('recentConnectorIds', null)
expect(stored).toBeNull()
})

it('switches to next connection when disconnecting active wallet in multi-wallet', async () => {
const mockA = mock({ accounts: ['addr-a'] })
const mockB = createConnector((_config) => ({
id: 'mock-b',
name: 'Mock B',
type: 'mock-b',
async connect() {
return { accounts: ['addr-b'] }
},
async disconnect() {},
async getAccounts() {
return ['addr-b']
},
async getProvider() {
return {}
},
async isAuthorized() {
return true
},
onAccountsChanged() {},
onDisconnect() {},
}))

const config = createConfig({
network: sparkMainnet,
connectors: [mockA, mockB],
})

await connect(config, { connector: config.connectors[0]! })
await connect(config, { connector: config.connectors[1]! })
expect(config.state.connections.size).toBe(2)

await disconnect(config, { connector: config.connectors[1]! })

expect(config.state.connections.size).toBe(1)
expect(config.state.status).toBe('connected')
expect(config.state.current).toBe(config.connectors[0]!.uid)
})

it('disconnects non-active wallet without changing current', async () => {
const mockA = mock({ accounts: ['addr-a'] })
const mockB = createConnector((_config) => ({
id: 'mock-b',
name: 'Mock B',
type: 'mock-b',
async connect() {
return { accounts: ['addr-b'] }
},
async disconnect() {},
async getAccounts() {
return ['addr-b']
},
async getProvider() {
return {}
},
async isAuthorized() {
return true
},
onAccountsChanged() {},
onDisconnect() {},
}))

const config = createConfig({
network: sparkMainnet,
connectors: [mockA, mockB],
})

await connect(config, { connector: config.connectors[0]! })
await connect(config, { connector: config.connectors[1]! })

await disconnect(config, { connector: config.connectors[0]! })

expect(config.state.connections.size).toBe(1)
expect(config.state.status).toBe('connected')
expect(config.state.current).toBe(config.connectors[1]!.uid)
})
})
12 changes: 11 additions & 1 deletion packages/core/src/actions/disconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,15 @@ export async function disconnect(
}
})

await config.storage?.removeItem('recentConnectorId')
// Remove this connector from recent list
if (connector) {
const existing =
(await config.storage?.getItem('recentConnectorIds', null)) ?? []
const updated = existing.filter((id: string) => id !== connector.id)
if (updated.length > 0) {
await config.storage?.setItem('recentConnectorIds', updated)
} else {
await config.storage?.removeItem('recentConnectorIds')
}
}
}
9 changes: 6 additions & 3 deletions packages/core/src/actions/estimateFee.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Config } from '../createConfig'
import type { Config, Connector } from '../createConfig'
import { ConnectorNotConnectedError } from '../errors/config'
import { ConnectorActionNotSupportedError } from '../errors/connector'

Expand All @@ -8,6 +8,8 @@ export type EstimateFeeParameters = {
to: string
/** Amount in satoshis. Optional for invoices that include amount. */
amount?: bigint | undefined
/** Connector to use. Defaults to current connection. */
connector?: Connector | undefined
}

/** Return type of {@link estimateFee}. */
Expand Down Expand Up @@ -41,9 +43,10 @@ export async function estimateFee(
): Promise<EstimateFeeReturnType> {
const { current, connections } = config.state

if (!current) throw new ConnectorNotConnectedError()
const uid = parameters.connector?.uid ?? current
if (!uid) throw new ConnectorNotConnectedError()

const connection = connections.get(current)
const connection = connections.get(uid)
if (!connection) throw new ConnectorNotConnectedError()

const { connector } = connection
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/actions/getBalance.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Config } from '../createConfig'
import type { Config, Connector } from '../createConfig'
import { ConnectorNotConnectedError } from '../errors/config'
import { ConnectorActionNotSupportedError } from '../errors/connector'

/** Parameters for {@link getBalance}. */
export type GetBalanceParameters = {
/** Address to get balance for. Defaults to current connection's first account. */
address?: string | undefined
/** Connector to use. Defaults to current connection. */
connector?: Connector | undefined
}

/** Return type of {@link getBalance}. */
Expand Down Expand Up @@ -39,9 +41,10 @@ export async function getBalance(
): Promise<GetBalanceReturnType> {
const { current, connections } = config.state

if (!current) throw new ConnectorNotConnectedError()
const uid = parameters.connector?.uid ?? current
if (!uid) throw new ConnectorNotConnectedError()

const connection = connections.get(current)
const connection = connections.get(uid)
if (!connection) throw new ConnectorNotConnectedError()

const address = parameters.address ?? connection.accounts[0]
Expand Down
Loading
Loading