コンテンツにスキップ
このページの翻訳は LLM によって生成されています。誤りに気づいた場合は GitHub で issue を開いてお知らせください。

Sai EVM連携ガイド

EVMの署名者とInterfaceコントラクトでSaiを操作します。JSONの wasmMsgBytes と資産の数量・アドレスを渡します。


  • Node.js ≥ 18。
  • pnpm、npm、yarnのいずれか。
Terminal window
mkdir sai-evm && cd sai-evm
pnpm init -y
pnpm add ethers bignumber.js
pnpm add -D typescript ts-node @types/node dotenv
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext

同じアプリでCosmos側も使うなら、@nibiruchain/nibijs @cosmjs/cosmwasm-stargate @cosmjs/stargate cosmjs-types を追加します。

設定を持つ .env を作成します。

Terminal window
# Testnet-2
EVM_RPC=https://evm-rpc.testnet-2.nibiru.fi/
# Or Mainnet
# EVM_RPC=https://evm-rpc.nibiru.fi/
PRIVATE_KEY=0x... # Your private key (keep this secret!)
INTERFACE_ADDR=0x... # Contract address from saiEvmInterfaceFromChainType(chainType)
src/evm.ts
import "dotenv/config"
import { ethers } from "ethers"
export function makeSigner() {
const rpc = process.env.EVM_RPC!
const pk = process.env.PRIVATE_KEY!
const provider = new ethers.JsonRpcProvider(rpc)
const signer = new ethers.Wallet(pk, provider)
return { provider, signer }
}

openTrade、executeSimpleFunctions、deposit、makeWithdrawRequest、redeem、executeVaultSimpleFunctions を公開するABIを読み込みます。

import PerpVaultEvmInterfaceAbi from "./PerpVaultEvmInterface.json" // Place your ABI file here

eth_estimateGas は無制限のCosmos SDKガスメーターで実行するため、実行時のERC-20サブ呼び出しにかかる63/64制限を反映せず、必要量より少なくなることがあります。十分な余裕と最低値を設定してください。料率はゼロなので、大きな上限に追加費用はなく、小さすぎると取引が失敗します。

const FALLBACK_GAS_LIMIT = 5_000_000n
const MIN_GAS_LIMIT = 2_500_000n
const FALLBACK_GAS_PRICE = ethers.parseUnits("1", "gwei")
const withGasBuffer = (g: bigint) => {
const padded = g <= 0n ? 0n : g * 2n
return padded > MIN_GAS_LIMIT ? padded : MIN_GAS_LIMIT
}
async function estimateGasWithFallback(
fn: () => Promise<bigint>,
info?: unknown
) {
try {
const est = await fn()
return { gasLimit: withGasBuffer(est) }
} catch (error) {
console.warn("Gas estimation failed, using fallback", info)
return { gasLimit: FALLBACK_GAS_LIMIT, gasPrice: FALLBACK_GAS_PRICE }
}
}
const toTxOverrides = ({
gasLimit,
gasPrice,
}: {
gasLimit: bigint
gasPrice?: bigint
}) => (gasPrice === undefined ? { gasLimit } : { gasLimit, gasPrice })

  • BANKはCosmos側の小数6桁。
  • ERC-20はトークンの桁数、通常6。
トークンERC-20アドレス
USDC0xAb68f1D1d91854383fd4Df9016E3040D03e8191a
stNIBI0xCae3d404AFB50016154a4B18091351065154E9bD
トークンERC-20アドレス
USDC0x0829F361A05D993d5CEb035cA6DF3446b060970b
stNIBI0xcA0a9Fb5FBF692fa12fD13c0A900EC56Bb3f0a7b

BANKとERC-20の合計前に、両方をBANKの6桁単位にそろえます。

const toBankUnits = (
amt: bigint,
sourceDecimals: number,
bankDecimals: number = 6
): bigint => {
if (sourceDecimals === bankDecimals) return amt
const diff = Math.abs(sourceDecimals - bankDecimals)
const factor = 10n ** BigInt(diff)
return sourceDecimals > bankDecimals ? amt / factor : amt * factor
}

contract.openTrade(
wasmMsgBytes, // JSON-encoded WASM message with is_evm_origin: true
collateralIndex, // Token index (e.g., 0 for first collateral)
totalAmountBankUnits, // Total amount in BANK units
useErc20Amount, // ERC-20 amount in token decimals
overrides // Gas overrides
)
import { ethers } from "ethers"
import BigNumber from "bignumber.js"
import PerpVaultEvmInterfaceAbi from "./PerpVaultEvmInterface.json"
import { makeSigner } from "./evm"
async function openTrade() {
const { signer } = makeSigner()
const iface = new ethers.Contract(
process.env.INTERFACE_ADDR!,
PerpVaultEvmInterfaceAbi.abi,
signer
)
const msg = {
open_trade: {
market_index: "MarketIndex(0)",
leverage: "5",
long: true,
collateral_index: "TokenIndex(0)",
trade_type: "trade",
open_price: "70000",
tp: undefined,
sl: undefined,
slippage_p: "0.01", // CosmWasm Decimal fraction: 0.01 = 1%
is_evm_origin: true, // IMPORTANT: Set to true for EVM origin
},
}
const bankDecimals = 6
const erc20Decimals = 6
const displayAmount = new BigNumber("100")
const bankAmount = ethers.parseUnits(displayAmount.toString(), bankDecimals)
const erc20Amount = ethers.parseUnits(displayAmount.toString(), erc20Decimals)
const toBankUnits = (
amt: bigint,
ercDec: number,
bankDec: number
): bigint => {
if (ercDec === bankDec) return amt
const diff = Math.abs(ercDec - bankDec)
const factor = 10n ** BigInt(diff)
return ercDec > bankDec ? amt / factor : amt * factor
}
const totalBank = bankAmount + toBankUnits(erc20Amount, erc20Decimals, bankDecimals)
const wasmMsgBytes = ethers.toUtf8Bytes(JSON.stringify(msg))
const gas = await estimateGasWithFallback(() =>
iface.openTrade.estimateGas(wasmMsgBytes, 0, totalBank, erc20Amount)
)
const tx = await iface.openTrade(
wasmMsgBytes,
0,
totalBank,
erc20Amount,
toTxOverrides(gas)
)
console.log("Transaction submitted:", tx.hash)
const receipt = await tx.wait()
console.log("Mined in block:", receipt?.blockNumber)
}

未決済ポジションを決済します。

async function closeTrade() {
const { signer } = makeSigner()
const iface = new ethers.Contract(
process.env.INTERFACE_ADDR!,
PerpVaultEvmInterfaceAbi.abi,
signer
)
const msg = { close_trade: { trade_index: "UserTradeIndex(0)" } }
const wasmMsgBytes = ethers.toUtf8Bytes(JSON.stringify(msg))
const gas = await estimateGasWithFallback(() =>
iface.executeSimpleFunctions.estimateGas(wasmMsgBytes)
)
const tx = await iface.executeSimpleFunctions(wasmMsgBytes, toTxOverrides(gas))
const receipt = await tx.wait()
console.log("Trade closed in block:", receipt?.blockNumber)
}

async function createReferralCode() {
const { signer } = makeSigner()
const iface = new ethers.Contract(
process.env.INTERFACE_ADDR!,
PerpVaultEvmInterfaceAbi.abi,
signer
)
const msg = { create_referrer_code: { code: "MYCODE" } }
const bytes = ethers.toUtf8Bytes(JSON.stringify(msg))
const gas = await estimateGasWithFallback(() =>
iface.executeSimpleFunctions.estimateGas(bytes)
)
const tx = await iface.executeSimpleFunctions(bytes, toTxOverrides(gas))
await tx.wait()
console.log("Referral code created")
}
async function redeemReferralCode() {
const { signer } = makeSigner()
const iface = new ethers.Contract(
process.env.INTERFACE_ADDR!,
PerpVaultEvmInterfaceAbi.abi,
signer
)
const msg = { redeem_referrer_code: { code: "PARTNER" } }
const bytes = ethers.toUtf8Bytes(JSON.stringify(msg))
const gas = await estimateGasWithFallback(() =>
iface.executeSimpleFunctions.estimateGas(bytes)
)
const tx = await iface.executeSimpleFunctions(bytes, toTxOverrides(gas))
await tx.wait()
console.log("Referral code redeemed")
}

ボールトに担保を入金します。

async function depositToVault() {
const { signer } = makeSigner()
const iface = new ethers.Contract(
process.env.INTERFACE_ADDR!,
PerpVaultEvmInterfaceAbi.abi,
signer
)
const msg = { deposit: {} }
const bytes = ethers.toUtf8Bytes(JSON.stringify(msg))
const bankDecimals = 6
const erc20Decimals = 6
const bankAmount = ethers.parseUnits("250", bankDecimals)
const erc20Amount = ethers.parseUnits("250", erc20Decimals)
const toBankUnits = (
amt: bigint,
ercDec: number,
bankDec: number
): bigint => {
if (ercDec === bankDec) return amt
const diff = Math.abs(ercDec - bankDec)
const factor = 10n ** BigInt(diff)
return ercDec > bankDec ? amt / factor : amt * factor
}
const totalBank = bankAmount + toBankUnits(erc20Amount, erc20Decimals, bankDecimals)
const vaultAddr = "<bech32 vault address>"
const collateralErc20 = "<erc20 token address>"
const gas = await estimateGasWithFallback(() =>
iface.deposit.estimateGas(
bytes,
totalBank,
erc20Amount,
vaultAddr,
collateralErc20,
true
)
)
const tx = await iface.deposit(
bytes,
totalBank,
erc20Amount,
vaultAddr,
collateralErc20,
true,
toTxOverrides(gas)
)
await tx.wait()
console.log("Deposited to vault")
}

ボールトから出金を申請します。

async function makeWithdrawRequest() {
const { signer } = makeSigner()
const iface = new ethers.Contract(
process.env.INTERFACE_ADDR!,
PerpVaultEvmInterfaceAbi.abi,
signer
)
const msg = { make_withdraw_request: {} }
const bytes = ethers.toUtf8Bytes(JSON.stringify(msg))
const sharesBank = 1_000_000n // 1.0 shares in BANK units
const sharesFromErc20 = 0n
const totalShares = sharesBank + sharesFromErc20
const vaultAddr = "<vault address>"
const gas = await estimateGasWithFallback(() =>
iface.makeWithdrawRequest.estimateGas(vaultAddr, bytes, totalShares, sharesFromErc20)
)
const tx = await iface.makeWithdrawRequest(
vaultAddr,
bytes,
totalShares,
sharesFromErc20,
toTxOverrides(gas)
)
await tx.wait()
console.log("Withdraw request created")
}

持分を償還します。

async function redeemVaultShares() {
const { signer } = makeSigner()
const iface = new ethers.Contract(
process.env.INTERFACE_ADDR!,
PerpVaultEvmInterfaceAbi.abi,
signer
)
const msg = { redeem: { shares: "500000" } } // 0.5 shares
const bytes = ethers.toUtf8Bytes(JSON.stringify(msg))
const vaultAddr = "<vault address>"
const sendToEvm = true
const gas = await estimateGasWithFallback(() =>
iface.redeem.estimateGas(bytes, vaultAddr, "500000", sendToEvm)
)
const tx = await iface.redeem(
bytes,
vaultAddr,
"500000",
sendToEvm,
toTxOverrides(gas)
)
await tx.wait()
console.log("Shares redeemed")
}

待機中の申請を取り消します。

async function cancelWithdrawRequest() {
const { signer } = makeSigner()
const iface = new ethers.Contract(
process.env.INTERFACE_ADDR!,
PerpVaultEvmInterfaceAbi.abi,
signer
)
const msg = { cancel_withdraw_request: { unlock_epoch: 123 } }
const bytes = ethers.toUtf8Bytes(JSON.stringify(msg))
const vaultAddr = "<vault address>"
const gas = await estimateGasWithFallback(() =>
iface.executeVaultSimpleFunctions.estimateGas(bytes, vaultAddr)
)
const tx = await iface.executeVaultSimpleFunctions(
bytes,
vaultAddr,
toTxOverrides(gas)
)
await tx.wait()
console.log("Withdraw request cancelled")
}

問題解決方法
”No EVM signer”makeSigner() の呼び出しと .env の PRIVATE_KEY を確認
ガス推定失敗自動的に 5,000,000 ガスと 1 gwei に切り替え
オンチェーンのリバートarchive RPCの debug_traceTransaction で最深部のエラーを確認
小数桁変換エラー加算前にBANK・ERC-20の桁を統一
無効な INTERFACE_ADDRTestnet-2かメインネットか、ネットワークに合うか確認
  • 必ずEVMの署名者を使ってください。Providerだけでは送信できません。
  • ガスの余裕を設けて不足を防ぎます。
  • 全取引メッセージの is_evm_origin: true を確認します。
  • 合計前に小数桁を変換します。
  • PRIVATE_KEY を安全に保ち、Gitにコミットしてはいけません。