feat(sources): Solana wallet adapter (native SOL + SPL tokens) - #116
Conversation
- New packages/sources/src/solana/ adapter:
- provider.ts: SolanaTransferProvider interface + RawSolanaTransfer type
using balance-delta approach (preBalances/postBalances, pre/postTokenBalances)
- providers/rpc.ts: SolanaRpcProvider using JSON-RPC getSignaturesForAddress
+ getTransaction with pagination, retry/backoff, versioned tx support
- adapter.ts: ingestSolana() translates deltas to crypto_in/crypto_out/fee_only
with deterministic IDs, dedup, and incremental cursor (newestSignature)
- index.ts: public re-exports
- Wiring:
- packages/sources/src/index.ts: export * as solana
- packages/sources/package.json: ./solana export + tsup entry
- packages/cli/src/config.ts: providers.solana config option
- packages/cli/src/commands/account.ts: solana in SUPPORTED_ACCOUNT_SOURCES
- packages/cli/src/commands/account.test.ts: update test (solana now supported)
- packages/cli/src/commands/sync.ts: syncSolana() case + function
- packages/cli/src/commands/SyncOutput.tsx: SolanaSyncResult + renderSolanaSyncOutput
- packages/cli/src/index.ts: SOURCE_HELP + sync examples updated
- Docs: README Solana wallet sync section + account/sync quickstart examples
Tests: provider.test.ts, adapter.test.ts, providers/rpc.test.ts covering
direction, fee legs, SPL tokens, deterministic IDs, idempotency, cursor,
RPC pagination, retry/backoff, failed tx skipping, non-fee-payer
The test file lives at providers/rpc.test.ts but was importing './providers/rpc.js' — a path that resolves one directory too deep. Change all six dynamic imports to './rpc.js'.
There was a problem hiding this comment.
🔎 Agent review (kiro · sonnet→opus) — CONCERNS
REVIEW: CONCERNS
RESOLVES: full
The Solana adapter is well-structured and meets all acceptance criteria, but it double-prefixes RawEvent IDs (solana:solana:), treats base-58 owner addresses case-insensitively (unsound for Solana), and --from resets the cursor while ignoring the supplied date. None break sync, but all should be addressed before merge.
3 concerns · 2 nits — 5 inline on the diff
| t: RawSolanaTransfer, | ||
| opts: SolanaAdapterOptions, | ||
| ): RawEvent | null { | ||
| const id = `solana:${t.providerId}`; |
There was a problem hiding this comment.
The RPC provider already emits providerId as solana:<signature> (and solana:<sig>:fee, solana:<sig>:spl:<mint>), then the adapter builds id = solana:${t.providerId}, yielding solana:solana:<sig>. The EVM convention is bare provider IDs plus one ${source}: prefix in the adapter (evm/adapter.ts line 127). IDs are deterministic and idempotent so sync still works, but these keys are persisted to SQLite; changing the scheme later forces duplicate re-inserts, so fix it now. Either drop the chain prefix in the provider or use the provider ID as-is here.
| const id = `solana:${t.providerId}`; | |
| const id = t.providerId; |
There was a problem hiding this comment.
↩️ Not addressed in this revise — needs a follow-up / out of scope
|
|
||
| // Find the owner's index in accountKeys. | ||
| const ownerIndex = accountKeys.findIndex( | ||
| k => k.pubkey.toLowerCase() === ownerAddress.toLowerCase(), |
There was a problem hiding this comment.
Owner matching lowercases both sides (k.pubkey.toLowerCase() === ownerAddress.toLowerCase(), also lines 282 and 292 for SPL balances). This is copied from the EVM adapter, where hex addresses are case-insensitive, but Solana base-58 pubkeys are case-sensitive — lowercasing is lossy and can theoretically match two distinct addresses. Compare the raw base-58 strings exactly instead. Low probability of a real collision, but it is conceptually incorrect for this chain and easy to get right.
| k => k.pubkey.toLowerCase() === ownerAddress.toLowerCase(), | |
| k => k.pubkey === ownerAddress, |
There was a problem hiding this comment.
↩️ Not addressed in this revise — needs a follow-up / out of scope
| // The Solana RPC doesn't support block-number filters on getSignaturesForAddress, | ||
| // so --from resets the cursor and the user gets all available history | ||
| // from the RPC (subject to node's history retention). | ||
| sinceSignature = undefined; |
There was a problem hiding this comment.
When opts.from is set, sinceSignature is forced to undefined so the full available history is refetched regardless of the date. The detailed README section does document this reset behavior, but the quickstart example daybook sync --source solana --from 2024-01-01 strongly implies date filtering — --from 2024-01-01 and --from 2020-01-01 behave identically. This diverges from EVM's date/block-aware --from and will surprise users with large duplicate-insert runs. Either filter emitted events by blockTime >= fromDate or reject a date value with a clear message.
There was a problem hiding this comment.
↩️ Not addressed in this revise — needs a follow-up / out of scope
| ): RawEvent | null { | ||
| const id = `solana:${t.providerId}`; | ||
| const timestamp = new Date(t.blockTime * 1000); | ||
| const delta = parseFloat(t.delta); |
There was a problem hiding this comment.
🧹 parseFloat zero-guard bypasses decimal.js
The zero-delta guard uses parseFloat(t.delta) then delta === 0, contrary to the repo's decimal.js-for-all-arithmetic rule. The first-pass claim that a 1e-18 SPL amount underflows to 0 is not correct — parseFloat only returns 0 below ~5e-324, unreachable at realistic token decimals, and the provider already filters zero deltas upstream (rpc.ts delta.isZero() / rawDelta !== 0). So this is a consistency nit, not a correctness bug; prefer new Decimal(t.delta).isZero().
There was a problem hiding this comment.
↩️ Not addressed in this revise — needs a follow-up / out of scope
| // ───────────────────────────────────────────────────────────────────────── | ||
|
|
||
| describe('ingestSolana — deterministic IDs', () => { | ||
| it('native transfer ID is solana:solana:<signature>', async () => { |
There was a problem hiding this comment.
🧹 Tests lock in the double-prefix ID
The deterministic-ID tests assert solana:solana:<sig> and solana:solana:<sig>:fee as expected, which masks the ID concern above. If the double prefix is fixed, these expectations must change to solana:<sig> / solana:<sig>:fee to match the EVM convention.
There was a problem hiding this comment.
↩️ Not addressed in this revise — needs a follow-up / out of scope
There was a problem hiding this comment.
🔎 Agent re-review (kiro · sonnet, delta) — CONCERNS
Try running kiro-cli login to re-authenticate, or kiro-cli profile to select a profile.
All tools are now trusted (!). Kiro will execute tools without asking for confirmation.
Agents can sometimes do unexpected things so understand the risks.
Learn more at https://kiro.dev/docs/cli/chat/security/#using-tools-trust-all-safely
Monthly request limit reached
You can enable overages to continue making requests.
The limits reset on 09/01.
What
Adds a complete Solana source adapter that fetches native SOL and SPL-token transfer history via the Solana JSON-RPC API and emits normalized RawEvents that flow through the existing classifier and tax engine.
New files under
packages/sources/src/solana/:provider.ts—SolanaTransferProviderinterface +RawSolanaTransferintermediate type using the balance-delta approach (pre/postBalances, pre/postTokenBalances)providers/rpc.ts—SolanaRpcProviderimplementing JSON-RPCgetSignaturesForAddress+getTransactionwith pagination, retry/backoff, and versioned transaction supportadapter.ts—ingestSolana()translates balance deltas tocrypto_in/crypto_out/fee_onlyRawEvents with deterministic IDs, dedup, and incremental cursorindex.ts, test filesWiring changes:
packages/sources/src/index.ts+package.json: Solana export and tsup build entrypackages/cli/src/config.ts:providers.solanaconfig option (endpoint + apiKeyEnv)packages/cli/src/commands/account.ts:solanainSUPPORTED_ACCOUNT_SOURCESpackages/cli/src/commands/sync.ts:syncSolana()implementation with cursor persistencepackages/cli/src/commands/SyncOutput.tsx:SolanaSyncResulttype +renderSolanaSyncOutputpackages/cli/src/index.ts: help text + sync examplesDocs: README Solana wallet sync section documenting balance-delta approach, fee attribution, SPL tokens, incremental sync, and deferred features.
Why
Plane: OSS-127
How
meta.preBalances/meta.postBalancesfor SOL andmeta.preTokenBalances/meta.postTokenBalancesfor SPL — captures DeFi inner instruction movements without instruction tree parsingsolana:solana:<sig>,solana:solana:<sig>:fee,solana:solana:<sig>:spl:<mint>repo.upsertSyncState()maxSupportedTransactionVersion: 0for v0 versioned transactionscrypto_in/crypto_outas per EVM adapter philosophyTesting
tsc -btypecheck passes cleanprovider.test.ts(helpers),adapter.test.ts(translation, IDs, cursor, dedup),providers/rpc.test.ts(mocked fetch: SOL delta, SPL delta, failed tx skip, fee payer,untilcursor, retry/backoff)account.test.tsupdated: solana now accepted, bitcoin still rejected🤖 Generated by the harbor agent loop. Reviewed by a human before merge.