@osero/client
v0.8.0
Published
The official TypeScript SDK for minting USDS and sUSDS across supported chains
Maintainers
Readme
@osero/client
The official TypeScript SDK for minting USDS and sUSDS on every chain where Sky / Spark runs a PSM. One API, wallet integrations for viem, ethers v6, and Privy server wallets, five chains out of the box.
What it does
Given a wallet holding USDC, @osero/client builds and sends the
right sequence of transactions to land USDS or sUSDS in any address
you name, no matter which chain you are on:
| Chain | Chain ID | Route |
| ------------ | -------: | --------------------------------------------------- |
| Ethereum | 1 | Spark UsdsPsmWrapper (+ ERC-4626 sUSDS deposit) |
| OP Mainnet | 10 | Spark PSM3 |
| Unichain | 130 | Spark PSM3 |
| Base | 8453 | Spark PSM3 |
| Arbitrum One | 42161 | Spark PSM3 |
The SDK figures out which contract to talk to, reads the live fee
(tin / tout) or swap quote, assembles the approval and swap
transactions, and hands you back a wallet-agnostic ExecutionPlan
that any adapter can broadcast.
It also exposes preview helpers for every exact-in flow so callers can quote the expected output amount before building or sending a plan.
The package also includes @osero/client/api, a small HTTP client for
the hosted Osero API. It can fetch supported API assets, build API
swap quotes, convert those quotes into SDK execution plans, and poll
bridge status for cross-chain quotes.
Install
pnpm add @osero/client viem
# (optional) for the ethers adapter:
pnpm add ethers
# (optional) for the Privy server-wallet adapter:
pnpm add @privy-io/nodeviem is a required peer dependency — the SDK uses it to encode
calldata and to build public clients internally.
ethers and @privy-io/node are optional; install them only if
you use @osero/client/ethers or @osero/client/privy.
Quick start
With viem
import { OseroClient } from '@osero/client';
import { mintSUsds } from '@osero/client/actions';
import { sendWith } from '@osero/client/viem';
import { createWalletClient, http, parseUnits } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { base } from 'viem/chains';
const privateKey = process.env.PRIVATE_KEY as `0x${string}` | undefined;
if (!privateKey) throw new Error('Set PRIVATE_KEY before sending transactions');
const client = OseroClient.create({
transports: {
8453: http('https://mainnet.base.org'),
},
});
const wallet = createWalletClient({
account: privateKeyToAccount(privateKey),
chain: base,
transport: http('https://mainnet.base.org'),
});
const result = await mintSUsds(client, {
chainId: 8453,
amount: parseUnits('100', 6), // 100 USDC
sender: wallet.account.address,
}).andThen(sendWith(wallet));
if (result.isErr()) {
console.error(result.error.name, result.error.message);
return;
}
console.log('sUSDS minted in tx', result.value.txHash);With ethers v6
import { OseroClient } from '@osero/client';
import { mintUsds } from '@osero/client/actions';
import { sendWith } from '@osero/client/ethers';
import { JsonRpcProvider, Wallet, parseUnits } from 'ethers';
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey) throw new Error('Set PRIVATE_KEY before sending transactions');
const provider = new JsonRpcProvider('https://arb1.arbitrum.io/rpc');
const signer = new Wallet(privateKey, provider);
const client = OseroClient.create();
const result = await mintUsds(client, {
chainId: 42161,
amount: parseUnits('1000', 6),
sender: await signer.getAddress(),
}).andThen(sendWith(signer));
if (result.isOk()) {
console.log('USDS minted in tx', result.value.txHash);
}With Privy server wallets
import { PrivyClient } from '@privy-io/node';
import { OseroClient } from '@osero/client';
import { mintUsds } from '@osero/client/actions';
import { sendWith, type PrivyWallet } from '@osero/client/privy';
import { parseUnits } from 'viem';
const privy = new PrivyClient({
appId: process.env.PRIVY_APP_ID!,
appSecret: process.env.PRIVY_APP_SECRET!,
});
const wallet = {
id: process.env.PRIVY_WALLET_ID!,
address: process.env.PRIVY_WALLET_ADDRESS as `0x${string}`,
authorizationContext: {
authorization_private_keys: [process.env.PRIVY_AUTHORIZATION_PRIVATE_KEY!],
},
} satisfies PrivyWallet;
const client = OseroClient.create();
const result = await mintUsds(client, {
chainId: 8453,
amount: parseUnits('100', 6),
sender: wallet.address,
}).andThen(sendWith(privy, wallet));
if (result.isOk()) {
console.log('USDS minted in tx', result.value.txHash);
}authorization_private_keys must be the base64-encoded PKCS8 private
key registered for your Privy app. Omit authorizationContext only if
your Privy wallet configuration does not require request authorization.
Actions
Every action returns a ResultAsync<ExecutionPlan, …> that you pipe
into sendWith (from @osero/client/viem, @osero/client/ethers, or
@osero/client/privy) to execute.
| Action | Direction | Mainnet shape | L2 shape |
| ------------- | ------------ | ------------------ | ------------------- |
| mintUsds | USDC → USDS | approve + sellGem | approve + PSM3 swap |
| mintSUsds | USDC → sUSDS | four-tx two-phase | approve + PSM3 swap |
| redeemUsds | USDS → USDC | approve + buyGem | approve + PSM3 swap |
| redeemSUsds | sUSDS → USDC | three-tx two-phase | approve + PSM3 swap |
Matching preview helpers return the quoted output amount as a raw
bigint wrapped in ResultAsync. They only take chainId and
amount because they do not build a sender-specific execution plan:
| Preview helper | Quotes | Input decimals |
| -------------------- | ------------ | -------------: |
| previewMintUsds | USDC → USDS | 6 |
| previewMintSUsds | USDC → sUSDS | 6 |
| previewRedeemUsds | USDS → USDC | 18 |
| previewRedeemSUsds | sUSDS → USDC | 18 |
import { previewMintSUsds } from '@osero/client/actions';
import { parseUnits } from 'viem';
const quote = await previewMintSUsds(client, {
chainId: 8453,
amount: parseUnits('100', 6),
});
if (quote.isOk()) {
console.log('expected sUSDS out:', quote.value);
}Request shape
type Request = {
chainId: number; // one of the supported chain IDs
amount: bigint; // input amount in the input token's native decimals
sender: `0x${string}`; // the wallet that pays the input
receiver?: `0x${string}`; // default = sender
slippageBps?: number; // default = client.config.defaultSlippageBps (5)
referralCode?: bigint; // emitted on L2 PSM3 swaps and mainnet sUSDS deposits
};Balance helpers
@osero/client also exposes read-only helpers for canonical token
balances so callers can stick with the SDK's chain registry and public
client wiring instead of dropping down to raw ERC-20 reads.
import {
getSUsdsBalance,
getTokenBalance,
getTokenBalances,
getUsdcBalance,
getUsdsBalance,
} from '@osero/client';Choose the helper that matches the job:
getTokenBalance(client, { chainId, account, token })reads one of the three canonical symbols:USDC,USDS, orsUSDSgetTokenBalances(client, { chainId, account })returns all three balances in one keyed result objectgetUsdcBalance,getUsdsBalance, andgetSUsdsBalancekeep the common single-token cases terse
const result = await getTokenBalances(client, {
chainId: 8453,
account: wallet.account.address,
});
if (result.isOk()) {
console.log(result.value.USDC);
console.log(result.value.USDS);
console.log(result.value.sUSDS);
}All balance helpers return raw bigint values and surface
UnsupportedChainError or UnexpectedError through the same
ResultAsync model used by the action builders.
Osero API client
Use @osero/client/api for hosted API quotes instead of building the
route locally. The client sends x-api-key, validates the public API
key shape before making requests, and returns typed ResultAsync
values.
import { flattenExecutionPlan } from '@osero/client';
import { OseroApiClient } from '@osero/client/api';
import { parseUnits } from 'viem';
const api = OseroApiClient.create({
apiKey: process.env.OSERO_API_KEY!,
// Defaults to https://api.osero.org/v1/
baseUrl: process.env.OSERO_API_BASE_URL,
});
const quote = await api.getSwapQuote({
fromAddress: '0x1111111111111111111111111111111111111111',
fromAssetId: 'base:usdc',
toAssetId: 'ethereum:susds',
amount: parseUnits('1', 6),
slippage: '0.5',
referralCode: 3000,
});
if (quote.isErr()) {
console.error(quote.error.name, quote.error.message);
return;
}
console.log('sUSDS out:', quote.value.quote.amountOut?.formatted);
console.log(flattenExecutionPlan(quote.value.executionPlan));To execute the hosted quote through a wallet, hand off the attached
executionPlan to the same viem, ethers, or Privy adapter used by local
action builders:
import { sendWith } from '@osero/client/viem';
const result = await api
.getSwapQuote(request)
.map((quote) => quote.executionPlan)
.andThen(sendWith(wallet));Common calls:
getSupportedAssets()returns the public asset IDs accepted by the quote endpoint.getSwapQuote(request)builds approval and execution transactions for supported counter asset ↔ sUSDS routes.getSwapStatus({ txHash, sourceChainId, bridgeProtocol })checks a bridge status request returned by a cross-chain quote.getSwapStatusForQuote(quote, txHash)is a convenience wrapper that pullssourceChainIdandbridgeProtocoloffquote.bridge.statusRequestfor you. Same-chain quotes have no bridge to track and return aValidationError.
The client-level API key can be overridden per request:
await api.getSupportedAssets({ apiKey: 'osero_partner-key' });Quote referralCode is optional. When provided, it must be an integer
from 3000 to 3999 and overrides the referral code attached to the
authenticated API key for that quote.
Error handling
@osero/client uses neverthrow
for functional error handling. Every action returns a
ResultAsync. Chain them with .andThen, then call .isOk() /
.isErr() at the top level:
const result = await mintUsds(client, request).andThen(sendWith(wallet));
if (result.isErr()) {
switch (result.error.name) {
case 'CancelError':
// user rejected the wallet prompt
break;
case 'ValidationError':
// bad input (amount <= 0, etc.)
break;
case 'UnsupportedChainError':
// chainId is not in SUPPORTED_CHAIN_IDS
break;
case 'TransactionError':
// tx was broadcast but reverted — inspect .txHash / .link
break;
case 'ApiRequestError':
// hosted API returned a non-2xx response — inspect .statusCode / .body
break;
case 'SigningError':
case 'UnexpectedError':
// RPC failure, bad signature, etc. — .cause has the original
break;
}
return;
}Every error class extends OseroError, which itself extends the
built-in Error, so instanceof OseroError is the broadest catch.
Configuration
import { OseroClient } from '@osero/client';
import { http } from 'viem';
const client = OseroClient.create({
// Override the default public transports for every chain you care
// about — strongly recommended for production.
transports: {
1: http('https://eth.llamarpc.com'),
10: http('https://mainnet.optimism.io'),
130: http('https://mainnet.unichain.org'),
8453: http('https://mainnet.base.org'),
42161: http('https://arb1.arbitrum.io/rpc'),
},
// Default slippage (in bps) used by any action that doesn't pass
// its own `slippageBps`. Defaults to 5 (= 0.05%).
defaultSlippageBps: 10,
});Set confirmation waits on the viem, ethers, or Privy adapter when broadcasting:
const result = await mintUsds(client, request).andThen(sendWith(wallet, { confirmations: 2 }));The Privy adapter can also receive transports for receipt polling,
using the same chain-keyed shape as OseroClient.create({ transports }).
For server-side retries with Privy, pass caller-managed idempotency keys. The SDK does not derive keys from transaction contents because two unrelated operations can produce identical transaction data. Store a fresh key with the operation you are about to execute and reuse that same key only when retrying that operation:
const result = await mintUsds(client, request).andThen(
sendWith(privy, wallet, {
idempotencyKeys: [storedIdempotencyKey],
}),
);Plans with approvals or multiple phases send more than one
transaction. In those cases, provide one key per transaction in the
same order returned by flattenExecutionPlan(plan). The adapter
fails before broadcasting anything if the number of keys does not
match the number of transactions, so no transaction is ever sent
without its retry protection.
The execution plan model
Every action returns an ExecutionPlan, a wallet-agnostic
description of the transactions that need to happen. The adapter
(sendWith) is the only piece that touches a real wallet. This
gives you three things for free:
Dry-run — inspect the plan without signing anything:
import { flattenExecutionPlan } from '@osero/client'; const result = await mintSUsds(client, request); if (result.isOk()) { for (const tx of flattenExecutionPlan(result.value)) { console.log(tx.operation, tx.to, tx.data); } }Portability — pass the same plan to a viem wallet, an ethers signer, a Privy server wallet, a custom batching relayer, or an account-abstraction bundler. Only
sendWithneeds to change.Testability — actions are pure functions over an
OseroClient; unit-testing them without a live chain is a matter of injecting a mockPublicClient.
Plans come in three shapes:
TransactionRequest— a single pre-encoded tx.Erc20ApprovalRequired— one or more approvals gating a single main tx (e.g. every L2 mint / redeem).MultiStepExecution— an ordered list of the above (e.g. mainnet sUSDS mint, which is USDC → USDS → sUSDS in two phases).
Supported chains & contracts
All addresses live in the source tree in src/lib/addresses.ts and
src/lib/tokens.ts, and are re-exported from the package root:
import { SUPPORTED_CHAIN_IDS, CHAINS, PSM_ADDRESSES, getToken } from '@osero/client';
console.log(PSM_ADDRESSES[8453].psm); // Spark PSM3 on Base
console.log(getToken(1, 'sUSDS').address); // sUSDS on mainnetBuilding & testing
This package is part of the Osero SDK Nx workspace. From the repo root:
pnpm nx build @osero/client # tsc → dist/
pnpm nx typecheck @osero/client # strict tsc --noEmit
pnpm nx test @osero/client # vitest runLicense
MIT
