@oasisprotocol/privana-sdk
v0.9.1
Published
React SDK for Privana - manage deposits, withdrawals, locks, and transfers
Downloads
3,943
Keywords
Readme
@oasisprotocol/privana-sdk
React SDK for Privana - manage deposits, withdrawals, locks, and transfers with ease.
Installation
npm install @oasisprotocol/privana-sdk
# or
bun add @oasisprotocol/privana-sdkPeer Dependencies
This SDK requires the following peer dependencies:
npm install react react-dom wagmi viem @tanstack/react-queryQuick Start
1. Wrap your app with the PrivanaProvider
import { PrivanaProvider } from '@oasisprotocol/privana-sdk'
import { WagmiProvider } from 'wagmi'
import { QueryClientProvider } from '@tanstack/react-query'
function App() {
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<PrivanaProvider
networkConfig={{
name: 'Sapphire Testnet',
chainId: 23295,
apiUrl: 'https://api.testnet.privana.finance',
accountingContract: '0xYourContractAddress',
}}
hostedAuth={{
clientId: 'honoroll-web',
redirectUri: 'https://honoroll.example.com/auth/callback',
}}
>
<YourApp />
</PrivanaProvider>
</QueryClientProvider>
</WagmiProvider>
)
}accountingContract must be the deployed Privana accounting contract address for the same
environment as apiUrl.
2. Or build custom UI with hooks
import {
useBalance,
useDeposit,
useTotalLockedBalance,
useWithdraw,
} from '@oasisprotocol/privana-sdk'
function CustomWallet() {
const { balanceFormatted } = useBalance()
const { totalLocked } = useTotalLockedBalance()
const { deposit } = useDeposit()
const { withdraw } = useWithdraw()
return (
<div>
<p>Available: {balanceFormatted}</p>
<p>Total locked: {totalLocked}</p>
<button onClick={() => deposit({ amount: 1000000n, tokenId: '0xYourTokenId' })}>
Deposit
</button>
</div>
)
}Private Reads
useBalance, useBatchBalances, useHistory, useLockedFunds, useExpiredLocks, and
useTotalLockedBalance support two auth modes:
useHistory({ offset: -1, limit: 50 }) fetches one authenticated history page. Non-negative
offsets count pages from the oldest entries, negative offsets count from the end, and each page is
returned oldest-to-newest. limit must be between 0 and 100.
History entries include lock-lifecycle kinds (modifyLock, unlockLock) and directional transfer
kinds (transferFromLockOut/transferFromLockIn, transferBalanceOut/transferBalanceIn). On the
outbound (Out) kinds counterparty is the recipient, and on the inbound (In) kinds it is the
sender.
Direct SIWE private reads
Default mode for same-origin Privana browser integrations:
GET /v1/accounting/auth/domainGET /v1/accounting/auth/nonce?address=0x...POST /v1/accounting/auth/login
The hooks cache the returned X-SIWE-Token, dedupe concurrent auth so a group of mounted
private-read hooks only triggers one sign prompt, and retry once on 401 by re-authenticating
through the same shared in-flight auth request.
Direct in-app SIWE auth (siweAuth)
For same-origin apps that want an explicit authenticated session (and JWT-authenticated writes),
enable siweAuth on PrivanaProvider. The connected wallet signs an EIP-4361 message in-app and
useSiweAuth() exposes login, logout, session, and the raw tokens:
import { PrivanaProvider, useSiweAuth } from '@oasisprotocol/privana-sdk'
;<PrivanaProvider siweAuth={{ autoLogin: true }}>
<AuthGate />
</PrivanaProvider>siweAuth accepts true, or an object with autoLogin (default true) and persistJwt
(default false). It is mutually exclusive with hostedAuth.
Persistent JWT sessions (persistJwt)
Set persistJwt: true to mirror the session in localStorage (key scoped by API URL and chain ID)
so a page reload restores an active session without another signature prompt:
;<PrivanaProvider siweAuth={{ autoLogin: true, persistJwt: true }}>
<App />
</PrivanaProvider>Logout captures the refresh token, clears local and cross-tab state immediately, suppresses automatic re-login, and attempts server-side revocation.
Security note: storing long-lived refresh credentials in
localStoragemakes them reachable from any JavaScript running on the page. An XSS vulnerability could exfiltrate them and forge a session, so only enablepersistJwton origins you fully control.
Hosted redirect auth for cross-domain apps
For widget or cross-domain frontends, configure hostedAuth on PrivanaProvider and use
useHostedRedirectAuth() to start the hosted sign-in and complete it on your callback route:
import { PrivanaProvider, useBalance, useHostedRedirectAuth } from '@oasisprotocol/privana-sdk'
function HostedAuthButton() {
const { login, logout, refresh, isAuthenticated, isLoading, error, session } =
useHostedRedirectAuth()
const { balanceFormatted } = useBalance()
return (
<div>
<button onClick={() => void login()} disabled={isLoading || isAuthenticated}>
Sign in with Privana
</button>
{isAuthenticated ? <button onClick={() => void refresh()}>Refresh Session</button> : null}
{isAuthenticated ? <button onClick={() => void logout()}>Logout</button> : null}
{session ? <p>Signed in as {session.address}</p> : null}
{error ? <p>{error.message}</p> : null}
<p>Balance: {balanceFormatted}</p>
</div>
)
}import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useHostedRedirectAuth } from '@oasisprotocol/privana-sdk'
function HostedAuthCallbackPage() {
const router = useRouter()
const { completeLogin } = useHostedRedirectAuth()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
void completeLogin()
.then((session) => {
if (!session) {
setError('No hosted authentication response was found.')
return
}
router.replace('/')
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Hosted authentication failed.')
})
}, [completeLogin, router])
if (error) return <p>Error: {error}</p>
return <p>Completing sign-in…</p>
}In hosted-auth mode:
- the SDK stores PKCE and
stateinsessionStorage, then redirects the browser to the hosted/auth/authorizepage on the Privana auth origin - the hosted auth page signs on the current wallet chain if it is supported, otherwise it switches to the provider
networkConfig.chainId - the hosted auth page redirects back to your registered callback URL with
code/stateorerror - your callback route calls
completeLogin()to exchange the code at/auth/token - private-read hooks use
Authorization: Bearer <access_token>and refresh once through/auth/jwt/refreshon401
Notes:
- low-level
PrivanaClient.getHostedAuthAuthorizeUrl()still mirrors backend authorize URL support and can build either response mode explicitly. - consumer apps must implement a callback route at the exact registered
redirect_uri. client_idand exactredirect_urivalues must be registered in backendAUTH_CLIENTS.- staging end-to-end verification requires that registration on the staging deployment.
- the standalone localhost popup page used during Firefox debugging was diagnostic only; it is not part of the supported SDK integration path.
Hooks
| Hook | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| useHostedRedirectAuth | Hosted redirect auth for widget apps |
| useBalance | Get token balance (available + locked) |
| useBatchBalances | Get multiple token balances |
| useDeposit | Deposit tokens. One flow per app, run by PrivanaProvider, so a deposit keeps crediting after unmount |
| useDepositVerification | Run checkDeposit + status polling against an existing on-chain transfer (used by useDeposit and useFiatOnRamp) |
| useFiatOnRamp | MoonPay on-ramp with provider-neutral recovery, verification, credit, and locking (from /on-ramp sub-export) |
| useTransakOnRamp | Explicit Transak session launch and hardened iframe over the same shared recovery and credit core |
| useWithdraw | Withdraw tokens |
| useLockFunds | Lock funds for a recipient |
| useUnlockFunds | Unlock expired locks |
| useTransfer | Transfer tokens |
| useLockedFunds | Get list of locked funds |
| useTotalLockedBalance | Get total locked balance for one token |
| useHistory | Get authenticated account activity |
| usePendingWithdrawals | Get pending withdrawal requests |
| useExpiredLocks | Get expired locks that can be claimed |
| useTokenList | List all registered tokens |
| useTokenInfo | Get info for a single token |
Reacting to credited deposits
A deposit keeps crediting after the deposit modal closes, so DepositModal's
onDepositSuccess fires only for deposits credited while it is open. To react to
every credit, subscribe once in a component that is always mounted:
useDeposit({
// With an allowance, the deposit is done once the lock is accepted.
onCredited: (_txHash, _response, lockPending) => {
if (!lockPending) refreshBalances()
},
onLockSubmitted: () => refreshBalances(),
})Fiat On-Ramp
The fiat on-ramp lets users buy tokens with a card and have them credited to
their Privana balance in one flow. An internal provider-neutral core owns
recovery, receipt verification, /deposits/check, credit, and optional
post-credit locking. MoonPay and Transak launch and widget behavior live in thin
provider-specific adapters.
The SDK product modals select one provider, token, and provider-side asset at
the PrivanaProvider boundary:
<PrivanaProvider
networkConfig={networkConfig}
onRamp={{
provider: 'transak',
tokenId: '0x...',
providerAssetCode: 'usdc',
}}
>
<App />
</PrivanaProvider>An explicit configuration locks card purchases to that token and fails closed
if the provider, token, or asset is invalid. When onRamp is omitted, the
product modal preserves its existing MoonPay behavior using
networkConfig.moonpayApiKey and the token's MoonPay mapping.
For Transak, the product modal requires a target at least 5% above Privana's on-chain minimum. This margin reduces the risk that provider estimate movement delivers a paid purchase below the amount that Privana can verify.
The entered crypto amount prefills Transak's editable quote. The final received amount may differ.
The provider delivers the purchased token directly to the server-derived Privana deposit address. Provider orders, amounts, events, and webhooks are correlation hints only. One unambiguous matching on-chain transfer, including its exact receipt log index, remains authoritative.
Exported from a separate entry point so consumers who don't use the on-ramp
don't pay the bundle cost of @moonpay/moonpay-react:
import { FiatOnRampForm, useFiatOnRamp, useTransakOnRamp } from '@oasisprotocol/privana-sdk/on-ramp'MoonPay setup
The SDK ships @moonpay/moonpay-react as a regular dependency, so it lands in
your node_modules automatically. If you import <MoonPayProvider> directly
(see below) you may also want to declare it in your own package.json to keep
your dependency surface explicit:
npm install @moonpay/moonpay-reactWrap your app in <MoonPayProvider> (only on routes that use the on-ramp,
to keep MoonPay out of unrelated bundles):
import { MoonPayProvider } from '@moonpay/moonpay-react'
;<MoonPayProvider apiKey={import.meta.env.VITE_MOONPAY_API_KEY} debug={import.meta.env.DEV}>
{/* on-ramp routes */}
</MoonPayProvider>Quick start with <FiatOnRampForm>
import { FiatOnRampForm } from '@oasisprotocol/privana-sdk/on-ramp'
;<FiatOnRampForm
tokenId="0x..." // Privana token id (e.g. USDC on Base)
currencyCode="usdc_base" // MoonPay currency code; test/live is controlled by the apiKey (pk_test_* → testnet, pk_live_* → mainnet)
baseCurrencyCode="usd" // optional, defaults to "usd"
defaultBaseCurrencyAmount="100" // optional pre-fill (MoonPay still lets the user edit)
onCredited={(txHash) => console.log('credited', txHash)}
onError={(err) => console.error(err)}
/>The form:
- fetches the user's Privana deposit address and passes it to MoonPay as the destination,
- sets
externalCustomerId = address.toLowerCase()so the backend can bind the MoonPay transaction to the SIWE-authenticated user, - creates a backend on-ramp intent (
POST /onramp/intent) and passes its id to MoonPay asexternalTransactionIdfor exact authenticated recovery, - persists at most ten unresolved signed intents under the authenticated user
and sends them as repeated
externalTransactionIdvalues on pending reads, - gates the "Buy" button on the configured token's minimum deposit (input-time check) and double-checks the delivered amount before triggering verification,
- on MoonPay's
transaction_created, fire-and-forget callsPOST /onramp/{id}with the MoonPay transaction id so the backend can reconcile both ids during provider reads, - treats MoonPay events as wake-up hints, polls the authenticated pending read
for the provider transaction and on-chain hash, derives the delivered amount
and exact log index from one unambiguous matching receipt log, then triggers
Privana verification (
checkDeposit+ status polling).
useFiatOnRamp for custom UI
If you need a different shell around the MoonPay widget, use the hook directly and wire MoonPay's widget callbacks yourself:
import { MoonPayBuyWidget } from '@moonpay/moonpay-react'
import { useFiatOnRamp } from '@oasisprotocol/privana-sdk/on-ramp'
const {
status, // 'idle' | 'awaiting-purchase' | 'awaiting-delivery' | 'verifying' | 'credited' | 'failed'
activeIntentId, // pass to MoonPay as externalTransactionId
pending, // recovery list (completed-but-unverified)
error,
depositAddress, // pass to MoonPay as walletAddress
minDepositBaseUnits, // for input validation
selectedToken, // resolved token config (decimals/symbol for the configured tokenId)
prepareOnRampIntent, // call before opening the widget
signUrl, // wire to onUrlSignatureRequested
handleTransactionCreated, // wire to onTransactionCreated
handleTransactionCompleted, // wire to onTransactionCompleted
handleWidgetClosed, // call from onClose / onCloseOverlay
finishPendingVerification, // call from the recovery CTA
refreshPending, // manual refresh of the pending list
} = useFiatOnRamp({ tokenId, onCredited, onError })useTransakOnRamp for a Transak checkout
Transak credentials and session creation remain in the backend. Call launch
only from an explicit user action; it creates the signed intent and then requests
one five-minute, single-use session. The hook keeps the opaque URL in memory and
returns a hardened iframe as widget:
import { useTransakOnRamp } from '@oasisprotocol/privana-sdk/on-ramp'
function TransakCheckout({ tokenId }) {
const {
widget,
isLaunching,
isWidgetOpen,
launch,
recreateSession,
closeWidget,
pending,
finishPendingVerification,
} = useTransakOnRamp({ tokenId, onCredited, onError })
return (
<>
<button
disabled={isLaunching || isWidgetOpen}
onClick={() => void launch({ providerAssetCode: 'usdc' }).catch(() => undefined)}
>
Buy with card
</button>
{widget}
</>
)
}Before each session request, including an authenticated retry or explicit
reopen, the SDK sends only the SHA-256 hash of the signed intent to the
same-origin /__onramp-ip-attest Worker and passes its fresh opaque claim to
the backend. The host must deploy that route on its Transak-approved origin.
recreateSession() retries the current unresolved intent only when its session
failed or expired before the provider iframe became interactive. After the
provider UI activates or order evidence exists, the adapter refuses to create a
second provider order under that intent. Unmount the direct hook to abandon its
local checkout UI; durable signed-intent recovery remains available. The
adapter uses the backend URL byte-for-byte, preserves the browser Referer, and
accepts messages only from the current iframe at the exact documented Transak
origin. Widget messages are polling hints only. Authenticated
/onramp/pending reads and the matching on-chain ERC-20 transfer remain the
recovery and credit path.
Pending / recovery
If the user closes or reloads between launch and verification, the SDK reloads
its bounded signed-intent set and combines exact provider lookups with the
authenticated derived-wallet lookup. A corrupt local intent is isolated and
removed without blocking valid recovery. Render pending with a "Finish
verification" CTA that calls finishPendingVerification(record) — no wallet
signature is required for deposit verification.
Required backend endpoints
The provider adapters call:
POST /v1/accounting/onramp/sign-url— HMAC-signs the MoonPay widget URLPOST /v1/accounting/onramp/session— creates a short-lived opaque Transak widget URLPOST /v1/accounting/onramp/intent— mints a signed provider/user/wallet/token/chain/asset intentPOST /v1/accounting/onramp/{transaction_id}— validates and echoes MoonPay compatibility metadata without becoming order stateGET /v1/accounting/onramp/pending— bounded provider reads for completed, strictly admitted transactions awaiting verification
And the existing deposit verification endpoints:
POST /v1/accounting/deposits/checkGET /v1/accounting/deposits/status/{id}
The MoonPay webhook (POST /v1/accounting/onramp/moonpay/webhook) is an optional
verified observability signal. It does not populate order state and is not
required for recovery or credit.
License
Apache-2.0
