Living doc tracking pain points, SDK candidates, and DX feedback as we integrate Chainflip lending. Updated every commit/milestone.
No official Lending SDK exists. CF team plans to build one (Phase 2). We're building directly against the State Chain JSON-RPC and SCALE encoding.
| Layer | Tool | Notes |
|---|---|---|
| RPC transport | Raw fetch + JSON-RPC 2.0 |
No official typed client for lending RPCs |
| SCALE encoding | scale-ts@^1.6.1 |
Zero deps, ~183 KB. Handles all pallet call encoding |
| Extrinsic types | @chainflip/extrinsics@^1.6.2 |
Types only, zero runtime deps. Useful for TS guidance |
| EIP-712 signing | Hand-rolled pipeline | cf_encode_non_native_call -> wallet signTypedData -> SCALE wrap |
| SS58 encoding | Manual (no lib) | Left-pad ETH addr 20->32 bytes, SS58 with prefix 2112 |
If CF builds a lending SDK, here's what would save us the most pain:
The biggest friction is hand-typing every RPC response. The actual shapes diverge from what you'd guess:
cf_oracle_pricesreturns{ base_asset: "Btc", quote_asset: "Usd", price_status: "UpToDate" }- not{ chain, asset }objects like other RPCs. The asset format is inconsistent across endpoints (some use{ chain, asset }objects, oracle uses short strings like"Btc","Eth").cf_safe_mode_statusesreturns deeply nested per-pallet objects with arrays of{ chain, asset }for each operation type - not the simpleRecord<string, boolean>you'd expect from the name.cf_lending_pool_supply_balancestakes an asset param (not account ID like the name suggests), returns positions grouped by asset with{ lp_id, total_amount }.cf_lending_confighas 15+ fields across LTV thresholds, fee config, swap config, and minimum amounts. Easy to miss fields without typed responses.
An SDK with a typed client (sdk.lending.pools(), sdk.lending.config(), etc.) that returns proper TypeScript types would eliminate the biggest source of bugs.
We hand-encode every extrinsic call (addLenderFunds, removeLenderFunds, addCollateral, requestLiquidityDepositAddress, etc.) by looking up pallet indices and call indices from runtime metadata.
Pain points:
- 1-indexed asset enum discriminants - CF's runtime asset enum starts at 1 (Eth=1, Flip=2, Usdc=3...), not 0. Easy to get wrong.
- Pallet/call indices are magic numbers - We hardcode
LENDING_POOLS_PALLET_INDEX = 53,LIQUIDITY_PROVIDER_PALLET_INDEX = 31, etc. These are stable across upgrades but undiscoverable without reading runtime metadata. - u128 amount encoding - Amounts are SCALE-encoded as little-endian u128 (16 bytes). Off-by-one in byte count = wrong encoding.
- Compact length prefixes for batch calls and outer extrinsic wrapping are fiddly.
- specVersion must match - The EIP-712 pipeline checks
state_getRuntimeVersion().specVersion >= 20012. An SDK could handle version negotiation.
Ideal: sdk.lending.encodeAddLenderFunds({ asset: 'BTC', amount: 100000000n }) returning the call hex.
Our current pipeline:
- SCALE-encode the call
cf_encode_non_native_call(hexCall, blocksToExpiry, nonceOrAccount, { Eth: 'Eip712' })- Strip
EIP712Domainfrom types (wallet already adds it) wallet.signTypedData(domain, types, message)- SCALE-encode the outer
environment.nonNativeSignedCallextrinsic with signature + signer author_submitExtrinsic(extrinsicHex)
This is 6 steps with 3 different encoding layers. An SDK wrapping this into sdk.lending.addLenderFunds({ asset, amount, signer }) would be huge.
ETH address -> SC account (SS58 with prefix 2112) derivation is undocumented. We reverse-engineered it from the bouncer test fixtures. An sdk.deriveAccount(ethAddress) helper would be nice.
| Package | Notes |
|---|---|
@chainflip/rpc |
Exists for swap RPCs. Doesn't cover lending yet. |
@chainflip/extrinsics |
Type defs only. No runtime encoding. |
@chainflip/sdk |
Swap SDK. No lending support. |
| Future lending SDK | CF team confirmed this is planned (Phase 2). |
If we were designing the SDK API:
// Read
sdk.lending.pools() // cf_lending_pools
sdk.lending.config() // cf_lending_config
sdk.lending.supplyBalances(asset) // cf_lending_pool_supply_balances
sdk.lending.loanAccounts(accountId) // cf_loan_accounts
sdk.lending.oraclePrices() // cf_oracle_prices
sdk.lending.safeModeStatuses() // cf_safe_mode_statuses
// Account
sdk.account.fromEthAddress(ethAddr) // SS58 derivation
sdk.account.info(accountId) // cf_account_info_v2
sdk.account.freeBalances(accountId) // cf_free_balances
// Write (encode + sign + submit in one call)
sdk.lending.addLenderFunds({ wallet, asset, amount })
sdk.lending.removeLenderFunds({ wallet, asset, amount? })
sdk.lending.addCollateral({ wallet, loanId?, collateral[] })
sdk.lending.requestLoan({ wallet, loanAsset, collateralAsset, amount })
// LP operations
sdk.lp.requestDepositAddress({ wallet, asset, boostFee? })
sdk.lp.registerAccount({ wallet })
sdk.lp.registerRefundAddress({ wallet, chain, address })
sdk.lp.withdrawAsset({ wallet, asset, amount, destinationAddress })- Is there a stable way to discover pallet/call indices at runtime (other than parsing full metadata)?
- Will lending RPCs be added to
@chainflip/rpc? - Is the
base_asset: "Btc"format in oracle prices intentional, or will it align with{ chain, asset }used elsewhere? - What's the timeline for a lending SDK?
- Will deposit channel events be surfable via RPC subscription, or do we need to poll?
Integrating the read-side (pool data, balances, rates, minimums) for the lending UI surfaced a fresh batch of DX pain points.
- Nearly all numeric values from RPC responses come as hex strings (
0x...): balances, amounts, thresholds, rates, minimums. - Every value needs manual
BigInt(hexString).toString()conversion, then precision division for human-readable amounts. - Different assets have different precisions (BTC=8, ETH=18, USDC/USDT=6, FLIP=18, DOT=10, SOL=9) and nothing in the RPC response tells you the precision. You just have to know.
- SDK ask: Return human-readable decimal strings, or at minimum include precision metadata alongside values.
cf_environmentis a massive kitchen-sink response (contains everything from ingress/egress config to pool configs to governance params).- Minimum deposit amounts per asset live at
result.ingress_egress.minimum_deposit_amounts[chain][asset]- deeply nested, hex-encoded base units. - You have to cross-reference the asset's precision to convert these to human-readable amounts.
- No dedicated endpoint like
cf_minimum_deposit_amount(asset)exists. - SDK ask:
sdk.lending.getMinimumDepositAmount(asset)returning a human-readable string.
cf_oracle_pricesreturns{ base_asset: "Btc", quote_asset: "Usd", price_status: "UpToDate" }using plain string identifiers.- Every other RPC endpoint uses structured
{ chain: "Bitcoin", asset: "BTC" }objects for asset identification. - Mapping between the two formats is manual and brittle - if CF adds a new asset, the mapping breaks silently.
- SDK ask: Consistent asset identifiers across all endpoints, or at minimum a canonical mapping function.
current_interest_ratefields use Substrate's Perbill (parts per billion, divide by 1e9) or Permill (parts per million, divide by 1e6).- Nothing in the response or docs tells you which scale a given rate uses. Had to figure it out by looking at live values and sanity-checking the resulting percentages.
- Some rates are per-block, others annualized - also undocumented.
- SDK ask: Normalize all rates to percentage or decimal. Include
rateType: "annual" | "perBlock"metadata.
minimum_supply_amount_usdis a hex string representing USD with 6 decimal precision (same as USDC).- Nothing in the response indicates the precision or denomination. You only discover the "6 decimals" part by checking live values against pool UIs.
- SDK ask: Return decoded USD amounts as decimal strings, or document the precision.
cf_free_balancesreturns per-asset balances as hex strings with{ chain, asset }identifiers (e.g.,{ chain: "Ethereum", asset: "USDC" }).- Mapping these to external asset standards (CAIP-19, ShapeShift AssetIds) requires a hand-built mapping table.
- SDK ask: Support CAIP-19 identifiers, or at minimum expose a
cfAssetToChainId(cfAsset)mapping.
- Opening a deposit channel for lending requires
requestLiquidityDepositAddressvia SCALE encoding + EIP-712 signing - the full 6-step pipeline. - There's no simple REST or dedicated RPC endpoint. BaaS (Broker as a Service) doesn't expose lending deposit channel endpoints yet.
- For the PoC we're building the full encode/sign/submit pipeline ourselves.
- SDK ask:
sdk.lending.openDepositChannel({ asset, boostFee? })that handles the full pipeline.
- Converting an ETH address to a State Chain account ID requires: left-pad 20 bytes to 32 bytes, blake2b hash for checksum, SS58 encode with network prefix 2112, base58check encode.
- This is completely undocumented. We reverse-engineered it from bouncer test fixtures and the Substrate SS58 spec.
- SDK ask:
sdk.account.fromEthAddress(ethAddress)returning the SS58-encoded account ID.
| Layer | Tool | Notes |
|---|---|---|
| RPC transport | Raw fetch + JSON-RPC 2.0 |
Same as PR1 |
| SCALE encoding | scale-ts@^1.6.1 |
Same as PR1 |
| Extrinsic types | @chainflip/extrinsics@^1.6.2 |
Same as PR1 |
| EIP-712 signing | Hand-rolled pipeline | Same as PR1 |
| SS58 encoding | Manual (blake2b + bs58) | Same as PR1 |
| Hex decoding | BigInt(hex).toString() |
Every numeric field. Tedious. |
| Asset mapping | Hand-built CF_ASSET_TO_SS_ASSET_ID map |
Maps CF {chain,asset} to ShapeShift AssetIds |
| Precision lookup | Per-asset constant map | BTC=8, ETH=18, USDC=6, etc. |
// New: minimum amounts
sdk.lending.getMinimumDepositAmount(asset) // -> "0.0001" (human-readable)
sdk.lending.getMinimumSupplyAmountUsd() // -> "100.00" (decoded USD)
// New: normalized rates
sdk.lending.getInterestRate(asset) // -> { annual: 0.0523, perBlock: 0.0000000827 }
sdk.lending.getCollateralizationRatio(asset) // -> { current: 1.5, liquidation: 1.1 }
// New: asset mapping
sdk.assets.toCaip19(cfAsset) // { chain: "Ethereum", asset: "USDC" } -> "eip155:1/erc20:0xa0b8..."
sdk.assets.fromCaip19(caip19Id) // reverse mapping
sdk.assets.precision(cfAsset) // -> 6 (for USDC)- What precision does
minimum_supply_amount_usduse? (We're assuming 6 based on live values, but this isn't documented.) - Are interest rates in Perbill or Permill? Is
current_interest_rateper-block or annualized? - Will BaaS expose
requestLiquidityDepositAddressfor lending operations? - Is there a plan to add precision metadata to RPC responses alongside hex-encoded amounts?
Implementing the full first-time + returning user deposit flow surfaced the most significant pain points yet. The read path was tedious but workable; the write path is where things get genuinely painful.
- For accounts that don't exist on State Chain,
cf_free_balancesreturns{}(empty object) instead of[](empty array). - This causes
.find()/.map()crashes at runtime. We had to addArray.isArray()guards in every consumer. - Our
cfFreeBalancesRPC wrapper normalizes the response viaObject.entries().flatMap(), but if the raw response is{}, that produces[]correctly. However, the actual crash was the raw response being an object with a different shape than expected for new accounts. - SDK ask: Always return
[]for accounts with no balances, never{}ornull.
- After submitting
requestLiquidityDepositAddressvia EIP-712, there's no way to get YOUR channel back. The only option iscf_all_open_deposit_channelswhich returns ALL open channels for ALL accounts on the entire network. - We poll this every 6s up to 30 times (3 min timeout), filtering for our account ID each time.
- The response is massive and the address data is deeply nested:
[accountId, channelId, { chain_accounts: [[encodedAddress, asset], ...] }]. - Address encoding varies by chain: ETH/Arb addresses come as byte arrays needing
0xhex prefix, BTC as variable-length byte arrays needing string decode, SOL as 32-byte arrays. - SDK ask:
cf_open_deposit_channels(accountId)endpoint that returns only channels for a specific account. Or better:requestLiquidityDepositAddressshould return the deposit address directly in the response instead of requiring polling.
- After sending funds to the deposit channel, we poll
cf_free_balancesevery 6s comparing against the initial balance snapshot to detect when funds land. - There's no websocket subscription, no event stream, no webhook. Pure polling.
- For a good UX this means we snapshot the initial free balance BEFORE starting the flow, then poll until
current > initial. - SDK ask:
sdk.waitForDeposit({ accountId, asset, minAmount })that handles the polling internally, or a subscription mechanism.
- First-time users need 3 sequential EIP-712 submissions:
registerLpAccount->registerLiquidityRefundAddress->requestLiquidityDepositAddress. - Each needs an incrementing nonce. The first call uses the SS58 account ID (string) as the
nonceOrAccountparam, which auto-assigns nonce 0. Subsequent calls must passlastNonce + 1(number). - If you query
cf_account_infofor the nonce between calls, it may not have updated yet (extrinsic still being processed). So we tracklastUsedNonceclient-side and increment manually. - The dual
nonceOrAccountparameter (string = account lookup, number = explicit nonce) is confusing and undocumented. - SDK ask: SDK should handle nonce management internally.
sdk.lp.registerAccount()->sdk.lp.registerRefundAddress(...)->sdk.lp.requestDepositAddress(...)should just work in sequence without the caller worrying about nonces.
- The RPC returns full EIP-712 typed data including the
EIP712Domaintype definition. - But wallets (MetaMask, WalletConnect) auto-add
EIP712Domainto the types. If you pass it through, the wallet sees a duplicate type and may reject or produce wrong signatures. - We have to strip
EIP712Domainfrom the types object before callingsignTypedData. - This gotcha cost hours of debugging. Zero documentation about it.
- SDK ask: Either don't include
EIP712Domainin the response (since wallets always add it), or document this clearly.
fundStateChainAccount(bytes32 nodeID, uint256 amount)on the Chainflip Gateway contract is how you create a State Chain account.- The
nodeIDis the ETH address left-padded to 32 bytes. This is not documented anywhere. - The Gateway ABI isn't published in any npm package. We inline the relevant function ABI.
- The Gateway contract address is hardcoded (not discoverable via RPC).
- Prior to funding, you also need to ERC-20 approve FLIP for the Gateway contract (standard, but still 2 transactions for what's conceptually "create account").
- SDK ask:
sdk.account.fund({ ethAddress, amount })that handles the approval + funding. Or expose the Gateway ABI + address via the SDK.
- To determine account state (funded? registered as LP? has refund address?), you call
cf_account_infowhich returns an object even for non-existent accounts. - You then check:
flip_balance > 0for funded,role === 'liquidity_provider'for registered,refund_addressesobject for per-chain refund addresses. refund_addressesisRecord<chain, address | null>- you checkObject.values(refundAddresses).some(addr => addr !== null).flip_balanceis a hex string.roleis a string enum. All different formats.- SDK ask:
sdk.account.status(ethAddress)returning{ exists: boolean, isFunded: boolean, isLpRegistered: boolean, refundAddresses: Record<chain, string> }.
- To batch multiple calls (e.g., register + set refund + open channel), you:
- SCALE-encode each individual call
- SCALE-encode the
Environment.batchwrapping those calls - Pass the batch hex to
cf_encode_non_native_callfor EIP-712 - Sign the EIP-712 data
- SCALE-encode the
Environment.nonNativeSignedCallouter extrinsic - Submit via
author_submitExtrinsic
- That's 3 layers of SCALE encoding + 1 EIP-712 sign for a single user action.
- Batch is limited to 10 calls (we enforce this client-side).
- SDK ask:
sdk.batch([sdk.lp.registerAccount(), sdk.lp.registerRefundAddress(...)])that handles all encoding layers.
encodeRegisterLiquidityRefundAddresstakes a{ chain, address }where the address must be SCALE-encoded differently per chain:- Ethereum/Arbitrum: 20 bytes (strip
0x, decode hex) - Bitcoin: variable-length bytes (script encoding)
- Solana: 32 bytes (base58 decode)
- Polkadot/Assethub: 32 bytes (SS58 decode)
- Ethereum/Arbitrum: 20 bytes (strip
- Each chain has a different SCALE tag (
Eth,Btc,Sol,Arb,Dot,Hub) that doesn't match theChainflipChaintype (Ethereum,Bitcoin,Solana, etc.). - SDK ask: Accept standard address strings and handle encoding internally.
// Account lifecycle
sdk.account.fund({ ethAddress }) // handles FLIP approve + Gateway funding
sdk.account.status(ethAddress) // { exists, isFunded, isLpRegistered, refundAddresses }
sdk.account.register({ wallet }) // registerLpAccount via EIP-712
sdk.account.setRefundAddress({ wallet, chain, address }) // handles per-chain address encoding
// Deposit (the big one)
sdk.deposit({
wallet,
asset: 'USDC',
amount: '1000000000', // base units
onStep: (step) => void, // callback for UI progress
onTxHash: (hash) => void, // callback for explorer links
})
// internally: check account state -> fund if needed -> register if needed ->
// set refund address if needed -> open channel -> wait for address ->
// send deposit -> poll for confirmation
// returns: Promise<{ txHash, depositAddress, finalBalance }>
// Or granular control:
const channel = await sdk.lp.requestDepositAddress({ wallet, asset: 'USDC' })
// channel.address is already decoded and ready to use
const txHash = await sdk.deposit.send({ wallet, to: channel.address, asset: 'USDC', amount })
await sdk.deposit.waitForConfirmation({ accountId, asset: 'USDC', minBalance })- Can
cf_all_open_deposit_channelsbe filtered by account ID? If not, is there a per-account endpoint planned? - Is there a plan for websocket subscriptions for balance changes / deposit confirmations?
- The
nonceOrAccountdual-type parameter incf_encode_non_native_call- is this documented anywhere? What's the recommended pattern for sequential calls? - Should the EIP-712 response from
cf_encode_non_native_callinclude or excludeEIP712Domain? Current behavior (including it) conflicts with wallet implementations. - Is the Gateway contract ABI published anywhere? Will it be part of the SDK?
- Is batch limited to 10 calls in the runtime, or is that a client-side convention?