@blend-money/sdk
v1.1.1
Published
Blend SDK for B2B neobank integrations.
Readme
Blend SDK
Type-safe SDK for interacting with the Blend protocol. Built with TypeScript and designed for precision in financial calculations.
What's inside?
The SDK provides:
- Integration API client: Type-safe access to Blend's B2B API (
/extern/:neobankId/:accountTypeId/*) - On-chain actions: Withdrawals and rebalances from Morpho ERC-4626 vaults via Safe wallets
- Transaction orchestration: Safe multisend and ERC-4337 UserOperation submission
- Precise math: Uses Decimal.js for exact financial calculations
- Isomorphic: Works natively in both browser and Node.js environments
Design Principles
- TypeScript-first: 100% TypeScript with strict type checking
- Decimal.js integration: Precise financial calculations without floating-point errors
- Modular design: Clean separation of concerns with focused modules
- Production-ready: Comprehensive error handling, retry logic, and validation
Quick Start
# Install dependencies
pnpm install
# Build
pnpm build
# Run development mode
pnpm devDevelopment
Prerequisites
- Node.js 20+
- pnpm (recommended package manager)
Available Scripts
pnpm build- Build the packagepnpm dev- Start development mode with watchpnpm test- Run testspnpm test:ui- Run tests in UI modepnpm test:coverage- Run tests with coveragepnpm test:run- Run tests once in CI modepnpm format- Format with Prettierpnpm check-types- Type-check the package
Features
- TypeScript: Full type safety across the SDK
- Decimal.js: Precise decimal arithmetic for financial calculations
- Axios: Robust HTTP client with retry logic and error handling
- ESM: Modern ES modules for better tree-shaking
- Isomorphic: Works in both browser and Node.js environments
Usage
Installation
pnpm add @blend-money/sdkDependencies: axios, decimal.js, permissionless, viem, zod
Basic Usage
import { BlendClient } from "@blend-money/sdk";
import { http } from "viem";
const client = new BlendClient({
baseUrl: "https://api.portal.blend.money",
apiKey: "blend_xxx",
neobankId: "acme-bank",
accountTypeId: "uuid-xxx",
transports: {
8453: http("https://base-mainnet.g.alchemy.com/v2/MY_KEY"),
},
paymasterTransport: http("https://api.pimlico.io/v2/8453/rpc?apikey=MY_KEY"),
});
// Get yield information
const yieldData = await client.yield.get();
// Get or create a Safe account for an EOA
const account = await client.safe.account(userEoa);
const balance = await client.balance.get(account.accountId);Testing
- Vitest with Node environment and MSW for HTTP mocking
- Coverage provider: v8; global thresholds are 100%
Technical Reference
Overview
The Blend SDK (@blend-money/sdk) is a B2B neobank integration SDK for the Blend protocol. It provides:
- Integration API client : Type-safe access to Blend's B2B API (
/extern/:neobankId/:accountTypeId/*) - On-chain actions : Withdrawals and rebalances from Morpho ERC-4626 vaults via Safe wallets
- Transaction orchestration : Safe multisend and ERC-4337 UserOperation submission
Principles
- Per-user parameters : EOA and Safe addresses are passed as method arguments, never stored in config
- Single config instance : Neobanks create one
BlendClientwith credentials and RPC transports - Isomorphic : Works in Node.js and browser environments
Architecture
flowchart LR
Neobank(["Neobank App"])
subgraph sdk ["Blend SDK"]
direction TB
BC["BlendClient"]
IM["Integration API"]
AM["On-chain Actions"]
BC --> IM
BC --> AM
end
subgraph withdraw ["On-chain"]
Safe["Safe Wallet (ERC-4337)"]
Vault["ERC-4626 Vault"]
Safe --> Vault
end
Neobank --> BC
IM -->|"REST + X-API-Key"| BlendAPI(["Blend API"])
AM -->|"calldata via Blend API"| SafeModule Layout
| Path | Purpose |
| -------------------------------------- | ------------------------------------------------------------ |
| src/blend-client.ts | Main client, wires all domain modules |
| src/modules/ | One class per domain: safe, balance, deposit, withdraw, etc. |
| src/utils/integration-http-client.ts | Axios client with retry and auth |
| src/utils/safe.ts | Safe multisend via ERC-4337 / Pimlico |
| src/utils/transaction.ts | TransactionHandler for submitting action plans |
| src/utils/chain.ts | Chain utilities (waitForNextBlock) |
Configuration
BlendClientConfig is passed to BlendClient once per neobank:
| Field | Type | Required | Description |
| -------------------- | --------------------------- | -------- | ----------------------------------------------------------------- |
| baseUrl | string | No | Blend portal API base (default: https://api.portal.blend.money) |
| apiKey | string | Yes | Sent as X-API-Key header |
| neobankId | string | Yes | Path segment: /extern/:neobankId/ |
| accountTypeId | string | Yes | Path segment: /extern/:neobankId/:accountTypeId/ |
| transports | Record<number, Transport> | Yes | Viem transports keyed by chain ID |
| paymasterTransport | Transport | Yes | Pimlico or similar bundler transport |
| timeoutMs | number | No | Request timeout (default: 15000) |
| retries | number | No | Max retries for retryable errors (default: 3) |
All HTTP requests to the integration API use:
baseUrl/extern/{neobankId}/{accountTypeId}/<route>Integration API
The SDK exposes B2B endpoints under /extern/:neobankId/:accountTypeId/* as flat namespaces on BlendClient.
Safe Operations
| Method | Description |
| --------------------------------------- | --------------------------------------------------------------------------------------- |
| client.safe.account(address) | Get or create account for an EOA. Returns accountId, safeAddress, chainsDeployed. |
| client.safe.resolve(address, chainId) | Resolve and validate a Safe for an EOA on a given chain |
| client.safe.request(address, chainId) | Request creation of a new Safe wallet. Returns { message: string } |
The account method is usually the first call: its accountId is needed for balance, positions, and returns.
Balance
| Method | Description |
| ----------------------------------------------- | ------------------------------------------------------------------ |
| client.balance.get(accountId) | Aggregate balance per chain and total USD |
| client.balance.getHistory(accountId, params?) | Historical balance snapshots. params: { startDate?, endDate? } |
Positions, Returns, Yield
| Method | Description |
| --------------------------------- | ------------------------------------------------------------------------- |
| client.positions.get(accountId) | Position events (deposits, withdrawals, rebalances) |
| client.returns.get(accountId) | Returns summary (deposited, withdrawn, net, returns USD/%) |
| client.yield.get() | Yield breakdown by configured vault strategy for the current account type |
Rebalance API
| Method | Description |
| ---------------------------------------------- | ------------------------------------- |
| client.rebalance.getCandidates() | Accounts with rebalance opportunities |
| client.rebalance.createRequest(requestData?) | Create a rebalance request |
| client.rebalance.getRequest(id) | Fetch rebalance request by ID |
Deposit
| Method | Description |
| ---------------------------------------------- | -------------------------------------------------- |
| client.deposit.getTokens(chainId?, address?) | Token catalog. Returns { chains, tokens } |
| client.deposit.getBalances(eoa, chainId) | Non-zero ERC-20 balances for an EOA on a chain |
| client.deposit.getQuote(params) | Quote for deposit. Requires accountId in params. |
Withdraw : Server-Built Calldata
The withdraw module requests pre-built calldata from the server. The server selects
source chains, encodes all Safe transactions, and returns an ordered payload set for
the neobank to submit.
const result = await client.withdraw.getCalldata({
address: userEoa,
destinationChainId: 8453,
amount: "1000000000000000000",
// isMaxWithdraw: true // redeems all shares on every chain
});
for (const payload of result.payloads) {
if (payload.liquidityReset) {
// submit as delegatecall: payload.liquidityReset.target / .data
}
for (const txn of payload.withdraw) {
// submit in order: txn.target / txn.data
}
if (payload.bridge) {
// execute Relay bridge quote to move funds to result.destinationChainId
}
}Returns 409 if a rebalance flow plan is already active for the account.
Transaction Execution
TransactionHandler
import { TransactionHandler } from "@blend-money/sdk";
const handler = new TransactionHandler(paymasterTransport);- Purpose: Executes action plans with a Safe and RPC clients
- Dependencies: Needs
paymasterTransport(e.g. Pimlico) for UserOperations
submitActionPlan
const { receipts, approvals } = await handler.submitActionPlan(
signer,
client,
safeAddress,
actionPlans,
options?
);- signer: Viem
WalletClient(signs UserOperations) - client: Viem
PublicClient(same chain as action plans) - safeAddress: Target Safe
- actionPlans: Array of
ActionPlanor() => Promise<ActionPlan>(lazy) - options.isContractSigner: Set
truewhen signer is a contract (e.g. Coinbase Smart Wallet) to use EIP-1271 signatures
Behavior:
- All action plans must use the same chain as
client.chain.id - For each plan:
- If
deployType === "multisend": sends a UserOperation viaSafeMultisendManager.submitMultisend() - Otherwise: sends approvals and main transactions directly with
signer.sendTransaction()
- If
- Between plans, waits for the next block via
waitForNextBlock()
submitEnableModuleTransaction
await handler.submitEnableModuleTransaction(
chain,
signer,
client,
safeAddress,
moduleAddress,
);Submits a UserOperation that calls enableModule on the Safe to enable a module.
SafeMultisendManager
Located in src/utils/safe.ts. Uses ERC-4337 (EntryPoint 0.7) and Pimlico:
- Safe 1.4.1 : Uses
SafeSmartAccountfrompermissionless - MultiSend : Batches calls via
0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526 - Contract signer support : EIP-712
SafeOpwith EIP-1271 contract signatures for smart wallet signers
Action Plans
type ActionPlan = {
deployType: "direct" | "multisend";
requiredApprovals: Txn[];
requiredTxns: Txn[];
chainId: ChainId;
};
type Txn = {
to: Hex;
data: Hex;
value: bigint;
chainId: ChainId;
account: Hex;
isDelegateCall?: boolean;
};- deployType:
"multisend"→ execute via Safe multisend;"direct"→ send each transaction separately - requiredApprovals: ERC-20 approvals or other prerequisite txns
- requiredTxns: Main execution transactions
- chainId: Chain where all transactions execute
combineActionPlans
import { combineActionPlans } from "@blend-money/sdk";
const combined = combineActionPlans([plan1, plan2, plan3]);Merges multiple plans on the same chain. All inputs must share the same chainId. The result uses deployType: "multisend" if any input does.
Type System
Core Types
| Type | Description |
| ------------------- | ---------------------------------------- |
| Hex | 0x${string} for addresses and calldata |
| ChainId | number |
| BlendClientConfig | Client configuration |
| Txn | Single transaction payload |
| ActionPlan | Plan of approvals and transactions |
Domain Types
| Type | Description |
| ----------------- | ----------------------------------------------- |
| Token | address, symbol, decimals, chainId |
| VaultConfig | vaultId, chainId, token, name, symbol |
| TokenAmount | value (bigint), decimals |
| AmountWithToken | amount + token |
Integration Types
Defined in src/types/integration.ts and aligned with API responses, e.g. SafeAccountResponse, BalanceResponse, PositionsResponse, ReturnsResponse, YieldResponse, ChainYieldBreakdown, YieldBreakdownSummary, CandidatesResult, RebalanceCandidate, RebalanceRequest, TokenCatalog, TokenCatalogChain, DepositToken, DepositBalance, DepositQuoteParams, and DepositQuoteResponse.
Error Handling
import { SdkError } from "@blend-money/sdk";SdkError properties: message, status, code, response.
Static constructors:
SdkError.notImplemented(feature)SdkError.networkError(message, originalError?)SdkError.validationError(message, field?)SdkError.rateLimited(retryAfter?)SdkError.notFoundError(message)SdkError.serverError(message)SdkError.timeout(timeoutMs)SdkError.fromAxiosError(error): maps HTTP errors toSdkError
Instance methods:
isRetryable(): true for 429, 5xx, or network failuresgetUserMessage(): human-readable messagetoString(): structured string representation
HTTP Client & Retry
createIntegrationHttpClient():
- Uses
baseUrl/extern/{neobankId}/{accountTypeId} - Adds
X-API-Keyheader - Retries on 429, 500, 502, 503, 504 with exponential backoff
- Configurable
timeoutMsandretries - Maps errors via
SdkError.fromAxiosError()
Utilities
| Export | Purpose |
| -------------------- | ------------------------------------ |
| createHttpClient | Generic HTTP client factory |
| combineActionPlans | Merge action plans on the same chain |
| SdkError | Error type and factory methods |
Validation (Zod-based): validateAddress, validateChainId, validateAmount, validateToken, validateTokenAmount, validateVaultConfig.
Token helpers: areTokensEqual, isNativeToken, validateToken, isValidToken, validateTokenAmount, isValidTokenAmount.
Usage Examples
1. Get or Create Account and Balance
const account = await client.safe.account(userEoa);
const balance = await client.balance.get(account.accountId);2. Withdraw Flow
const result = await client.withdraw.getCalldata({
address: userEoa,
destinationChainId: 8453,
amount: "1000000000000000000",
});
for (const payload of result.payloads) {
if (payload.liquidityReset) {
// submit liquidityReset as delegatecall via your relay
}
for (const txn of payload.withdraw) {
// submit each txn in order via your relay
}
if (payload.bridge) {
// execute Relay bridge quote
}
}3. Deposit Quote
const account = await client.safe.account(userEoa);
const quote = await client.deposit.getQuote({
chainId: 8453,
inputAssetAddress: usdcAddress,
eoa: userEoa,
accountId: account.accountId,
amount: "1000000",
});
// Pass quote to your bridge execution UI4. Trigger Rebalance (async, scheduler-driven)
const request = await client.rebalance.createRequest();
// Poll status:
const status = await client.rebalance.getRequest(request.requestId);Integrations Enum
import { Integrations } from "@blend-money/sdk";
// Integrations.MESA_CAPITAL
// Integrations.USX_MESA_CAPITALUsed to identify available strategy platforms when resolving vault strategies.
Quick Reference
| Task | Method / Export |
| --------------------- | -------------------------------------------------------------- |
| Get account for EOA | client.safe.account(address) |
| Get balance | client.balance.get(accountId) |
| Deposit quote | client.deposit.getQuote(params) |
| Withdraw calldata | client.withdraw.getCalldata(params) |
| Trigger rebalance | client.rebalance.createRequest() |
| Submit action plans | handler.submitActionPlan(signer, client, safeAddress, plans) |
| Enable module on Safe | handler.submitEnableModuleTransaction(...) |
| Combine plans | combineActionPlans(plans) |
| Map HTTP errors | SdkError.fromAxiosError(error) |
Contributing
- Fork the repository
- Create a feature branch:
git checkout -b feature/amazing-feature - Make your changes and add tests
- Run the test suite:
pnpm test - Commit your changes:
git commit -m 'Add amazing feature' - Push to the branch:
git push origin feature/amazing-feature - Open a Pull Request
License
MIT License - see LICENSE file for details.
