RPC escape hatch#
Seesaw's TypeScript SDK offers opt-in API-first reads with validated RPC fallback. Configure an RPC transport explicitly: the SDK does not silently select a public endpoint. Trading and settlement instructions execute on Solana; indexing, analytics, discovery, oracle delivery and proof production also use offchain services.
Configure the SDK#
This example uses an existing application-owned @solana/kit RPC client.
Replace the endpoint, program and market addresses with values from the same
deployment. Keep RPC credentials out of public bundles; a relay must support
the account methods and filters below independently of the indexer.
import { address, createSolanaRpc, type Base58EncodedBytes } from '@solana/kit';
import { base58Encode, createSeesawClient, type RpcDataSource } from '@seesaw/core';
const transport = createSolanaRpc('https://YOUR_RPC_ENDPOINT');
const programAddress = address('YOUR_DEPLOYED_PROGRAM_ADDRESS');
const marketAddress = address('YOUR_MARKET_ADDRESS');
// RpcDataSource accepts decoded bytes, not JSON-RPC base64 tuples.
const decode = (value: { owner: string; data: readonly [string, string] }) => ({
owner: address(value.owner),
data: Uint8Array.from(atob(value.data[0]), (character) => character.charCodeAt(0)),
});
const rpc: RpcDataSource = {
async getAccountInfo(key) {
const response = await transport
.getAccountInfo(key, {
encoding: 'base64',
commitment: 'confirmed',
})
.send();
return {
value: response.value ? decode(response.value) : null,
context: { slot: response.context.slot, commitment: 'confirmed' },
};
},
async getProgramAccounts(program, config) {
const rows = await transport
.getProgramAccounts(program, {
encoding: 'base64',
commitment: 'confirmed',
filters: config.filters.map((filter) =>
'dataSize' in filter
? { dataSize: BigInt(filter.dataSize) }
: {
memcmp: {
offset: BigInt(filter.memcmp.offset),
encoding: 'base58' as const,
bytes: (typeof filter.memcmp.bytes === 'string'
? filter.memcmp.bytes
: base58Encode(filter.memcmp.bytes)) as Base58EncodedBytes,
},
}
),
})
.send();
// Standard getProgramAccounts has no server-side limit. Reject excess
// rows; slicing would silently present incomplete holdings as complete.
if (rows.length > config.limit) throw new Error('RPC owner scan exceeds limit');
return {
value: rows.map((row) => ({
address: row.pubkey,
account: decode(row.account),
})),
};
},
};
const client = createSeesawClient({
apiUrl: 'https://YOUR_API_ENDPOINT',
retryOptions: { maxRetries: 0 },
resilientData: {
rpc,
policy: {
programAddress,
scanLimit: 100,
commitment: 'confirmed',
},
},
});
const result = await client.data!.getMarketDetail(marketAddress);
switch (result.source) {
case 'api':
console.log('Indexed market', result.value);
break;
case 'rpc':
console.log('Live market account', result.value, result.slot);
break;
case 'unavailable':
console.error(result.reason, result.error);
break;
}
The same facade exposes getMarket, getPositions(owner) and
getOpenOrders(owner). Existing client.api, instruction builders, PDA
helpers and streams retain their existing behavior. Without resilientData,
client.data is absent. API and RPC results deliberately have different
shapes: a raw market account does not contain an indexed chart or orderbook.
Failure semantics#
- Network errors, timeouts, an open API circuit breaker, HTTP 5xx and malformed successful responses trigger RPC fallback. API HTTP 4xx and explicit caller cancellation propagate; the facade does not bypass access or policy errors.
- RPC validates program ownership, account layout and discriminators, position ownership, canonical PDAs and bumps. External markets additionally require validated market metadata. Invalid or missing accounts fail closed.
- Direct readers throw
RpcDataSourceError, retaining operation, address and cause. If the resilient facade exhausts both sources, it returnssource: 'unavailable',value: null, the RPC error and the original API error. This is not an empty successful portfolio. - Owner scans require a limit in
1..1000. Adapters must reject oversized results rather than truncate them. Order scans also enforce the result bound after reading each position's canonical book. Public RPC providers may deny scans; choose a transport supporting these methods and filters. sourcedistinguishes indexed API, RPC and unavailable data. RPC slot and commitment metadata are optional. Multiple reads report shared metadata only when all responses agree; missing metadata does not imply one atomic snapshot.- Transaction submission and confirmation remain separate from reads. Preserve a submitted signature when confirmation is unknown, reconcile it through RPC, and do not resubmit automatically. Indexer ingestion cannot prove transaction success. RPC failure itself cannot be repaired by an indexer escape hatch.
App behavior and verification#
Known market addresses, wallet positions and open orders can use RPC when indexed reads fail. The apps label their source and unavailable analytics. Discovery, charts, P&L, history and leaderboard services cannot be reconstructed from these account reads. Pyth lifecycle bundles require the user's provider key. The mobile wallet's configured RPC and web RPC transport/relay must stay reachable.
Reclaim proof production requires its provider, or the user can import proof
bytes produced earlier. Importing those bytes does not make the current app
submission flow independent of the API: web always fetches
/v2/reclaim/verifier-context before submission, and mobile requires that
endpoint initially or when no verifier context is cached. Reclaim submission
is not an API-independent escape route yet, even when proof bytes are already
available. The pure SDK planner and on-chain verifier do not remove this
dependency from the current app flows.
Local decoder, facade, hook, transaction-outcome, referrer and admin tests
provide deterministic coverage. The matrix's INDIRECT_APP_SUPPORT pins the
standalone Reclaim planner. Four web bounded-cancel gaps and keeper observation
entries remain; see the feature inventory.
The Playwright 35-api-outage.spec.ts lane rejects product /api/v1, /api/v2 and /v2 requests
with HTTP 503 and closes indexer WebSockets, retaining RPC and wallet access.
It checks source/degraded messaging and a wallet cancellation against the live
book. Run it against a freshly seeded local stack whose wallet owns a live order:
E2E_API_OUTAGE=1 pnpm --filter @seesaw/web test:e2e 35-api-outage.spec.ts --reporter=line
The Detox 18-api-outage.test.ts lane requires a debug app/Metro started with
EXPO_PUBLIC_API_URL=http://127.0.0.1:1, an independent working RPC, a seeded
market and connected wallet. Pass the same environment to the test process:
E2E_API_OUTAGE=1 EXPO_PUBLIC_API_URL=http://127.0.0.1:1 pnpm --filter @seesaw/mobile exec detox test --configuration android.debug.emulator e2e/18-api-outage.test.ts
Detox URL blacklisting only affects synchronization and is not an outage mechanism. The mobile lane checks live market/portfolio reads and degraded analytics. Device wallet cancellation, ForceClose and referrer setup under outage remain external signoffs; unit coverage does not replace them. Neither lane is a completed staging or production signoff merely because it exists.
Behavioral references: packages/core/src/rpcDataSource.ts,
packages/core/src/resilientData.ts, packages/core/src/client.ts, and the
rpcDataSource, resilientData, rpcEscapeHatch, useEssentialRpcFallback
test suites. Release readiness follows the
production readiness requirements.