@surf_liquid/core-sdk
v0.4.1
Published
Framework-agnostic Surfliquid SDK for wallet auth, vault deployment, deposits, and withdrawals.
Readme
@surf_liquid/core-sdk
Framework-agnostic TypeScript SDK for Surfliquid — wallet authentication, vault deployment, deposits, withdrawals, portfolio queries, and activity feeds.
Integration examples & AI skill: runnable sample apps and an integration
SKILL.mdlive in the surfliquid-sdk-core-integration repo.
Table of Contents
- Installation
- Quick Start
- Initialization
- Gas on Ethereum mainnet
- Step-by-Step Integration
- API Reference
- Events
- Types
- Error Handling
- Examples
Installation
npm install @surf_liquid/core-sdk ethersethers v6 is a required peer dependency.
Quick Start
import { SurfClient } from "@surf_liquid/core-sdk";
const client = SurfClient.create({
projectName: "my-app",
appId: "your-app-id",
autoApprove: true,
});
await client.verifyApp(); // validate your appId (throws on a bad/missing appId)
await client.connectWallet("metamask");
await client.authenticate();
const vault = await client.getVault();
if (!vault.exists) await client.deployVault();
const tx = await client.deposit({
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
amount: "10.0",
});
await tx.wait();Initialization
SurfClient.create(config) — recommended
const client = SurfClient.create({
projectName: "my-app", // Required — sent as X-Surf-Project-Name header
appId: "your-app-id", // Required — sent as X-App-ID header on first user login
environment: "mainnet", // "mainnet" | "testnet" — default: "mainnet"
chainId: 8453, // Default: Base (8453) on mainnet
autoApprove: true, // Auto-approve ERC20 spend before deposit — default: false
apiBaseUrl: "https://api.surfliquid.com", // Optional — uses default if omitted
});SurfClient.builder() — fluent API
Use this when you need to register custom chains, tokens, or wallet adapters at build time.
const client = SurfClient.builder()
.setProject("my-app", "your-app-id")
.setEnvironment("mainnet")
.setChain(8453)
.setAutoApprove(true)
.build();Config options
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| projectName | string | Yes | — | Your project name |
| appId | string | Yes | — | Your app / project ID |
| environment | "mainnet" \| "testnet" | No | "mainnet" | Network environment |
| chainId | number | No | 8453 | Active chain ID |
| autoApprove | boolean | No | false | Auto-approve ERC20 allowance before deposits |
| rpcUrl | string | No | Chain default | Custom RPC endpoint |
| apiBaseUrl | string | No | https://api.surfliquid.com | Custom API base URL |
| factoryAddress | `0x${string}` | No | Chain default | Override vault factory address |
Supported chains
| Chain | Chain ID | Environment |
|-------|----------|-------------|
| Base | 8453 | mainnet |
| Ethereum | 1 | mainnet |
| Polygon | 137 | mainnet |
| Base Sepolia | 84532 | testnet |
Gas on Ethereum mainnet (low-fee defaults)
On Ethereum mainnet (chainId: 1) only, the SDK attaches explicit EIP-1559 fee overrides to every transaction it sends (vault deploy, deposit, withdraw, and the ERC-20 approval / WETH-wrap steps) so transactions cost as little as possible. On every other chain (Base, Polygon, Base Sepolia, and any custom chain) the SDK supplies no fee overrides and lets the wallet/provider pick fees as usual — this behavior is Ethereum-specific and there is no integration change required on any chain.
What the SDK sets on Ethereum:
type: 2(EIP-1559) is set explicitly. This is deliberate: without an explicittype: 2, wallets such as MetaMask ignore a dapp's supplied 1559 fees and substitute their own "market" estimate, which is higher.maxPriorityFeePerGas(the tip) is derived frometh_feeHistoryover the last ~20 blocks: the SDK takes the per-block 10th-percentile priority fees, uses their median, and floors the result at 0.1 gwei (100_000_000wei) so the tx stays includable.maxFeePerGasisbaseFee * 1.25 + tip— i.e. only 1.25x base-fee headroom, not the 2x "survive 6 blocks" headroom that ethers/EIP-1559 defaults to.- If
eth_feeHistoryis unavailable, it falls back to the latest block'sbaseFeePerGasplus the 0.1 gwei floor tip.
"Site suggested" vs the wallet's own "Low" preset. These values are tuned to line up with a wallet's "Low" fee tier. The reason the 2x headroom was dropped to 1.25x is that MetaMask was surfacing the 2x figure as a scarily high "max" on site-suggested (dapp-supplied) transactions, even though the effective cost is only ~baseFee + tip. With the 1.25x cap, the SDK's "Site suggested" fee no longer reads higher than the wallet's own lowest (Low) option. The trade-off is the same one the wallet's Low tier makes: a sharp base-fee spike between submission and inclusion can delay mining. Users who want faster inclusion can still override the fee in their wallet at confirmation time.
Effective cost is still approximately
baseFee + tip; the headroom only caps the worst-case ceiling — you are not charged the max.
Robust write-gas handling (Ethereum mainnet)
To make first deposits and back-to-back deposit/withdraw flows reliable, the SDK pre-estimates the gas limit for write transactions itself (via the read RPC) and passes it to the wallet as an explicit gasLimit, instead of letting the wallet re-estimate. This is scoped to Ethereum mainnet (chainId: 1) — the chain where the failures below were observed and where the explicit low-fee overrides already apply — and covers deployVault, deposit (both the initialDeposit and userDeposit paths), and withdraw. A small 20% buffer is added to the estimate so a slight state shift between estimate and mining can't cause out-of-gas (you still only pay for gas actually used).
The estimate is done against the SDK's own read provider with the connected wallet as from. If that estimate hits a genuine contract revert, the error is re-thrown immediately (retrying wouldn't help); if it fails for a transient/non-revert reason, the SDK returns no gas limit and falls back to letting the wallet estimate — so robustness never makes a call strictly worse.
Deposit allowance-settle wait. When autoApprove sends an approval before a deposit, the SDK then polls the allowance (up to 8 times, ~1s apart) via the read provider until the new allowance is visible before submitting the deposit. If the budget elapses it proceeds anyway (a genuinely-insufficient allowance surfaces on the deposit itself).
Which estimateGas failures this fixes:
- Post-approval first-deposit revert. The deposit's gas estimation can race the approval: if the estimating node hasn't yet observed the freshly-set allowance, the deposit's
transferFromreverts duringestimateGaswithrequire(false)/ no revert data. The allowance-settle wait plus SDK-side pre-estimation removes this race. - Stale-state re-estimation on deposit/withdraw. Submitting a withdraw (or a second deposit) right after a deposit could make the wallet re-estimate against stale state and fail with
missing revert data/could not decode result data. Pre-estimating against the read provider and passing an explicitgasLimitavoids the wallet's faulty re-estimate.
All of this is internal — no integration change is required; deposits/withdrawals/deploys are simply more reliable on Ethereum.
Step-by-Step Integration
1. Create the client
const client = SurfClient.create({
projectName: "my-app",
appId: "your-app-id",
autoApprove: true,
});2. Register event listeners
Set up listeners before connecting so no events are missed.
client.on("wallet:connected", ({ address, chainId }) => {
console.log("Connected:", address, "on chain", chainId);
});
client.on("auth:authenticated", ({ address, authenticated }) => {
console.log("Authenticated:", authenticated, "as", address);
});
client.on("vault:deployed", ({ vaultAddress }) => {
console.log("Vault at:", vaultAddress);
});
client.on("deposit:completed", ({ asset, amount, txHash }) => {
console.log(`Deposited ${amount} of ${asset} — tx: ${txHash}`);
});
client.on("error", ({ code, message }) => {
console.error(`[${code}] ${message}`);
});3. Connect wallet
// Supported: "metamask" | "trust" | "coinbase" | "rabby" | "phantom" | "walletconnect" | "injected"
const state = await client.connectWallet("metamask");
console.log(state.address); // "0x..."
console.log(state.chainId); // 84534. Authenticate
const auth = await client.authenticate();
console.log(auth.authenticated); // true
// Auth is cookie-based: the session token lives in an httpOnly cookie set by the
// backend, so `auth.token` is always null. Gate on `auth.authenticated` instead.
console.log(auth.token); // null5. Check or deploy vault
const vault = await client.getVault();
if (!vault.exists) {
const result = await client.deployVault();
console.log("Deployed at:", result.vaultAddress);
} else {
console.log("Vault:", vault.userVaultAddress);
console.log("Total value: $" + vault.totalValueUSD);
console.log("APY:", vault.apyBreakdown?.currentAPY + "%");
}6. Deposit
const tx = await client.deposit({
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
amount: "10.0", // human-readable, not wei
});
await tx.wait();7. Check portfolio
const portfolio = await client.getPortfolioSummary();
console.log("Active assets:", portfolio.activeCount);
portfolio.assets.forEach((addr, i) => {
console.log(addr, "→ profit:", portfolio.profits[i].toString());
});8. Withdraw
// Partial
const tx = await client.withdraw({
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
amount: "5.0",
});
await tx.wait();
// Full (omit amount)
const tx = await client.withdraw({
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
});
await tx.wait();API Reference
Setup
verifyApp()
Validates your appId against the backend (GET /api/sdk/public/overview with the x-app-id header). Call it right after create() to fail fast on a bad app ID. Throws MISSING_PROJECT_ID if no appId was provided, or INVALID_APP_ID if the backend rejects it.
await client.verifyApp(); // resolves if the appId is valid; throws otherwiseWallet
connectWallet(walletName)
Connects a wallet and switches it to the configured chain.
const state = await client.connectWallet("metamask");
// state: { address, chainId, connected }disconnectWallet()
await client.disconnectWallet();getWalletState()
Returns current wallet state or null if not connected.
const state = client.getWalletState(); // WalletState | nullswitchChain(chainId)
Re-points the whole SDK at chainId: it updates config.chainId, rpcUrl, factoryAddress, and the read provider from the chain registry, and switches the connected wallet too (works read-only without a wallet). Throws UNSUPPORTED_CHAIN for a chain not registered for the active environment. Your auth (the cookie session) is preserved — it's per-wallet, not per-chain. Any custom rpcUrl / factoryAddress you set at creation are replaced by the target chain's registry values.
await client.switchChain(137); // SDK now operates on PolygonregisterWalletAdapter(name, adapter)
Register a custom wallet adapter at runtime.
client.registerWalletAdapter("my-wallet", new MyAdapter());Authentication
authenticate()
Signs a nonce with the connected wallet and logs in. Auth is cookie-based: on login the backend sets an httpOnly session cookie (SameSite=None; Secure) — the token is never returned in the response body, so auth.token is always null. Gate on auth.authenticated / auth.user, never on auth.token. Flow: getNonce → signMessage → login.
const auth = await client.authenticate();
// auth: { token, address, authenticated, user } — token is always null (lives in the cookie)Requires wallet connected.
Cross-origin: the SDK sends every request with
credentials: "include"(noAuthorization: Bearerheader). For the cookie to work cross-origin the API must return a specificAccess-Control-Allow-Origin(never*) plusAccess-Control-Allow-Credentials: true, and your web origin must be allowlisted.
refreshSession()
Extends the session using the existing httpOnly cookie — no wallet signature and no body. Calls POST /api/auth/refresh; the backend rotates the cookie (new 7-day token) and invalidates the old one immediately. Returns { expiresAt } (ISO 8601). Call it proactively before expiry (e.g. when less than 24h remain) to keep the session alive without re-signing.
const { expiresAt } = await client.refreshSession();
console.log("Session valid until", expiresAt);Throws
SurfError(API_ERROR, status 401) if the cookie is missing, expired, or revoked.
getAuthState()
const auth = client.getAuthState(); // AuthState (synchronous, no request)logout()
await client.logout();Vault
getVault(walletAddress?)
Fetches vault info. Falls back to connected wallet if no address passed. Public — no auth required.
const vault = await client.getVault();
// or: await client.getVault("0xAnyAddress");
vault.exists // boolean
vault.userVaultAddress // "0x..." | null
vault.homeChainId // 8453
vault.vaultVersion // "v4"
vault.isActive // boolean
vault.totalValueUSD // 13.03
vault.totalDepositedUSD // 13.03
vault.apyBreakdown // { currentAPY, nativeAPY, merklAPY, leagueAPY, totalAPY?, apy7d?, apy14d?, apy30d? } — windowed APYs are portfolio-weighted, null until enough history
vault.earned // { totalEarningsUSD, nativeEarningsUSD, merklRewardsUSD, leagueEarnedUSD }
vault.league // { rank, totalXP, estimatedSURF, estimatedSURFUSD, joinDate } | null
vault.assets // VaultAsset[] — per-chain asset breakdowndeployVault()
Deploys a new vault. Flow: prepare → on-chain deployVault → confirm.
const result = await client.deployVault();
result.vaultAddress // "0x..."
result.transactionHash // "0x..."
result.salt // "0x..."Requires a connected wallet (the SDK enforces a wallet, not auth — the backend still expects an authenticated session). Throws
VAULT_ALREADY_EXISTSif a vault already exists.
getSupportedAssets(chainId?)
Returns supported assets with live APY data. Public — no wallet or auth required.
const assets = await client.getSupportedAssets(); // all chains
const assets = await client.getSupportedAssets(8453); // Base only
const assets = await client.getSupportedAssets(137); // Polygon only
assets[0].assetSymbol // "USDC"
assets[0].assetAddress // "0x..."
assets[0].chainId // 8453
assets[0].chainStatus // "ACTIVE" | "STAKING"
assets[0].currentAPY // 15.99
assets[0].nativeAPY // 13.08
assets[0].merklAPY // 0.05
assets[0].leagueAPY // 2.86
assets[0].apy7d // 14.80 — trailing 7-day APY (null until 7 days of history)
assets[0].apy14d // 15.10 — trailing 14-day APY (null until 14 days)
assets[0].apy30d // 15.40 — trailing 30-day APY (null until 30 days)getAgentMessages(walletAddress?, page?, limit?, from?, to?)
Returns paginated activity feed — deposits, withdrawals, rebalances, bridges, Merkl claims. Public. from/to are optional ISO-8601 strings; when provided, results are filtered by timestamp inclusive on both ends.
const result = await client.getAgentMessages(); // connected wallet, page 1, limit 20
const result = await client.getAgentMessages("0x...", 2, 10);
// Filter by a date range (ISO 8601, inclusive on both ends)
const result = await client.getAgentMessages(
"0x...", 1, 20,
"2026-01-01T00:00:00.000Z", // from
"2026-03-31T23:59:59.999Z", // to
);
result.total // 42
result.pages // 3
result.messages // AgentMessage[]
result.messages[0].message // human-readable description
result.messages[0].transactionType // "USER_DEPOSIT" | "REBALANCE_COMPLETED" | "MERKL_CLAIM" | ...
result.messages[0].executedBy // "USER" | "AGENT"
result.messages[0].chainId // 8453
result.messages[0].txHash // "0x..."
result.messages[0].timestamp // "2026-04-02T12:21:41.000Z"
// Structured fields — present only when applicable to the event type:
result.messages[0].amount // 10.0 | null
result.messages[0].token // "USDC" | null
result.messages[0].fromVault // { name, address, apy } | null
result.messages[0].toVault // { name, address, apy } | null
result.messages[0].apyBefore // 13.2 | null
result.messages[0].apyAfter // 15.4 | null
result.messages[0].signal // string | nullgetOwnerVaults(owner?) / getOwnerVaultCount(owner?)
const vaults = await client.getOwnerVaults(); // string[]
const count = await client.getOwnerVaultCount(); // numberisVaultFromFactory(vaultAddress)
const valid = await client.isVaultFromFactory("0x..."); // booleangetAllowedAssets(vaultAddress?)
const assets = await client.getAllowedAssets(); // string[]getFeeInfo(vaultAddress?)
const fees = await client.getFeeInfo();
fees.feePercentage // bigint
fees.rebalanceFeePercentage // bigint
fees.merklClaimFeePercentage // bigintDeposits & Withdrawals
deposit(params)
const tx = await client.deposit({
asset: "0x...", // token address
amount: "10.0", // human-readable (not wei)
wrapEth: false, // wrap native ETH → WETH first
bestVault: "0x...", // optional: override Morpho vault
});
await tx.wait();If
autoApprove: true, the SDK automatically approves the token spend if needed.
withdraw(params)
const tx = await client.withdraw({
asset: "0x...",
amount: "5.0", // omit for full withdrawal
});
await tx.wait();getWithdrawableAmount(asset, vaultAddress?)
const wei = await client.getWithdrawableAmount("0x..."); // bigintgetBestVault(assetSymbol)
const options = await client.getBestVault("USDC");
// [{ chainId: 8453, vaultAddress: "0x..." }, ...]hasInitialDeposit(asset, vaultAddress?)
const done = await client.hasInitialDeposit("0x..."); // booleanPortfolio
getPortfolioSummary(vaultAddress?)
const p = await client.getPortfolioSummary();
p.activeCount // number
p.assets // string[] — asset addresses
p.deposited // bigint[] — deposited amounts in wei
p.currentValues // bigint[] — current values in wei
p.profits // bigint[] — profits in weigetAssetProfit(asset, vaultAddress?)
const profit = await client.getAssetProfit("0x..."); // bigint (wei)getAssetProfitPercentage(asset, vaultAddress?)
const pct = await client.getAssetProfitPercentage("0x..."); // bigintToken Utilities
getTokenBalance(token, owner?)
const balance = await client.getTokenBalance("0xusdcAddress"); // connected wallet
const balance = await client.getTokenBalance("0xusdcAddress", "0x..."); // any address
// returns bigint (wei)getSupportedTokens()
Returns tokens registered in the SDK for the active chain (synchronous).
const tokens = client.getSupportedTokens();
// [{ address, symbol, decimals, vaultAddresses }, ...]getConfig()
const config = client.getConfig();
config.chainId // 8453
config.environment // "mainnet"
config.apiBaseUrl // "https://api.surfliquid.com"Events
client.on("event:name", handler);
client.off("event:name", handler);| Event | Payload | When |
|-------|---------|------|
| wallet:connected | WalletState | Wallet connects |
| wallet:disconnected | void | Wallet disconnects |
| wallet:accountChanged | { oldAddress, newAddress } | Account switch |
| wallet:chainChanged | { chainId } | Chain switch |
| auth:authenticated | AuthState | Login complete |
| auth:logout | void | Logout called |
| vault:deployed | DeployVaultResult | Vault deployed |
| deposit:started | { asset, amount } | Deposit begins |
| deposit:approved | { asset, txHash } | ERC20 approval sent |
| deposit:completed | { asset, amount, txHash } | Deposit confirmed |
| withdraw:started | { asset, amount } | Withdrawal begins |
| withdraw:completed | { asset, amount, txHash } | Withdrawal confirmed |
| error | { code, message } | Any SDK error |
Types
interface WalletState {
address: string;
chainId: number;
connected: boolean;
}
interface AuthState {
token: string | null; // Always null under cookie auth — the token lives in an httpOnly cookie, not here
address: string | null;
authenticated: boolean;
user: UserProfile | null;
}
interface RefreshResult {
expiresAt: string; // ISO 8601 expiry of the freshly issued session token
}
interface VaultInfo {
userVaultAddress: string | null;
deploymentSalt: string | null;
exists: boolean;
homeChainId?: number | null;
vaultVersion?: string | null;
isActive?: boolean;
totalValueUSD?: number | null;
totalDepositedUSD?: number | null;
earned?: VaultEarned | null;
apyBreakdown?: VaultApyBreakdown | null;
league?: VaultLeague | null;
assets?: VaultAsset[];
// Per-chain vault addresses. A vault can be deployed at a different address per
// chain (e.g. Ethereum), so this can differ from userVaultAddress.
chainAddresses?: VaultChainAddress[];
}
interface VaultChainAddress {
chainId: number;
vaultAddress: string;
}
interface VaultApyBreakdown {
currentAPY: number;
nativeAPY: number;
merklAPY: number;
leagueAPY: number;
totalAPY?: number;
// Trailing-window APYs (portfolio-weighted). Null until enough history exists.
apy7d?: number | null;
apy14d?: number | null;
apy30d?: number | null;
}
interface VaultAsset {
assetAddress: string;
assetSymbol: string;
assetDecimals: number;
chainId: number;
chainStatus: string;
currentAPY: number;
nativeAPY: number;
merklAPY: number;
leagueAPY: number;
// Trailing-window APYs for this asset. Null until enough history exists.
apy7d?: number | null;
apy14d?: number | null;
apy30d?: number | null;
// ...balances, earnings, and vault metadata
}
interface SupportedAsset {
assetAddress: string;
assetSymbol: string;
assetDecimals: number;
chainId: number;
chainStatus: string;
currentAPY: number;
nativeAPY: number;
merklAPY: number;
leagueAPY: number;
// Trailing-window APYs. Null until enough history exists (e.g. apy7d is null with < 7 days).
apy7d?: number | null;
apy14d?: number | null;
apy30d?: number | null;
}
interface VaultRef {
name: string;
address: string;
apy: number;
}
interface AgentMessage {
message: string;
txHash: string;
timestamp: string;
transactionType:
| "INITIAL_DEPOSIT" | "USER_DEPOSIT" | "USER_WITHDRAWAL" | "REBALANCE" | "CROSS_CHAIN_REBALANCE" | "MIGRATE"
| "DEPOSIT" | "WITHDRAWAL" | "REBALANCE_COMPLETED" | "REBALANCE_CANCELLED" | "MERKL_CLAIM"
| "ASSET_ADDED" | "ASSET_REMOVED" | "ASSET_SWAPPED"
| string;
executedBy: "USER" | "AGENT";
vaultVersion: string;
chainId: number;
// Optional structured fields — omitted when not applicable to the event type:
amount?: number | null;
token?: string | null;
fromVault?: VaultRef | null;
toVault?: VaultRef | null;
apyBefore?: number | null;
apyAfter?: number | null;
signal?: string | null;
}
interface DepositParams {
asset: string;
amount: string;
vaultAddress?: string;
bestVault?: string;
wrapEth?: boolean;
}
interface WithdrawParams {
asset: string;
amount?: string;
vaultAddress?: string;
}
interface TransactionResult {
hash: string;
wait: () => Promise<any>;
}Error Handling
All errors are instances of SurfError with a typed code and message.
import { SurfError, SurfErrorCode } from "@surf_liquid/core-sdk";
try {
await client.deposit({ asset: "0x...", amount: "10" });
} catch (err) {
if (err instanceof SurfError) {
switch (err.code) {
case SurfErrorCode.WALLET_NOT_CONNECTED: break;
case SurfErrorCode.INSUFFICIENT_BALANCE: break;
case SurfErrorCode.DEPOSIT_FAILED: break;
}
}
}| Code | Cause |
|------|-------|
| INVALID_CONFIG | Missing or invalid configuration |
| MISSING_PROJECT_ID | appId not provided |
| INVALID_APP_ID | appId rejected by the backend (verifyApp()) |
| UNSUPPORTED_CHAIN | chainId not registered for environment |
| ENVIRONMENT_CHAIN_MISMATCH | chainId doesn't belong to the configured environment |
| WALLET_NOT_INSTALLED | Wallet extension not found |
| WALLET_NOT_CONNECTED | Operation requires connected wallet |
| WALLET_REJECTED | User rejected the wallet request |
| WRONG_CHAIN | Wallet is on wrong chain |
| AUTH_FAILED | Login request failed |
| SIGNATURE_REJECTED | User rejected message signing |
| VAULT_NOT_FOUND | No vault exists for this address |
| VAULT_ALREADY_EXISTS | Vault already deployed |
| VAULT_DEPLOY_FAILED | On-chain deployment failed |
| INSUFFICIENT_BALANCE | Token balance too low |
| APPROVE_FAILED | ERC20 approval failed |
| DEPOSIT_FAILED | Deposit transaction failed |
| WITHDRAW_FAILED | Withdrawal transaction failed |
| NO_BEST_VAULT | No Morpho vault found for asset |
| API_ERROR | Backend API error |
| RPC_ERROR | RPC/blockchain call failed |
| TRANSACTION_FAILED | On-chain transaction failed |
Examples
Full runnable examples + AI integration guide: surfliquid-sdk-core-integration — a React + Vite sample app and a
SKILL.mdan AI agent can use to integrate this SDK. The snippets below are quick references.
React
import { useEffect, useState } from "react";
import { SurfClient } from "@surf_liquid/core-sdk";
const client = SurfClient.create({
projectName: "my-app",
appId: "your-app-id",
autoApprove: true,
});
export function App() {
const [address, setAddress] = useState<string | null>(null);
const [vault, setVault] = useState<string | null>(null);
useEffect(() => {
client.on("wallet:connected", (s) => setAddress(s.address));
client.on("wallet:disconnected", () => setAddress(null));
}, []);
async function setup() {
await client.connectWallet("metamask");
await client.authenticate();
const info = await client.getVault();
if (!info.exists) {
const r = await client.deployVault();
setVault(r.vaultAddress);
} else {
setVault(info.userVaultAddress);
}
}
async function deposit() {
const tx = await client.deposit({
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
amount: "10.0",
});
await tx.wait();
}
return (
<div>
<button onClick={setup}>Connect & Setup</button>
{address && <p>Wallet: {address}</p>}
{vault && <button onClick={deposit}>Deposit 10 USDC</button>}
</div>
);
}Vanilla JS
<script type="module">
import { SurfClient } from "/node_modules/@surf_liquid/core-sdk/dist/index.js";
const client = SurfClient.create({
projectName: "my-app",
appId: "your-app-id",
autoApprove: true,
});
// No wallet needed — fetch supported assets
const assets = await client.getSupportedAssets(8453);
console.table(assets.map(a => ({
symbol: a.assetSymbol,
APY: a.currentAPY + "%",
address: a.assetAddress,
})));
// Full flow
await client.connectWallet("metamask");
await client.authenticate();
const vault = await client.getVault();
if (!vault.exists) await client.deployVault();
const tx = await client.deposit({
asset: assets[0].assetAddress,
amount: "10.0",
});
await tx.wait();
</script>Node.js (read-only)
import { SurfClient } from "@surf_liquid/core-sdk";
const client = SurfClient.create({
projectName: "dashboard",
appId: "your-app-id",
});
// No wallet needed for public endpoints
const vault = await client.getVault("0xUserWalletAddress");
console.log("Vault:", vault.userVaultAddress);
console.log("Value: $" + vault.totalValueUSD);
const assets = await client.getSupportedAssets();
console.log("Supported:", assets.map(a => a.assetSymbol));
const { messages } = await client.getAgentMessages("0xUserWalletAddress");
messages.forEach(m => console.log(`[${m.executedBy}] ${m.message}`));Custom wallet adapter
import { SurfClient, IWalletAdapter } from "@surf_liquid/core-sdk";
import type { Signer } from "ethers";
class MyWalletAdapter implements IWalletAdapter {
async connect(chainId: number) { /* return WalletState */ }
async disconnect() {}
async switchChain(chainId: number) {}
async getSigner(): Promise<Signer> { /* return ethers Signer */ }
async signMessage(message: string): Promise<string> { /* return sig */ }
onAccountsChanged(cb: (accounts: string[]) => void) {}
onChainChanged(cb: (chainId: number) => void) {}
onDisconnect(cb: () => void) {}
}
const client = SurfClient.builder()
.setProject("my-app", "your-app-id")
.registerWalletAdapter("my-wallet", new MyWalletAdapter())
.build();
await client.connectWallet("my-wallet");WalletConnect
import { SurfClient, WalletConnectAdapter } from "@surf_liquid/core-sdk";
import { EthereumProvider } from "@walletconnect/ethereum-provider";
const client = SurfClient.create({ projectName: "my-app", appId: "your-app-id" });
const wcProvider = await EthereumProvider.init({
projectId: "your-walletconnect-project-id", // from cloud.walletconnect.com
chains: [8453],
showQrModal: true,
});
client.registerWalletAdapter("walletconnect", new WalletConnectAdapter(() => wcProvider));
await client.connectWallet("walletconnect");Register custom chain / token
const client = SurfClient.builder()
.setProject("my-app", "your-app-id")
.registerChain("mainnet", {
chainId: 42161,
rpcUrl: "https://arb1.arbitrum.io/rpc",
factoryAddress: "0x...",
wethAddress: "0x82af49447d8a07e3bd95bd0d56f35241523fbab1",
tokens: [],
})
.registerToken("mainnet", 42161, {
address: "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
symbol: "USDC",
decimals: 6,
vaultAddresses: [],
})
.setChain(42161)
.build();Notes
projectNameandappIdare required.projectIdis accepted as a backwards-compatible alias forappId.- Default environment is
mainnet; default mainnet chain is Base (8453). - Amounts passed to
deposit()andwithdraw()are human-readable strings (e.g."10.5"), not wei. - Withdraw amounts are encoded using the token's on-chain
decimals()value. getVault(),getSupportedAssets(), andgetAgentMessages()are public — no wallet or auth required when passing an explicit wallet address.- Set
autoApprove: trueon the client to skip manual ERC20 approval before deposits. - Auth is cookie-based. Login sets an httpOnly session cookie (
SameSite=None; Secure); the token is never in the response body, soauthState.tokenis alwaysnull. Gate onauthState.authenticated/authState.user. The SDK sends every request withcredentials: "include"(noAuthorization: Bearerheader), so the API must return a specificAccess-Control-Allow-Origin(never*) plusAccess-Control-Allow-Credentials: true, and your web origin must be allowlisted. UserefreshSession()to rotate the cookie before it expires without re-signing. - A vault can have a different address per chain.
VaultInfo.chainAddresseslists per-chain vault addresses; on some chains (e.g. Ethereum) the address differs fromuserVaultAddress(the home-chain address). The SDK resolves the correct per-chain address automatically when you omit thevaultAddressargument ondeposit/withdraw/getPortfolioSummary/getWithdrawableAmount/etc. Don't passuserVaultAddressexplicitly on a non-home chain — it points at an address with no vault contract there.
