"Reference for the Osero SDK (`@osero/client`), a TypeScript client for USDS/sUSDS mint and redeem transactions across Ethereum mainnet and L2s via viem or ethers v6. This skill should be used whenever code imports `@osero/client`, `@osero/client/actions`, `@osero/client/viem`, or `@osero/client/ethers`, or when the user asks about the SDK's public API, `ExecutionPlan` model, supported chains/tokens/PSM addresses, error taxonomy, or wallet adapters. Triggers: 'mint USDS on Base', 'redeem sUSDS', 'inspect a plan without signing', 'use the SDK with ethers v6'."
Resources
14Install
npx skillscat add osero-io/kit Install via the SkillsCat registry.
Osero SDK (@osero/client)
A TypeScript SDK for routing USDC through Sky's USDS / sUSDS peg-stability
infrastructure. One API surface, wallet-agnostic plans, two first-class
adapters (viem and ethers v6), five supported chains. Every action returns
a ResultAsync from neverthrow — nothing in the action or plan layer
throws.
Single-file by design. This skill is a complete API reference for
a small, stable SDK; splitting it acrossreferences/*.mdwould force
readers to chase pointers for every adjacent question. Keep it in one
file unless the surface area roughly doubles.
When to Use
Load this skill when any of the following is true:
- Writing, reviewing, refactoring, or generating TypeScript that imports
@osero/client,@osero/client/actions,@osero/client/viem, or@osero/client/ethers. - Building or inspecting an
ExecutionPlan— including dry-runs, gas
estimation, or UI previews — even when no transaction will be broadcast. - Wiring up a viem
WalletClientor ethers v6Signerto callsendWith(...). - Answering questions about supported chains, tokens, PSM addresses,
referral-code semantics, slippage handling, or theneverthrowerror
taxonomy used by every action. - Reading or editing files under
packages/client/srcand needing the
public-API contract.
When NOT to Use
Skip this skill (and defer to the listed source instead) when:
- The question is about Sky / Spark contract internals beyond what the
SDK exposes — readPSM_GUIDE.mdat the repo root fortin,tout,sellGem,buyGem, the Lite PSM, or the Spark wrapper. - The question is about repository tooling (Nx targets, Vitest config,
oxlint/oxfmt, Changesets release flow) — readCLAUDE.mdandAGENTS.mdat the repo root. - The work is unrelated Web3 or generic TypeScript code that does not
touch@osero/client. This skill's triggers and pitfalls assume the
SDK is in the call graph. - Modifying SDK internals (action implementations, adapter code, ABI
bytes). Use the source files underpackages/client/srcdirectly —
this skill describes the public surface, not the internals.
Mental model
caller SDK wallet
------ --- ------
action(client, request) ──► ExecutionPlan (wallet-agnostic)
│
│ .andThen(sendWith(walletOrSigner))
▼
adapter walks tx list ──► sendTransaction(...) per step
│ wait for each receipt
▼
TransactionResult { txHash, operations[] }- Actions are pure plan builders. They may read on-chain state (fees,
swap previews) through theOseroClient's viem public clients, but
they never sign or broadcast. - An
ExecutionPlanis a tagged union of pre-encoded transactions.
The same plan can be executed by any wallet library — viem, ethers,
a custom batching relayer, or an account-abstraction bundler. - Adapters (
sendWith) are the only layer that touches signing.
They broadcast each transaction in the plan in order and wait for each
receipt before moving on. - Errors are typed classes from
src/lib/errors.ts, flowing throughneverthrow'sResult/ResultAsync. Use.isOk()/.isErr()at
the top level, nevertry/catchon SDK call sites.
Installation
viem is a required peer dependency even for ethers users — the SDK
uses it internally to encode calldata and build public clients.ethers is optional; install it only if the caller uses the ethers
adapter.
pnpm add @osero/client viem
# Optional — only when @osero/client/ethers is in use:
pnpm add ethersPeer-dep ranges (packages/client/package.json):
viem ^2.21.0(required)ethers ^6.14.0(optional)
Package exports
@osero/client ships exactly four subpath exports. Do not invent
other import paths.
| Subpath | What it exports |
|---|---|
@osero/client |
OseroClient, chain/token/address registries, balance helpers, plan helpers, error classes, math helpers, ABIs, all core types, and the neverthrow re-exports |
@osero/client/actions |
Action builders (mintUsds, mintSUsds, redeemUsds, redeemSUsds), preview helpers (previewMint*, previewRedeem*), and chain action helpers (chain, listChains) |
@osero/client/viem |
sendWith(walletClient[, options]) viem adapter + its option type + ConnectedWalletClient |
@osero/client/ethers |
sendWith(signer[, options]) ethers adapter + its option type |
Everything re-exported from @osero/client
// ABIs
erc20Abi, erc4626Abi, litePsmAbi, psm3Abi, usdsPsmWrapperAbi
// Adapters (plan introspection / type guards)
flattenExecutionPlan
isErc20ApprovalRequired
isMultiStepExecution
isTransactionRequest
// Addresses
PSM_ADDRESSES, PsmAddresses
// Balance helpers
getSUsdsBalance, getTokenBalance, getTokenBalances,
getUsdcBalance, getUsdsBalance,
GetBalancesRequest, GetTokenBalanceError,
GetTokenBalanceRequest, TokenBalances
// Chains
CHAINS, ChainMetadata, getChain, isSupportedChainId, listChains,
OseroChainId, SUPPORTED_CHAIN_IDS
// Client config
ClientConfig, ResolvedClientConfig
// Errors
CancelError, InsufficientBalanceError, OseroError, SigningError,
TransactionError, UnexpectedError, UnsupportedChainError,
ValidationError
// Math helpers
applySlippage, BPS, USDC_TO_USDS_SCALE, WAD,
usdcFromUsdsViaBuyGem, usdsFromUsdcViaSellGem,
usdsNeededForUsdcViaBuyGem
// Client class
OseroClient, OseroPublicClient
// Plan construction helpers
makeApprovalRequiredPlan, makeApprovalTransaction,
makeMultiStepPlan, makeSingleApprovalPlan, makeTransactionRequest
// neverthrow re-exports
err, errAsync, fromAsyncThrowable, fromPromise, fromThrowable,
ok, okAsync, Result, ResultAsync
// Tokens
getToken, listTokens, Token, TokenSymbol
// Core types
ActionError, Erc20Approval, Erc20ApprovalRequired, ExecutionPlan,
ExecutionPlanHandler, ExecutionStep, MultiStepExecution,
OperationType, SendWithError, TransactionRequest, TransactionResultNote on the
neverthrowre-exports: always importResult,ResultAsync,ok,err,okAsync,errAsync,fromPromise,fromAsyncThrowable,
andfromThrowablefrom@osero/client, not fromneverthrow
directly, so the dependency stays swappable across SDK versions.
Supported chains
Five chains, one tuple, one semantic flag:
| Chain ID | Name | isMainnet |
Route |
|---|---|---|---|
| 1 | Ethereum | true |
Spark UsdsPsmWrapper (+ ERC-4626 sUSDS deposit) |
| 10 | OP Mainnet | false |
Spark PSM3 |
| 130 | Unichain | false |
Spark PSM3 |
| 8453 | Base | false |
Spark PSM3 |
| 42161 | Arbitrum One | false |
Spark PSM3 |
import {
CHAINS,
SUPPORTED_CHAIN_IDS,
getChain,
isSupportedChainId,
listChains,
type ChainMetadata,
type OseroChainId,
} from '@osero/client';
SUPPORTED_CHAIN_IDS; // readonly [1, 10, 130, 8453, 42161]
isSupportedChainId(8453); // type guard → chainId is OseroChainId
getChain(8453); // ChainMetadata | null (null if unsupported)
listChains(); // readonly ChainMetadata[]
CHAINS[1].viemChain; // viem `Chain` object for mainnetChainMetadata shape:
type ChainMetadata = {
readonly chainId: OseroChainId;
readonly name: string; // e.g. "Ethereum"
readonly shortName: string; // e.g. "eth"
readonly viemChain: ViemChain; // viem/chains entry
readonly isMainnet: boolean; // true iff chainId === 1
readonly explorerUrl: string; // e.g. "https://etherscan.io"
};isMainnet is a semantic flag, not a geographic one — it is true
only for Ethereum L1 (chain ID 1) because that is the branch point where
actions switch from PSM3 to UsdsPsmWrapper + LitePSM + ERC-4626.
Tokens
Three canonical symbols per chain — USDC, USDS, sUSDS — exposed
through a typed registry:
import { getToken, listTokens, type Token, type TokenSymbol } from '@osero/client';
type TokenSymbol = 'USDC' | 'USDS' | 'sUSDS';
type Token = {
readonly chainId: OseroChainId;
readonly address: `0x${string}`;
readonly symbol: TokenSymbol;
readonly decimals: number; // 6 for USDC, 18 for USDS/sUSDS
readonly name: string;
};
getToken(8453, 'USDC'); // always populated for supported chains
listTokens(1); // [USDC, USDS, sUSDS] in stable orderToken addresses live in packages/client/src/lib/tokens.ts. Do not
hard-code them — use getToken(chainId, symbol).
Decimal conventions:
- USDC: 6 decimals →
parseUnits(amount, 6) - USDS: 18 decimals →
parseUnits(amount, 18) - sUSDS: 18 decimals →
parseUnits(amount, 18)
PSM addresses
import { PSM_ADDRESSES, type PsmAddresses } from '@osero/client';
type PsmAddresses = {
readonly psm: `0x${string}`; // L2: PSM3; mainnet: UsdsPsmWrapper
readonly litePsm?: `0x${string}`; // mainnet only: underlying Sky Lite PSM
};
PSM_ADDRESSES[1].psm; // Spark UsdsPsmWrapper
PSM_ADDRESSES[1].litePsm; // Sky MCD_LITE_PSM_USDC_A (read-only audit)
PSM_ADDRESSES[8453].psm; // Spark PSM3 on BaseThe SDK routes funds through psm only. litePsm is surfaced for
callers who want to verify tin() / tout() independently.
tin/toutglossary.tinis the Sky Lite PSM's governance-set
fee (in WAD, fraction of1e18) charged onsellGem— converting
USDC into USDS.toutis the symmetric fee charged onbuyGem—
converting USDS back into USDC. Both have been0since launch; the
SDK reads them on every call so that a future governance change is
picked up automatically.
OseroClient
The SDK's stateless read-side entry point. It caches one viemPublicClient per chain, created lazily on first access.
import { OseroClient, type OseroPublicClient } from '@osero/client';
import { http } from 'viem';
const client = OseroClient.create({
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'),
},
defaultSlippageBps: 10, // override default of 5 bps (0.05%)
confirmations: 1, // stored on client.config; not auto-read by adapters
});
client.getPublicClient(8453); // OseroPublicClient (memoised)
client.config.defaultSlippageBps;ClientConfig
type ClientConfig = {
readonly transports?: Partial<Record<OseroChainId, Transport>>;
readonly defaultSlippageBps?: number; // default 5
readonly confirmations?: number; // default 1; read by callers, not adapters
};All fields are optional. OseroClient.create() with no arguments returns
a usable client backed by viem's public HTTP transports (rate-limited;
override transports for production).
ResolvedClientConfig is the same shape with every field required; it is
what client.config exposes at runtime.
confirmationsis not threaded through automatically. The viem and
ethers adapters each readconfirmationsfrom their ownSendWithOptionsargument and default to1if it is missing. SettingconfirmationsonOseroClient.create({...})only stores the value onclient.configso callers can forward it themselves, e.g.sendWith(wallet, { confirmations: client.config.confirmations }).
Public methods
| Method | Purpose |
|---|---|
OseroClient.create(config?) |
Factory — the only way to construct a client. |
client.config |
ResolvedClientConfig (all fields populated with defaults). |
client.getPublicClient(chainId) |
Memoised viem PublicClient for a chain. Throws UnsupportedChainError if the chain is unknown. Contrast with getChain(chainId), which returns null for unsupported chains instead of throwing. |
client._setPublicClientForTesting(chainId, publicClient) |
Test-only. Inject a fake viem client. Marked @internal. |
OseroPublicClient is the exported alias for Client<Transport, Chain, undefined, PublicRpcSchema, PublicActions> — a generic viem public client
that supports every read method.
Actions
Every action has the same shape:
action(client: OseroClient, request: Request): ResultAsync<Plan, Error>The request object is token-specific but shares these fields:
type CommonRequest = {
readonly chainId: number; // must be in SUPPORTED_CHAIN_IDS
readonly amount: bigint; // raw, in the input token's native decimals
readonly sender: `0x${string}`; // pays the input token; `from` on every tx
readonly receiver?: `0x${string}`; // default = sender
readonly slippageBps?: number; // default = client.config.defaultSlippageBps (5)
readonly referralCode?: bigint; // default = 0n
};Validation rules:
amountmust be strictly greater than 0 — otherwise the action
returnsValidationErroron theamountfield.chainIdnot inSUPPORTED_CHAIN_IDS→UnsupportedChainError.- On mainnet
mintSUsds,referralCodemust fit in auint16
(0n ≤ code ≤ 65_535n) because the sUSDSdeposit(…, uint16)overload
is used. Out-of-range →ValidationError. - On L2s,
slippageBpsmust be an integer in[0, 10_000](enforced byapplySlippage).
Action × plan-shape matrix
| Action | Direction | Input decimals | L2 plan | Mainnet plan |
|---|---|---|---|---|
mintUsds |
USDC → USDS | 6 | Erc20ApprovalRequired |
Erc20ApprovalRequired |
mintSUsds |
USDC → sUSDS | 6 | Erc20ApprovalRequired |
MultiStepExecution (4 tx / 2 phases) |
redeemUsds |
USDS → USDC | 18 | Erc20ApprovalRequired |
Erc20ApprovalRequired |
redeemSUsds |
sUSDS → USDC | 18 | Erc20ApprovalRequired |
MultiStepExecution (3 tx / 2 phases) |
Each action has a matching preview helper that returns the quoted
output as a raw bigint without building a plan. Previews only needchainId and amount:
| Preview | Quotes | Input decimals | Output decimals |
|---|---|---|---|
previewMintUsds |
USDC → USDS | 6 | 18 |
previewMintSUsds |
USDC → sUSDS | 6 | 18 |
previewRedeemUsds |
USDS → USDC | 18 | 6 |
previewRedeemSUsds |
sUSDS → USDC | 18 | 6 |
mintUsds — USDC → USDS
import { mintUsds, previewMintUsds } from '@osero/client/actions';
import type { Erc20ApprovalRequired } from '@osero/client';
type MintUsdsRequest = CommonRequest;
type MintUsdsError = ValidationError | UnsupportedChainError | UnexpectedError;
previewMintUsds(client, { chainId, amount });
// ResultAsync<bigint, MintUsdsError>
mintUsds(client, request);
// ResultAsync<Erc20ApprovalRequired, MintUsdsError>- L2 flow (Base / Arbitrum / OP / Unichain):
USDC.approve(PSM3, amount)PSM3.swapExactIn(USDC, USDS, amount, minOut, receiver, referralCode)
- Quote comes from
PSM3.previewSwapExactIn.minOutappliesslippageBpsto that quote.
- Mainnet flow (chain ID 1):
USDC.approve(UsdsPsmWrapper, amount)UsdsPsmWrapper.sellGem(receiver, amount)
- Quote is computed off-chain from
LitePSM.tin()(governance-set
fee-in).slippageBpsandreferralCodeare ignored on mainnet.
mintSUsds — USDC → sUSDS
import { mintSUsds, previewMintSUsds } from '@osero/client/actions';
import type { Erc20ApprovalRequired, MultiStepExecution } from '@osero/client';
type MintSUsdsRequest = CommonRequest;
type MintSUsdsError = ValidationError | UnsupportedChainError | UnexpectedError;
previewMintSUsds(client, { chainId, amount });
// ResultAsync<bigint, MintSUsdsError>
mintSUsds(client, request);
// ResultAsync<Erc20ApprovalRequired | MultiStepExecution, MintSUsdsError>- L2 flow: identical shape to
mintUsds, but the PSM3 output asset
issUSDSinstead ofUSDS. Single approval + single swap. - Mainnet flow is a
MultiStepExecutionwith 4 transactions
in 2 phases:- Phase 1:
USDC.approve(UsdsPsmWrapper, amount)UsdsPsmWrapper.sellGem(sender, amount)— first arg issender,
notreceiver, because the intermediate USDS must land in the
wallet that will approve and deposit it in phase 2. Contrast
withmintUsdsmainnet, which callssellGem(receiver, amount)
because there is no second phase.
- Phase 2:
USDS.approve(sUSDS, usdsOut)sUSDS.deposit(usdsOut, receiver[, referralCode])—receiver
ends up with the ERC-4626 shares.
depositoverload selection: onlyreferralCode === undefined
triggers the 2-argdeposit(uint256, address)overload. Passing0nexplicitly (or any otherbigint) routes to the 3-argdeposit(uint256, address, uint16)overload — not the same as
omitting the field.usdsOutis read off-chain fromLitePSM.tin()and scaled from USDC's
6 decimals to USDS's 18. Slippage is not applied on mainnet — both
legs are deterministic.referralCoderange on mainnet:0n ≤ code ≤ 65_535n.
- Phase 1:
redeemUsds — USDS → USDC
import { redeemUsds, previewRedeemUsds } from '@osero/client/actions';
import type { Erc20ApprovalRequired } from '@osero/client';
type RedeemUsdsRequest = CommonRequest;
type RedeemUsdsError = ValidationError | UnsupportedChainError | UnexpectedError;
previewRedeemUsds(client, { chainId, amount });
// ResultAsync<bigint, RedeemUsdsError>
redeemUsds(client, request);
// ResultAsync<Erc20ApprovalRequired, RedeemUsdsError>- L2 flow:
USDS.approve(PSM3, amount)PSM3.swapExactIn(USDS, USDC, amount, minOut, receiver, referralCode)
- Mainnet flow uses
UsdsPsmWrapper.buyGem, which is exact-out
ongemAmt(USDC output, 6-dec), not exact-in on USDS. The SDK
does the inversion for the caller:USDS.approve(UsdsPsmWrapper, amount)UsdsPsmWrapper.buyGem(receiver, gemAmt)
gemAmtis computed from the caller's USDS budget (amount) and the
currentLitePSM.tout(), then reduced byslippageBpsso a
smalltoutincrease between plan and execution can't cause a
revert. Any unused USDS stays insender's balance.
redeemSUsds — sUSDS → USDC
import { redeemSUsds, previewRedeemSUsds } from '@osero/client/actions';
import type { Erc20ApprovalRequired, MultiStepExecution } from '@osero/client';
type RedeemSUsdsRequest = CommonRequest;
type RedeemSUsdsError = ValidationError | UnsupportedChainError | UnexpectedError;
previewRedeemSUsds(client, { chainId, amount });
// ResultAsync<bigint, RedeemSUsdsError>
redeemSUsds(client, request);
// ResultAsync<Erc20ApprovalRequired | MultiStepExecution, RedeemSUsdsError>- L2 flow:
sUSDS.approve(PSM3, amount)PSM3.swapExactIn(sUSDS, USDC, amount, minOut, receiver, referralCode)
- Mainnet flow is a
MultiStepExecutionwith 3 transactions:sUSDS.redeem(amount, sender, sender)— no approval needed becausesenderowns the shares; sender also receives the USDS.USDS.approve(UsdsPsmWrapper, usdsOut)UsdsPsmWrapper.buyGem(receiver, gemAmt)wheregemAmtisusdsOutconverted viatoutand floored byslippageBps.
usdsOutcomes fromsUSDS.previewRedeem(amount)at plan time.
The Sky Savings Rate (SSR) accrues upwards, so the live value at
execution is always ≥ the quoted value — the approval never
underflows.
chain / listChains (action helpers)
ResultAsync-shaped wrappers over the registry, exported from@osero/client/actions for API symmetry. Prefer the synchronousgetChain / listChains from @osero/client when chaining isn't needed.
import { chain, listChains } from '@osero/client/actions';
await listChains(client); // ResultAsync<readonly ChainMetadata[], UnexpectedError>
await chain(client, { chainId: 8453 }); // ResultAsync<ChainMetadata | null, UnexpectedError>The ExecutionPlan model
Every action returns a discriminated union tagged by __typename:
type ExecutionPlan = TransactionRequest | Erc20ApprovalRequired | MultiStepExecution;TransactionRequest
A fully-encoded, ready-to-sign EVM transaction. Adapters callsendTransaction with exactly these fields.
type TransactionRequest = {
readonly __typename: 'TransactionRequest';
readonly chainId: number;
readonly from: `0x${string}`;
readonly to: `0x${string}`;
readonly data: `0x${string}`;
readonly value: bigint; // always 0n today
readonly operation: OperationType; // semantic tag (see below)
};Erc20ApprovalRequired
One or more ordered ERC-20 approvals gating a single main transaction.
Every L2 action and every mainnet swap action (excluding the sUSDS
multi-step flows) returns this shape.
type Erc20Approval = {
readonly token: `0x${string}`;
readonly spender: `0x${string}`;
readonly amount: bigint;
readonly byTransaction: TransactionRequest; // tx that performs the approval
};
type Erc20ApprovalRequired = {
readonly __typename: 'Erc20ApprovalRequired';
readonly approvals: readonly Erc20Approval[];
readonly originalTransaction: TransactionRequest; // the main tx
};Execution order: every approvals[i].byTransaction is broadcast and
confirmed in order, then originalTransaction.
MultiStepExecution
An ordered list of ExecutionSteps where each step must fully confirm
before the next starts. Mainnet mintSUsds (USDC → USDS → sUSDS) and
mainnet redeemSUsds (sUSDS → USDS → USDC) use this shape.
type ExecutionStep = TransactionRequest | Erc20ApprovalRequired;
type MultiStepExecution = {
readonly __typename: 'MultiStepExecution';
readonly steps: readonly ExecutionStep[];
};MultiStepExecution is never nested inside another MultiStepExecution.
OperationType
Stable provenance tag on every TransactionRequest. Lets callers classify
a step without decoding calldata. The union is exactly 7 string literals:
type OperationType =
| 'APPROVE_ERC20'
| 'MINT_USDS'
| 'MINT_SUSDS'
| 'DEPOSIT_USDS_FOR_SUSDS'
| 'REDEEM_USDS_FOR_USDC'
| 'REDEEM_SUSDS_FOR_USDC'
| 'REDEEM_SUSDS_FOR_USDS';After a successful sendWith, TransactionResult.operations contains
the sequence of OperationTypes in execution order. For a mainnetmintSUsds that reads as:
['APPROVE_ERC20', 'MINT_USDS', 'APPROVE_ERC20', 'DEPOSIT_USDS_FOR_SUSDS']Plan introspection helpers
import {
flattenExecutionPlan,
isErc20ApprovalRequired,
isMultiStepExecution,
isTransactionRequest,
} from '@osero/client';flattenExecutionPlan(plan)→readonly TransactionRequest[]. Walks
the plan and returns every tx that will actually be broadcast, in
execution order. Useful for dry-runs, gas estimation, and UI previews.isTransactionRequest(p),isErc20ApprovalRequired(p),isMultiStepExecution(p)— exhaustive narrowing type guards.
Plan construction helpers
These are marked @internal because actions already use them, but they
are exported for callers that want to build custom plans (e.g. batching
across actions, writing a custom adapter).
import {
makeTransactionRequest, // wrap pre-encoded calldata
makeApprovalTransaction, // build an ERC-20 approve tx
makeSingleApprovalPlan, // one approval + one main tx
makeApprovalRequiredPlan, // many approvals + one main tx
makeMultiStepPlan, // ordered ExecutionStep[]
} from '@osero/client';Signatures:
makeTransactionRequest(args: {
chainId: number;
from: `0x${string}`;
to: `0x${string}`;
data: `0x${string}`;
value?: bigint; // default 0n
operation: OperationType;
}): TransactionRequest;
makeApprovalTransaction(args: {
chainId: number;
from: `0x${string}`;
token: `0x${string}`;
spender: `0x${string}`;
amount: bigint;
}): TransactionRequest; // operation: 'APPROVE_ERC20'
makeSingleApprovalPlan(args: {
chainId: number;
from: `0x${string}`;
token: `0x${string}`;
spender: `0x${string}`;
amount: bigint;
mainTransaction: TransactionRequest;
}): Erc20ApprovalRequired;
makeApprovalRequiredPlan(
originalTransaction: TransactionRequest,
approvals: readonly Erc20Approval[],
): Erc20ApprovalRequired;
makeMultiStepPlan(steps: readonly ExecutionStep[]): MultiStepExecution;Always use these over hand-built plan objects — they keep the__typename tags, default value, and operation provenance
consistent.
Executing plans: wallet adapters
An adapter turns a plan into real transactions. Both adapters expose the
same curried/direct signature and ultimately funnel through the sharedrunExecutionPlan loop in src/lib/adapters.ts.
type ExecutionPlanHandler<T extends ExecutionPlan = ExecutionPlan> = (
plan: T,
) => ResultAsync<TransactionResult, SendWithError>;
type TransactionResult = {
readonly txHash: `0x${string}`; // hash of the FINAL tx in the plan
readonly operations: readonly OperationType[]; // full executed sequence
};
type SendWithError = CancelError | SigningError | TransactionError | UnexpectedError;viem adapter (@osero/client/viem)
import { sendWith, type SendWithOptions, type ConnectedWalletClient } from '@osero/client/viem';
type SendWithOptions = {
readonly confirmations?: number; // default 1
};
// Curried form: sendWith(walletClient) → ExecutionPlanHandler
// Direct form: sendWith(walletClient, plan) → ResultAsync<TransactionResult, …>
// Both accept an optional SendWithOptions.Behaviour & requirements:
- Requires a viem
WalletClient<Transport, Chain, Account>— bothaccountandchainmust be set.ConnectedWalletClientis the
exported type alias that expresses this constraint at the type
level; it is not a runtime check. The actual guard lives insidesendWith, which throws synchronously (not aResulterror — a
plainError) if either field is missing on the value passed in. - Per-tx flow:
estimateGas(with a 15% buffer added on top, matching
the Aave SDK default) →sendTransaction→waitForTransactionReceipt
(confirmationsfrom options, default 1). Each step resolves before
the next one starts. - A reverted receipt (
receipt.status === 'reverted') becomes aTransactionErrorwithtxHashset to the reverting tx andlink
set to the chain's block-explorer URL for that tx (when the chain
metadata includes one). UserRejectedRequestError(or a walkedTransactionExecutionError
wrapping one) →CancelError. Any other viem error →SigningError.- The viem adapter does not hot-switch chains for you, but viem itself
can handle chain switching upstream of the wallet client.
Curried usage (the canonical form):
import { OseroClient } from '@osero/client';
import { mintUsds } 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 rpc = 'https://mainnet.base.org';
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const client = OseroClient.create({ transports: { 8453: http(rpc) } });
const wallet = createWalletClient({ account, chain: base, transport: http(rpc) });
const result = await mintUsds(client, {
chainId: 8453,
amount: parseUnits('100', 6),
sender: account.address,
}).andThen(sendWith(wallet, { confirmations: 2 }));
if (result.isErr()) {
console.error(result.error.name, result.error.message);
} else {
console.log(result.value.txHash); // final tx hash
console.log(result.value.operations.join(' → ')); // provenance trail
}Direct usage (inspect before signing):
const planResult = await mintUsds(client, request);
if (planResult.isErr()) return planResult;
// ...show plan to the user...
const result = await sendWith(wallet, planResult.value);ethers adapter (@osero/client/ethers)
import { sendWith, type SendWithOptions } from '@osero/client/ethers';
type SendWithOptions = {
readonly confirmations?: number; // default 1
};Behaviour & requirements:
- Requires an ethers v6
Signerwith aproviderattached. A
detached signer producesUnexpectedError. - The signer must already be connected to the target chain. Unlike
the viem adapter, the ethers adapter does not switch chains. It
readssigner.provider.getNetwork()and, ifchainIddisagrees with
the plan'schainId, short-circuits every step withUnexpectedError
(no transactions are sent). - Per-tx flow:
signer.sendTransaction({ to, data, value, from })→response.wait(confirmations)→ map the receipt to eithertxHashor aTransactionError(receipt.status === 0). ethers.isError(err, 'ACTION_REJECTED')→CancelError. Other
ethers errors →SigningError. Null receipts →UnexpectedError.
import { OseroClient } from '@osero/client';
import { mintUsds } from '@osero/client/actions';
import { sendWith } from '@osero/client/ethers';
import { JsonRpcProvider, Wallet, parseUnits } from 'ethers';
import { http } from 'viem';
const rpc = 'https://arb1.arbitrum.io/rpc';
const provider = new JsonRpcProvider(rpc, 42161);
const signer = new Wallet(process.env.PRIVATE_KEY!, provider);
// OseroClient still uses viem transports under the hood:
const client = OseroClient.create({ transports: { 42161: http(rpc) } });
const sender = (await signer.getAddress()) as `0x${string}`;
const result = await mintUsds(client, {
chainId: 42161,
amount: parseUnits('100', 6),
sender,
}).andThen(sendWith(signer));Execution semantics (both adapters)
- Transactions are broadcast strictly in order. The adapter waits for
each receipt (withconfirmationssatisfied) before starting the
next tx. This is what makes approvals land before the swap they gate,
and what makes phase 1 of aMultiStepExecutionsettle before phase 2
begins. TransactionResult.txHashis only the final tx hash. Intermediate
tx hashes are not currently surfaced — if the caller needs them they
must read events or use a block explorer.
Balance helpers
All four helpers return a raw bigint (in the token's native decimals)
wrapped in ResultAsync. They reuse OseroClient's transport wiring
and the canonical token registry, so prefer them over hand-rolled
ERC-20 balanceOf calls.
import {
getTokenBalance,
getTokenBalances,
getUsdcBalance,
getUsdsBalance,
getSUsdsBalance,
type GetBalancesRequest,
type GetTokenBalanceError,
type GetTokenBalanceRequest,
type TokenBalances,
} from '@osero/client';
type GetTokenBalanceRequest = {
readonly chainId: number;
readonly account: `0x${string}`;
readonly token: TokenSymbol; // 'USDC' | 'USDS' | 'sUSDS'
};
type GetBalancesRequest = {
readonly chainId: number;
readonly account: `0x${string}`;
};
type TokenBalances = {
readonly USDC: bigint;
readonly USDS: bigint;
readonly sUSDS: bigint;
};
type GetTokenBalanceError = UnsupportedChainError | UnexpectedError;| Helper | Returns |
|---|---|
getTokenBalance |
ResultAsync<bigint, GetTokenBalanceError> |
getTokenBalances |
ResultAsync<TokenBalances, GetTokenBalanceError> |
getUsdcBalance |
ResultAsync<bigint, GetTokenBalanceError> |
getUsdsBalance |
ResultAsync<bigint, GetTokenBalanceError> |
getSUsdsBalance |
ResultAsync<bigint, GetTokenBalanceError> |
const result = await getTokenBalances(client, {
chainId: 8453,
account: wallet.account.address,
});
if (result.isOk()) {
const { USDC, USDS, sUSDS } = result.value;
}Error taxonomy
Every SDK error extends the abstract base class OseroError, which itself
extends Error. You can narrow with instanceof OseroError, butswitch (result.error.name) is the idiomatic form because the name
field is assigned in every constructor and is part of the public contract.
import {
OseroError, // abstract base
ValidationError,
UnsupportedChainError,
InsufficientBalanceError,
CancelError,
SigningError,
TransactionError,
UnexpectedError,
} from '@osero/client';Field reference
| Class | name |
Extra fields | When it appears |
|---|---|---|---|
ValidationError<Context> |
'ValidationError' |
context: Context (e.g. { field }) |
Bad input (amount ≤ 0, bad referralCode, etc.) |
UnsupportedChainError |
'UnsupportedChainError' |
chainId: number |
chainId not in SUPPORTED_CHAIN_IDS |
InsufficientBalanceError |
'InsufficientBalanceError' |
token, required, available |
Unused today. Reserved for a future balance-preflight pass; current actions never produce it. |
CancelError |
'CancelError' |
— | User rejected the wallet prompt |
SigningError |
'SigningError' |
— | Wallet failed to sign for a non-cancel reason |
TransactionError |
'TransactionError' |
txHash: Hex, link?: string |
Tx was broadcast but reverted |
UnexpectedError |
'UnexpectedError' |
— (always wraps cause) |
RPC failure, ethers chain mismatch, unclassified runtime error |
Union types
// Returned by actions before handoff to sendWith:
type ActionError =
| ValidationError
| UnsupportedChainError
| InsufficientBalanceError
| UnexpectedError;
// Returned by sendWith:
type SendWithError = CancelError | SigningError | TransactionError | UnexpectedError;Each action also exports a per-action alias:
type MintUsdsError = ValidationError | UnsupportedChainError | UnexpectedError;
type MintSUsdsError = ValidationError | UnsupportedChainError | UnexpectedError;
type RedeemUsdsError = ValidationError | UnsupportedChainError | UnexpectedError;
type RedeemSUsdsError = ValidationError | UnsupportedChainError | UnexpectedError;Handling
const result = await mintUsds(client, request).andThen(sendWith(wallet));
if (result.isErr()) {
switch (result.error.name) {
case 'ValidationError':
/* amount <= 0, bad referralCode, etc. */ break;
case 'UnsupportedChainError':
/* result.error.chainId */ break;
case 'CancelError':
/* user rejected */ break;
case 'TransactionError': {
// .txHash is the reverting tx; .link may be populated on viem
console.error(result.error.txHash, result.error.link);
break;
}
case 'SigningError':
case 'UnexpectedError':
/* inspect .cause */ break;
}
return;
}
const { txHash, operations } = result.value;Factory helpers
The internal .from(cause) factories on CancelError, SigningError,TransactionError, and UnexpectedError are used by the adapters to
normalise wallet errors. You can call them in custom adapters; you
generally do not need them in application code.
Math helpers
Used internally by the mainnet actions to compute usdsOut / gemAmt
from the Lite PSM fees. Exposed so callers can audit or rebuild the
math.
import {
BPS, // 10_000n (100% in basis points)
WAD, // 1_000_000_000_000_000_000n (1e18)
USDC_TO_USDS_SCALE, // 1_000_000_000_000n (1e12)
applySlippage,
usdsFromUsdcViaSellGem,
usdsNeededForUsdcViaBuyGem,
usdcFromUsdsViaBuyGem,
} from '@osero/client';Signatures & semantics:
applySlippage(quote: bigint, slippageBps: number): bigint
// Floors `quote` by `slippageBps`. Throws RangeError for non-integer or
// out-of-[0, 10_000] inputs.
// applySlippage(1_000_000n, 5) === 999_500n // 5 bps = 0.05%
usdsFromUsdcViaSellGem(gemAmt: bigint, tin: bigint): bigint
// USDS output from Sky sellGem given USDC input `gemAmt` (6-dec) and
// current `tin` (18-dec fraction of WAD). usdsOutWad = gemAmt * 1e12 * (WAD - tin) / WAD
usdsNeededForUsdcViaBuyGem(gemAmt: bigint, tout: bigint): bigint
// USDS required to buy `gemAmt` (6-dec) via Sky buyGem given `tout`.
// usdsInWad = gemAmt * 1e12 + gemAmt * 1e12 * tout / WAD
usdcFromUsdsViaBuyGem(usdsInWad: bigint, tout: bigint): bigint
// Inverse of the above: given USDS budget (18-dec) and current `tout`,
// compute the maximum USDC output (6-dec). Floors for safety.See the tin / tout glossary in the PSM addresses
section for the fee semantics; both helpers are exact at runtime
regardless of the current values.
Result / ResultAsync re-exports
import {
Result,
ResultAsync,
ok,
err,
okAsync,
errAsync,
fromPromise,
fromThrowable,
fromAsyncThrowable,
} from '@osero/client';These are thin re-exports of `neverthrow`
— same semantics, same methods (.andThen, .map, .mapErr, .combine,.isOk, .isErr, etc.). Always import them from @osero/client so the
dependency remains swappable.
Typical chain:
const result = await mintSUsds(client, request)
.andThen(sendWith(wallet)) // Result stays Err if the plan step failed
.mapErr((err) => {
// optional: normalise errors upstream
if (err.name === 'CancelError') return new Error('user cancelled');
return err;
});Combining preview + balance reads:
import { ResultAsync } from '@osero/client';
const combined = await ResultAsync.combine([
previewMintSUsds(client, { chainId: 8453, amount: parseUnits('100', 6) }),
getUsdcBalance(client, { chainId: 8453, account }),
]);
if (combined.isOk()) {
const [expectedShares, usdcBalance] = combined.value;
}Public ABIs
The SDK bundles five minimal ABIs covering every function it calls.
Re-use them in read-only UIs or custom adapters.
import {
erc20Abi, // approve, allowance, balanceOf, decimals
erc4626Abi, // asset, deposit (2 + 3 arg), redeem, previewDeposit, previewRedeem, etc.
litePsmAbi, // tin, tout, pocket — mainnet audit reads
psm3Abi, // swapExactIn, swapExactOut, previewSwapExactIn, previewSwapExactOut
usdsPsmWrapperAbi, // sellGem, buyGem, and their quote helpers
} from '@osero/client';erc20Abiis a minimal subset; it is not the full OpenZeppelin
ERC-20. It only covers what the SDK actually uses.erc4626Abiincludes both the 2-arg and 3-arg (… , uint16 referral)
overloads ofdeposit; viem'sencodeFunctionDatadisambiguates by
arity.psm3AbiandusdsPsmWrapperAbiare the Spark contracts' public
interfaces; the underlying SkyLitePSMexposes more thanlitePsmAbi
— the SDK only readstin,tout, and (informatively)pocket.
Dry-run / plan inspection
Inspect a plan before signing to validate generated SDK code without
risking funds. No wallet, no private key — only eth_calls against the
public RPC.
import {
flattenExecutionPlan,
isErc20ApprovalRequired,
isMultiStepExecution,
isTransactionRequest,
OseroClient,
} from '@osero/client';
import { mintSUsds } from '@osero/client/actions';
import { http, parseUnits } from 'viem';
const client = OseroClient.create({ transports: { 1: http('https://eth.llamarpc.com') } });
const SENDER = '0x1111111111111111111111111111111111111111' as const;
const planResult = await mintSUsds(client, {
chainId: 1,
amount: parseUnits('100', 6),
sender: SENDER,
});
if (planResult.isOk()) {
const plan = planResult.value;
if (isTransactionRequest(plan)) {
console.log('single tx:', plan.operation);
} else if (isErc20ApprovalRequired(plan)) {
console.log('approvals:', plan.approvals.length);
console.log('main op:', plan.originalTransaction.operation);
} else if (isMultiStepExecution(plan)) {
console.log('phases:', plan.steps.length);
}
for (const tx of flattenExecutionPlan(plan)) {
console.log(tx.operation, tx.to, tx.data.slice(0, 10));
}
}The canonical repo walk-through is
`examples/src/dry-run/inspect-plan.ts`
— it builds a plan for every action against both an L2 and mainnet,
without broadcasting anything.
Examples in this repo
Run from the repo root:
pnpm install
cp examples/.env.example examples/.env # fill in PRIVATE_KEY + optional RPC URLs
# Safe — no funds, no tx:
pnpm --filter @osero/examples dry-run:inspect-plan
# Broadcast — real txs, real gas:
pnpm --filter @osero/examples viem:mint-usds
pnpm --filter @osero/examples viem:mint-susds-mainnet
pnpm --filter @osero/examples viem:redeem-susds
pnpm --filter @osero/examples viem:roundtrip
pnpm --filter @osero/examples ethers:mint-usds
pnpm --filter @osero/examples ethers:roundtripPointers for common tasks (read these for canonical usage patterns):
| Goal | Example file |
|---|---|
| Inspect every plan shape without signing | `examples/src/dry-run/inspect-plan.ts` |
Curried viem sendWith — USDC → USDS on Base |
`examples/src/viem/mint-usds.ts` |
Mainnet MultiStepExecution — USDC → sUSDS on L1 |
`examples/src/viem/mint-susds-mainnet.ts` |
Eager sendWith — build plan, inspect, then send |
`examples/src/viem/redeem-susds.ts` |
| Full round-trip using preview + balance helpers (viem) | `examples/src/viem/roundtrip-usdc-susds.ts` |
| Same round-trip through ethers v6 | `examples/src/ethers/roundtrip-usdc-susds.ts` |
| ethers v6 mint — the minimal adapter switch vs. viem | `examples/src/ethers/mint-usds.ts` |
Pretty-printing plans (describePlan, describeResult) |
`examples/src/shared/format.ts` |
| Env loading / RPC URL lookup | `examples/src/shared/env.ts` |
Common pitfalls
- Do not import actions from the root. Actions live at
@osero/client/actions. The root (@osero/client) does not re-export
them. amountis always raw. Use viem's / ethers'parseUnits— never
pass floats or human-readable strings.- Decimals differ by direction. Mint actions take USDC (6 dec).
Redeem actions take USDS (18 dec) or sUSDS (18 dec). - Every action returns
ResultAsync. Alwaysawaitand branch on.isOk()/.isErr()— nevertry/catch. - Pipe with
.andThen(sendWith(wallet)). The curried form flows
the plan into the adapter while preserving the error union. The
direct form (sendWith(wallet, plan)) is the right call when the
plan needs to be inspected (or shown to the user) before signing. - Viem wallet needs
account+chainup-front.sendWithfrom@osero/client/viemthrows synchronously (plainError) otherwise. - Ethers signer must already be on the target chain. The ethers
adapter does not hot-switch; a mismatch short-circuits withUnexpectedErrorbefore any tx is sent. OseroClientstill needs viem transports even for ethers users.
The client reads on-chain state (PSM3 previews, Lite PSM fees, sUSDSpreviewRedeem) through viem public clients.- Do not assume
TransactionResult.txHashcovers every step. It is
only the hash of the final tx. Useoperationsfor the full
provenance trail, or listen for events if intermediate hashes are
required. - Do not hard-code addresses. Use
getToken(chainId, symbol)andPSM_ADDRESSES[chainId]. - Do not bind a wallet to
OseroClient. The client is stateless
from the caller's perspective; signing is an adapter concern. - Mainnet mints/redeems of sUSDS are multi-step. The plan has
multiple tx hashes (2–4 depending on direction). Budget for the
extra block time in UI flows. - Mainnet
redeemUsdsusesbuyGem(exact-out). The SDK derivesgemAmtfrom the caller's USDS budget; a tiny bit of USDS stays insender's balance. Do not assume the fullamountis pulled. referralCodeon mainnetmintSUsdsis auint16. Pass0n
(default) if unused, otherwise keep within[0n, 65_535n].
Type index
Quick lookup of every public type name re-exported from @osero/client:
// Client
OseroClient class
OseroPublicClient type alias for viem PublicClient
ClientConfig type (user-facing)
ResolvedClientConfig type (after defaults applied)
// Chain registry
ChainMetadata type
OseroChainId type 1 | 10 | 130 | 8453 | 42161
SUPPORTED_CHAIN_IDS const tuple
// Tokens
Token type
TokenSymbol type 'USDC' | 'USDS' | 'sUSDS'
// Addresses
PsmAddresses type
// Actions — requests
MintUsdsRequest type
PreviewMintUsdsRequest type
MintSUsdsRequest type
PreviewMintSUsdsRequest type
RedeemUsdsRequest type
PreviewRedeemUsdsRequest type
RedeemSUsdsRequest type
PreviewRedeemSUsdsRequest type
// Actions — errors
MintUsdsError type
MintSUsdsError type
RedeemUsdsError type
RedeemSUsdsError type
// Plans
OperationType type union of 7 string literals
TransactionRequest type
Erc20Approval type
Erc20ApprovalRequired type
ExecutionStep type TransactionRequest | Erc20ApprovalRequired
MultiStepExecution type
ExecutionPlan type discriminated union
ExecutionPlanHandler<T> type plan-executor function
TransactionResult type { txHash, operations }
// Error unions
ActionError type plan-building errors
SendWithError type execution errors
// Balances
GetTokenBalanceRequest type
GetBalancesRequest type
TokenBalances type { USDC, USDS, sUSDS }
GetTokenBalanceError type
// viem adapter
ConnectedWalletClient type WalletClient<Transport, Chain, Account>
SendWithOptions type (viem) { confirmations? }
// ethers adapter
SendWithOptions type (ethers) { confirmations? }Source references
For canonical usage in this repo, start here:
packages/client/README.md— short tour with both viem and ethers
quick-starts.packages/client/src/index.ts— authoritative list of every re-export.packages/client/src/lib/types.ts— everyExecutionPlan/ result
type.packages/client/src/lib/actions/*.ts— each action's full validation
and branching logic.packages/client/src/lib/chains.ts,tokens.ts,addresses.ts— the
single source of truth for chain IDs, tokens, and PSM addresses.packages/client/src/viem.ts/ethers.ts— adapter internals,
gas buffering, error mapping.PSM_GUIDE.md(repo root) — background ontin,tout,sellGem,buyGem, and how the Spark wrapper fronts the Sky Lite PSM.