Shared account state
Fast independent cash and share reads with a shared browser-safe SDK store.
SDK 0.13.0 adds @crisp-trade/partner-sdk/account-state. One store per app
provider owns request sharing, polling, expiry, retries and account-switch
safety. Keep CRISP credentials on your server. No new service is required.
Names and boundaries
| Resource | Meaning |
|---|---|
cashBalance | Display cash held by the managed wallet, not portfolio value |
availableCash | All-in spending budget after existing reservations and fees |
availableShares(tokenId) | Shares currently available to sell for one token |
Use decimal strings throughout financial calculations. null means unknown,
never zero. Available cash is not a withdrawal amount or a maximum BUY notional:
include the new order's fees within that budget. The backend still validates
every submission. The store does not reserve money or replay orders.
Your backend
Expose authenticated same-origin routes. Resolve the CRISP external user ID from the verified session, never from an unchecked browser parameter.
// Separate route handlers: neither waits for the other.
const cash = await crisp.getFunding(user.externalUserId, { details: 'summary' });
// Return cash.data.balance?.formatted ?? null from /api/account/cash.
const available = await crisp.getTradingAvailability(user.externalUserId);
// Return available.data.availableToTrade?.formatted ?? null
// from /api/account/available-cash. Requires wallet:read.getFunding(..., { details: 'trading' }) remains compatible but combines reads.
Prefer the independent paths for progressive UI. Deposit/withdrawal dialogs
can load details: 'full' on demand. Never use display cash as an availability
fallback. See balances for the HTTP field contracts.
Browser store
The following BFF example returns { amount: string | null } for amounts and
{ positions: [] } for positions. Adapt your BFF's envelope once here, not in
every component. Share token IDs are decimal strings, not condition IDs.
import { createAccountStore, decimalAmount } from '@crisp-trade/partner-sdk/account-state';
async function read(path: string, signal: AbortSignal) {
const response = await fetch(path, { signal, credentials: 'same-origin', cache: 'no-store' });
if (!response.ok) throw new Error('Account unavailable');
return response.json();
}
const store = createAccountStore({
loaders: {
cashBalance: async ({ signal }) => decimalAmount((await read('/api/account/cash', signal)).amount),
availableCash: async ({ signal }) => decimalAmount((await read('/api/account/available-cash', signal)).amount),
positions: async ({ signal }) => (await read('/api/account/positions', signal)).positions,
},
loadAvailableShares: async (tokenId, { signal }) =>
decimalAmount((await read(`/api/account/shares?tokenId=${encodeURIComponent(tokenId)}`, signal)).amount),
});
store.setAccount(sessionKey); // Opaque identity that changes when the session/account changes.
const available = store.field('availableCash');
const stop = available.subscribe(() => render(available.getSnapshot()));
// The snapshot updates immediately, independently of positions/cash/history.
// On screen unmount: stop(). On logout/provider unmount: store.clearAccount().Construction does no I/O. Mount a store in a client provider, not a server module
singleton. cashBalance and availableCash preload by default. Override
preload to add positions when a persistent header displays portfolio value.
Other fields load when subscribed, or through explicit refresh. Two subscribers
share one request and a fresh snapshot; unsubscribing stops their fallback polls.
React adapter
The core has no React dependency. Create the store once with useState, expose
it through your context, and use stable handles:
const field = store.field('availableCash');
const cash = useSyncExternalStore(field.subscribe, field.getSnapshot, field.getServerSnapshot);
// cash.data: string | null; cash.refreshing does not erase a fresh value.
// cash.status: idle | loading | ready | unavailable
// cash.freshness: unknown | fresh | staleUse the same pattern with a memoized store.availableShares(tokenId) handle.
Never call setAccount or start network reads during render. In a lifecycle
effect set the current session key and clear it on cleanup; also clear
synchronously on logout before another account is shown.
Realtime and order feedback
Forward the app's existing realtime stream:
store.onEvent(event.type); // Notification callback
store.onEvent('reconciliation'); // Reconnect/resync callback
store.afterOrder('accepted'); // Accepted, including a resting limit order
store.afterOrder('filled'); // Confirmed execution
store.afterOrder('unknown'); // Ambiguous response; reconcile, do not resubmit
store.afterOrder('rejected'); // No invalidation for a definite rejectionPass only results for the current session. Preserve your command's session guard
when awaiting a request across logout. The store never calls order APIs itself.
Events immediately invalidate affected availability and coalesce reads over
50 ms. Post-order/fill reconciliation uses a bounded 1/3/10/30/60/120-second
schedule shared by overlapping fills, plus ordinary polling. Only preloaded
and subscribed fields are refreshed. Existing createAccountSync remains
compatible, but do not run it alongside this store for the same reads.
Call pause() while the page is hidden; resume() when visible or back online.
Default polling is 30 seconds, share polling/expiry 15 seconds, cash availability
expiry 60 seconds, and read deadline 10 seconds. Three fast retries use bounded
backoff before returning to ordinary polling. Deadlines still settle when a
loader ignores abort. Account changes abort and fence all old responses.
Display resources retain stale data on failure; availability is cleared on
failure, expiry or invalidation. Show loading/unavailable explicitly. Polling,
expiry, and timeouts are not latency guarantees: measure real transport/RPC
latency independently. Optional onTiming reports resource, duration and
success only, without account IDs, balances, tokens or credentials.