Skip to content

Commit c5650d4

Browse files
committed
Modularize Solana agent tools package
1 parent f41de07 commit c5650d4

10 files changed

Lines changed: 445 additions & 331 deletions

File tree

packages/solana-agent-tools/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ while the example that loads the plugin into SAK runs on Node 22.
2222
go through the existing repo path: devnet guard, `policy.enforce()`, explicit user approval,
2323
simulation/signing UX, ledger receipt, and UI transaction surface.
2424

25+
## Source layout
26+
27+
| File | Responsibility |
28+
|---|---|
29+
| `src/constants.ts` | Public ids used by read tools (`SOL_MINT`, `USDC_MINT`, `TOKEN_PROGRAM_ID`). |
30+
| `src/types.ts` | Shared interfaces for connections, prices, balances, wallets, and transfer-intent simulation. |
31+
| `src/connection.ts` | Dependency injection for the guarded devnet connection and mocked fetch. |
32+
| `src/wallet.ts` | Read-only wallet facade; every signer method throws. |
33+
| `src/balances.ts` | SOL and SPL token balance reads. |
34+
| `src/prices.ts` | Jupiter Price API V3 and Pyth Hermes reads. |
35+
| `src/intent.ts` | Non-executable transfer-intent simulation through the repo policy gate. |
36+
| `src/tools.ts` | Stable facade that composes the allowlisted tools. |
37+
| `src/sak.ts` | Solana Agent Kit-compatible plugin/action adapter. |
38+
2539
## Solana Agent Kit
2640

2741
```ts
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'
2+
import { TOKEN_PROGRAM_ID } from './constants.js'
3+
import { resolveConnection } from './connection.js'
4+
import type { SolanaAgentToolOptions, TokenBalance, WalletBalance } from './types.js'
5+
6+
interface ParsedTokenAmount {
7+
mint?: string
8+
owner?: string
9+
amount?: string
10+
decimals?: number
11+
uiAmount?: number | null
12+
uiAmountString?: string
13+
}
14+
15+
function tokenAmountInfo(parsed: unknown): ParsedTokenAmount | null {
16+
if (!parsed || typeof parsed !== 'object') return null
17+
const info = (parsed as { info?: unknown }).info
18+
if (!info || typeof info !== 'object') return null
19+
const tokenAmount = (info as { tokenAmount?: unknown }).tokenAmount
20+
if (!tokenAmount || typeof tokenAmount !== 'object') return null
21+
const t = tokenAmount as Record<string, unknown>
22+
return {
23+
mint: typeof (info as Record<string, unknown>).mint === 'string'
24+
? String((info as Record<string, unknown>).mint)
25+
: undefined,
26+
owner: typeof (info as Record<string, unknown>).owner === 'string'
27+
? String((info as Record<string, unknown>).owner)
28+
: undefined,
29+
amount: typeof t.amount === 'string' ? t.amount : undefined,
30+
decimals: typeof t.decimals === 'number' ? t.decimals : undefined,
31+
uiAmount: typeof t.uiAmount === 'number' || t.uiAmount === null ? t.uiAmount : undefined,
32+
uiAmountString: typeof t.uiAmountString === 'string' ? t.uiAmountString : undefined,
33+
}
34+
}
35+
36+
/** Read a wallet's SOL balance through the repo's guarded devnet connection. */
37+
export async function readWalletBalance(
38+
address: string,
39+
opts: SolanaAgentToolOptions = {},
40+
): Promise<WalletBalance> {
41+
const pubkey = new PublicKey(address)
42+
const lamports = await resolveConnection(opts).getBalance(pubkey)
43+
return {
44+
address: pubkey.toBase58(),
45+
lamports,
46+
sol: lamports / LAMPORTS_PER_SOL,
47+
cluster: 'devnet',
48+
}
49+
}
50+
51+
/** Read parsed SPL token balances for an owner without touching signing or transfer APIs. */
52+
export async function readTokenBalances(
53+
owner: string,
54+
opts: SolanaAgentToolOptions = {},
55+
): Promise<TokenBalance[]> {
56+
const ownerKey = new PublicKey(owner)
57+
const accounts = await resolveConnection(opts).getParsedTokenAccountsByOwner(ownerKey, {
58+
programId: TOKEN_PROGRAM_ID,
59+
})
60+
return accounts.value.flatMap((entry): TokenBalance[] => {
61+
const parsed = tokenAmountInfo(entry.account.data.parsed)
62+
if (!parsed?.mint || !parsed.amount || parsed.decimals == null || parsed.uiAmountString == null) return []
63+
return [{
64+
account: entry.pubkey.toBase58(),
65+
mint: parsed.mint,
66+
owner: parsed.owner ?? ownerKey.toBase58(),
67+
amount: parsed.amount,
68+
decimals: parsed.decimals,
69+
uiAmount: parsed.uiAmount ?? null,
70+
uiAmountString: parsed.uiAmountString,
71+
}]
72+
})
73+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { DEVNET_RPC, solanaConnection } from '@pay/agent-runtime'
2+
import type { ReadonlySolanaConnection, SolanaAgentToolOptions } from './types.js'
3+
4+
/** Resolve the guarded devnet connection unless a test/example injects a read-only connection. */
5+
export function resolveConnection(opts: SolanaAgentToolOptions): ReadonlySolanaConnection {
6+
return opts.connection ?? solanaConnection(opts.rpcUrl ?? DEVNET_RPC)
7+
}
8+
9+
/** Resolve the fetch implementation, letting tests and examples mock external price providers. */
10+
export function resolveFetch(opts: SolanaAgentToolOptions): typeof fetch {
11+
return opts.fetch ?? fetch
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { PublicKey } from '@solana/web3.js'
2+
3+
/** Wrapped SOL mint id used for read-only price lookups. */
4+
export const SOL_MINT = 'So11111111111111111111111111111111111111112'
5+
6+
/** USDC mint id used as a read-only Jupiter price alias. */
7+
export const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
8+
9+
/** SPL Token program id for parsed token-account balance reads. */
10+
export const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {
2+
LAMPORTS_PER_SOL,
3+
PublicKey,
4+
SystemProgram,
5+
type TransactionInstruction,
6+
} from '@solana/web3.js'
7+
import { enforce } from '@pay/agent-runtime'
8+
import type { TransferIntentInput, TransferIntentSimulation } from './types.js'
9+
10+
function instructionShape(ix: TransactionInstruction): TransferIntentSimulation['instruction'] {
11+
return {
12+
programId: ix.programId.toBase58(),
13+
dataBase64: Buffer.from(ix.data).toString('base64'),
14+
keys: ix.keys.map((k) => ({
15+
pubkey: k.pubkey.toBase58(),
16+
isSigner: k.isSigner,
17+
isWritable: k.isWritable,
18+
})),
19+
}
20+
}
21+
22+
/**
23+
* Build an instruction-shaped SOL transfer intent without returning a signable transaction.
24+
*
25+
* This is for agent reasoning and UI review only. It runs the repo policy gate when provided and
26+
* always returns `executable: false`.
27+
*/
28+
export async function simulateTransferIntent(
29+
input: TransferIntentInput,
30+
): Promise<TransferIntentSimulation> {
31+
if (input.amountSol <= 0) throw new Error('amountSol must be positive')
32+
const buyer = new PublicKey(input.buyer)
33+
const recipient = new PublicKey(input.recipient)
34+
const lamports = Math.round(input.amountSol * LAMPORTS_PER_SOL)
35+
const policyDecision = input.policy
36+
? enforce({
37+
kind: 'deposit',
38+
round: input.round ?? 0,
39+
service: input.service,
40+
amountSol: input.amountSol,
41+
payout: recipient.toBase58(),
42+
...(input.awardedPriceSol != null ? { awardedPriceSol: input.awardedPriceSol } : {}),
43+
...(input.spentSol != null ? { spentSol: input.spentSol } : {}),
44+
...(input.lastDepositAt != null ? { lastDepositAt: input.lastDepositAt } : {}),
45+
...(input.now != null ? { now: input.now } : {}),
46+
}, input.policy)
47+
: { ok: true, violations: [] }
48+
const ix = SystemProgram.transfer({
49+
fromPubkey: buyer,
50+
toPubkey: recipient,
51+
lamports,
52+
})
53+
if (input.reference) ix.keys.push({ pubkey: new PublicKey(input.reference), isSigner: false, isWritable: false })
54+
return {
55+
kind: 'transfer-intent',
56+
executable: false,
57+
cluster: 'devnet',
58+
service: input.service,
59+
buyer: buyer.toBase58(),
60+
recipient: recipient.toBase58(),
61+
amountSol: input.amountSol,
62+
lamports,
63+
...(input.reference ? { reference: input.reference } : {}),
64+
policyDecision,
65+
reason: policyDecision.ok
66+
? 'dry-run only: no signature requested and no transaction broadcast'
67+
: `blocked by policy: ${policyDecision.violations.join('; ')}`,
68+
instruction: instructionShape(ix),
69+
}
70+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { SOL_MINT, USDC_MINT } from './constants.js'
2+
import { resolveFetch } from './connection.js'
3+
import type { PythPrice, SolanaAgentToolOptions, TokenPrice } from './types.js'
4+
5+
function aliasMint(id: string): string {
6+
const token = id.trim()
7+
if (/^sol$/i.test(token)) return SOL_MINT
8+
if (/^usdc$/i.test(token)) return USDC_MINT
9+
return token
10+
}
11+
12+
/** Fetch a read-only USD token price from Jupiter Price API V3. */
13+
export async function fetchTokenPrice(
14+
id: string,
15+
opts: SolanaAgentToolOptions = {},
16+
): Promise<TokenPrice> {
17+
const mint = aliasMint(id)
18+
const base = opts.jupiterPriceBaseUrl ?? 'https://api.jup.ag/price/v3'
19+
const url = new URL(base)
20+
url.searchParams.set('ids', mint)
21+
const headers: Record<string, string> = {}
22+
const apiKey = opts.jupiterApiKey ?? process.env.JUPITER_API_KEY
23+
if (apiKey) headers['x-api-key'] = apiKey
24+
const res = await resolveFetch(opts)(url, { headers })
25+
if (!res.ok) throw new Error(`Jupiter price ${res.status}: ${(await res.text()).slice(0, 160)}`)
26+
const body = await res.json() as Record<string, unknown>
27+
const item = body[mint]
28+
if (!item || typeof item !== 'object') throw new Error(`Jupiter price missing for ${mint}`)
29+
const o = item as Record<string, unknown>
30+
const usdPrice = Number(o.usdPrice)
31+
if (!Number.isFinite(usdPrice)) throw new Error(`Jupiter price for ${mint} has no numeric usdPrice`)
32+
return {
33+
id: mint,
34+
usdPrice,
35+
...(typeof o.decimals === 'number' ? { decimals: o.decimals } : {}),
36+
...(typeof o.blockId === 'number' ? { blockId: o.blockId } : {}),
37+
...(typeof o.liquidity === 'number' ? { liquidity: o.liquidity } : {}),
38+
...(typeof o.priceChange24h === 'number' ? { priceChange24h: o.priceChange24h } : {}),
39+
...(typeof o.createdAt === 'string' ? { createdAt: o.createdAt } : {}),
40+
provider: 'jupiter',
41+
}
42+
}
43+
44+
/** Fetch a read-only latest price update from Pyth Hermes. */
45+
export async function fetchPythPrice(
46+
priceFeedId: string,
47+
opts: SolanaAgentToolOptions = {},
48+
): Promise<PythPrice> {
49+
const id = priceFeedId.trim().replace(/^0x/i, '')
50+
if (!/^[0-9a-fA-F]{64}$/.test(id)) throw new Error('Pyth priceFeedId must be a 32-byte hex string')
51+
const base = opts.pythHermesBaseUrl ?? 'https://hermes.pyth.network/v2/updates/price/latest'
52+
const url = new URL(base)
53+
url.searchParams.append('ids[]', id)
54+
const res = await resolveFetch(opts)(url)
55+
if (!res.ok) throw new Error(`Pyth Hermes ${res.status}: ${(await res.text()).slice(0, 160)}`)
56+
const body = await res.json() as { parsed?: Array<{ id?: string; price?: Record<string, unknown> }> }
57+
const parsed = body.parsed?.find((p) => p.id?.replace(/^0x/i, '').toLowerCase() === id.toLowerCase())
58+
const price = parsed?.price
59+
if (!price) throw new Error(`Pyth price missing for ${id}`)
60+
const rawPrice = String(price.price ?? '')
61+
const exponent = Number(price.expo)
62+
const confidenceRaw = String(price.conf ?? '')
63+
const publishTime = Number(price.publish_time)
64+
const scaled = Number(rawPrice) * 10 ** exponent
65+
const confidence = Number(confidenceRaw) * 10 ** exponent
66+
if (!Number.isFinite(scaled) || !Number.isFinite(confidence) || !Number.isFinite(publishTime)) {
67+
throw new Error(`Pyth price for ${id} is malformed`)
68+
}
69+
return {
70+
id,
71+
price: scaled,
72+
rawPrice,
73+
confidence,
74+
exponent,
75+
publishTime,
76+
provider: 'pyth-hermes',
77+
}
78+
}

packages/solana-agent-tools/src/sak.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface SdkActionExample {
1111
explanation: string
1212
}
1313

14+
/** Minimal action shape shared by Solana Agent Kit, OpenAI tools, and LangChain adapters. */
1415
export interface SdkAction {
1516
name: string
1617
similes: string[]
@@ -20,6 +21,7 @@ export interface SdkAction {
2021
handler(agent: unknown, input: Record<string, unknown>): Promise<Record<string, unknown>>
2122
}
2223

24+
/** SAK-compatible plugin shape for the read-only tool table. */
2325
export interface ReadonlySolanaToolsPlugin {
2426
name: 'pay-readonly-solana-tools'
2527
methods: ReadonlySolanaAgentTools
@@ -56,6 +58,7 @@ function actionResult(value: object): Record<string, unknown> {
5658
return value as unknown as Record<string, unknown>
5759
}
5860

61+
/** Create the five allowlisted SAK-style actions backed by `ReadonlySolanaAgentTools`. */
5962
export function createReadonlySolanaActions(tools: ReadonlySolanaAgentTools): SdkAction[] {
6063
return [
6164
{
@@ -135,6 +138,7 @@ export function createReadonlySolanaActions(tools: ReadonlySolanaAgentTools): Sd
135138
]
136139
}
137140

141+
/** Create a Solana Agent Kit-compatible plugin without requesting signing authority from SAK. */
138142
export function createReadonlySolanaPlugin(
139143
tools: ReadonlySolanaAgentTools = createSolanaAgentTools(),
140144
): ReadonlySolanaToolsPlugin {

0 commit comments

Comments
 (0)