Skip to content

feat(sources): Solana wallet adapter (native SOL + SPL tokens) - #116

Merged
gfargo-horizon-agent[bot] merged 2 commits into
mainfrom
agent/daybook-127-daybook-7-non-evm-chain-support-solana-b
Aug 30, 2026
Merged

gfargo-horizon-agent[bot] merged 2 commits into
mainfrom
agent/daybook-127-daybook-7-non-evm-chain-support-solana-b

Conversation

@gfargo-horizon-agent

@gfargo-horizon-agent gfargo-horizon-agent Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

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 — SolanaTransferProvider interface + RawSolanaTransfer intermediate type using the balance-delta approach (pre/postBalances, pre/postTokenBalances)
  • providers/rpc.ts — SolanaRpcProvider implementing JSON-RPC getSignaturesForAddress + getTransaction with pagination, retry/backoff, and versioned transaction support
  • adapter.ts — ingestSolana() translates balance deltas to crypto_in/crypto_out/fee_only RawEvents with deterministic IDs, dedup, and incremental cursor
  • index.ts, test files

Wiring changes:

  • packages/sources/src/index.ts + package.json: Solana export and tsup build entry
  • packages/cli/src/config.ts: providers.solana config option (endpoint + apiKeyEnv)
  • packages/cli/src/commands/account.ts: solana in SUPPORTED_ACCOUNT_SOURCES
  • packages/cli/src/commands/sync.ts: syncSolana() implementation with cursor persistence
  • packages/cli/src/commands/SyncOutput.tsx: SolanaSyncResult type + renderSolanaSyncOutput
  • packages/cli/src/index.ts: help text + sync examples

Docs: README Solana wallet sync section documenting balance-delta approach, fee attribution, SPL tokens, incremental sync, and deferred features.

Why

Plane: OSS-127

How

  • Balance-delta approach: uses meta.preBalances/meta.postBalances for SOL and meta.preTokenBalances/meta.postTokenBalances for SPL — captures DeFi inner instruction movements without instruction tree parsing
  • Fee attribution: fee leg only emitted when owner is the fee payer (account index 0)
  • Deterministic IDs: solana:solana:<sig>, solana:solana:<sig>:fee, solana:solana:<sig>:spl:<mint>
  • Incremental sync: newest transaction signature persisted as cursor via repo.upsertSyncState()
  • Retry/backoff: HTTP 429 and RPC rate-limit codes handled with exponential backoff (overridable _sleep for testing)
  • maxSupportedTransactionVersion: 0 for v0 versioned transactions
  • Deferred: NFT classification, DeFi/staking semantics — adapter correctly emits crypto_in/crypto_out as per EVM adapter philosophy

Testing

  • tsc -b typecheck passes clean
  • 3 new test files: provider.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, until cursor, retry/backoff)
  • account.test.ts updated: solana now accepted, bitcoin still rejected
  • CI pending

🤖 Generated by the harbor agent loop. Reviewed by a human before merge.

- 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'.

@gfargo-horizon-agent gfargo-horizon-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔎 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}`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Double-prefixed RawEvent ID: solana:solana:

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.

Suggested change
const id = `solana:${t.providerId}`;
const id = t.providerId;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↩️ 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(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Base-58 owner match uses toLowerCase()

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.

Suggested change
k => k.pubkey.toLowerCase() === ownerAddress.toLowerCase(),
k => k.pubkey === ownerAddress,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↩️ 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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ --from ignores its date value entirely

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↩️ 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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↩️ 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 () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

↩️ Not addressed in this revise — needs a follow-up / out of scope

@gfargo-horizon-agent gfargo-horizon-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔎 Agent re-review (kiro · sonnet, delta) — CONCERNS

⚠️ WARNING: Failed to retrieve MCP settings; MCP functionality disabled.
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.

@gfargo-horizon-agent
gfargo-horizon-agent Bot merged commit 858d777 into main Aug 30, 2026
1 check passed
@gfargo-horizon-agent
gfargo-horizon-agent Bot deleted the agent/daybook-127-daybook-7-non-evm-chain-support-solana-b branch August 30, 2026 06:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants