> ## Documentation Index
> Fetch the complete documentation index at: https://docs.superearn.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 고정이율 볼트 연동하기

> 네트워크별 컨트랙트 주소를 설정하고 VedaRouter 입금, AtomicQueue 출금 요청, 고정이율 환율 조회를 연동하는 방법을 안내합니다.

**VedaVault**는 Veda의 BoringVault를 기반으로 하는 SuperEarn 고정 APR 볼트 스택입니다. 이 페이지의 `VAULT`는 **BoringVault 주소이자 ERC-20 share 토큰**입니다. 사용자는 **VedaRouter에 입금**하고 **AtomicQueue에 직접 출금 요청**을 보냅니다. BoringVault 자체에 ERC-4626 `deposit/redeem/previewRedeem`을 호출하는 방식이 아닙니다.

입금·출금 요청·환율 조회 흐름은 네트워크에 관계없이 동일합니다. 아래에서 연동할 네트워크와 해당 컨트랙트 주소를 선택하세요. `ASSET`은 해당 배포의 기초 자산 토큰이며, Kairos에서는 mockUSDT를 사용합니다.

구조와 APR 반영 원리는 [고정이율 볼트 구조와 환율 갱신](/ko/protocol/fixed-rate-vault.md)을 먼저 참고하세요. 기본 정기 환율 갱신의 운영 목표는 **USDT(6 decimals) 24시간마다, JPYC(18 decimals) 1시간마다**입니다. 이 페이지는 실제 연동 호출을 설명하며, 자동 keeper가 없는 Kairos에서는 갱신을 팀과 별도로 조율합니다.

## 1. 네트워크와 컨트랙트 주소

메인넷 컨트랙트 주소는 정식 배포 후 추가할 예정이며, 현재 메인넷 주소 칸은 비워 두었습니다.

| 네트워크 | 용도 |
| - | - |
| Kaia 메인넷 | 실제 서비스 연동; 주소 추후 공개 |
| Kaia Kairos (chain ID 1001) | mockUSDT로 연동 테스트; [Kairos에서 테스트하기](#7-kairos에서-테스트하기) 참고 |

선택한 네트워크의 주소를 사용하세요. Router·Keeper·Gateway·CustomVault·Controller는 Transparent proxy이며, **표의 proxy 주소로 연동합니다**.

| 컨트랙트 | Kaia 메인넷 주소 | Kairos 테스트넷 주소 |
| - | - | - |
| 기초 자산 토큰 (`ASSET`; Kairos에서는 mockUSDT) | | [`0xFD9a576384e9872fb6f7b3ed24c9be915E809C78`](https://kairos.kaiascan.io/address/0xFD9a576384e9872fb6f7b3ed24c9be915E809C78) |
| VedaRouter — 사용자 입금 proxy | | [`0xCF5A814F8D5fa5E51C7D25d00Af1fa930EDc51Bf`](https://kairos.kaiascan.io/address/0xCF5A814F8D5fa5E51C7D25d00Af1fa930EDc51Bf) |
| BoringVault — VedaVault share 토큰, 18 decimals | | [`0xada4481a03c26d378554e0F24247DaFc8Afa542F`](https://kairos.kaiascan.io/address/0xada4481a03c26d378554e0F24247DaFc8Afa542F) |
| Accountant — 확정 환율 | | [`0xc9de16B4E7Ab99c5466c3bA4F958C113faF20eef`](https://kairos.kaiascan.io/address/0xc9de16B4E7Ab99c5466c3bA4F958C113faF20eef) |
| AtomicQueue — 사용자 출금 요청 | | [`0x1Cb256edad979cfc25622b35fD54d848Cdce8DD5`](https://kairos.kaiascan.io/address/0x1Cb256edad979cfc25622b35fD54d848Cdce8DD5) |
| FixedAprRateController — APR·예상 환율 | | [`0xA3914f4685838359b2a8403E8acea85f2cc4cB97`](https://kairos.kaiascan.io/address/0xA3914f4685838359b2a8403E8acea85f2cc4cB97) |
| VedaKeeper — keeper 운용 proxy | | [`0xC819088522bedD6f4AC78bE42B98B384F5c5795b`](https://kairos.kaiascan.io/address/0xC819088522bedD6f4AC78bE42B98B384F5c5795b) |
| Teller — 내부 발행·상환 | | [`0xEbD6156c992000D78035663995d3e3896B29d2c3`](https://kairos.kaiascan.io/address/0xEbD6156c992000D78035663995d3e3896B29d2c3) |
| VedaCustomVaultGateway proxy | | [`0xcd6C5b0c39039990f3F9341037c4a8Ad14a85827`](https://kairos.kaiascan.io/address/0xcd6C5b0c39039990f3F9341037c4a8Ad14a85827) |
| CustomVault proxy | | [`0x89837c5e0715551c31e7a52d75aC6465645Ea5c3`](https://kairos.kaiascan.io/address/0x89837c5e0715551c31e7a52d75aC6465645Ea5c3) |
| 출금 요청 transfer lock hook | | [`0xf97B8B74F4293a460246a23bD7A6fB6B7a7806e3`](https://kairos.kaiascan.io/address/0xf97B8B74F4293a460246a23bD7A6fB6B7a7806e3) |
| AtomicSolverV4 | | [`0xFe1C618980A58dD2fDA4d8E77A0D6C916B03BCf6`](https://kairos.kaiascan.io/address/0xFe1C618980A58dD2fDA4d8E77A0D6C916B03BCf6) |
| ManagerWithMerkleVerification | | [`0x59fF09424EC310eb614abC063a43E97949355F7e`](https://kairos.kaiascan.io/address/0x59fF09424EC310eb614abC063a43E97949355F7e) |
| BaseDecoderAndSanitizer | | [`0xd5e955EBFDbBb2EB2D547dCC97152687b9BEB6B5`](https://kairos.kaiascan.io/address/0xd5e955EBFDbBb2EB2D547dCC97152687b9BEB6B5) |
| RolesAuthority | | [`0x2734eE3104fA6CcdaAaABA68e4E33D3EE38B08EC`](https://kairos.kaiascan.io/address/0x2734eE3104fA6CcdaAaABA68e4E33D3EE38B08EC) |
| ProxyAdmin | | [`0xC29491B25823a44467a9bcA422B56a789eF86b7E`](https://kairos.kaiascan.io/address/0xC29491B25823a44467a9bcA422B56a789eF86b7E) |

입금된 자산은 BoringVault에 보관됩니다. keeper는 Gateway를 통해 CustomVault로 자산을 이동하거나 출금 유동성을 회수할 수 있습니다. Gateway는 CustomVault shares를 보유하고 입금 중 기초 자산을 일시 보유하며, 정상 CustomVault 출금은 BoringVault로 직접 지급됩니다.

## 2. 연동 경로와 단위

| 동작 | 사용자 승인 대상 | 사용자 호출 | 결과 |
| - | - | - | - |
| 입금 | **VedaRouter에 ASSET 승인** | `Router.deposit(assets, minimumShares, receiver)` | receiver에게 BoringVault shares 발행 |
| 출금 요청 | **AtomicQueue에 BoringVault shares 승인** | `Queue.safeUpdateAtomicRequest(VAULT, ASSET, request, ACCOUNTANT, 0)` | 요청 저장, 자산 이동 없음 |
| 출금 체결 | 추가 사용자 승인 없음 | **keeper**가 VedaKeeper로 체결 | 요청자에게 ASSET 지급, 별도 claim 없음 |
| 취소 | 없음 | 동일 pair의 `offerAmount = 0` 요청 등록 | 요청 해제, 이후 Queue allowance도 0으로 정리 가능 |

Router가 내부적으로 BoringVault에 승인하고 Teller의 `bulkDeposit`을 호출합니다. **사용자가 Teller 또는 BoringVault에 underlying을 승인하지 않습니다.** Teller 직접 `deposit`, `depositWithPermit`, `bulkDeposit`은 일반 사용자에게 열려 있지 않습니다. `approve` 후 `deposit`을 호출하세요.

* 기초 자산: `ASSET.decimals()`를 조회하세요. 6 decimals인 경우 `1 token = 1_000_000` 최소 단위입니다.
* BoringVault shares: **18 decimals**. `1 share = 10^18`.
* `R = Accountant.getRate()`는 **1e18 share 최소 단위(1 share)당 ASSET 최소 단위**입니다. 이 연동에서 ASSET은 Accountant의 base 자산입니다. **JPYC 기반 rate는 18 decimals, USDT 기반 rate는 6 decimals**이며, shares는 두 경우 모두 18 decimals입니다. 1 share = 1 자산이면 JPYC rate는 `10^18`, USDT rate는 `10^6`입니다. 현재 값은 Accountant에서 조회하세요.
* 입금 견적: `shares = floor(assets × 10^18 / R)`.
* 확정 환율 기준 가치: `assets = floor(shares × R / 10^18)`.
* 실제 요청 지급액: `floor(offerAmount × atomicPrice / 10^18)`.

모든 계산은 `bigint`로 하고 금액을 JavaScript `number`로 변환하지 마세요. 아래 예제는 입금 최소 수령량에 **10 bps(0.1%)** 허용 범위를 적용합니다. 이는 예제의 선택이며 앱 정책에 맞게 조정하세요. `minimumShares = 0`은 사용하지 않습니다. **이 입금용 허용 범위를 출금 `atomicPrice`에 적용하지 마세요.** 출금 가격을 낮추면 실제 지급액이 줄어듭니다.

### 승인 대상과 금액

`approve`는 **토큰 컨트랙트**에 호출하고, `spender`에 지출 권한을 줄 컨트랙트를 넣습니다. 승인만으로 입금이나 출금 요청이 생성되지는 않습니다.

| 목적 | 트랜잭션 대상 (`to`) | spender | 승인량 |
| - | - | - | - |
| 입금 | `ASSET` 토큰 | **VedaRouter proxy** | 이번 입금의 `assets`만큼, ASSET 최소 단위 |
| 출금 요청 | **BoringVault share 토큰** | **AtomicQueue** | 이번 요청의 `offerAmount`만큼, 18-decimal share 단위 |

예를 들어 6-decimal 자산 100개를 입금하면 `ASSET.approve(Router, 100_000_000)`, 10 shares 출금은 `VAULT.approve(Queue, 10 × 10^18)`입니다. **출금하려고 ASSET을 Queue에 승인하지 마세요.** 사용자 지갑의 기초 자산을 추가로 가져가거나 부족분을 청구하는 경로가 아닙니다.

> [!WARNING]
>
> **무제한 승인이나 이번 입금·요청량을 초과하는 승인을 사용하지 마세요.** 전액 입금·출금이라면 그 금액과 같은 유한 승인은 가능합니다. 기존 allowance가 필요량보다 크면 이를 그대로 재사용하지 말고 `approve(spender, 0)`을 확정한 뒤 필요한 양만 승인하세요. allowance는 만료일이 없으며, 요청의 deadline과 별개입니다. 토큰을 나중에 다시 받아도 잔여 allowance가 적용됩니다. 최소 승인은 잘못된 spender, 계약 취약점, 권한·업그레이드 변경으로 인한 노출을 제한하며 모든 위험을 없애지는 않습니다.

* 지갑에서 **chain ID, 토큰 주소, spender, 수량**을 확인하세요. 이름·심볼만으로 확인하지 마세요. 공개된 해당 네트워크의 주소만 사용하고, 메인넷 주소가 공란인 동안 임의의 주소나 QA 주소로 대체하지 마세요.
* 이 연동을 위해 Teller, Solver, VedaKeeper, Gateway, 운영자 EOA에 토큰을 승인하거나, BoringVault shares를 Router에 승인할 필요는 없습니다. 특히 EOA를 spender로 지정하면 그 키 보유자가 `transferFrom`을 직접 호출할 수 있습니다. 잘못 승인했다면 **그 토큰에서 실제 잘못된 spender의 allowance를 0으로** 바꾸세요. 아래 helper는 지정된 Router/Queue 승인만 정리합니다.
* 토큰을 Router·Vault·Queue·Keeper 주소로 단순 `transfer`하지 마세요. 자동 입금·출금 처리가 되지 않으며 shares 발행이나 자동 반환도 보장되지 않습니다. 입금에는 `deposit`, 새 출금 요청에는 `safeUpdateAtomicRequest`, 취소에는 `updateAtomicRequest`를 사용하세요.
* permit 서명도 지출 권한입니다. 앱이 permit을 별도로 지원한다면 동일한 토큰·체인·spender·금액과 서명 deadline을 확인하세요. permit deadline은 **서명 제출 기한**이며 제출 후 생긴 allowance의 만료일이 아닙니다. 이 예제는 `approve`를 사용합니다.

### 비동기 요청과 원자적 체결

입금은 **승인 트랜잭션 → Router deposit 트랜잭션**입니다. deposit 안에서 자산 이동과 shares 발행이 함께 성공하거나 되돌려집니다. 이미 성공한 approve는 이후 deposit 실패·서명 거절로 되돌려지지 않습니다.

출금은 **share 승인 → 요청 등록 → 나중에 keeper 체결**입니다. 요청을 등록해도 shares는 지갑에 남습니다. 권한 있는 운영자가 VedaKeeper를 호출하면 \*\*AtomicQueue가 spender로서 요청량의 shares를 `transferFrom`\*\*합니다. 운영자 EOA가 사용자 지갑에서 직접 인출하는 방식이 아닙니다. 이때 shares 상환과 ASSET 지급은 **한 체결 트랜잭션 안에서 원자적으로** 처리되며, 지급에 실패하면 shares 이동도 되돌려집니다. 한 요청은 전량 체결하며 부분 체결하지 않습니다.

유효한 요청과 충분한 share 잔액·allowance가 있으면 **추가 사용자 서명 없이 deadline 이내에 체결될 수 있습니다**. 요청 트랜잭션 성공을 출금 완료로 표시하지 마세요. 요청 확인, 체결 대기, 지급 완료를 구분하고, 지급은 `AtomicRequestFulfilled`와 ASSET 전송으로 확인하세요. keeper 역할 제한이나 transfer lock을 불필요한 allowance를 남겨도 된다는 근거로 삼지 마세요.

## 3. 실행 가능한 지갑 연동 예제

`npm install viem` 후 TypeScript 브라우저 앱에서 아래 파일을 사용합니다. **네트워크와 컨트랙트 주소를 담은 `FixedRateDeployment`를 `connectFixedRate`에 전달하세요.** 입금·출금 요청·취소·조회 메서드는 두 네트워크에서 공통으로 사용합니다. 포함된 `kairosDeployment`로 바로 테스트할 수 있으며, 메인넷 설정은 주소 공개 후 구성합니다.

최소 ABI가 포함되어 있고 기초 자산의 decimals는 선택한 토큰에서 조회합니다. 입력은 소수점 문자열이며 토큰 정밀도를 넘는 자릿수는 거절합니다. helper는 기존의 더 큰 승인도 0으로 초기화한 뒤 정확한 필요량만 승인합니다. 각 메서드는 트랜잭션 영수증을 기다리므로 approve 확정 후 다음 호출이 실행됩니다. 지갑 계정 또는 네트워크가 바뀌면 다시 연결하세요. 예제는 사용자 본인에게 shares를 발행하며, Router의 `receiver`를 바꾸어 다른 주소에 발행할 수도 있습니다.

`deployment.json`을 직접 설정으로 변환할 때는 주소 키를 다음과 같이 매핑하세요. `asset`, `accountant`, `controller`, `teller`는 같은 키를 쓰고, `router ← vedaRouter`, `vault ← boringVault`, `queue ← atomicQueue`, `keeper ← vedaKeeper`입니다. `chain`은 지갑·RPC와 동일한 viem Chain 설정이어야 합니다. 포함된 `kairosDeployment`에는 이 매핑이 적용되어 있습니다.

```typescript theme={null}
import {
  createPublicClient, createWalletClient, custom, defineChain, http,
  erc20Abi, parseAbi, parseUnits, type EIP1193Provider, type Hash, type Address, type Chain,
} from 'viem';

export const kairosAddresses = {
  "asset": "0xFD9a576384e9872fb6f7b3ed24c9be915E809C78",
  "router": "0xCF5A814F8D5fa5E51C7D25d00Af1fa930EDc51Bf",
  "vault": "0xada4481a03c26d378554e0F24247DaFc8Afa542F",
  "accountant": "0xc9de16B4E7Ab99c5466c3bA4F958C113faF20eef",
  "queue": "0x1Cb256edad979cfc25622b35fD54d848Cdce8DD5",
  "controller": "0xA3914f4685838359b2a8403E8acea85f2cc4cB97",
  "keeper": "0xC819088522bedD6f4AC78bE42B98B384F5c5795b",
  "teller": "0xEbD6156c992000D78035663995d3e3896B29d2c3"
} as const;
export const kairos = defineChain({
  id: 1001, name: 'Kaia Kairos',
  nativeCurrency: { name: 'KAIA', symbol: 'KAIA', decimals: 18 },
  rpcUrls: { default: { http: ['https://public-en-kairos.node.kaia.io'] } },
  blockExplorers: { default: { name: 'KaiaScan', url: 'https://kairos.kaiascan.io' } },
});
export type FixedRateDeployment = {
  chain: Chain;
  addresses: Record<keyof typeof kairosAddresses, Address>;
};
export const kairosDeployment: FixedRateDeployment = {
  chain: kairos, addresses: kairosAddresses,
};
export const mockAbi = parseAbi(['function mint(address receiver, uint256 amount)']);
export const routerAbi = parseAbi([
  'function deposit(uint256 assets, uint256 minimumShares, address receiver) returns (uint256 shares)',
  'function paused() view returns (bool)',
  'function supplyCap() view returns (uint256)',
  'function whitelistEnabled() view returns (bool)',
  'event Deposited(address indexed payer, address indexed receiver, uint256 assets, uint256 shares)',
]);
export const accountantAbi = parseAbi([
  'function getRate() view returns (uint256)',
  'function getRateSafe() view returns (uint256)',
  'function accountantState() view returns (address payoutAddress, uint96 highwaterMark, uint128 feesOwedInBase, uint128 totalSharesLastUpdate, uint96 exchangeRate, uint16 allowedExchangeRateChangeUpper, uint16 allowedExchangeRateChangeLower, uint64 lastUpdateTimestamp, bool isPaused, uint24 minimumUpdateDelayInSeconds, uint16 managementFee, uint16 performanceFee)',
  'event ExchangeRateUpdated(uint96 oldRate, uint96 newRate, uint64 currentTime)',
]);
export const queueAbi = parseAbi([
  'struct AtomicRequest { uint64 deadline; uint88 atomicPrice; uint96 offerAmount; bool inSolve; }',
  'function updateAtomicRequest(address offer, address want, AtomicRequest request)',
  'function safeUpdateAtomicRequest(address offer, address want, AtomicRequest request, address accountant, uint256 discount)',
  'function getUserAtomicRequest(address user, address offer, address want) view returns (AtomicRequest)',
  'function isPaused() view returns (bool)',
  'event AtomicRequestUpdated(address indexed user, address indexed offerToken, address indexed wantToken, uint256 amount, uint256 deadline, uint256 minPrice, uint256 timestamp)',
  'event AtomicRequestFulfilled(address indexed user, address indexed offerToken, address indexed wantToken, uint256 offerAmountSpent, uint256 wantAmountReceived, uint256 timestamp)',
]);
export const controllerAbi = parseAbi([
  'function aprBps() view returns (uint256)',
  'function lastPokeAt() view returns (uint256)',
  'function minInterval() view returns (uint256)',
  'function previewNextRate() view returns (uint256 newRate, uint256 dt)',
]);
const pauseAbi = parseAbi(['function isPaused() view returns (bool)']);
const keeperAbi = parseAbi(['function paused() view returns (bool)']);

// Invoke from a wallet-connect button. Reconnect after accountsChanged/chainChanged.
export async function connectFixedRate(
  provider: EIP1193Provider,
  deployment: FixedRateDeployment,
  rpcUrl = deployment.chain.rpcUrls.default.http[0],
) {
  const { chain, addresses: a } = deployment;
  const wallet = createWalletClient({ chain, transport: custom(provider) });
  const [account] = await wallet.requestAddresses();
  if (!account) throw new Error('Connect a wallet');
  if (await wallet.getChainId() !== chain.id) throw new Error(`Switch wallet to ${chain.name} (${chain.id})`);
  const client = createPublicClient({ chain, transport: http(rpcUrl) });
  if (await client.getChainId() !== chain.id) throw new Error(`RPC must use chain ${chain.id}`);
  const assetDecimals = await client.readContract({ address: a.asset, abi: erc20Abi,
    functionName: 'decimals' });
  const wait = async (hash: Hash) => {
    const receipt = await client.waitForTransactionReceipt({ hash });
    if (receipt.status !== 'success') throw new Error(`Transaction reverted: ${hash}`);
    return receipt;
  };
  async function approveExact(token: `0x${string}`, spender: `0x${string}`, amount: bigint) {
    const allowance = await client.readContract({ address: token, abi: erc20Abi,
      functionName: 'allowance', args: [account, spender] });
    if (allowance === amount) return;
    if (allowance !== 0n) await wait(await wallet.writeContract({ account,
      address: token, abi: erc20Abi, functionName: 'approve', args: [spender, 0n] }));
    await wait(await wallet.writeContract({ account, address: token, abi: erc20Abi,
      functionName: 'approve', args: [spender, amount] }));
  }
  // Revocation calls the token directly; it also works while Queue is paused.
  async function revokeAllowance(token: Address, spender: Address) {
    const remaining = await client.readContract({ address: token, abi: erc20Abi,
      functionName: 'allowance', args: [account, spender] });
    if (remaining === 0n) return; // Already revoked.
    return wait(await wallet.writeContract({ account, address: token, abi: erc20Abi,
      functionName: 'approve', args: [spender, 0n] }));
  }
  const revokeDepositAllowance = () => revokeAllowance(a.asset, a.router);
  const revokeQueueAllowance = () => revokeAllowance(a.vault, a.queue);
  function parseAmount(value: string, decimals: number) {
    if (!/^(0|[1-9]\d*)(\.\d+)?$/.test(value)
      || (value.split('.')[1]?.length ?? 0) > decimals) {
      throw new Error('Use a positive decimal string within token precision');
    }
    return parseUnits(value, decimals);
  }
  // Test token helper: available only for the published Kairos mockUSDT.
  async function mintMockUSDT(amount = '1000') {
    if (chain.id !== kairos.id || a.asset.toLowerCase() !== kairosAddresses.asset.toLowerCase()) {
      throw new Error('mockUSDT mint is only available for the Kairos test deployment');
    }
    const units = parseAmount(amount, assetDecimals);
    if (units <= 0n) throw new Error('Amount must be positive');
    const { request } = await client.simulateContract({ account, address: a.asset,
      abi: mockAbi, functionName: 'mint', args: [account, units] });
    return wait(await wallet.writeContract(request));
  }
  async function deposit(amount: string, slippageBps = 10n) {
    const assets = parseAmount(amount, assetDecimals);
    if (assets <= 0n || slippageBps < 0n || slippageBps >= 10000n) throw new Error('Invalid amount/slippage');
    await approveExact(a.asset, a.router, assets);
    const rate = await client.readContract({ address: a.accountant, abi: accountantAbi,
      functionName: 'getRateSafe' });
    const minimumShares = (assets * 10n ** 18n / rate) * (10000n - slippageBps) / 10000n;
    if (minimumShares === 0n) throw new Error('Deposit too small');
    const { request } = await client.simulateContract({ account, address: a.router,
      abi: routerAbi, functionName: 'deposit', args: [assets, minimumShares, account] });
    return wait(await wallet.writeContract(request));
  }
  async function requestWithdraw(shareAmount: string) {
    const shares = parseAmount(shareAmount, 18);
    if (shares <= 0n || shares > (1n << 96n) - 1n) throw new Error('Invalid share amount');
    const old = await client.readContract({ address: a.queue, abi: queueAbi,
      functionName: 'getUserAtomicRequest', args: [account, a.vault, a.asset] });
    // Clear even expired stored requests before granting a new allowance.
    if (old.offerAmount > 0n) throw new Error('Cancel stored request before approving a new one, including expired requests');
    const balance = await client.readContract({ address: a.vault, abi: erc20Abi,
      functionName: 'balanceOf', args: [account] });
    if (shares > balance) throw new Error('Insufficient shares');
    await approveExact(a.vault, a.queue, shares);
    const rate = await client.readContract({ address: a.accountant, abi: accountantAbi,
      functionName: 'getRateSafe' });
    if (rate <= 0n || rate > (1n << 88n) - 1n) throw new Error('Invalid atomic price');
    if (shares * rate / 10n ** 18n === 0n) throw new Error('Withdrawal rounds to zero');
    const block = await client.getBlock();
    const atomicRequest = { deadline: block.timestamp + 7n * 86400n,
      // Ignored by safeUpdateAtomicRequest: price is read from Accountant at execution.
      atomicPrice: 0n, offerAmount: shares, inSolve: false };
    const { request } = await client.simulateContract({ account, address: a.queue,
      abi: queueAbi, functionName: 'safeUpdateAtomicRequest',
      args: [a.vault, a.asset, atomicRequest, a.accountant, 0n] });
    return wait(await wallet.writeContract(request));
  }
  async function cancelWithdraw() {
    // The safe entrypoint rejects zero amounts; use the regular entrypoint to cancel.
    const { request } = await client.simulateContract({ account, address: a.queue,
      abi: queueAbi, functionName: 'updateAtomicRequest', args: [a.vault, a.asset,
        { deadline: 0n, atomicPrice: 0n, offerAmount: 0n, inSolve: false }] });
    // Return cancellation success independently of allowance cleanup.
    return wait(await wallet.writeContract(request));
  }
  async function getStatus() {
    const block = await client.getBlock();
    const blockNumber = block.number;
    const [assets, shares, rate, request, state, aprBps, lastPokeAt, preview,
      routerPaused, tellerPaused, queuePaused, keeperPaused, allowance, depositAllowance] = await Promise.all([
      client.readContract({ address: a.asset, abi: erc20Abi, functionName: 'balanceOf', args: [account], blockNumber }),
      client.readContract({ address: a.vault, abi: erc20Abi, functionName: 'balanceOf', args: [account], blockNumber }),
      client.readContract({ address: a.accountant, abi: accountantAbi, functionName: 'getRate', blockNumber }),
      client.readContract({ address: a.queue, abi: queueAbi, functionName: 'getUserAtomicRequest', args: [account, a.vault, a.asset], blockNumber }),
      client.readContract({ address: a.accountant, abi: accountantAbi, functionName: 'accountantState', blockNumber }),
      client.readContract({ address: a.controller, abi: controllerAbi, functionName: 'aprBps', blockNumber }),
      client.readContract({ address: a.controller, abi: controllerAbi, functionName: 'lastPokeAt', blockNumber }),
      client.readContract({ address: a.controller, abi: controllerAbi, functionName: 'previewNextRate', blockNumber }),
      client.readContract({ address: a.router, abi: routerAbi, functionName: 'paused', blockNumber }),
      client.readContract({ address: a.teller, abi: pauseAbi, functionName: 'isPaused', blockNumber }),
      client.readContract({ address: a.queue, abi: queueAbi, functionName: 'isPaused', blockNumber }),
      client.readContract({ address: a.keeper, abi: keeperAbi, functionName: 'paused', blockNumber }),
      client.readContract({ address: a.vault, abi: erc20Abi, functionName: 'allowance', args: [account, a.queue], blockNumber }),
      client.readContract({ address: a.asset, abi: erc20Abi, functionName: 'allowance', args: [account, a.router], blockNumber }),
    ]);
    const requestState = request.offerAmount === 0n ? 'none'
      : request.deadline < block.timestamp ? 'expired' : 'open';
    return { account, blockNumber, assets, shares, rate, value: shares * rate / 10n ** 18n,
      aprBps, lastPokeAt, previewRate: preview[0], request, requestState,
      requestPayout: request.offerAmount * request.atomicPrice / 10n ** 18n,
      sharesLocked: requestState === 'open', allowance, depositAllowance,
      accountantPaused: state[8], routerPaused, tellerPaused, queuePaused, keeperPaused };
  }
  return { account, client, mintMockUSDT, deposit, requestWithdraw, cancelWithdraw,
    revokeDepositAllowance, revokeQueueAllowance, getStatus };
}
```

연결한 지갑의 EIP-1193 provider를 `provider`로 전달합니다(예: 지갑 SDK의 provider 또는 주입형 지갑의 `window.ethereum`). `deployment`에는 선택한 네트워크 설정을 전달합니다. 입금 전 해당 기초 자산을 준비하세요. 테스트 토큰 준비는 [Kairos에서 테스트하기](#7-kairos에서-테스트하기)를 참고하세요.

```typescript theme={null}
import { connectFixedRate, type FixedRateDeployment } from './client';

async function integrate(provider: Parameters<typeof connectFixedRate>[0], deployment: FixedRateDeployment) {
  const api = await connectFixedRate(provider, deployment);
  await api.deposit('100');          // 기초 자산 100개 입금 후 shares 수령
  const before = await api.getStatus();
  const requestReceipt = await api.requestWithdraw('10');
  const pending = await api.getStatus();
  return { before, pending, requestReceipt };
}
```

## 4. 출금 요청의 정확한 동작

```solidity theme={null}
struct AtomicRequest {
    uint64 deadline;     // Unix seconds; block.timestamp > deadline이면 만료
    uint88 atomicPrice;  // 1e18 shares당 ASSET 최소 단위
    uint96 offerAmount;  // 18-decimal share 수량
    bool inSolve;       // 사용자 입력은 false; 체결 중 Queue가 관리
}
```

### atomicPrice: safe 함수로 실행 시점의 Accountant 환율 저장

> [!WARNING]
>
> **`atomicPrice`는 최소 보장 가격이 아니라 실제 지급액을 결정하는 고정 가격입니다.** 신규 요청에는 \*\*`safeUpdateAtomicRequest(VAULT, ASSET, request, ACCOUNTANT, 0)`\*\*을 사용하세요. 마지막 인자인 `discount=0`은 할인 없이 요청 트랜잭션 실행 시점의 Accountant 환율을 저장한다는 뜻입니다. 이후 환율이 올라가도 기존 요청 가격은 자동으로 바뀌지 않습니다.

1. BoringVault shares를 **AtomicQueue에 요청 수량만큼** 승인하고 확정을 기다리세요.
2. \*\*공식 배포 주소의 `Accountant.getRateSafe()`\*\*로 미리보기 가격을 조회하세요. paused이거나 조회가 실패하면 중단합니다. `floor(offerAmount × rate / 10^18)`로 예상 지급액을 계산하고 수량·가격·지급액을 표시하세요. 가격과 반올림된 지급액은 모두 0보다 커야 합니다.
3. `request.atomicPrice`는 **`0n` placeholder**로 두고 safe 함수를 호출하세요. safe 함수는 이 필드를 무시하고, 전달한 Accountant의 \*\*`getRateInQuoteSafe(ASSET)`\*\*를 트랜잭션 안에서 조회해 가격을 덮어씁니다. \*\*`ACCOUNTANT`에는 반드시 해당 네트워크의 공식 주소, `discount`에는 `0n`\*\*을 넣으세요. 임의의 Accountant를 사용자에게 선택하게 하지 마세요. `vault()` 일치 검사는 공식 Accountant임을 보장하지 않습니다.
4. 확정 후 `getUserAtomicRequest` 또는 `getStatus()`로 저장된 가격과 지급액을 확인하세요. 조회와 실행 사이에 poke가 발생하면 미리보기와 등록 가격이 달라질 수 있습니다. 이미 체결되어 요청이 지워졌다면 등록 영수증의 `AtomicRequestUpdated.minPrice`와 `AtomicRequestFulfilled` 이벤트를 확인하세요. 이벤트 필드 이름이 `minPrice`여도 실제 고정 정산 가격입니다.

예제의 `requestWithdraw()`는 이 safe 경로를 사용합니다. `atomicPrice: 0n`은 **safe 함수에서만** 무시되는 입력입니다. 일반 `updateAtomicRequest`로 수량이 있는 요청에 가격 0을 넣으면 무상 양도를 허용할 수 있으므로 절대 그렇게 사용하지 마세요.

safe 함수는 요청량 대비 잔액·allowance, deadline, 양수 수량, Accountant의 vault 일치, discount 상한과 `uint88` 가격 범위를 검증합니다. Queue 또는 Accountant가 paused이면 실패합니다. 그러나 **자동 poke, 향후 지급 보장, 기존 요청 재가격, 0으로 반올림되는 지급액 방지** 기능은 아닙니다. `discount`는 bps가 아니라 **1,000,000 기준 비율**이며 상한은 10,000(1%)입니다. 이 연동에서는 항상 0을 사용하세요. ASSET이 Accountant의 base인 현재 구성에서는 `getRateInQuoteSafe(ASSET)`와 `getRateSafe()`의 단위·값이 같습니다. 다른 quote 자산을 조회해야 할 때에는 전자를 쓰되, 이 출금 연동의 지원 pair를 임의로 변경하지 마세요.

일반 `updateAtomicRequest`도 취소를 위해 공개되어 있습니다. 이 함수에 수량을 넣어 직접 등록하면 **입력 가격을 그대로 저장**하므로 safe 경로가 강제되는 것은 아닙니다. 예를 들어 현재 USDT rate가 `1_000_000`, 요청량이 `100 × 10^18`일 때:

| 등록 방법 | 저장 가격 | 정상 체결 시 지급액 |
| - | - | - |
| safe 함수, 공식 Accountant, discount 0 | 실행 시 rate가 `1_000_000`이면 동일 값 | **100 USDT** |
| 일반 update, atomicPrice `500_000` 직접 입력 | `500_000` | **50 USDT** |

두 번째 요청은 나중에 rate가 `1_010_000`이 되어도 **50 USDT**로 정산됩니다. 잘못된 요청은 아래 취소·승인 철회 절차를 따르고 safe 경로로 다시 등록하세요. 이미 먼저 체결된 지급은 되돌릴 수 없습니다.

### 요청 상태와 잠금

* 요청은 **`(user, VAULT, ASSET)`당 1개**입니다. 같은 pair에 다시 쓰면 누적되지 않고 덮어씁니다. 예제는 재승인 전에 만료된 요청을 포함한 기존 저장 요청을 취소하게 합니다. 이 연동이 지원하는 pair는 `(VAULT, ASSET)` 하나뿐이며, 임의의 offer/want 토큰을 사용하지 마세요.
* `updateAtomicRequest`는 잔액·승인·가격을 모두 검증해주지 않습니다. 수량은 보유량과 allowance 이하, `atomicPrice > 0`, 예상 지급액도 0보다 크게 확인해야 합니다. **가격 0 요청은 무상 양도를 허용하므로 절대 생성하지 마세요.**
* 요청 시 shares는 지갑에 남습니다. 이 `(VAULT, ASSET)` pair에 활성 요청이 있으면 해당 지갑의 **모든 outbound share `transfer`·`transferFrom`이 차단**됩니다. 요청 수량만 부분적으로 잠그는 방식이 아닙니다. Queue의 체결 전송은 예외이며 Teller deny-list 검사는 유지됩니다.
* 요청 취소·체결 또는 `block.timestamp > deadline`이면 이 요청에 의한 잠금은 해제됩니다. 만료는 storage 삭제·자동 체결을 뜻하지 않습니다. Teller deny-list 등 다른 제한은 별개입니다.
* 예제의 만료는 **7일**입니다. 이는 대기 기간이나 체결 보장이 아닙니다. 만료 후에도 출금을 원하면 요청을 재등록하세요.
* Queue가 paused이면 **취소도 revert**합니다. 팀에 unpause를 요청하세요. share allowance를 0으로 만들면 체결을 막을 수 있지만, 활성 요청의 transfer lock은 취소·만료 전까지 유지됩니다. 취소와 체결이 경합하면 먼저 확정된 트랜잭션이 적용됩니다.
* `offerAmount == 0`만으로 미생성·취소·체결을 구별할 수 없습니다. `AtomicRequestFulfilled` 이벤트와 자산 잔액으로 체결을 확인하세요. 출금 수신자는 요청자 본인이며 별도 receiver 인자는 없습니다. 래퍼 컨트랙트가 요청하면 그 컨트랙트가 shares를 보유·승인하고 지급도 받습니다.

### 요청 취소와 승인 철회

**취소에는 `updateAtomicRequest`로 `offerAmount=0`을 등록합니다. safe 함수는 수량 0을 거부하므로 취소에 사용할 수 없습니다.**

두 작업은 별도 트랜잭션입니다. `cancelWithdraw()`는 요청만 지우고 취소 영수증을 반환합니다. `revokeQueueAllowance()`는 BoringVault의 Queue allowance만 0으로 만듭니다.

```typescript theme={null}
const cancellationReceipt = await api.cancelWithdraw();
// 여기서 요청 취소는 확정되었습니다. 다음 서명을 거절해도 취소가 되돌려지지 않습니다.
await api.revokeQueueAllowance();
const status = await api.getStatus(); // request.offerAmount === 0n, allowance === 0n 확인
```

| 상황 | 필요한 처리 |
| - | - |
| 입금 승인 후 deposit 실패·중단 | `revokeDepositAllowance()`로 ASSET → Router 승인 철회 |
| share 승인 후 요청 등록 실패·중단 | 요청 상태를 먼저 조회하고, `revokeQueueAllowance()`로 승인 철회 |
| 요청 취소 성공 후 승인 철회 실패·서명 거절 | 취소 성공과 승인 정리 미완료를 따로 표시하고, 철회만 재시도 |
| 요청 만료 | 체결은 불가능하지만 요청 저장값과 allowance는 남음. 저장된 요청을 취소하고 allowance 철회 |
| 체결 완료 | 요청량은 0이 됨. 정확한 유한 share 승인은 사용량만큼 소진되지만 남은 allowance도 조회·철회 |
| Queue paused 또는 잘못된 가격의 활성 요청 | `revokeQueueAllowance()`를 직접 호출해 체결 권한 철회. Queue가 unpause된 뒤 요청도 취소 |

**allowance 철회는 요청을 삭제하거나 share transfer lock을 풀지 않습니다.** 취소·만료 전에는 잠금이 유지되고, 만료 전 다시 승인하면 저장된 옛 가격의 요청이 다시 체결 가능해집니다. 재승인 전에 요청을 조회하고, 더 이상 원하지 않는 요청을 취소하세요. 예제는 만료된 요청도 명시적으로 지운 뒤 새 승인을 진행하도록 합니다.

Queue가 paused여도 토큰의 `approve(Queue, 0)`은 별도 호출입니다. 다만 철회·취소는 **온체인 확정 후에만** 효과가 있고, 이미 먼저 체결된 요청을 되돌릴 수 없습니다. 전송 중·타임아웃 상태에서는 트랜잭션 해시와 요청·allowance·지급 이벤트를 확인한 뒤 재시도하세요. 요청·승인 작업은 계정당 한 번에 하나만 진행하고, 지갑 계정이나 네트워크가 바뀌면 진행을 중단하고 다시 연결하세요.

## 5. 환율과 운영 상태 조회

`getRate()`는 **마지막 온체인 확정값**입니다. 시간이 지나도 호출만으로 이자가 반영되지 않습니다. 권한이 있는 운영자의 Controller `poke`가 절대 시간에 anchor된 고정 곡선을 Accountant에 반영합니다.

```text theme={null}
newRate = floor(anchorRate × ratePerSecond^(now - anchorTime))
```

`previewNextRate()`는 현재 시점의 예상 `(newRate, dt)`를 반환하는 view 함수입니다. **입출금에 사용되는 확정 환율이나 지급 약속이 아니며**, pause·업데이트 한계 조건에 따라 실제 poke는 실패할 수 있습니다. 환율이 절대 경과 시간에만 의존하므로 **poke 주기는 실현 APY를 바꾸지 않습니다** — 늦거나 불규칙한 poke도 같은 곡선 위에 놓이며 drift가 없습니다. `aprBps()`는 실효 APY를 bps로(2%) 반환합니다. 정수 절삭은 rate 바닥에서 단위 미만만 깎습니다.

환율 갱신과 출금 체결은 keeper의 역할입니다. 일반 사용자 권한으로 Controller `poke`, Keeper `poke/redeemSolve`, Queue `solve`를 호출할 수 없습니다. 수동 테스트 일정 조율은 Kairos 테스트 안내를 참고하세요.

| 확인 항목 | 조회 / 영향 |
| - | - |
| 확정 환율 | `Accountant.getRate()`; paused여도 조회 가능 |
| 거래용 환율 | `Accountant.getRateSafe()`; Accountant paused면 revert |
| Accountant pause | `accountantState()` 반환값의 `isPaused` (**0부터 세면 index 8**); 입금·상환 차단 |
| Router pause / 정책 | `paused`, `supplyCap`, `whitelistEnabled`, `whitelisted(account)`; 입금에 적용 |
| Teller pause / 제한 | `isPaused`, `shareLockPeriod`, `fromDenyList`, `toDenyList`, `operatorDenyList`; Router는 shareLockPeriod가 0이어야 함 |
| Queue pause | `isPaused()`; 요청 등록·수정·취소·체결 차단 |
| Keeper pause | `paused()`; 팀의 체결 운용 제한. 일반 사용자 요청과는 별개 |
| 환율 최신성 | `Controller.lastPokeAt()`, `aprBps()`, `previewNextRate()` |

예제 `getStatus()`는 한 블록 기준 잔액·환율·요청·pause를 조회합니다. `sharesLocked`는 **이 `(VAULT, ASSET)` pair의 출금 요청 잠금만** 나타내며 모든 transfer 가능 여부를 보장하지 않습니다. 상태 조회에 성공해도 cap·whitelist·deny-list·유동성 등의 이유로 쓰기는 실패할 수 있으므로, 예제처럼 제출 전 `simulateContract`와 실제 receipt를 확인하세요.

### 기본 환율 갱신 주기

**정기 keeper 운영 목표는 USDT(6 decimals) 24시간마다, JPYC(18 decimals) 1시간마다 환율을 갱신하는 것입니다.** JPYC는 rate의 정밀도가 높아 짧은 간격의 정수 절삭 영향이 매우 작습니다. 두 경우 모두 `getRate()`는 성공한 checkpoint 사이에서 동일하게 유지됩니다. 입금·출금 요청·조회가 자동으로 poke하지 않으며, 출금 체결도 별도 작업입니다. 이 주기는 출금 대기 기간이나 완료 보장 시간이 아닙니다.

구조 페이지의 [기본 keeper 운영 규칙](/ko/protocol/fixed-rate-vault.md#기본-keeper-운영-규칙)에서 자산별 주기, 실행 전후 확인, 지연·실패 대응, 정수 절삭 비교를 확인하세요. 실제 확정 여부는 `lastPokeAt()`과 rate를 읽어 판단하고, 예상 rate와 다음 예정 시각을 확정값으로 표시하지 마세요. 주기는 오프체인 운영 설정이며 decimals를 보고 컨트랙트가 자동 실행하지 않습니다. Controller `minInterval`과 Accountant `minimumUpdateDelayInSeconds`가 해당 주기를 허용해야 하고, APR 변경 시 별도 checkpoint가 발생할 수 있습니다. Kairos에서는 자동 keeper를 운영하지 않으므로 팀과 수동 갱신·체결 테스트를 조율합니다.

## 6. 이벤트와 실패 처리

전체 ABI JSON에는 아래 이벤트와 custom error가 포함되어 있습니다. `getLogs` 또는 `watchContractEvent`로 **해당 주소의 이벤트**를 읽고, 선택한 배포에 맞는 인덱싱 시작 블록을 설정하세요. Kairos 연동 테스트에서는 다운로드한 배포 JSON의 `verifiedBlock`을 사용할 수 있습니다.

| 목적 | 주소 / 이벤트 |
| - | - |
| 사용자 입금 기록 | **Router `Deposited(payer, receiver, assets, shares)`** |
| share 발행·소각 | BoringVault `Enter`, `Exit`, ERC-20 `Transfer` |
| 환율 갱신 | Accountant `ExchangeRateUpdated`; Controller `Poked` |
| 요청 생성·수정·취소 | Queue `AtomicRequestUpdated`; `amount = 0`이면 취소 |
| 지급 완료 | Queue `AtomicRequestFulfilled`; `wantAmountReceived`가 실제 지급액 |

Router는 Teller `bulkDeposit`을 사용하므로 Teller **`Deposit` 이벤트는 발생하지 않습니다**. Teller의 `BulkDeposit`과 Router의 `Deposited`를 구분하세요.

| 증상 | 확인 / 조치 |
| - | - |
| 트랜잭션이 전송되지 않음 | 지갑·RPC가 선택한 네트워크와 일치하는지, 가스 토큰이 있는지 확인 |
| 입금 승인 부족 | underlying allowance의 spender가 **Router proxy**인지 확인 |
| `MinimumMintNotMet` / 입금 simulate 실패 | 확정 환율을 다시 조회하고 minimumShares, 잔액·cap·whitelist·deny-list·pause 확인 |
| share transfer 실패 | 활성 요청을 취소하거나 만료 확인; Teller 제한도 확인 |
| 요청이 계속 pending | 요청 만료와 체결 상태 확인; Kairos에서는 팀과 수동 체결 일정 조율 |
| 체결 실패 | deadline, share 잔액·Queue allowance, 요청 가격, pause와 출금 유동성을 팀과 확인 |
| 요청 값은 0인데 지급 여부가 불명확 | Fulfilled 이벤트와 해당 tx의 ASSET 전송 확인 |

[EarnUSDT 연동](/ko/developers/integrate-earn-usdt.md)은 별도 상품의 SuperEarnRouter·CooldownVault 흐름입니다. 이 페이지의 VedaRouter·AtomicQueue 흐름과 ABI를 혼용하지 마세요.

## 7. Kairos에서 테스트하기

\*\*Kairos 테스트넷(chain ID 1001)\*\*에서는 `ASSET`으로 \*\*mockUSDT(`mUSDT`, 6 decimals)\*\*를 사용합니다. mockUSDT와 테스트넷 볼트 shares는 경제적 가치가 없습니다. 테스트 배포는 mockUSDT와 단일 자산 CustomVault를 연결합니다.

* RPC: `https://public-en-kairos.node.kaia.io` ([Kaia 공식 RPC 안내](https://docs.kaia.io/references/public-en/)).
* 가스용 테스트 KAIA는 [Kaia Faucet](https://www.kaia.io/faucet)에서 받으세요. mockUSDT는 아래 `mint` 호출로 별도로 발행합니다.
* 배포 검증: **2026-10-14**, 블록 **230316651**. 초기 APR **200 bps = 연 2%**, supply cap 제한 없음, whitelist 꺼짐, Teller share-lock 기간 0. 설정은 거버넌스가 변경할 수 있으므로 연동 시 조회하세요.

아래에서 **Kairos** 주소, 전체 ABI, 본문과 동일한 TypeScript 모듈을 내려받을 수 있습니다. ABI 번들은 `asset`, `vedaRouter`, `boringVault`, `accountant`, `atomicQueue` 등 배포 JSON과 동일한 키를 사용합니다.

지갑에서 Kairos를 선택한 뒤 `mockUSDT.mint(receiver, amount)`를 호출하거나 다음 helper를 사용하세요.

```typescript theme={null}
import { connectFixedRate, kairosDeployment } from './client';

const api = await connectFixedRate(provider, kairosDeployment);
await api.mintMockUSDT('1000');     // 누구나 본인 지갑에 1,000 mUSDT 발행 가능
await api.deposit('100');
const requestReceipt = await api.requestWithdraw('10');
// requestReceipt.transactionHash를 팀에 전달하여 체결 일정을 조율하세요.
```

`mint`는 권한·일일 한도·승인 없이 호출할 수 있어 팀 승인 없이 테스트 토큰을 준비할 수 있습니다. mockUSDT는 EIP-2612 permit을 지원하지 않으므로 `approve` 후 `deposit`을 호출합니다. helper를 사용하면 승인을 자동 처리합니다. 테스트 mint 기능은 공개된 Kairos mockUSDT에만 사용합니다.

> [!NOTE]
>
> **테스트넷에는 자동 keeper를 운영하지 않습니다.** mockUSDT 발행·입금·출금 요청·취소·조회는 직접 테스트할 수 있습니다. **이율 반영(`poke`) 또는 redeem 체결까지 확인하려면 SuperEarn 팀에 연락하여 연동 테스트를 진행하세요.** [contact@superearn.io](mailto:contact@superearn.io)에 Kairos 지갑 주소, 요청 트랜잭션 해시(있는 경우), 테스트할 수량, 희망 일정을 보내주세요. 단순히 기다리는 것으로 환율이 갱신되거나 출금이 체결되지는 않습니다.

이자까지 포함해 redeem을 테스트하려면 **팀의 poke → 최신 rate 조회 → 새 요청 → 팀 체결** 순서로 진행하세요. 기존 요청은 취소하고 새로 등록합니다. 초기 rate `1_000_000`, 목표 APY 2%에서는 anchor 이후 약 \*\*1,593초(26분 33초)\*\*가 지나야 rate가 처음으로 1 증가합니다. rate는 anchor 이후 경과 시간의 함수이므로 그 전에 poke해도 이 시점이 늦춰지지 않으며, 환율은 그대로인 채 `lastPokeAt`만 갱신될 수 있습니다.

mockUSDT는 자체 이자를 만들지 않습니다. 팀이 이자분 지급에 필요한 mock 유동성을 준비하고 체결 일정을 안내합니다.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.