The unified third-party integration layer. Owns every outbound call to pricing APIs (CoinGecko, Finnhub, DeFiLlama, Frankfurter), CEX accounts (11 venues), broker APIs (IBKR, Wise), public chains (Bitcoin, Solana, TRON, TON, Etherscan-multichain), AI inference (OpenAI), and the manual-pricing Google Sheets fallback.
Every provider conforms to one or more capability interfaces in
core/capabilities.ts. The ProviderRegistry (core/registry.ts)
slots each provider into capability-scoped buckets via duck-typed
guards, and orchestrators (PricingService, WalletDiscoveryService,
TransactionImportCoordinator, etc.) dispatch by capability + token.
src/
├── index.ts barrel — re-exports `core/*`
├── core/
│ ├── boot.ts buildProviderRegistry({ mode, redis, env, providers })
│ ├── capabilities.ts 9 capability interfaces + duck-typed guards
│ ├── config.ts loadProvidersConfig() — package-owned env shape
│ ├── errors.ts ProviderError + classifyError + .fromHttp(res)
│ ├── rate-limiter-registry.ts single namespace map (boot fails on duplicates)
│ ├── registry.ts capability-bucketed dispatch
│ ├── testing.ts createMockSelfCredContext, makeMockToken
│ ├── types.ts PriceQuote, HoldingSnapshot, TransactionEvent, ProviderContext
│ ├── base/
│ │ ├── base-cex-provider.ts pagination + asset-identity for stream-history CEX (Kraken)
│ │ ├── base-hmac-cex-provider.ts signed-HTTP scaffolding for the 11 simpler CEX
│ │ └── base-evm-provider.ts EVM-chain scaffolding (Etherscan)
│ └── utils/
│ └── fetch.ts fetchWithTimeout — every provider's HTTP path
├── providers/
│ ├── _openai-responses.ts shared Responses API client (OpenAI)
│ └── <name>/ one directory per provider (30 today, two of them e2e stubs)
└── tests/
├── core/ capabilities, registry, errors, config, rate-limiter, base, fetch
└── providers/ representative provider tests (binance)
| Capability | Interface | Examples |
|---|---|---|
current-price |
CurrentPriceProvider |
CoinGecko, Finnhub, Frankfurter, DeFiLlama |
historical-price |
HistoricalPriceProvider |
CoinGecko, Frankfurter, Kraken |
current-balances |
BalanceProvider |
every CEX, every chain, IBKR, Wise |
transactions |
TransactionsProvider |
Kraken, Etherscan, Solana, TRON, IBKR |
token-identity |
TokenIdentityProvider |
CoinGecko, Finnhub |
credential-validator |
CredentialValidator |
every CEX, IBKR, Wise |
account-discoverer |
AccountDiscoveryProvider |
IBKR, Wise |
address-validator |
AddressValidatorProvider |
every chain |
ai-inference |
AIInferenceProvider |
OpenAI |
Registration is duck-typed: a provider class doesn't need to declare
which interfaces it implements. The registry runs is*Provider(provider)
guards for every capability and slots the instance into every bucket
that returns true. A typo in a method name silently disables a
capability, so a provider's test calls the capability method it claims
before that becomes a "no transactions provider for
institutionCode='kraken'" runtime surprise.
buildProviderRegistry() constructs the factories selected by each app's
composition root. Tier 1 uses direct platform providers. Tier 2 wraps platform
factories with platformProviderFactories from @scani/cloud-client, replacing
AI, market-data, token-identity and public-wallet capabilities with cloud
adapters. Those adapters call the Scani-hosted data-provider using one cloud key.
personalProviderFactories keeps credentialed exchange and brokerage operations
local while routing their public pricing methods through the cloud. Decrypted
personal credentials never cross that boundary. Tier 2 S3 also stays local;
cloud processing receives only its required inputs. Unset/Tier 3 retains the
existing managed provider selection.
The cloud service constructs the direct upstream providers with Scani-owned keys. Redis-backed provider rate limits remain shared across its replicas; adding a process must not multiply an upstream budget. Customer API/worker instances do not need those platform keys in Tier 2.
import { buildProviderRegistry } from '@scani/providers/core/boot';
import { coingeckoFactory } from '@scani/providers/providers/coingecko';
import { aiOpenAIFactory } from '@scani/providers/providers/ai-openai';
// … etc.
await buildProviderRegistry({
redis: providerRedis,
env: process.env,
providers: [
coingeckoFactory,
finnhubFactory,
aiOpenAIFactory,
// order = dispatch priority
],
});mkdir packages/clients/providers/src/providers/<name>/- Add
index.tsexporting:- A class implementing one or more capability interfaces.
- A
<name>Factory: ProviderFactorythat constructs the class givendeps(env, redis, rate-limiter-registry, …).
- Pick a base:
- Signed-HTTP CEX (HMAC-style auth, balance + creds-validate
mostly) → extend
BaseHmacCexProvider. Subclass implementssignRequest()for the venue's signing math; the base owns rate-limit execution, error wrapping intoProviderError, and credential extraction. - Stream-history CEX (paginated transactions, asset-identity
mapping) → extend
BaseCexProvider. Subclass implementsmapAssetIdentity()+fetchHistoryPaginated(); the base owns sign-by-kind enforcement andPartial<NewToken>translation. - EVM chain → extend
BaseEvmProvider. - Anything else → implement the capability interfaces directly.
- Signed-HTTP CEX (HMAC-style auth, balance + creds-validate
mostly) → extend
- Use
fetchWithTimeoutfromcore/utils/fetch.tsfor every HTTP call (timeout + 429/5xx retry + URL pre-validation). - Throw
ProviderErrorfromcore/errors.ts(orProviderError.fromHttp(this.providerKey, res)) — nevernew Error('X HTTP 401'). Lets the registry's classifier route the error to retry / quarantine / surface paths. - Register the factory in the consuming app's
buildProviderRegistry({ providers: [..., <name>Factory] })call. - Add a short
README.mdin your provider directory documenting the upstream API base URL, env vars, capabilities, special notes (auth quirks, pagination notes, known gaps). - Add a test under
tests/providers/<name>.test.tsusingcore/testing.ts'screateMockSelfCredContext+makeMockTokenhelpers.
Owned by core/config.ts (zod-validated; every field optional because
providers are conditionally active). Apps that depend on
@scani/providers MUST NOT redeclare these in their own env schemas.
| Env var | Used by |
|---|---|
COINGECKO_API_KEY |
CoinGecko (raises rate cap) |
FINNHUB_API_KEY |
Finnhub |
ETHERSCAN_API_KEY |
Etherscan multichain |
HELIUS_API_KEY |
Solana (Helius RPC) |
OPENAI_API_KEY |
OpenAI |
GOOGLE_SHEETS_ID |
GoogleSheetsProvider |
GOOGLE_SERVICE_ACCOUNT_KEY |
GoogleSheetsProvider |
Per-user CEX credentials (apiKey / apiSecret / passphrase) live in
user_integration_credentials (encrypted via @scani/security) and
arrive at the provider via ctx.resolveCredentials(ctx.credentialsRef),
not via env.
Self-credentialed providers (every CEX, IBKR, Wise) use only the
session-scoped ctx.credentialsRef. The WithUserCreds<T> type brand on
BalanceProvider.fetchBalances + TransactionsProvider.fetchTransactions
makes passing a context without credentialsRef a compile-time error.
A CredentialPool that would have let platform-credentialed providers
borrow any user's API credentials at request time was deleted in
SC-1022, having been constructed and wired at boot with its one
functional method called from nowhere. Its two bookkeeping tables
(credential_pool_state, credential_pool_borrow_log) are deliberately
retained — see packages/infra/db/src/schema/user-integration-credentials.ts.
bun test --preload ./packages/business/domain/test-preload.ts \
packages/clients/providers --timeout 30000Tests use core/testing.ts helpers (createMockSelfCredContext,
makeMockToken).
- F1 — Per-provider READMEs are flesh-and-blood for the 7 most-touched venues (CoinGecko, DeFiLlama, Etherscan, Kraken, Binance, IBKR, Wise) — full upstream URL, capability table, auth shape, env vars, rate limit + namespace, error taxonomy, known quirks, and pointer files. The remaining 20 stubs match the original short-form pattern; flesh out as the venues evolve.
F2 — Cloud-mode bridge coverage. Moot. The bridge and the routers it called were deleted in SC-587; there is no second egress path left to cover.- ~~F3 —
googleapis(~160MB) is a top-level dep~~. Done. GoogleSheetsProvider now lives in its own sub-workspace@scani/providers-google-sheetswithgoogleapisas the only unique dep. PricingService obtains it via the registry instead ofnew GoogleSheetsProvider(...)— backend + worker boot construct it viagoogleSheetsFactory(...)andregistry.register(...)it. data-provider keeps its registry Google-Sheets-free (it holds no per-user DB state). Domain code (@scani/domain) no longer carriesgoogleapistransitively; only api + worker do. F4 —. Done.BaseCexProvider+BaseEvmProviderdeeper test coveragetests/core/base/{base-cex-provider,base-evm-provider}.test.tscover sign-by-kind enforcement, counter/fee sign inference, asset-identity skip-on-unknown, page advancement + infinite-loop guard, since/until filtering, and the chain-config dispatch path. Concrete provider tests (Kraken / Etherscan / Binance) still exercise the wiring end-to-end on top.- F5 — Coinbase OAuth migration support. Today its API-key path
works through
BaseHmacCexProvider; an OAuth-bearer mode would bypasssignRequestentirely. Add as asignRequestvariant when the OAuth flow lands.