From 982f031db15daab740c9a528fc7c0363f86e056c Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 13 Aug 2026 11:51:57 +0300 Subject: [PATCH 1/3] feat(scenarios): extend Kamino support across all six programs --- crates/core/src/scenarios/README.md | 17 +- .../kamino-liquidation-arbitrage.json | 58 +- .../fixtures/kamino_farms_farm_state.bin | Bin 0 -> 8336 bytes .../fixtures/kamino_liquidity_strategy.bin | Bin 0 -> 4064 bytes .../scenarios/fixtures/kamino_obligation.bin | Bin 0 -> 3344 bytes .../src/scenarios/fixtures/kamino_reserve.bin | Bin 0 -> 8624 bytes .../fixtures/kamino_scope_oracle_prices.bin | Bin 0 -> 28712 bytes .../scenarios/fixtures/kamino_swap_order.bin | Bin 0 -> 424 bytes .../src/scenarios/protocols/kamino/README.md | 275 ++ .../protocols/kamino/farms/v1/idl.json | 885 +++++ .../protocols/kamino/farms/v1/overrides.yaml | 219 ++ .../protocols/kamino/liquidity/v1/idl.json | 3276 +++++++++++++++++ .../kamino/liquidity/v1/overrides.yaml | 231 ++ .../protocols/kamino/scope/v1/idl.json | 1590 ++++++++ .../protocols/kamino/scope/v1/overrides.yaml | 127 + .../protocols/kamino/swap/v1/idl.json | 546 +++ .../protocols/kamino/swap/v1/overrides.yaml | 114 + .../scenarios/protocols/kamino/v1/idl.json | 1936 ++++++++-- .../protocols/kamino/v1/overrides.yaml | 603 ++- .../protocols/kamino/vault/v1/idl.json | 1781 +++++++++ .../protocols/kamino/vault/v1/overrides.yaml | 224 ++ crates/core/src/scenarios/registry.rs | 1277 ++++++- crates/core/src/surfnet/svm.rs | 362 +- crates/types/src/scenarios.rs | 154 +- 24 files changed, 13334 insertions(+), 341 deletions(-) create mode 100644 crates/core/src/scenarios/fixtures/kamino_farms_farm_state.bin create mode 100644 crates/core/src/scenarios/fixtures/kamino_liquidity_strategy.bin create mode 100644 crates/core/src/scenarios/fixtures/kamino_obligation.bin create mode 100644 crates/core/src/scenarios/fixtures/kamino_reserve.bin create mode 100644 crates/core/src/scenarios/fixtures/kamino_scope_oracle_prices.bin create mode 100644 crates/core/src/scenarios/fixtures/kamino_swap_order.bin create mode 100644 crates/core/src/scenarios/protocols/kamino/README.md create mode 100644 crates/core/src/scenarios/protocols/kamino/farms/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/kamino/liquidity/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/kamino/scope/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/kamino/swap/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/kamino/vault/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 4368f2b85..04043dc21 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -16,7 +16,7 @@ Protocols that are natively supported by Surfpool will have their IDLs included - **Pyth v2** - Price oracle with 4 price feed templates (SOL/USD, BTC/USD, ETH/BTC, ETH/USD) - **Jupiter v6** - DEX aggregator with TokenLedger manipulation template - **Switchboard On-Demand** - On-demand oracle with QuoteAccount override template -- **Kamino v1.x** – Lending protocol with Reserve liquidity, risk config, and Obligation health override templates +- **Kamino** – Lending (v1.23.0), Scope oracle, Farms, Swap/LIMO, Earn vaults and Liquidity, across six programs. See [protocols/kamino/README.md](./protocols/kamino/README.md) - **Drift v2** - Perp and spot markets, user state, and global state For custom protocols, an IDL can be registered at runtime using the [`surfnet_registerIdl`](https://docs.surfpool.run/rpc/cheatcodes#surfnet-registeridl) RPC cheatcode. @@ -26,6 +26,21 @@ Scenarios can be registered at runtime using the [`surfnet_registerScenario`](ht This cheatcode takes in a scenario definition in JSON format, which includes the scenario name, description, and a list of overrides to apply to accounts. Each override contains a map of the field in the account to override (as indexed in the IDL), and the value to apply for that key. +Field keys use dot notation. Segments address struct fields by name and array elements by +zero-based index, so `liquidity.total_available_amount`, `deposits.0.deposited_amount` and +`config.borrow_rate_curve.points.3.borrow_rate_bps` are all valid. Supplying a composite value (a +whole struct or array) also works, but it must be **complete** - every field of every element, +padding included - because the account is re-encoded with Borsh. An out-of-range index or a +non-numeric segment on an array is a hard error, never a silent write elsewhere. + +By default an override applies to exactly one slot. Set `"persist": true` and it is re-applied on +every following slot, which is needed when something else writes the account in between - a +transaction, or another override fetching it fresh. Persist inputs nothing in the scenario writes +(an oracle price, a disabled switch, a risk parameter), never state the transactions under test +mutate: re-applying reverts their writes at the start of the next slot, so a pool would refill +itself after every swap. Re-queuing is idempotent, so an override is never applied twice to one +slot. + ### Override Templates Directly using the `surfnet_registerScenario` endpoint requires building out a map of account keys that are specific to the schema of the account that is being written to. This is a cumbersome process in most cases. diff --git a/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json b/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json index 72d5c2888..c511bdd4f 100644 --- a/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json +++ b/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json @@ -1,30 +1,44 @@ { "id": "kamino-liquidation-arb-example", - "name": "Kamino Liquidation Arbitrage - POPCAT/SOL", - "description": "A scenario replicating the liquidation arbitrage from tx 5xDtqZcY4CzDHjdT61VsGuF1YL7fADUhPz6hCdA2RVMFMhUjuSh5rqkrLKFXfh4gXevMN1L2NjnCaRCAZYxVmqpz. This scenario sets up a Kamino obligation to be liquidatable, and manipulates Whirlpool and Raydium AMM pool states to create a profitable arbitrage opportunity.", - "tags": ["liquidation", "arbitrage", "kamino", "whirlpool", "raydium"], + "name": "Kamino Liquidation Arbitrage - POPCAT/USDC", + "description": "Puts a live Kamino obligation on the Altcoins Market underwater and leaves a profitable exit. Halving POPCAT in Scope makes Kamino value the collateral below its USDC debt while the Whirlpool pools keep their real price, so a liquidator seizes POPCAT cheaply and sells it POPCAT -> SOL -> USDC. Obligation: 3iprSGrEQdBxhmqV399tYQQPG8Z1Hh2aYFrBwgqFXjGS", + "tags": ["liquidation", "arbitrage", "kamino", "scope", "whirlpool"], "overrides": [ { - "id": "obligation-unhealthy", - "templateId": "kamino-obligation-health", - "label": "Make Obligation Unhealthy", + "id": "scope-crash-popcat", + "templateId": "kamino-scope-price", + "label": "POPCAT crashes 50% in Scope (index 492)", "scenarioRelativeSlot": 0, "enabled": true, "fetchBeforeUse": true, + "persist": true, "account": { - "pubkey": "3iprSGrEQdBxhmqV399tYQQPG8Z1Hh2aYFrBwgqFXjGS" + "pubkey": "3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C" }, "values": { - "borrowed_value_sf": 1000000000000000000, - "unhealthy_borrow_value_sf": 500000000000000000, - "deposited_value_sf": 800000000000000000, - "allowed_borrow_value_sf": 600000000000000000 + "prices.492.price.value": 2124828, + "prices.492.price.exp": 8 } }, { - "id": "whirlpool-popcat-sol-price", + "id": "popcat-reserve-tighten-threshold", + "templateId": "kamino-reserve-config", + "label": "POPCAT reserve liquidates above 29% LTV (was 40%)", + "scenarioRelativeSlot": 0, + "enabled": true, + "fetchBeforeUse": true, + "account": { + "pubkey": "3xSpNvuHAfyzpWxUg2kJkVHWhrBcGLL7RxkZyzecQZkw" + }, + "values": { + "config.liquidation_threshold_pct": 29, + "config.max_liquidation_bonus_bps": 1000 + } + }, + { + "id": "whirlpool-popcat-sol-depth", "templateId": "whirlpool-popcat-sol", - "label": "Set Whirlpool POPCAT/SOL Price", + "label": "Deepen POPCAT/SOL so the exit does not slip", "scenarioRelativeSlot": 0, "enabled": true, "fetchBeforeUse": true, @@ -32,27 +46,21 @@ "pubkey": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE" }, "values": { - "liquidity": "5000000000000000", - "sqrt_price": "1844674407370955161", - "tick_current_index": 0 + "liquidity": 5000000000000000 } }, { - "id": "raydium-amm-popcat-sol-state", - "templateId": "raydium-amm-popcat-sol", - "label": "Set Raydium AMM POPCAT/SOL State", + "id": "whirlpool-sol-usdc-depth", + "templateId": "whirlpool-sol-usdc", + "label": "Deepen SOL/USDC to close the route back to USDC", "scenarioRelativeSlot": 0, "enabled": true, "fetchBeforeUse": true, "account": { - "pubkey": "FRhB8L7Y9Qq41qZXYLtC2nw8An1RJfLLxRF2x9RwLLMo" + "pubkey": "HJPjoWUrhoZzkNfRpHuieeFk9WcZWjwy6PBjZ81ngndJ" }, "values": { - "status": 1, - "state": 1, - "lp_amount": 10000000000000, - "fees.swap_fee_numerator": 25, - "fees.swap_fee_denominator": 10000 + "liquidity": 50000000000000000 } } ] diff --git a/crates/core/src/scenarios/fixtures/kamino_farms_farm_state.bin b/crates/core/src/scenarios/fixtures/kamino_farms_farm_state.bin new file mode 100644 index 0000000000000000000000000000000000000000..7f78f5701097cb1c9ac2d889e9771920913a2bb7 GIT binary patch literal 8336 zcmX?>cEiiwY4N_fi5{;^i#Oh2Ou2s9D0|YBfJx0M!RI%q_xSS6D_SBU8JKzFVND1_ z?BZ9)1Rq{r+wX4#OQdtFV+yZ zZ7Fhiv%k!hjR6AK?tXiCJ$2#ao2MVToqJumMrKRv+xTeniB>&dTkHNatb{5fno191 zjZxVA@N0+{?L9})t%!UHC*B+8iqV()Xx{$dqK6Ny<73o((n0_|en$ED(=%zI zJF1%2AuyU=@uyex@S$~ljG9kc2%yK$C?9`%CM|SFRns~IM$;?)^okxnw2qH~HlGpd zfIui+lDCN!#GLjpYQ?A7ZPB0B{dj)tRiLhn%jU@~6@AS)t8Jubef3&!{`7+@*AHHp z_m=IG`aZum|4sUwm0L>mZ>Mw5|9th(a)W<()oNM3)Lq8Z}}x1n3q509vWP+5i9m literal 0 HcmV?d00001 diff --git a/crates/core/src/scenarios/fixtures/kamino_liquidity_strategy.bin b/crates/core/src/scenarios/fixtures/kamino_liquidity_strategy.bin new file mode 100644 index 0000000000000000000000000000000000000000..5280d59b4eff2eb0a4764b5cdd201c9f4b66478f GIT binary patch literal 4064 zcmdnD>G=-BUFp25B%du;nyZk@Z6ICxLt4NyVAbVj9bxXy?H4nP1Bw3TJ3;&C@xRz(xRXn)4{KWg$txe8O&X-=^ zDlFaeCE@KFMw80eQ+v-VXhm`0H@E@9a z;D%;dnfbT)&(Hjqy?lOu>yopp1j8(+%b(D>v1atu_vLGly+L_PY%GYflbj zct4Q3XPv$4vTg(8g;jz_McL*}@Cos+{C|7)gwPrpj>hDhr)MxQYwB>_n$;C~I$rKY zAA^uM$9uNWY0+gmK37FJqw_nDUYhk||4fffbvKr>y=PI(j3{&CJpDPVc#q`X>n~e_ zQ{vxNT#~*1x#%~~rmlm{i}RB9`v)06(EOHubVCr^WVN(capxn<*4Y;y!r2o z*^>06r6VRXD(sonQ~o(kHruQ3^V~hb4)50X*zDtW<(%R0Ld~{ab!+>EVm7Eb98ema z5{C-7Kxt*2Oy&nGSspj#G=8pQSa@(zhK6!Wj7swJdDdl=(P+_ggO{z__OIyFe*b;J z0?)@V_ZS8l6sZ2nJHJrzxK-ls{+44qPfT}~u+Dq8U+gH0`>vI+aDcg&5ypVhgu)R` zIh;#3eJ{#9U||cl93Mk3vP=-4JdtuQn2A$(2pXWc==2{8Qi(HZR24NtfKd8`p&L}vY&byHR5Ea$u!vwm0G zhS!`jdvBfXU%d3C`dSZN)p6mw1)^R7IfS-`O?%`x6cWQNjj~0(~|$W_KT^B<$1=r zo6A=|%H#YY_40Ms(GO6=zbV#RC2HnqGBq<3{=VCV9aKek`sfS82N&kd|hhf0xZ)u{T>5Eu;s0wI8&-_ZG2 zkEm_-t@~>J1DXjp&AR_?&)<*pzj8wj{tp2RAPU&5Az&R)S`Mrvg|Na=6{8_A8Un*C F1OR>?kih@| literal 0 HcmV?d00001 diff --git a/crates/core/src/scenarios/fixtures/kamino_reserve.bin b/crates/core/src/scenarios/fixtures/kamino_reserve.bin new file mode 100644 index 0000000000000000000000000000000000000000..c61702e793cf550be8fedf133a48e732092cd231 GIT binary patch literal 8624 zcmdP?bmo-Qck6ma1_+ST6PE>+%2tBW&#y-O(4e%UrbbiL@s4!>@(L-REkybE2> z_0DSbL#Q^gDUPC(Ut{0s9dojXU*onh@N4|@FoEgz53;0eA{gV|UMYc}#U=#aoVEiI= zdCb=rBCi~_c`f0_?K%ONRAB*c7KqIT6F{Rgmi_Sdt7m|#N0X*Hm+kJihu2dVPQH2i zq1(CFm1|_Sw7!jxHlJwK^R>0^Kf_9@TR3XqXb6mkz-S1JhQMeDjE2C-34!O^)4ww- zZqz-Ua{gn)x-B`ss~-Hlq8&X|Lr&1rMkxl?ewx6#XrHRkclBAiZP%x^e@^gUy7}hh zi|fCx_OGtpd-3-CkrQB}J{=8#(GVC7fzc2c4S~@R7#<-YViXYdh4BS*GDCwEC)i60 zGK75w8Z&c{XJF6)GE5X180IlCFlZ<69yh~=iDlIGj z6-j7bW}z~Lq?AKc7CQbME<`-}~?RJ#XjE3;+6d zuW32kj@)bU_1jmYeK@UZ{Qi_a?Sotzch+hwn|Q%0f|ruM;6F82BJ-38f>0Oy2|w}k zIflYxzt-hn2#>Npv)r>bjSzfE;x+)B77MO${h42`tD)lv-K1*`0(yFYp;|!ChK1f2 zhFK~?2+8MRfHP#l;nK5yblz84f?gUVz2eH|bLw|hh_`@z?VbUe!ha91xb6)uz2wlF zP(3r@Tpz(Vqy`9p-;=4B%X*M}IFpI?;FLEB-2yM|D`L_MLkY&0GyMB4G!BkW4iR%;S?6d0$E02^0Q(JPu^(dcWKj#75^C$B2AlKVSC?2KH zRDfg4FaAO4ql7rpucM4SL~(m+{W>{uJAnAvD8_ZnX;i%!5{73_xBMALe1BAYpK>xaH)Js5m3Swx98LripZ(fb-8mM3LEY%j0`c=@}Dy?Z!Xh$I>`BdV`6= zr$>d&A7wvlpqE*4^Flskmh%VbRzQOa*fsgsjmh7dVd z#yK8sY7 zDIv?(*L%{b(-^og?$p)*!(b&*Vx_R{zochzpy~C|s!mm|9UMgp4$1Z;ocE zOZ;*4Zi!4!<`)fst6;%>)OiyN_r11HsAPLv3Fy5G>K8}nmAq-6id>4}lCcgxE{F3^ ztBX2ef^QrE?hh6|_vWbd7CwlSuNl6%zRx*6!7p>s4-G;QE=j2~e~hVCiwx@o@7j zo&?H^cT#^n$F9}D7r_zREnLytF+WUP(YIoG$asgXmkKa(k5737nzh=ik^Mi|e%$KG zqTy94FF}vx8&vxdVvvpbM`aJ8r{;R&GPQ6h53|pa?z$ll!7xzCyZ;vp^5qT?m$3?8}R3-HS z8}CO9EUQwFwIkyO8k?_P#2=3w?+a@;&h#u_LDdDY{$dw0HYFUxU$n9v{patrNA?%f zs^^W*+R?{fyliedO7{C<>(2{%84ftOb>RuznVe ziHkdwxm0ZqIz1jH4mbXKaIaPa+_$wD<8`ogLHr4IEZnQ*p8aVBzxJ+)j786bCoysC z9KHOG(x~I_OXelm{z}G+4dZZdFX!U*ySf0g9ee#G>EQ@P15uAoT-;%ltAsk974dP- zpr5?aaVWfX$9tQ2)r@gd*m;W?vzWLPFb>AHp5e(|=DY=LyV73I#FYYiBUt7|6>SrB zovR{}WIYI5pIMt-!NQHrytk~)WDfGYw)42iYmpA?*~JA^13MUJ5QIk5fIfu&5nz~-}vBY3zL?r-mG&O;w( z`0Kd-nN6Rg<6gCW*q&FH6~<^EJ_dQ0ZM~alv6RHaVe_B_L?Qp?m<+oT@j^{oyt;%S zVEt^b#m>8~7`U8g;Y;rf;cAfcN3itLJ5n$F{Q){oAxiu|BC^ar@5cC_dB}MwBYyA) zrN_{5-6f9RbUu~(G;lJeqt72xPnY-6@7`$0Ugt@H2aUZvs8Lytd2+d-FiWV^SnRUcG4L^d0XR7&QF zG@uu@{GMHfhqL7s@qUQ-5IG)j>j_2M;Z3K9+in`wb;gv{pvMtzyerxc28mPFSIj~m zUs^XfHnihDx?U7r@8aq+NopM~Y#mZBwGI~+hs$So!6npL_16|`njrU0!1i;(^S|qh zgX=DO=`MU`|HS8QKaU>oxc%bpqL=Q%XUQ3HizHgqE?`=^Uo1Rv@&0i47V&!zvci>15~PbG!Tr*gVA}`x=UZbW z_qD=~hv1IeSO!?naPg|A#ReY-=fR8q%IJJ{Gz|%-ar*$D)n~B)TF zYxc$2$MXGJCcEKNiCvfJID*y7+@OmVU;jYk;O#If>+(%Hj$rATUl*@HBI^m*_m!lU z;^8`5<;S~BeG+wU`$9C4{rdU9FZG4bB;|vxdyEJBGH`Vu@3OtWF2Z2Kn7XYrpYZlm zZ<_L1Y#(yo7uJ4+APKheshF-q}JO0vDe$n$> zG!0nlUmt$-G5x+7So@tB89w&cr|397cPwLFI&58_t~3k_hr7QYO^ZEuzit}vpX-FJ^Y|)Ah`14&S=4)GU~b~|GKMKI5h75_q&Hr7mv)7;~Ms$ zE?|5A*}55cxM7W9y5}#_@5_U2S9ETiU%B`aIu2JuzAKsQ`C#|){k@xsgWcC^xxw5e zOya%nU0s)7_^nJdRYKYwR=&;~4!Vs3rIPy%VeQBDPT7+|$6*FK4%hCu zxaP!xA}Fa(*!m@E0TTzi@7nL4m=Y?z`*lnlEWOt4?=s|r^%v(nChi=_FKoX%uxYE< z6689he1J0r^J=o}J;-{w8ZbI!1_y*58n8a>70{(ow0!y!KPkh|m0o9bhz~=dhMB<+(Mx(c58JNF^@(@{#M7F!%9$rsfIQi!3hi>OySFVxS()u<&+I*r_&)3$v{|qb7G4ds>f4fOt zd~dR(euzkv{j@0K-A$K{GtJv{PEMgIg^+%QCBo|&fWSzZ2~307AOPd9h-U)x8Nnow RVEhkeO?RA{0TUX`GywU?a{>SW literal 0 HcmV?d00001 diff --git a/crates/core/src/scenarios/protocols/kamino/README.md b/crates/core/src/scenarios/protocols/kamino/README.md new file mode 100644 index 000000000..da0d9e342 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/README.md @@ -0,0 +1,275 @@ +# Kamino + +Surfpool bundles IDLs and override templates for **six Kamino programs**, so a scenario can put a +Kamino market into whatever state you need before your code runs against it. + +This is a how-to. For how scenarios work in general see the [scenarios README](../../README.md) +every field's own purpose and units are on the template itself, visible in Studio and via +`get_override_templates`. + +## Two rules that decide whether an override sticks + +**1. Override inputs, not results.** Kamino stores settings someone chose (`liquidation_threshold_pct`) +and values it computed from them (`market_price_sf`, the Obligation's `*_value_sf`). Before a +liquidation it runs `refresh_reserve` and `refresh_obligation`, which recompute every computed value. +So overriding a computed value is discarded moments later. + +| Want to change | Override this | Not this | +|---|---|---| +| A price | `kamino-scope-price` | `liquidity.market_price_sf` | +| Position health | `kamino-reserve-config` → `liquidation_threshold_pct` | `kamino-obligation-health` | + +**2. Add `"persist": true`** only to inputs your scenario never writes - prices, risk config, +caps. Never to state your transactions mutate (reserve liquidity, obligation or vault balances): +re-applying reverts their writes each slot, so a swap leaves no trace and the arbitrage it measures +is not real. + +## Number formats + +| You'll see | It means | Example | +|---|---|---| +| `_sf` | value x 2^60 | $1.00 → `1152921504606846976` | +| `_bps` | basis points | `100` = 1% | +| `_pct` | whole percent | `74` = 74% | +| Scope `value` / `exp` | `value / 10^exp` | `$0.15` → value `15000000`, exp `8` | +| Farm stake, `reward_per_share_scaled` | value x 2^18 | | +| Token amounts | the mint's smallest unit | 1 USDC → `1000000` | + +## Finding the Scope entry for a token + +Every reserve names its price source. Read the reserve's +`config.token_info.scope_configuration`: + +- `price_feed` - which Scope account to override +- `price_chain` - which entry in it (65535 means unused). If two entries are listed, the price is + the **first multiplied by the second** - that's how a token quoted in SOL is priced. + +Verified 2026-08-11: + +| Scope account | Entries | +|---|---| +| `3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH` | SOL 3, USDC 13, PYUSD 148, cbBTC 175 | +| `3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C` | SOL 0, JLP 416, POPCAT 492 | + +--- + +# Recipes + +## Make a position liquidatable + +Two independent levers where either works, both together is safest. + +```json +{ + "templateId": "kamino-scope-price", + "scenarioRelativeSlot": 0, "enabled": true, + "fetchBeforeUse": true, "persist": true, + "account": { "pubkey": "3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C" }, + "values": { "prices.492.price.value": 2124828, "prices.492.price.exp": 8 } +} +``` + +``` +kamino-reserve-config on the collateral reserve + config.liquidation_threshold_pct: 29 # was 40 +``` + +**Why:** halving the collateral's price halves what Kamino thinks it is worth. Lowering the +threshold shrinks the borrow limit. Both survive `refresh_obligation`. See +[`examples/kamino-liquidation-arbitrage.json`](../../examples/kamino-liquidation-arbitrage.json) +for a complete, tested scenario. + +## Turn a liquidation into an arbitrage + +Crash the price in Scope but leave the DEX pools at their real price - the gap between them is the +profit. Add depth so the exit does not slip: + +``` +whirlpool-popcat-sol liquidity: 5000000000000000 # sell the seized collateral +whirlpool-sol-usdc liquidity: 50000000000000000 # route back to the debt token +``` + +## Age a loan instantly + +``` +kamino-reserve-state + liquidity.cumulative_borrow_rate_bsf.value.0: +``` + +**Why:** Kamino derives what a borrower owes from the ratio between this index and the borrower's +snapshot of it. Raising it accrues interest without waiting. + +## Force a reserve to run dry + +``` +kamino-reserve-state liquidity.total_available_amount: 0 +kamino-reserve-limits withdraw_queue.next_withdrawable_ticket_sequence_number: 7 +kamino-lending-market-risk withdraw_ticket_issuance_enabled: 1 +``` + +**Why:** an empty reserve defers withdrawals into a queue. The market-level switch must be on or the +feature never activates. Build the ticket itself with `kamino-withdraw-ticket`. + +## Block an action to test the rejection + +``` +kamino-reserve-limits config.borrow_limit: 0 # no new borrows here +kamino-reserve-status config.status: 1 # reserve obsolete +kamino-lending-market-risk emergency_mode: 1 # market-wide wind-down +kamino-liquidity-strategy-guards withdraw_blocked: 1 # strategy exit blocked +kamino-swap-global-config flash_take_order_blocked: 1 # no flash fills +``` + +## Build a position from scratch + +``` +kamino-obligation-positions + deposits.0.deposit_reserve: + deposits.0.deposited_amount: 10000000000 + borrows.0.borrow_reserve: + borrows.0.borrowed_amount_sf: + has_debt: 1 +``` + +**Why:** element paths let you set one slot. Supplying a whole array needs all 8 (deposits) or 5 +(borrows) entries complete, padding included. + +## Give a farm user claimable rewards + +Fastest - an already-accrued balance, tests only the claim path: + +``` +kamino-farms-user-rewards rewards_issued_unclaimed.0: 500000000 + last_claim_ts.0: 0 +``` + +Realistic - let the program compute the accrual: + +``` +kamino-farms-reward-accumulator reward_infos.0.reward_per_share_scaled: +``` + +**Why:** claimable is `active_stake_scaled x reward_per_share_scaled - rewards_tally_scaled`. +Raising the farm's side and leaving the user's tally alone creates the gap they can claim. + +## Simulate elapsed time + +Every reward and fee mechanism accrues from a timestamp. Move it into the past and the next +accrual covers a longer period - no clock advancing needed. + +``` +kamino-farms-reward-emissions reward_infos.0.last_issuance_ts +kamino-vault-fees last_fee_charge_timestamp +kamino-vault-rewards reward_info.last_issuance_ts +kamino-liquidity-strategy-rewards kamino_rewards.0.last_issuance_ts +``` + +## Make an Earn vault look profitable, or fail + +``` +# earned yield: assets up, shares unchanged +kamino-vault-state token_available: 1000000000 + +# clean share-price assertion: no fees +kamino-vault-fees performance_fee_bps: 0 + management_fee_bps: 0 + +# withdrawal failure: all weight in one reserve, then starve it +kamino-vault-allocation vault_allocation_strategy.0.target_allocation_weight: 100 +kamino-reserve-state liquidity.total_available_amount: 0 +``` + +## Partially fill a limit order + +``` +kamino-swap-order + initial_input_amount: 1000000000 + remaining_input_amount: 500000000 # half filled + expected_output_amount: 100000000 # cheap for the taker + tip_amount: +``` + +--- + +# Troubleshooting + +| Rejection | Fix | +|---|---| +| Price rejected as stale | Set `prices.N.last_updated_slot` / `unix_timestamp` to now, or raise `config.token_info.max_age_price_seconds` on `kamino-reserve-oracle` | +| Price rejected for TWAP divergence | Move the matching entry with `kamino-scope-twap`, or raise `max_twap_divergence_bps` | +| Your override silently did nothing | The field name does not exist in the IDL - surfpool logs a `warn!` and drops the whole override. Check the log | +| `expected svm::u128, found string` | Numbers must be JSON numbers, not quoted strings | +| `Account with discriminator ... not found in IDL` | The account is not Anchor-based (e.g. Raydium AMM v4). It cannot be overridden through the IDL path | +| `Failed to resolve account address` | The `pubkey` is not valid base58 | +| Override reverted after a transaction touched the account | Add `"persist": true` - but only if that field is an input, not state the transaction is meant to change | +| A value the program recomputes will not stay put | Pin the input it reads instead: Scope price over a Reserve's cached price, `liquidation_threshold_pct` over the Obligation's health fields | + +--- + +# Template index + +**Kamino Lend** · `KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD` + +| Template | Overrides | +|---|---| +| `kamino-reserve-state` | Kamino Reserve liquidity, accrued fees and cached price | +| `kamino-reserve-config` | Kamino Reserve LTV, liquidation thresholds and bonuses | +| `kamino-reserve-status` | Kamino Reserve status and usage restrictions | +| `kamino-reserve-limits` | Kamino Reserve caps and the withdrawal queue | +| `kamino-reserve-fees` | Kamino Reserve origination, flash-loan and protocol fees | +| `kamino-reserve-interest-rate` | the Kamino Reserve borrow-rate curve | +| `kamino-reserve-oracle` | which oracle a Kamino Reserve reads, and its staleness guards | +| `kamino-reserve-rewards` | Kamino Reserve reward emissions | +| `kamino-reserve-debt-term` | Kamino Reserve fixed-term debt settings | +| `kamino-withdraw-ticket` | a Kamino queued-withdrawal ticket | +| `kamino-reserve-main-sol` | the SOL reserve of Kamino's Main Market | +| `kamino-reserve-main-usdc` | the USDC reserve of Kamino's Main Market | +| `kamino-obligation-health` | Kamino Obligation health metrics | +| `kamino-obligation-positions` | the deposits and borrows of a Kamino Obligation | +| `kamino-obligation-orders` | Kamino Obligation stop-loss and take-profit orders | +| `kamino-lending-market-risk` | Kamino market-wide switches and liquidation limits | +| `kamino-lending-market-elevation-groups` | Kamino e-mode elevation groups | + +**Scope oracle** · `HFn8GnPADiny6XqUoWE8uRPPxb29ikn4yTuPa9MF2fWJ` + +| Template | Overrides | +|---|---| +| `kamino-scope-price` | a price in Kamino's Scope oracle | +| `kamino-scope-price-source` | where a Scope index reads its price from | +| `kamino-scope-twap` | a Kamino Scope TWAP entry | + +**Farms** · `FarmsPZpWu9i7Kky8tPN37rs2TpmMrAZrC7S7vJa91Hr` + +| Template | Overrides | +|---|---| +| `kamino-farms-reward-emissions` | a Kamino farm's reward schedule and budget | +| `kamino-farms-reward-accumulator` | a Kamino farm's reward accumulator and staked totals | +| `kamino-farms-user-rewards` | one user's farm stake and reward balances | +| `kamino-farms-farm-config` | Kamino farm caps, lockups and cooldowns | +| `kamino-farms-global-config` | the Kamino Farms treasury fee | + +**Swap (LIMO)** · `LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF` + +| Template | Overrides | +|---|---| +| `kamino-swap-order` | a Kamino limit order's amounts and fill progress | +| `kamino-swap-global-config` | Kamino limit order global switches and fees | + +**Earn vaults** · `KvauGMspG5k6rtzrqqn7WNn3oZdyKqLKwK2XWQ8FLjd` + +| Template | Overrides | +|---|---| +| `kamino-vault-state` | Kamino Earn vault balances and deposit limits | +| `kamino-vault-fees` | Kamino Earn vault performance, management and exit fees | +| `kamino-vault-allocation` | how a Kamino Earn vault spreads deposits across reserves | +| `kamino-vault-rewards` | Kamino Earn vault reward emissions | +| `kamino-vault-reserve-whitelist` | a Kamino Earn vault reserve whitelist entry | + +**Liquidity** · `6LtLpnUFNByNXLyCoK9wA2MykKAmQNZKBdY8s47dehDc` + +| Template | Overrides | +|---|---| +| `kamino-liquidity-strategy-balances` | a Kamino Liquidity strategy's holdings and shares | +| `kamino-liquidity-strategy-rewards` | Kamino Liquidity strategy reward balances | +| `kamino-liquidity-strategy-guards` | Kamino Liquidity strategy caps and slippage guards | +| `kamino-liquidity-strategy-fees` | the Kamino Liquidity strategy's cut of fees and rewards | diff --git a/crates/core/src/scenarios/protocols/kamino/farms/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/farms/v1/idl.json new file mode 100644 index 000000000..94b811930 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/farms/v1/idl.json @@ -0,0 +1,885 @@ +{ + "address": "FarmsPZpWu9i7Kky8tPN37rs2TpmMrAZrC7S7vJa91Hr", + "metadata": { + "name": "farms", + "version": "1.6.5", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "FarmState", + "discriminator": [ + 198, + 102, + 216, + 74, + 63, + 66, + 163, + 190 + ] + }, + { + "name": "GlobalConfig", + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] + }, + { + "name": "UserState", + "discriminator": [ + 72, + 177, + 85, + 249, + 76, + 167, + 186, + 126 + ] + }, + { + "name": "OraclePrices", + "discriminator": [ + 89, + 128, + 118, + 221, + 6, + 72, + 180, + 146 + ] + } + ], + "types": [ + { + "name": "FarmConfigOption", + "type": { + "kind": "enum", + "variants": [ + { + "name": "UpdateRewardRps" + }, + { + "name": "UpdateRewardMinClaimDuration" + }, + { + "name": "WithdrawAuthority" + }, + { + "name": "DepositWarmupPeriod" + }, + { + "name": "WithdrawCooldownPeriod" + }, + { + "name": "RewardType" + }, + { + "name": "RpsDecimals" + }, + { + "name": "LockingMode" + }, + { + "name": "LockingStartTimestamp" + }, + { + "name": "LockingDuration" + }, + { + "name": "LockingEarlyWithdrawalPenaltyBps" + }, + { + "name": "DepositCapAmount" + }, + { + "name": "SlashedAmountSpillAddress" + }, + { + "name": "ScopePricesAccount" + }, + { + "name": "ScopeOraclePriceId" + }, + { + "name": "ScopeOracleMaxAge" + }, + { + "name": "UpdateRewardScheduleCurvePoints" + }, + { + "name": "UpdatePendingFarmAdmin" + }, + { + "name": "UpdateStrategyId" + }, + { + "name": "UpdateDelegatedRpsAdmin" + }, + { + "name": "UpdateVaultId" + }, + { + "name": "UpdateExtraDelegatedAuthority" + }, + { + "name": "UpdateIsRewardUserOnceEnabled" + }, + { + "name": "UpdateDelegatedAuthority" + }, + { + "name": "UpdateIsHarvestingPermissionless" + } + ] + } + }, + { + "name": "GlobalConfigOption", + "type": { + "kind": "enum", + "variants": [ + { + "name": "SetPendingGlobalAdmin" + }, + { + "name": "SetTreasuryFeeBps" + } + ] + } + }, + { + "name": "LockingMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "None" + }, + { + "name": "Continuous" + }, + { + "name": "WithExpiry" + } + ] + } + }, + { + "name": "RewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "token", + "type": { + "defined": { + "name": "TokenInfo" + } + } + }, + { + "name": "rewards_vault", + "type": "pubkey" + }, + { + "name": "rewards_available", + "type": "u64" + }, + { + "name": "reward_schedule_curve", + "type": { + "defined": { + "name": "RewardScheduleCurve" + } + } + }, + { + "name": "min_claim_duration_seconds", + "type": "u64" + }, + { + "name": "last_issuance_ts", + "type": "u64" + }, + { + "name": "rewards_issued_unclaimed", + "type": "u64" + }, + { + "name": "rewards_issued_cumulative", + "type": "u64" + }, + { + "name": "reward_per_share_scaled", + "type": "u128" + }, + { + "name": "placeholder0", + "type": "u64" + }, + { + "name": "reward_type", + "type": "u8" + }, + { + "name": "rewards_per_second_decimals", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 6 + ] + } + }, + { + "name": "padding1", + "type": { + "array": [ + "u64", + 20 + ] + } + } + ] + } + }, + { + "name": "RewardPerTimeUnitPoint", + "type": { + "kind": "struct", + "fields": [ + { + "name": "ts_start", + "type": "u64" + }, + { + "name": "reward_per_time_unit", + "type": "u64" + } + ] + } + }, + { + "name": "RewardScheduleCurve", + "type": { + "kind": "struct", + "fields": [ + { + "name": "points", + "docs": [ + "This is a stepwise function, meaning that each point represents", + "how many rewards are issued per time unit since the beginning", + "of that point until the beginning of the next point.", + "This is not a linear curve, there is no interpolation going on.", + "A curve can be [[t0, 100], [t1, 50], [t2, 0]]", + "meaning that from t0 to t1, 100 rewards are issued per time unit,", + "from t1 to t2, 50 rewards are issued per time unit, and after t2 it stops", + "Another curve, can be [[t0, 100], [u64::max, 0]]", + "meaning that from t0 to u64::max, 100 rewards are issued per time unit" + ], + "type": { + "array": [ + { + "defined": { + "name": "RewardPerTimeUnitPoint" + } + }, + 20 + ] + } + } + ] + } + }, + { + "name": "RewardType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Proportional" + }, + { + "name": "Constant" + } + ] + } + }, + { + "name": "TimeUnit", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Seconds" + }, + { + "name": "Slots" + } + ] + } + }, + { + "name": "TokenInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "decimals", + "type": "u64" + }, + { + "name": "token_program", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 6 + ] + } + } + ] + } + }, + { + "name": "DatedPrice", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price", + "type": { + "defined": { + "name": "Price" + } + } + }, + { + "name": "last_updated_slot", + "type": "u64" + }, + { + "name": "unix_timestamp", + "type": "u64" + }, + { + "name": "reserved", + "type": { + "array": [ + "u64", + 2 + ] + } + }, + { + "name": "reserved2", + "type": { + "array": [ + "u16", + 3 + ] + } + }, + { + "name": "index", + "type": "u16" + } + ] + } + }, + { + "name": "Price", + "type": { + "kind": "struct", + "fields": [ + { + "name": "value", + "type": "u64" + }, + { + "name": "exp", + "type": "u64" + } + ] + } + }, + { + "name": "FarmState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "farm_admin", + "type": "pubkey" + }, + { + "name": "global_config", + "type": "pubkey" + }, + { + "name": "token", + "type": { + "defined": { + "name": "TokenInfo" + } + } + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "RewardInfo" + } + }, + 10 + ] + } + }, + { + "name": "num_reward_tokens", + "type": "u64" + }, + { + "name": "num_users", + "docs": [ + "Data used to calculate the rewards of the user" + ], + "type": "u64" + }, + { + "name": "total_staked_amount", + "docs": [ + "The number of token in the `farm_vault` staked (getting rewards and fees)", + "Set such as `farm_vault.amount = total_staked_amount + total_pending_amount`" + ], + "type": "u64" + }, + { + "name": "farm_vault", + "type": "pubkey" + }, + { + "name": "farm_vaults_authority", + "type": "pubkey" + }, + { + "name": "farm_vaults_authority_bump", + "type": "u64" + }, + { + "name": "delegate_authority", + "docs": [ + "Only used for delegate farms", + "Set to `default()` otherwise" + ], + "type": "pubkey" + }, + { + "name": "time_unit", + "docs": [ + "Raw representation of a `TimeUnit`", + "Seconds = 0, Slots = 1" + ], + "type": "u8" + }, + { + "name": "is_farm_frozen", + "docs": [ + "Automatically set to true in case of a full authority withdrawal", + "If true, the farm is frozen and no more deposits are allowed" + ], + "type": "u8" + }, + { + "name": "is_farm_delegated", + "docs": [ + "Indicates if the farm is a delegate farm", + "If true, the farm is a delegate farm and the `delegate_authority` is set*" + ], + "type": "u8" + }, + { + "name": "is_reward_user_once_enabled", + "docs": [ + "If set to 1, indicates that the \"reward user once\" feature is enabled" + ], + "type": "u8" + }, + { + "name": "is_harvesting_permissionless", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 3 + ] + } + }, + { + "name": "withdraw_authority", + "docs": [ + "Withdraw authority for the farm, allowed to lock deposited funds and withdraw them", + "Set to `default()` if unused (only the depositors can withdraw their funds)" + ], + "type": "pubkey" + }, + { + "name": "deposit_warmup_period", + "docs": [ + "Delay between a user deposit and the moment it is considered as staked", + "0 if unused" + ], + "type": "u32" + }, + { + "name": "withdrawal_cooldown_period", + "docs": [ + "Delay between a user unstake and the ability to withdraw his deposit." + ], + "type": "u32" + }, + { + "name": "total_active_stake_scaled", + "docs": [ + "Total active stake of tokens in the farm (scaled from `Decimal` representation)." + ], + "type": "u128" + }, + { + "name": "total_pending_stake_scaled", + "docs": [ + "Total pending stake of tokens in the farm (scaled from `Decimal` representation).", + "(can be used by `withdraw_authority` but don't get rewards or fees)" + ], + "type": "u128" + }, + { + "name": "total_pending_amount", + "docs": [ + "Total pending amount of tokens in the farm" + ], + "type": "u64" + }, + { + "name": "slashed_amount_current", + "docs": [ + "Slashed amounts from early withdrawal" + ], + "type": "u64" + }, + { + "name": "slashed_amount_cumulative", + "type": "u64" + }, + { + "name": "slashed_amount_spill_address", + "type": "pubkey" + }, + { + "name": "locking_mode", + "docs": [ + "Locking stake" + ], + "type": "u64" + }, + { + "name": "locking_start_timestamp", + "type": "u64" + }, + { + "name": "locking_duration", + "type": "u64" + }, + { + "name": "locking_early_withdrawal_penalty_bps", + "type": "u64" + }, + { + "name": "deposit_cap_amount", + "type": "u64" + }, + { + "name": "scope_prices", + "type": "pubkey" + }, + { + "name": "scope_oracle_price_id", + "type": "u64" + }, + { + "name": "scope_oracle_max_age", + "type": "u64" + }, + { + "name": "pending_farm_admin", + "type": "pubkey" + }, + { + "name": "strategy_id", + "type": "pubkey" + }, + { + "name": "delegated_rps_admin", + "type": "pubkey" + }, + { + "name": "vault_id", + "type": "pubkey" + }, + { + "name": "second_delegated_authority", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 74 + ] + } + } + ] + } + }, + { + "name": "GlobalConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "global_admin", + "type": "pubkey" + }, + { + "name": "treasury_fee_bps", + "type": "u64" + }, + { + "name": "treasury_vaults_authority", + "type": "pubkey" + }, + { + "name": "treasury_vaults_authority_bump", + "type": "u64" + }, + { + "name": "pending_global_admin", + "type": "pubkey" + }, + { + "name": "padding1", + "type": { + "array": [ + "u128", + 126 + ] + } + } + ] + } + }, + { + "name": "UserState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user_id", + "type": "u64" + }, + { + "name": "farm_state", + "type": "pubkey" + }, + { + "name": "owner", + "type": "pubkey" + }, + { + "name": "is_farm_delegated", + "docs": [ + "Indicate if this user state is part of a delegated farm" + ], + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "rewards_tally_scaled", + "docs": [ + "Rewards tally used for computation of gained rewards", + "(scaled from `Decimal` representation)." + ], + "type": { + "array": [ + "u128", + 10 + ] + } + }, + { + "name": "rewards_issued_unclaimed", + "docs": [ + "Number of reward tokens ready for claim" + ], + "type": { + "array": [ + "u64", + 10 + ] + } + }, + { + "name": "last_claim_ts", + "type": { + "array": [ + "u64", + 10 + ] + } + }, + { + "name": "active_stake_scaled", + "docs": [ + "User stake deposited and usable, generating rewards and fees.", + "(scaled from `Decimal` representation)." + ], + "type": "u128" + }, + { + "name": "pending_deposit_stake_scaled", + "docs": [ + "User stake deposited but not usable and not generating rewards yet.", + "(scaled from `Decimal` representation)." + ], + "type": "u128" + }, + { + "name": "pending_deposit_stake_ts", + "docs": [ + "After this timestamp, pending user stake can be moved to user stake", + "Initialized to now() + delayed user stake period" + ], + "type": "u64" + }, + { + "name": "pending_withdrawal_unstake_scaled", + "docs": [ + "User deposits unstaked, pending for withdrawal, not usable and not generating rewards.", + "(scaled from `Decimal` representation)." + ], + "type": "u128" + }, + { + "name": "pending_withdrawal_unstake_ts", + "docs": [ + "After this timestamp, user can withdraw their deposit." + ], + "type": "u64" + }, + { + "name": "bump", + "docs": [ + "User bump used for account address validation" + ], + "type": "u64" + }, + { + "name": "delegatee", + "docs": [ + "Delegatee used for initialisation - useful to check against" + ], + "type": "pubkey" + }, + { + "name": "last_stake_ts", + "type": "u64" + }, + { + "name": "rewards_issued_cumulative", + "docs": [ + "Cumulative rewards issued to the user - ONLY used for stats/analytics", + "DO NOT USE IN ANY CALCULATIONS", + "Old userStates will have this field populated only from the point of release", + "not reflecting any historical data before this was released" + ], + "type": { + "array": [ + "u64", + 10 + ] + } + }, + { + "name": "padding1", + "type": { + "array": [ + "u64", + 40 + ] + } + } + ] + } + }, + { + "name": "OraclePrices", + "type": { + "kind": "struct", + "fields": [ + { + "name": "oracle_mappings", + "type": "pubkey" + }, + { + "name": "prices", + "type": { + "array": [ + { + "defined": { + "name": "DatedPrice" + } + }, + 512 + ] + } + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml new file mode 100644 index 000000000..17d89175e --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml @@ -0,0 +1,219 @@ +protocol: kamino-farms +version: v1.6.5 +account_type: FarmState +idl_file_path: idl.json + +tags: + - rewards + - staking + - farming + - lending + - defi + +templates: + - id: kamino-farms-reward-emissions + name: Override Farm Reward Emissions + description: Override a Kamino farm's reward schedule and budget + idl_account_name: FarmState + properties: + - path: reward_infos.0.token.mint + label: Reward token mint + description: "The token this reward slot pays out. Example: USDC's mint" + - path: reward_infos.0.rewards_vault + label: Reward vault + description: >- + Token account the farm pays rewards out of. Example: any token account for the reward mint + - path: reward_infos.0.rewards_available + label: Rewards remaining + description: "Reward budget still available, in the reward token's smallest unit. Example: 1000000000" + - path: reward_infos.0.reward_schedule_curve + label: Emission schedule + description: "Emission rate over time: 20 {ts_start, reward_per_time_unit} points. Example: 1000" + - path: reward_infos.0.rewards_per_second_decimals + label: Rate decimals + description: "Decimal places applied to the emission rate, allowing sub-unit precision. Example: 6" + - path: reward_infos.0.min_claim_duration_seconds + label: Min claim interval + description: "Seconds a user must wait between harvests. Example: 0" + - path: reward_infos.0.last_issuance_ts + label: Last issuance time + description: "When rewards were last accrued (unix seconds). Example: 1780000000" + - path: reward_infos.0.reward_type + label: Reward type + description: How the emission rate is applied; unlabelled in the IDL, keep as found + - path: num_reward_tokens + label: Active reward slots + description: "How many of the 10 reward slots are in use. Example: 1" + address: + type: pubkey + llm_context: | + A farm has 10 reward slots, reward_infos.0 through reward_infos.9, one per reward token. + + HOW TO USE THIS TEMPLATE: + 1. Replace the 0 in the property paths with the slot you are filling + 2. Raise num_reward_tokens to cover it, or the program does not iterate that slot + 3. Set reward_infos.N.rewards_available, or emissions stop when the budget empties + 4. Set the rate through reward_schedule_curve - EXACTLY 20 {ts_start, reward_per_time_unit} + entries sorted ascending; a flat rate is one point at ts_start 0 + 5. Whether the rate is per second, slot or day comes from time_unit on kamino-farms-farm-config + + EXAMPLE - "flat emission from the beginning of time": + reward_infos.0.reward_schedule_curve.points.0.ts_start: 0 + reward_infos.0.reward_schedule_curve.points.0.reward_per_time_unit: 1000 + reward_infos.0.rewards_available: 1000000000 + num_reward_tokens: 1 + + - id: kamino-farms-reward-accumulator + name: Override Farm Reward Accumulator + description: Override a Kamino farm's reward accumulator and staked totals + idl_account_name: FarmState + properties: + - path: reward_infos.0.reward_per_share_scaled + label: Reward per share + description: "Rewards paid per unit of stake since the farm began (scaled x2^18). Example: 5000000" + - path: reward_infos.0.rewards_issued_unclaimed + label: Unclaimed pool + description: >- + Rewards issued by the farm but not yet harvested by users, in the reward token's smallest + unit. Example: 500000 + - "total_active_stake_scaled" + - "total_pending_stake_scaled" + - "total_staked_amount" + address: + type: pubkey + llm_context: | + Claimable rewards = active_stake_scaled * reward_per_share_scaled - rewards_tally_scaled, + where the tally is the user's marker from their last claim (kamino-farms-user-rewards). + + HOW TO USE THIS TEMPLATE: + 1. To hand a user a reward without simulating time, RAISE + reward_infos.N.reward_per_share_scaled here and LEAVE their tally alone + 2. To test dilution, halve total_active_stake_scaled while holding the accumulator constant + 3. Keep total_staked_amount consistent with the scaled totals, or reward maths and withdrawal + accounting disagree + + Stake and reward_per_share_scaled are scaled by 2^18. + + EXAMPLE - "every staker is owed more": + reward_infos.0.reward_per_share_scaled: 5000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-farms-user-rewards + name: Override Farm User Rewards + description: Override one user's farm stake and reward balances + idl_account_name: UserState + properties: + - path: farm_state + label: Farm + description: "The farm this user position belongs to. Example: the farm's address" + - path: owner + label: Owner + description: "Wallet that owns this staked position and may harvest it. Example: your test wallet" + - "delegatee" + - "is_farm_delegated" + - "active_stake_scaled" + - "pending_deposit_stake_scaled" + - "pending_deposit_stake_ts" + - "pending_withdrawal_unstake_scaled" + - "pending_withdrawal_unstake_ts" + - "rewards_tally_scaled" + - "rewards_issued_unclaimed" + - path: last_claim_ts + label: Last claim per reward + description: "Per-reward-slot timestamp of the last harvest (unix seconds). Example: 0" + - path: last_stake_ts + label: Last stake time + description: "When this user last staked (unix seconds). Example: 1780000000" + address: + type: pubkey + llm_context: | + The per-user half of reward distribution. Each array has 10 slots, one per reward token, + matching reward_infos on the FarmState. + + TWO WAYS TO GIVE A USER REWARDS: + 1. SIMPLEST - set rewards_issued_unclaimed.0 directly. An already-accrued balance, so this + tests only the claim path + 2. REALISTIC - lower rewards_tally_scaled.0 (or raise the farm's reward_per_share_scaled) and + let the program compute the accrual + + Set last_claim_ts.0 far in the past to get past min_claim_duration_seconds on the farm. + Raising active_stake_scaled without raising total_active_stake_scaled on the FarmState makes + the farm over-distribute - useful for insolvency tests, not a realistic starting state. + + EXAMPLE - "user has 500 tokens waiting to be harvested": + rewards_issued_unclaimed.0: 500000000 + last_claim_ts.0: 0 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-farms-farm-config + name: Override Farm Configuration + description: Override Kamino farm caps, lockups and cooldowns + idl_account_name: FarmState + properties: + - "is_farm_frozen" + - "is_farm_delegated" + - path: is_harvesting_permissionless + label: Permissionless harvest + description: >- + 1 lets anyone trigger a harvest on a user's behalf, 0 restricts it to the owner. Example: 1 + - path: deposit_cap_amount + label: Deposit cap + description: "Maximum total stake, in the staked token's smallest unit. Example: 0" + - "deposit_warmup_period" + - "withdrawal_cooldown_period" + - "locking_mode" + - path: locking_start_timestamp + label: Lockup start + description: "When the lockup window opens (unix seconds). Example: 1780000000" + - path: locking_duration + label: Lockup duration + description: "How long stake stays locked, in seconds. Example: 604800" + - path: locking_early_withdrawal_penalty_bps + label: Early exit penalty + description: "Haircut applied when unstaking before the lockup ends in bps. Example: 500" + - "time_unit" + - path: scope_prices + label: Scope price account + description: >- + The Scope OraclePrices account used to value the staked token. Example: + 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH + - path: scope_oracle_price_id + label: Scope index + description: "Which Scope entry values the staked token, 0-511. Example: 3" + - path: scope_oracle_max_age + label: Max price age + description: "How old the Scope price may be before the farm rejects it, in seconds. Example: 600" + address: + type: pubkey + llm_context: | + CRITICAL: time_unit rescales EVERY reward rate on the farm, so change it deliberately. + 0 = seconds, 1 = slots, 2 = days. + + HOW TO USE THIS TEMPLATE: + 1. Zero deposit_warmup_period and withdrawal_cooldown_period so a stake or unstake settles in + the same scenario + 2. Set is_farm_frozen: 1 to block stake and unstake while still allowing harvests + 3. scope_prices and scope_oracle_price_id point at a Scope entry - use the kamino-scope + templates to move that price + + EXAMPLE - "no waiting periods": + deposit_warmup_period: 0 + withdrawal_cooldown_period: 0 + + - id: kamino-farms-global-config + name: Override Farms Global Config + description: Override the Kamino Farms treasury fee + idl_account_name: GlobalConfig + # Do not add the admin pubkeys here. Surfpool runs with signature verification disabled, + # so a scenario can already sign as the real admin without changing who it is. + properties: + - path: treasury_fee_bps + label: Treasury fee + description: >- + The protocol's cut of all rewards in bps, taken before users receive anything. Example: 0 + address: + type: pubkey \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/kamino/liquidity/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/idl.json new file mode 100644 index 000000000..443d6c5ed --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/idl.json @@ -0,0 +1,3276 @@ +{ + "address": "6LtLpnUFNByNXLyCoK9wA2MykKAmQNZKBdY8s47dehDc", + "metadata": { + "name": "yvaults", + "version": "0.1.0", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "Whirlpool", + "discriminator": [ + 63, + 149, + 209, + 12, + 225, + 128, + 99, + 9 + ] + }, + { + "name": "Position", + "discriminator": [ + 170, + 188, + 143, + 228, + 122, + 64, + 247, + 208 + ] + }, + { + "name": "PoolState", + "discriminator": [ + 247, + 237, + 227, + 245, + 215, + 195, + 222, + 70 + ] + }, + { + "name": "PersonalPositionState", + "discriminator": [ + 70, + 111, + 150, + 126, + 230, + 15, + 25, + 117 + ] + }, + { + "name": "ProtocolPositionState", + "discriminator": [ + 100, + 226, + 145, + 99, + 146, + 218, + 160, + 106 + ] + }, + { + "name": "WhirlpoolStrategy", + "discriminator": [ + 190, + 178, + 231, + 184, + 49, + 186, + 103, + 13 + ] + }, + { + "name": "GlobalConfig", + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] + }, + { + "name": "CollateralInfos", + "discriminator": [ + 127, + 210, + 52, + 226, + 74, + 169, + 111, + 9 + ] + }, + { + "name": "ScopeChainAccount", + "discriminator": [ + 180, + 51, + 138, + 247, + 240, + 173, + 119, + 79 + ] + }, + { + "name": "TermsSignature", + "discriminator": [ + 197, + 173, + 136, + 91, + 182, + 49, + 113, + 19 + ] + } + ], + "types": [ + { + "name": "PositionRewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "growth_inside_checkpoint", + "type": "u128" + }, + { + "name": "amount_owed", + "type": "u64" + } + ] + } + }, + { + "name": "WhirlpoolRewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "docs": [ + "Reward token mint." + ], + "type": "pubkey" + }, + { + "name": "vault", + "docs": [ + "Reward vault token account." + ], + "type": "pubkey" + }, + { + "name": "authority", + "docs": [ + "Authority account that has permission to initialize the reward and set emissions." + ], + "type": "pubkey" + }, + { + "name": "emissions_per_second_x64", + "docs": [ + "Q64.64 number that indicates how many tokens per second are earned per unit of liquidity." + ], + "type": "u128" + }, + { + "name": "growth_global_x64", + "docs": [ + "Q64.64 number that tracks the total tokens earned per unit of liquidity since the reward", + "emissions were turned on." + ], + "type": "u128" + } + ] + } + }, + { + "name": "RewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "reward_state", + "docs": [ + "Reward state" + ], + "type": "u8" + }, + { + "name": "open_time", + "docs": [ + "Reward open time" + ], + "type": "u64" + }, + { + "name": "end_time", + "docs": [ + "Reward end time" + ], + "type": "u64" + }, + { + "name": "last_update_time", + "docs": [ + "Reward last update time" + ], + "type": "u64" + }, + { + "name": "emissions_per_second_x64", + "docs": [ + "Q64.64 number indicates how many tokens per second are earned per unit of liquidity." + ], + "type": "u128" + }, + { + "name": "reward_total_emissioned", + "docs": [ + "The total amount of reward emissioned" + ], + "type": "u64" + }, + { + "name": "reward_claimed", + "docs": [ + "The total amount of claimed reward" + ], + "type": "u64" + }, + { + "name": "token_mint", + "docs": [ + "Reward token mint." + ], + "type": "pubkey" + }, + { + "name": "token_vault", + "docs": [ + "Reward vault token account." + ], + "type": "pubkey" + }, + { + "name": "authority", + "docs": [ + "The owner that has permission to set reward param" + ], + "type": "pubkey" + }, + { + "name": "reward_growth_global_x64", + "docs": [ + "Q64.64 number that tracks the total tokens earned per unit of liquidity since the reward", + "emissions were turned on." + ], + "type": "u128" + } + ] + } + }, + { + "name": "RebalanceRaw", + "type": { + "kind": "struct", + "fields": [ + { + "name": "params", + "type": { + "array": [ + "u8", + 128 + ] + } + }, + { + "name": "state", + "type": { + "array": [ + "u8", + 256 + ] + } + }, + { + "name": "reference_price_type", + "type": "u8" + } + ] + } + }, + { + "name": "CollateralInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "lower_heuristic", + "type": "u64" + }, + { + "name": "upper_heuristic", + "type": "u64" + }, + { + "name": "exp_heuristic", + "type": "u64" + }, + { + "name": "max_twap_divergence_bps", + "type": "u64" + }, + { + "name": "scope_twap_price_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "scope_price_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "name", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "max_age_price_seconds", + "type": "u64" + }, + { + "name": "max_age_twap_seconds", + "type": "u64" + }, + { + "name": "max_ignorable_amount_as_reward", + "type": "u64" + }, + { + "name": "disabled", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "scope_staking_rate_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "scope_feed", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 4 + ] + } + } + ] + } + }, + { + "name": "CollateralInfoParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "lower_heuristic", + "type": "u64" + }, + { + "name": "upper_heuristic", + "type": "u64" + }, + { + "name": "exp_heuristic", + "type": "u64" + }, + { + "name": "max_twap_divergence_bps", + "type": "u64" + }, + { + "name": "scope_twap_price_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "scope_price_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "name", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "max_age_price_seconds", + "type": "u64" + }, + { + "name": "max_age_twap_seconds", + "type": "u64" + }, + { + "name": "max_ignorable_amount_as_reward", + "type": "u64" + }, + { + "name": "disabled", + "type": "u8" + }, + { + "name": "scope_staking_rate_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "scope_feed", + "type": "pubkey" + } + ] + } + }, + { + "name": "KaminoRewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "decimals", + "type": "u64" + }, + { + "name": "reward_vault", + "type": "pubkey" + }, + { + "name": "reward_mint", + "type": "pubkey" + }, + { + "name": "reward_collateral_id", + "type": "u64" + }, + { + "name": "last_issuance_ts", + "type": "u64" + }, + { + "name": "reward_per_second", + "type": "u64" + }, + { + "name": "amount_uncollected", + "type": "u64" + }, + { + "name": "amount_issued_cumulative", + "type": "u64" + }, + { + "name": "amount_available", + "type": "u64" + } + ] + } + }, + { + "name": "WithdrawalCaps", + "type": { + "kind": "struct", + "fields": [ + { + "name": "config_capacity", + "type": "i64" + }, + { + "name": "current_total", + "type": "i64" + }, + { + "name": "last_interval_start_timestamp", + "type": "u64" + }, + { + "name": "config_interval_length_seconds", + "type": "u64" + } + ] + } + }, + { + "name": "Price", + "type": { + "kind": "struct", + "fields": [ + { + "name": "value", + "type": "u64" + }, + { + "name": "exp", + "type": "u64" + } + ] + } + }, + { + "name": "RebalanceAutodriftParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "init_drift_ticks_per_epoch", + "type": "u32" + }, + { + "name": "ticks_below_mid", + "type": "i32" + }, + { + "name": "ticks_above_mid", + "type": "i32" + }, + { + "name": "frontrun_multiplier_bps", + "type": "u16" + }, + { + "name": "staking_rate_a_source", + "type": { + "defined": { + "name": "StakingRateSource" + } + } + }, + { + "name": "staking_rate_b_source", + "type": { + "defined": { + "name": "StakingRateSource" + } + } + }, + { + "name": "init_drift_direction", + "type": { + "defined": { + "name": "DriftDirection" + } + } + } + ] + } + }, + { + "name": "RebalanceAutodriftWindow", + "type": { + "kind": "struct", + "fields": [ + { + "name": "staking_rate_a", + "type": { + "option": { + "defined": { + "name": "Price" + } + } + } + }, + { + "name": "staking_rate_b", + "type": { + "option": { + "defined": { + "name": "Price" + } + } + } + }, + { + "name": "epoch", + "type": "u64" + }, + { + "name": "theoretical_tick", + "type": "i32" + }, + { + "name": "strat_mid_tick", + "type": "i32" + } + ] + } + }, + { + "name": "RebalanceAutodriftState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_window", + "type": { + "defined": { + "name": "RebalanceAutodriftWindow" + } + } + }, + { + "name": "current_window", + "type": { + "defined": { + "name": "RebalanceAutodriftWindow" + } + } + }, + { + "name": "step", + "type": { + "defined": { + "name": "RebalanceAutodriftStep" + } + } + } + ] + } + }, + { + "name": "RebalanceDriftParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "start_mid_tick", + "type": "i32" + }, + { + "name": "ticks_below_mid", + "type": "i32" + }, + { + "name": "ticks_above_mid", + "type": "i32" + }, + { + "name": "seconds_per_tick", + "type": "u64" + }, + { + "name": "direction", + "type": { + "defined": { + "name": "DriftDirection" + } + } + } + ] + } + }, + { + "name": "RebalanceDriftState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "step", + "type": { + "defined": { + "name": "RebalanceDriftStep" + } + } + }, + { + "name": "last_drift_timestamp", + "type": "u64" + }, + { + "name": "last_mid_tick", + "type": "i32" + } + ] + } + }, + { + "name": "RebalanceExpanderState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "initial_pool_price", + "type": "u128" + }, + { + "name": "expansion_count", + "type": "u16" + } + ] + } + }, + { + "name": "RebalanceManualState", + "type": { + "kind": "struct" + } + }, + { + "name": "PeriodicRebalanceState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_rebalance_timestamp", + "type": "u64" + } + ] + } + }, + { + "name": "RebalancePricePercentageWithResetState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_rebalance_lower_reset_pool_price", + "type": "u128" + }, + { + "name": "last_rebalance_upper_reset_pool_price", + "type": "u128" + } + ] + } + }, + { + "name": "RebalancePricePercentageState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_rebalance_lower_pool_price", + "type": "u128" + }, + { + "name": "last_rebalance_upper_pool_price", + "type": "u128" + } + ] + } + }, + { + "name": "RebalanceTakeProfitState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "step", + "type": { + "defined": { + "name": "RebalanceTakeProfitStep" + } + } + } + ] + } + }, + { + "name": "BinAddLiquidityStrategy", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uniform", + "fields": [ + { + "name": "current_bin_index", + "type": "i32" + }, + { + "name": "lower_bin_index", + "type": "i32" + }, + { + "name": "upper_bin_index", + "type": "i32" + }, + { + "name": "amount_x_to_deposit", + "type": "u64" + }, + { + "name": "amount_y_to_deposit", + "type": "u64" + }, + { + "name": "x_current_bin", + "type": "u64" + }, + { + "name": "y_current_bin", + "type": "u64" + } + ] + }, + { + "name": "CurrentTick", + "fields": [ + "i32" + ] + } + ] + } + }, + { + "name": "SimulationPrice", + "type": { + "kind": "enum", + "variants": [ + { + "name": "PoolPrice" + }, + { + "name": "SqrtPrice", + "fields": [ + "u128" + ] + }, + { + "name": "TickIndex", + "fields": [ + "i32" + ] + } + ] + } + }, + { + "name": "DexSpecificPrice", + "type": { + "kind": "enum", + "variants": [ + { + "name": "SqrtPrice", + "fields": [ + "u128" + ] + }, + { + "name": "Q64_64", + "fields": [ + "u128" + ] + } + ] + } + }, + { + "name": "RemoveLiquidityMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Liquidity", + "fields": [ + "u128" + ] + }, + { + "name": "Bps", + "fields": [ + "u16" + ] + }, + { + "name": "All" + } + ] + } + }, + { + "name": "WithdrawalCapAccumulatorAction", + "type": { + "kind": "enum", + "variants": [ + { + "name": "KeepAccumulator" + }, + { + "name": "ResetAccumulator" + } + ] + } + }, + { + "name": "RebalanceEffects", + "type": { + "kind": "enum", + "variants": [ + { + "name": "NewRange", + "fields": [ + "i32", + "i32" + ] + }, + { + "name": "WithdrawAndFreeze" + } + ] + } + }, + { + "name": "SwapLimit", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Bps", + "fields": [ + "u64" + ] + }, + { + "name": "Absolute", + "fields": [ + { + "name": "src_amount_to_swap", + "docs": [ + "Amount of src token expected by the user to perform the swap" + ], + "type": "u64" + }, + { + "name": "dst_amount_to_vault", + "docs": [ + "Amount of dst token the user provides in exchange" + ], + "type": "u64" + }, + { + "name": "a_to_b", + "type": "bool" + } + ] + } + ] + } + }, + { + "name": "MintingMethod", + "type": { + "kind": "enum", + "variants": [ + { + "name": "PriceBased" + }, + { + "name": "Proportional" + } + ] + } + }, + { + "name": "GlobalConfigOption", + "type": { + "kind": "enum", + "variants": [ + { + "name": "EmergencyMode" + }, + { + "name": "BlockDeposit" + }, + { + "name": "BlockInvest" + }, + { + "name": "BlockWithdraw" + }, + { + "name": "BlockCollectFees" + }, + { + "name": "BlockCollectRewards" + }, + { + "name": "BlockSwapRewards" + }, + { + "name": "BlockSwapUnevenVaults" + }, + { + "name": "WithdrawalFeeBps" + }, + { + "name": "DeprecatedSwapDiscountBps" + }, + { + "name": "ActionsAuthority" + }, + { + "name": "DeprecatedTreasuryFeeVaults" + }, + { + "name": "AdminAuthority" + }, + { + "name": "BlockEmergencySwap" + }, + { + "name": "BlockLocalAdmin" + }, + { + "name": "UpdateTokenInfos" + }, + { + "name": "ScopeProgramId" + }, + { + "name": "UpdateScopePriceId" + }, + { + "name": "MinPerformanceFeeBps" + }, + { + "name": "MinSwapUnevenSlippageToleranceBps" + }, + { + "name": "MinReferencePriceSlippageToleranceBps" + }, + { + "name": "ActionsAfterRebalanceDelaySeconds" + }, + { + "name": "TreasuryFeeVaultReceiver" + }, + { + "name": "AddScopePriceId" + }, + { + "name": "MaxDeviationFromRefPriceOnInvestBps" + }, + { + "name": "InvestCooldownSlots" + }, + { + "name": "MinInvestTriggerValueUsd" + } + ] + } + }, + { + "name": "StrategyConfigOption", + "type": { + "kind": "enum", + "variants": [ + { + "name": "UpdateDepositCap" + }, + { + "name": "UpdateDepositCapIxn" + }, + { + "name": "UpdateWithdrawalCapACapacity" + }, + { + "name": "UpdateWithdrawalCapAInterval" + }, + { + "name": "UpdateWithdrawalCapACurrentTotal" + }, + { + "name": "UpdateWithdrawalCapBCapacity" + }, + { + "name": "UpdateWithdrawalCapBInterval" + }, + { + "name": "UpdateWithdrawalCapBCurrentTotal" + }, + { + "name": "UpdateMaxDeviationBps" + }, + { + "name": "UpdateSwapVaultMaxSlippage" + }, + { + "name": "UpdateStrategyType" + }, + { + "name": "UpdateDepositFee" + }, + { + "name": "UpdateWithdrawFee" + }, + { + "name": "UpdateCollectFeesFee" + }, + { + "name": "UpdateReward0Fee" + }, + { + "name": "UpdateReward1Fee" + }, + { + "name": "UpdateReward2Fee" + }, + { + "name": "UpdateAdminAuthority" + }, + { + "name": "KaminoRewardIndex0TS" + }, + { + "name": "KaminoRewardIndex1TS" + }, + { + "name": "KaminoRewardIndex2TS" + }, + { + "name": "KaminoRewardIndex0RewardPerSecond" + }, + { + "name": "KaminoRewardIndex1RewardPerSecond" + }, + { + "name": "KaminoRewardIndex2RewardPerSecond" + }, + { + "name": "UpdateDepositBlocked" + }, + { + "name": "UpdateRaydiumProtocolPositionOrBaseVaultAuthority" + }, + { + "name": "UpdateRaydiumPoolConfigOrBaseVaultAuthority" + }, + { + "name": "UpdateInvestBlocked" + }, + { + "name": "UpdateWithdrawBlocked" + }, + { + "name": "UpdateLocalAdminBlocked" + }, + { + "name": "DeprecatedUpdateCollateralIdA" + }, + { + "name": "DeprecatedUpdateCollateralIdB" + }, + { + "name": "UpdateFlashVaultSwap" + }, + { + "name": "AllowDepositWithoutInvest" + }, + { + "name": "UpdateSwapVaultMaxSlippageFromRef" + }, + { + "name": "ResetReferencePrices" + }, + { + "name": "UpdateStrategyCreationState" + }, + { + "name": "UpdateIsCommunity" + }, + { + "name": "UpdateRebalanceType" + }, + { + "name": "UpdateRebalanceParams" + }, + { + "name": "UpdateDepositMintingMethod" + }, + { + "name": "UpdateLookupTable" + }, + { + "name": "UpdateReferencePriceType" + }, + { + "name": "UpdateReward0Amount" + }, + { + "name": "UpdateReward1Amount" + }, + { + "name": "UpdateReward2Amount" + }, + { + "name": "UpdateFarm" + }, + { + "name": "UpdateRebalancesCapCapacity" + }, + { + "name": "UpdateRebalancesCapInterval" + }, + { + "name": "UpdateRebalancesCapCurrentTotal" + }, + { + "name": "DeprecatedUpdateSwapUnevenAuthority" + }, + { + "name": "UpdatePendingStrategyAdmin" + }, + { + "name": "UpdateMaxDeviationFromRefPriceOnInvestBps" + } + ] + } + }, + { + "name": "StrategyStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uninitialized" + }, + { + "name": "Active" + }, + { + "name": "Frozen" + }, + { + "name": "Rebalancing" + }, + { + "name": "NoPosition" + } + ] + } + }, + { + "name": "StrategyType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Stable" + }, + { + "name": "Pegged" + }, + { + "name": "Volatile" + } + ] + } + }, + { + "name": "CreationStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "IGNORED" + }, + { + "name": "SHADOW" + }, + { + "name": "LIVE" + }, + { + "name": "DEPRECATED" + }, + { + "name": "STAGING" + } + ] + } + }, + { + "name": "ExecutiveWithdrawAction", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Freeze" + }, + { + "name": "Unfreeze" + }, + { + "name": "Rebalance" + } + ] + } + }, + { + "name": "ReferencePriceType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "POOL" + }, + { + "name": "TWAP" + } + ] + } + }, + { + "name": "LiquidityCalculationMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Deposit" + }, + { + "name": "Withdraw" + } + ] + } + }, + { + "name": "UpdateCollateralInfoMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "CollateralId" + }, + { + "name": "LowerHeuristic" + }, + { + "name": "UpperHeuristic" + }, + { + "name": "ExpHeuristic" + }, + { + "name": "TwapDivergence" + }, + { + "name": "UpdateScopeTwap" + }, + { + "name": "UpdateScopeChain" + }, + { + "name": "UpdateName" + }, + { + "name": "UpdatePriceMaxAge" + }, + { + "name": "UpdateTwapMaxAge" + }, + { + "name": "UpdateDisabled" + }, + { + "name": "UpdateStakingRateChain" + }, + { + "name": "UpdateMaxIgnorableAmountAsReward" + }, + { + "name": "UpdateScopeFeed" + } + ] + } + }, + { + "name": "BalanceStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Balanced" + }, + { + "name": "Unbalanced" + } + ] + } + }, + { + "name": "RebalanceAutodriftStep", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uninitialized" + }, + { + "name": "Autodrifting" + } + ] + } + }, + { + "name": "StakingRateSource", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Constant" + }, + { + "name": "Scope" + } + ] + } + }, + { + "name": "DriftDirection", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Increasing" + }, + { + "name": "Decreasing" + } + ] + } + }, + { + "name": "RebalanceDriftStep", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uninitialized" + }, + { + "name": "Drifting" + } + ] + } + }, + { + "name": "ExpanderStep", + "type": { + "kind": "enum", + "variants": [ + { + "name": "ExpandOrContract", + "fields": [ + "u16" + ] + }, + { + "name": "Recenter" + } + ] + } + }, + { + "name": "RebalanceTakeProfitToken", + "type": { + "kind": "enum", + "variants": [ + { + "name": "A" + }, + { + "name": "B" + } + ] + } + }, + { + "name": "RebalanceTakeProfitStep", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uninitialized" + }, + { + "name": "TakingProfit" + }, + { + "name": "Finished" + } + ] + } + }, + { + "name": "RebalanceAction", + "type": { + "kind": "enum", + "variants": [ + { + "name": "NewPriceRange", + "fields": [ + { + "defined": { + "name": "DexSpecificPrice" + } + }, + { + "defined": { + "name": "DexSpecificPrice" + } + } + ] + }, + { + "name": "NewTickRange", + "fields": [ + "i32", + "i32" + ] + }, + { + "name": "WithdrawAndFreeze" + } + ] + } + }, + { + "name": "RebalanceType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Manual" + }, + { + "name": "PricePercentage" + }, + { + "name": "PricePercentageWithReset" + }, + { + "name": "Drift" + }, + { + "name": "TakeProfit" + }, + { + "name": "PeriodicRebalance" + }, + { + "name": "Expander" + }, + { + "name": "Autodrift" + } + ] + } + }, + { + "name": "CollateralTestToken", + "type": { + "kind": "enum", + "variants": [ + { + "name": "USDC" + }, + { + "name": "USDH" + }, + { + "name": "SOL" + }, + { + "name": "ETH" + }, + { + "name": "BTC" + }, + { + "name": "MSOL" + }, + { + "name": "STSOL" + }, + { + "name": "USDT" + }, + { + "name": "ORCA" + }, + { + "name": "MNDE" + }, + { + "name": "HBB" + }, + { + "name": "JSOL" + }, + { + "name": "USH" + }, + { + "name": "DAI" + }, + { + "name": "LDO" + }, + { + "name": "SCNSOL" + }, + { + "name": "UXD" + }, + { + "name": "HDG" + }, + { + "name": "DUST" + }, + { + "name": "USDR" + }, + { + "name": "RATIO" + }, + { + "name": "UXP" + }, + { + "name": "JITOSOL" + }, + { + "name": "RAY" + }, + { + "name": "BONK" + }, + { + "name": "SAMO" + }, + { + "name": "LaineSOL" + }, + { + "name": "BSOL" + } + ] + } + }, + { + "name": "ScopePriceIdTest", + "type": { + "kind": "enum", + "variants": [ + { + "name": "SOL" + }, + { + "name": "ETH" + }, + { + "name": "BTC" + }, + { + "name": "SRM" + }, + { + "name": "RAY" + }, + { + "name": "FTT" + }, + { + "name": "MSOL" + }, + { + "name": "scnSOL_SOL" + }, + { + "name": "BNB" + }, + { + "name": "AVAX" + }, + { + "name": "DaoSOL_SOL" + }, + { + "name": "SaberMSOL_SOL" + }, + { + "name": "USDH" + }, + { + "name": "StSOL" + }, + { + "name": "CSOL_SOL" + }, + { + "name": "CETH_ETH" + }, + { + "name": "CBTC_BTC" + }, + { + "name": "CMSOL_SOL" + }, + { + "name": "wstETH" + }, + { + "name": "LDO" + }, + { + "name": "USDC" + }, + { + "name": "CUSDC_USDC" + }, + { + "name": "USDT" + }, + { + "name": "ORCA" + }, + { + "name": "MNDE" + }, + { + "name": "HBB" + }, + { + "name": "CORCA_ORCA" + }, + { + "name": "CSLND_SLND" + }, + { + "name": "CSRM_SRM" + }, + { + "name": "CRAY_RAY" + }, + { + "name": "CFTT_FTT" + }, + { + "name": "CSTSOL_STSOL" + }, + { + "name": "SLND" + }, + { + "name": "DAI" + }, + { + "name": "JSOL_SOL" + }, + { + "name": "USH" + }, + { + "name": "UXD" + }, + { + "name": "USDH_TWAP" + }, + { + "name": "USH_TWAP" + }, + { + "name": "UXD_TWAP" + }, + { + "name": "HDG" + }, + { + "name": "DUST" + }, + { + "name": "USDR" + }, + { + "name": "USDR_TWAP" + }, + { + "name": "RATIO" + }, + { + "name": "UXP" + }, + { + "name": "KUXDUSDCORCA" + }, + { + "name": "JITOSOL_SOL" + }, + { + "name": "SOL_EMA" + }, + { + "name": "ETH_EMA" + }, + { + "name": "BTC_EMA" + }, + { + "name": "SRM_EMA" + }, + { + "name": "RAY_EMA" + }, + { + "name": "FTT_EMA" + }, + { + "name": "MSOL_EMA" + }, + { + "name": "BNB_EMA" + }, + { + "name": "AVAX_EMA" + }, + { + "name": "STSOL_EMA" + }, + { + "name": "USDC_EMA" + }, + { + "name": "USDT_EMA" + }, + { + "name": "SLND_EMA" + }, + { + "name": "DAI_EMA" + }, + { + "name": "wstETH_TWAP" + }, + { + "name": "DUST_TWAP" + }, + { + "name": "BONK" + }, + { + "name": "BONK_TWAP" + }, + { + "name": "SAMO" + }, + { + "name": "SAMO_TWAP" + }, + { + "name": "BSOL" + }, + { + "name": "LaineSOL" + } + ] + } + }, + { + "name": "DEX", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Orca" + }, + { + "name": "Raydium" + }, + { + "name": "Meteora" + } + ] + } + }, + { + "name": "Whirlpool", + "docs": [ + "External types" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpools_config", + "type": "pubkey" + }, + { + "name": "whirlpool_bump", + "type": { + "array": [ + "u8", + 1 + ] + } + }, + { + "name": "tick_spacing", + "type": "u16" + }, + { + "name": "tick_spacing_seed", + "type": { + "array": [ + "u8", + 2 + ] + } + }, + { + "name": "fee_rate", + "type": "u16" + }, + { + "name": "protocol_fee_rate", + "type": "u16" + }, + { + "name": "liquidity", + "type": "u128" + }, + { + "name": "sqrt_price", + "type": "u128" + }, + { + "name": "tick_current_index", + "type": "i32" + }, + { + "name": "protocol_fee_owed_a", + "type": "u64" + }, + { + "name": "protocol_fee_owed_b", + "type": "u64" + }, + { + "name": "token_mint_a", + "type": "pubkey" + }, + { + "name": "token_vault_a", + "type": "pubkey" + }, + { + "name": "fee_growth_global_a", + "type": "u128" + }, + { + "name": "token_mint_b", + "type": "pubkey" + }, + { + "name": "token_vault_b", + "type": "pubkey" + }, + { + "name": "fee_growth_global_b", + "type": "u128" + }, + { + "name": "reward_last_updated_timestamp", + "type": "u64" + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "WhirlpoolRewardInfo" + } + }, + 3 + ] + } + } + ] + } + }, + { + "name": "Position", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpool", + "type": "pubkey" + }, + { + "name": "position_mint", + "type": "pubkey" + }, + { + "name": "liquidity", + "type": "u128" + }, + { + "name": "tick_lower_index", + "type": "i32" + }, + { + "name": "tick_upper_index", + "type": "i32" + }, + { + "name": "fee_growth_checkpoint_a", + "type": "u128" + }, + { + "name": "fee_owed_a", + "type": "u64" + }, + { + "name": "fee_growth_checkpoint_b", + "type": "u128" + }, + { + "name": "fee_owed_b", + "type": "u64" + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "PositionRewardInfo" + } + }, + 3 + ] + } + } + ] + } + }, + { + "name": "PoolState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "docs": [ + "Bump to identify PDA" + ], + "type": "u8" + }, + { + "name": "amm_config", + "type": "pubkey" + }, + { + "name": "owner", + "type": "pubkey" + }, + { + "name": "token_mint0", + "docs": [ + "Token pair of the pool, where token_mint_0 address < token_mint_1 address" + ], + "type": "pubkey" + }, + { + "name": "token_mint1", + "type": "pubkey" + }, + { + "name": "token_vault0", + "docs": [ + "Token pair vault" + ], + "type": "pubkey" + }, + { + "name": "token_vault1", + "type": "pubkey" + }, + { + "name": "observation_key", + "docs": [ + "observation account key" + ], + "type": "pubkey" + }, + { + "name": "mint_decimals0", + "docs": [ + "mint0 and mint1 decimals" + ], + "type": "u8" + }, + { + "name": "mint_decimals1", + "type": "u8" + }, + { + "name": "tick_spacing", + "docs": [ + "The minimum number of ticks between initialized ticks" + ], + "type": "u16" + }, + { + "name": "liquidity", + "docs": [ + "The currently in range liquidity available to the pool." + ], + "type": "u128" + }, + { + "name": "sqrt_price_x64", + "docs": [ + "The current price of the pool as a sqrt(token_1/token_0) Q64.64 value" + ], + "type": "u128" + }, + { + "name": "tick_current", + "docs": [ + "The current tick of the pool, i.e. according to the last tick transition that was run." + ], + "type": "i32" + }, + { + "name": "observation_index", + "docs": [ + "the most-recently updated index of the observations array" + ], + "type": "u16" + }, + { + "name": "observation_update_duration", + "type": "u16" + }, + { + "name": "fee_growth_global0_x64", + "docs": [ + "The fee growth as a Q64.64 number, i.e. fees of token_0 and token_1 collected per", + "unit of liquidity for the entire life of the pool." + ], + "type": "u128" + }, + { + "name": "fee_growth_global1_x64", + "type": "u128" + }, + { + "name": "protocol_fees_token0", + "docs": [ + "The amounts of token_0 and token_1 that are owed to the protocol." + ], + "type": "u64" + }, + { + "name": "protocol_fees_token1", + "type": "u64" + }, + { + "name": "swap_in_amount_token0", + "docs": [ + "The amounts in and out of swap token_0 and token_1" + ], + "type": "u128" + }, + { + "name": "swap_out_amount_token1", + "type": "u128" + }, + { + "name": "swap_in_amount_token1", + "type": "u128" + }, + { + "name": "swap_out_amount_token0", + "type": "u128" + }, + { + "name": "status", + "docs": [ + "Bitwise representation of the state of the pool", + "bit0, 1: disable open position and increase liquidity, 0: normal", + "bit1, 1: disable decrease liquidity, 0: normal", + "bit2, 1: disable collect fee, 0: normal", + "bit3, 1: disable collect reward, 0: normal", + "bit4, 1: disable swap, 0: normal" + ], + "type": "u8" + }, + { + "name": "padding", + "docs": [ + "Leave blank for future use" + ], + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "RewardInfo" + } + }, + 3 + ] + } + }, + { + "name": "tick_array_bitmap", + "docs": [ + "Packed initialized tick array state" + ], + "type": { + "array": [ + "u64", + 16 + ] + } + }, + { + "name": "total_fees_token0", + "docs": [ + "except protocol_fee and fund_fee" + ], + "type": "u64" + }, + { + "name": "total_fees_claimed_token0", + "docs": [ + "except protocol_fee and fund_fee" + ], + "type": "u64" + }, + { + "name": "total_fees_token1", + "type": "u64" + }, + { + "name": "total_fees_claimed_token1", + "type": "u64" + }, + { + "name": "fund_fees_token0", + "type": "u64" + }, + { + "name": "fund_fees_token1", + "type": "u64" + }, + { + "name": "open_time", + "type": "u64" + }, + { + "name": "padding1", + "type": { + "array": [ + "u64", + 25 + ] + } + }, + { + "name": "padding2", + "type": { + "array": [ + "u64", + 32 + ] + } + } + ] + } + }, + { + "name": "PersonalPositionState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "docs": [ + "Bump to identify PDA" + ], + "type": "u8" + }, + { + "name": "nft_mint", + "docs": [ + "Mint address of the tokenized position" + ], + "type": "pubkey" + }, + { + "name": "pool_id", + "docs": [ + "The ID of the pool with which this token is connected" + ], + "type": "pubkey" + }, + { + "name": "tick_lower_index", + "docs": [ + "The lower bound tick of the position" + ], + "type": "i32" + }, + { + "name": "tick_upper_index", + "docs": [ + "The upper bound tick of the position" + ], + "type": "i32" + }, + { + "name": "liquidity", + "docs": [ + "The amount of liquidity owned by this position" + ], + "type": "u128" + }, + { + "name": "fee_growth_inside0_last_x64", + "docs": [ + "The token_0 fee growth of the aggregate position as of the last action on the individual position" + ], + "type": "u128" + }, + { + "name": "fee_growth_inside1_last_x64", + "docs": [ + "The token_1 fee growth of the aggregate position as of the last action on the individual position" + ], + "type": "u128" + }, + { + "name": "token_fees_owed0", + "docs": [ + "The fees owed to the position owner in token_0, as of the last computation" + ], + "type": "u64" + }, + { + "name": "token_fees_owed1", + "docs": [ + "The fees owed to the position owner in token_1, as of the last computation" + ], + "type": "u64" + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "PositionRewardInfo" + } + }, + 3 + ] + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 8 + ] + } + } + ] + } + }, + { + "name": "ProtocolPositionState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "docs": [ + "Bump to identify PDA" + ], + "type": "u8" + }, + { + "name": "pool_id", + "docs": [ + "The ID of the pool with which this token is connected" + ], + "type": "pubkey" + }, + { + "name": "tick_lower_index", + "docs": [ + "The lower bound tick of the position" + ], + "type": "i32" + }, + { + "name": "tick_upper_index", + "docs": [ + "The upper bound tick of the position" + ], + "type": "i32" + }, + { + "name": "liquidity", + "docs": [ + "The amount of liquidity owned by this position" + ], + "type": "u128" + }, + { + "name": "fee_growth_inside0_last_x64", + "docs": [ + "The token_0 fee growth per unit of liquidity as of the last update to liquidity or fees owed" + ], + "type": "u128" + }, + { + "name": "fee_growth_inside1_last_x64", + "docs": [ + "The token_1 fee growth per unit of liquidity as of the last update to liquidity or fees owed" + ], + "type": "u128" + }, + { + "name": "token_fees_owed0", + "docs": [ + "The fees owed to the position owner in token_0" + ], + "type": "u64" + }, + { + "name": "token_fees_owed1", + "docs": [ + "The fees owed to the position owner in token_1" + ], + "type": "u64" + }, + { + "name": "reward_growth_inside", + "docs": [ + "The reward growth per unit of liquidity as of the last update to liquidity" + ], + "type": { + "array": [ + "u128", + 3 + ] + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 8 + ] + } + } + ] + } + }, + { + "name": "WhirlpoolStrategy", + "type": { + "kind": "struct", + "fields": [ + { + "name": "admin_authority", + "type": "pubkey" + }, + { + "name": "global_config", + "type": "pubkey" + }, + { + "name": "base_vault_authority", + "type": "pubkey" + }, + { + "name": "base_vault_authority_bump", + "type": "u64" + }, + { + "name": "pool", + "type": "pubkey" + }, + { + "name": "pool_token_vault_a", + "type": "pubkey" + }, + { + "name": "pool_token_vault_b", + "type": "pubkey" + }, + { + "name": "tick_array_lower", + "type": "pubkey" + }, + { + "name": "tick_array_upper", + "type": "pubkey" + }, + { + "name": "position", + "type": "pubkey" + }, + { + "name": "position_mint", + "type": "pubkey" + }, + { + "name": "position_metadata", + "type": "pubkey" + }, + { + "name": "position_token_account", + "type": "pubkey" + }, + { + "name": "token_a_vault", + "type": "pubkey" + }, + { + "name": "token_b_vault", + "type": "pubkey" + }, + { + "name": "deprecated0", + "type": { + "array": [ + "pubkey", + 2 + ] + } + }, + { + "name": "deprecated1", + "type": { + "array": [ + "u64", + 2 + ] + } + }, + { + "name": "token_a_mint", + "type": "pubkey" + }, + { + "name": "token_b_mint", + "type": "pubkey" + }, + { + "name": "token_a_mint_decimals", + "type": "u64" + }, + { + "name": "token_b_mint_decimals", + "type": "u64" + }, + { + "name": "token_a_amounts", + "type": "u64" + }, + { + "name": "token_b_amounts", + "type": "u64" + }, + { + "name": "token_a_collateral_id", + "type": "u64" + }, + { + "name": "token_b_collateral_id", + "type": "u64" + }, + { + "name": "deprecated2", + "type": "pubkey" + }, + { + "name": "deprecated3", + "type": "pubkey" + }, + { + "name": "shares_mint", + "type": "pubkey" + }, + { + "name": "shares_mint_decimals", + "type": "u64" + }, + { + "name": "shares_mint_authority", + "type": "pubkey" + }, + { + "name": "shares_mint_authority_bump", + "type": "u64" + }, + { + "name": "shares_issued", + "type": "u64" + }, + { + "name": "status", + "type": "u64" + }, + { + "name": "reward0_amount", + "type": "u64" + }, + { + "name": "reward0_vault", + "type": "pubkey" + }, + { + "name": "reward0_collateral_id", + "type": "u64" + }, + { + "name": "reward0_decimals", + "type": "u64" + }, + { + "name": "reward1_amount", + "type": "u64" + }, + { + "name": "reward1_vault", + "type": "pubkey" + }, + { + "name": "reward1_collateral_id", + "type": "u64" + }, + { + "name": "reward1_decimals", + "type": "u64" + }, + { + "name": "reward2_amount", + "type": "u64" + }, + { + "name": "reward2_vault", + "type": "pubkey" + }, + { + "name": "reward2_collateral_id", + "type": "u64" + }, + { + "name": "reward2_decimals", + "type": "u64" + }, + { + "name": "deposit_cap_usd", + "type": "u64" + }, + { + "name": "fees_a_cumulative", + "type": "u64" + }, + { + "name": "fees_b_cumulative", + "type": "u64" + }, + { + "name": "reward0_amount_cumulative", + "type": "u64" + }, + { + "name": "reward1_amount_cumulative", + "type": "u64" + }, + { + "name": "reward2_amount_cumulative", + "type": "u64" + }, + { + "name": "deposit_cap_usd_per_ixn", + "type": "u64" + }, + { + "name": "withdrawal_cap_a", + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "withdrawal_cap_b", + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "max_price_deviation_bps", + "type": "u64" + }, + { + "name": "swap_vault_max_slippage_bps", + "type": "u32" + }, + { + "name": "swap_vault_max_slippage_from_reference_bps", + "type": "u32" + }, + { + "name": "strategy_type", + "type": "u64" + }, + { + "name": "padding0", + "type": "u64" + }, + { + "name": "withdraw_fee", + "type": "u64" + }, + { + "name": "fees_fee", + "type": "u64" + }, + { + "name": "reward0_fee", + "type": "u64" + }, + { + "name": "reward1_fee", + "type": "u64" + }, + { + "name": "reward2_fee", + "type": "u64" + }, + { + "name": "position_timestamp", + "type": "u64" + }, + { + "name": "kamino_rewards", + "type": { + "array": [ + { + "defined": { + "name": "KaminoRewardInfo" + } + }, + 3 + ] + } + }, + { + "name": "strategy_dex", + "type": "u64" + }, + { + "name": "raydium_protocol_position_or_base_vault_authority", + "type": "pubkey" + }, + { + "name": "allow_deposit_without_invest", + "type": "u64" + }, + { + "name": "raydium_pool_config_or_base_vault_authority", + "type": "pubkey" + }, + { + "name": "deposit_blocked", + "type": "u8" + }, + { + "name": "creation_status", + "type": "u8" + }, + { + "name": "invest_blocked", + "type": "u8" + }, + { + "name": "share_calculation_method", + "docs": [ + "share_calculation_method can be either DOLAR_BASED=0 or PROPORTION_BASED=1" + ], + "type": "u8" + }, + { + "name": "withdraw_blocked", + "type": "u8" + }, + { + "name": "reserved_flag2", + "type": "u8" + }, + { + "name": "local_admin_blocked", + "type": "u8" + }, + { + "name": "flash_vault_swap_allowed", + "type": "u8" + }, + { + "name": "reference_swap_price_a", + "type": { + "defined": { + "name": "Price" + } + } + }, + { + "name": "reference_swap_price_b", + "type": { + "defined": { + "name": "Price" + } + } + }, + { + "name": "is_community", + "type": "u8" + }, + { + "name": "rebalance_type", + "type": "u8" + }, + { + "name": "flash_swap_in_progress", + "type": "u8" + }, + { + "name": "padding1", + "type": { + "array": [ + "u8", + 5 + ] + } + }, + { + "name": "rebalance_raw", + "type": { + "defined": { + "name": "RebalanceRaw" + } + } + }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "token_a_fees_from_rewards_cumulative", + "type": "u64" + }, + { + "name": "token_b_fees_from_rewards_cumulative", + "type": "u64" + }, + { + "name": "strategy_lookup_table", + "type": "pubkey" + }, + { + "name": "last_swap_uneven_step_timestamp", + "type": "u64" + }, + { + "name": "farm", + "type": "pubkey" + }, + { + "name": "rebalances_cap", + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "padding3_non_zeroed", + "type": { + "array": [ + "u64", + 4 + ] + } + }, + { + "name": "token_a_token_program", + "type": "pubkey" + }, + { + "name": "token_b_token_program", + "type": "pubkey" + }, + { + "name": "pending_admin", + "type": "pubkey" + }, + { + "name": "max_deviation_from_ref_price_on_invest_bps", + "type": "u32" + }, + { + "name": "padding3", + "type": "u32" + }, + { + "name": "last_invest_slot", + "type": "u64" + }, + { + "name": "padding4", + "type": "u64" + }, + { + "name": "padding5", + "type": { + "array": [ + "u128", + 12 + ] + } + }, + { + "name": "padding6", + "type": { + "array": [ + "u128", + 32 + ] + } + }, + { + "name": "padding7", + "type": { + "array": [ + "u128", + 32 + ] + } + }, + { + "name": "padding8", + "type": { + "array": [ + "u128", + 32 + ] + } + } + ] + } + }, + { + "name": "GlobalConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "emergency_mode", + "type": "u64" + }, + { + "name": "block_deposit", + "type": "u64" + }, + { + "name": "block_invest", + "type": "u64" + }, + { + "name": "block_withdraw", + "type": "u64" + }, + { + "name": "block_collect_fees", + "type": "u64" + }, + { + "name": "block_collect_rewards", + "type": "u64" + }, + { + "name": "block_swap_rewards", + "type": "u64" + }, + { + "name": "block_swap_uneven_vaults", + "type": "u32" + }, + { + "name": "block_emergency_swap", + "type": "u32" + }, + { + "name": "min_withdrawal_fee_bps", + "type": "u64" + }, + { + "name": "scope_program_id", + "type": "pubkey" + }, + { + "name": "deprecated", + "type": "pubkey" + }, + { + "name": "padding0_non_zeroed", + "type": { + "array": [ + "u64", + 256 + ] + } + }, + { + "name": "actions_authority", + "type": "pubkey" + }, + { + "name": "admin_authority", + "type": "pubkey" + }, + { + "name": "treasury_fee_vaults", + "type": { + "array": [ + "pubkey", + 256 + ] + } + }, + { + "name": "token_infos", + "type": "pubkey" + }, + { + "name": "block_local_admin", + "type": "u64" + }, + { + "name": "min_performance_fee_bps", + "type": "u64" + }, + { + "name": "min_swap_uneven_slippage_tolerance_bps", + "type": "u64" + }, + { + "name": "min_reference_price_slippage_tolerance_bps", + "type": "u64" + }, + { + "name": "actions_after_rebalance_delay_seconds", + "type": "u64" + }, + { + "name": "treasury_fee_vault_receiver", + "type": "pubkey" + }, + { + "name": "scope_price_ids", + "type": { + "array": [ + "pubkey", + 16 + ] + } + }, + { + "name": "max_deviation_from_ref_price_on_invest_bps", + "type": "u32" + }, + { + "name": "padding1", + "type": "u32" + }, + { + "name": "invest_cooldown_slots", + "type": "u64" + }, + { + "name": "min_invest_trigger_value_usd", + "type": "u64" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 1968 + ] + } + } + ] + } + }, + { + "name": "CollateralInfos", + "type": { + "kind": "struct", + "fields": [ + { + "name": "infos", + "type": { + "array": [ + { + "defined": { + "name": "CollateralInfo" + } + }, + 303 + ] + } + } + ] + } + }, + { + "name": "ScopeChainAccount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chain_array", + "type": { + "array": [ + { + "array": [ + "u16", + 4 + ] + }, + 512 + ] + } + } + ] + } + }, + { + "name": "TermsSignature", + "type": { + "kind": "struct", + "fields": [ + { + "name": "signature", + "type": { + "array": [ + "u8", + 64 + ] + } + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml new file mode 100644 index 000000000..856890193 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml @@ -0,0 +1,231 @@ +protocol: kamino-liquidity +version: v0.1.0 +account_type: WhirlpoolStrategy +idl_file_path: idl.json + +tags: + - liquidity + - concentrated-liquidity + - yield + - defi + +templates: + - id: kamino-liquidity-strategy-balances + name: Override Liquidity Strategy Balances + description: Override a Kamino Liquidity strategy's holdings and shares + idl_account_name: WhirlpoolStrategy + properties: + - path: token_a_amounts + label: Idle token A + description: "Token A held outside the position, in the mint's smallest unit. Example: 1000000000" + - path: token_b_amounts + label: Idle token B + description: "Token B held outside the position, in the mint's smallest unit. Example: 1000000" + - path: shares_issued + label: Shares outstanding + description: "Total shares held by depositors. Example: 1000000000" + - path: position_timestamp + label: Position opened + description: "When the current position was opened (unix seconds). Example: 1780000000" + - path: last_invest_slot + label: Last invest slot + description: "Slot at which the strategy last deployed idle funds into the pool. Example: 370000000" + address: + type: pubkey + llm_context: | + Share price = total holdings (idle plus what is inside the position) / shares_issued. + + HOW TO USE THIS TEMPLATE: + 1. Raise token_a_amounts / token_b_amounts alone to simulate the strategy earning fees + 2. Raise shares_issued alone to dilute holders + 3. Pair with the underlying pool's own template - strategy_dex on + kamino-liquidity-strategy-guards says whether that is whirlpool-*, raydium-clmm-* or + meteora-* + + The tick range and in-range liquidity live on the DEX's own position account, owned by Orca or + Raydium rather than Kamino, so override those through that protocol. + + EXAMPLE - "the strategy collected 1 SOL of fees": + token_a_amounts: 1000000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-liquidity-strategy-rewards + name: Override Liquidity Strategy Rewards + description: Override Kamino Liquidity strategy reward balances + idl_account_name: WhirlpoolStrategy + properties: + - path: reward0_amount + label: DEX reward 0 + description: >- + Rewards harvested from the underlying DEX pool for slot 0, in that reward token's smallest + unit. Example: 1000000 + - path: reward1_amount + label: DEX reward 1 + description: "Harvested DEX pool rewards for slot 1, smallest unit. Example: 1000000" + - path: reward2_amount + label: DEX reward 2 + description: "Harvested DEX pool rewards for slot 2, smallest unit. Example: 1000000" + - path: kamino_rewards.0.reward_per_second + label: Kamino rate + description: >- + Kamino's own emission rate for this slot, in the reward token's smallest unit per second. + Example: 1000 + - path: kamino_rewards.0.amount_uncollected + label: Accrued, undistributed + description: "Rewards accrued but not yet distributed. Example: 5000000" + - path: kamino_rewards.0.amount_available + label: Budget remaining + description: "Reward budget left for this slot. Example: 1000000000" + - path: kamino_rewards.0.last_issuance_ts + label: Last issuance time + description: "When this slot last accrued (unix seconds). Example: 1780000000" + - path: kamino_rewards.0.reward_mint + label: Kamino reward mint + description: >- + Token this Kamino reward slot pays out. Example: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v + (USDC) + - path: farm + label: Linked farm + description: >- + Kamino Farms account if this strategy also pays through Farms. Example: the farm's address + address: + type: pubkey + llm_context: | + TWO reward streams: reward0_amount through reward2_amount are harvested from the underlying + DEX pool, while kamino_rewards holds Kamino's own emissions on top (3 slots). + + HOW TO USE THIS TEMPLATE: + 1. Replace the 0 in kamino_rewards paths with the slot you want (0-2) + 2. Raising kamino_rewards.N.amount_uncollected is the quickest way to give a strategy a + pending reward to hand out + 3. When farm is set, the strategy also pays through Kamino Farms - use the kamino-farms-* + templates for the per-user side + + EXAMPLE - "strategy has rewards ready to distribute": + kamino_rewards.0.amount_uncollected: 5000000 + kamino_rewards.0.amount_available: 1000000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-liquidity-strategy-guards + name: Override Liquidity Strategy Guards + description: Override Kamino Liquidity strategy caps and slippage guards + idl_account_name: WhirlpoolStrategy + properties: + - path: status + label: Strategy status + description: Strategy lifecycle state; unlabelled in the IDL, keep as found + - path: strategy_type + label: Strategy type + description: Rebalancing style; unlabelled in the IDL, keep as found + - path: strategy_dex + label: Underlying DEX + description: >- + Which venue the position runs on: 0 = Orca Whirlpool, 1 = Raydium CLMM, 2 = Meteora. Example: + 0 + - path: deposit_blocked + label: Deposits blocked + description: "1 blocks new deposits, 0 allows them. Example: 1" + - path: withdraw_blocked + label: Withdrawals blocked + description: "1 blocks withdrawals, 0 allows them. Example: 1" + - path: invest_blocked + label: Investing blocked + description: >- + 1 stops the strategy deploying idle funds into the pool, leaving deposits sitting in the + vaults. Example: 1 + - path: creation_status + label: Creation status + description: Setup progress; unlabelled in the IDL, keep as found + - path: allow_deposit_without_invest + label: Deposit without invest + description: "1 lets a deposit settle without immediately deploying into the pool. Example: 1" + - path: flash_vault_swap_allowed + label: Flash swap allowed + description: >- + 1 permits flash swaps through the strategy vaults, the path an arbitrage bot uses. Example: 1 + - path: deposit_cap_usd + label: Deposit cap (USD) + description: "Total deposit ceiling in whole US dollars. Example: 0" + - path: deposit_cap_usd_per_ixn + label: Per-deposit cap (USD) + description: "Ceiling for a single deposit instruction, in whole US dollars. Example: 1000" + - path: max_price_deviation_bps + label: Max price deviation + description: >- + Max pool-price deviation from reference before the strategy refuses to act, in bps. Example: + 2000 + - path: max_deviation_from_ref_price_on_invest_bps + label: Max deviation on invest + description: "Same guard, applied when deploying idle funds in bps. Example: 2000" + - path: swap_vault_max_slippage_bps + label: Max swap slippage + description: "Slippage tolerated on an internal rebalancing swap in bps. Example: 100" + - path: swap_vault_max_slippage_from_reference_bps + label: Max slippage vs reference + description: "Slippage tolerated against the reference price on an internal swap in bps. Example: 100" + - path: rebalance_type + label: Rebalance type + description: Which rule picks new tick bounds; unlabelled in the IDL, keep as found + - path: withdrawal_cap_a.config_capacity + label: Token A withdrawal cap + description: "Maximum token A withdrawable per interval, smallest unit. Example: -1" + - path: withdrawal_cap_a.current_total + label: Token A withdrawn so far + description: "Running total withdrawn in the current interval. Example: 0" + - path: withdrawal_cap_b.config_capacity + label: Token B withdrawal cap + description: "Maximum token B withdrawable per interval, smallest unit. Example: -1" + - path: withdrawal_cap_b.current_total + label: Token B withdrawn so far + description: "Running total of token B withdrawn this interval. Example: 0" + address: + type: pubkey + llm_context: | + strategy_dex tells you which pool template to pair this with: 0 = Orca Whirlpool, + 1 = Raydium CLMM, 2 = Meteora. + + HOW TO USE THIS TEMPLATE: + 1. If you move the underlying pool price and the transaction is rejected, raise + max_price_deviation_bps and max_deviation_from_ref_price_on_invest_bps + 2. Set a withdrawal_cap_*.config_capacity of -1 to disable that cap + 3. deposit_blocked / withdraw_blocked / invest_blocked are 0/1 switches + + EXAMPLE - "let a 20% pool price move through": + max_price_deviation_bps: 2000 + max_deviation_from_ref_price_on_invest_bps: 2000 + + - id: kamino-liquidity-strategy-fees + name: Override Liquidity Strategy Fees + description: Override the Kamino Liquidity strategy's cut of fees and rewards + idl_account_name: WhirlpoolStrategy + properties: + - path: withdraw_fee + label: Withdrawal fee + description: "Charged when a depositor exits in bps. Example: 0" + - path: fees_fee + label: Fee share + description: "Kamino's cut of trading fees earned by the position in bps. Example: 0" + - path: reward0_fee + label: Reward 0 fee + description: "Kamino's cut of reward slot 0 in bps. Example: 0" + - path: reward1_fee + label: Reward 1 fee + description: "Kamino's cut of reward slot 1 in bps. Example: 0" + - path: reward2_fee + label: Reward 2 fee + description: "Kamino's cut of reward slot 2 in bps. Example: 0" + address: + type: pubkey + llm_context: | + Use this template to remove protocol fees so an expected share price is easier to assert on. + + EXAMPLE - "no fees at all": + withdraw_fee: 0 + fees_fee: 0 + reward0_fee: 0 + reward1_fee: 0 + reward2_fee: 0 \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/kamino/scope/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/scope/v1/idl.json new file mode 100644 index 000000000..b5133b286 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/scope/v1/idl.json @@ -0,0 +1,1590 @@ +{ + "address": "HFn8GnPADiny6XqUoWE8uRPPxb29ikn4yTuPa9MF2fWJ", + "metadata": { + "name": "scope", + "version": "0.39.0", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "Configuration", + "discriminator": [ + 192, + 79, + 172, + 30, + 21, + 173, + 25, + 43 + ] + }, + { + "name": "MintsToScopeChains", + "discriminator": [ + 156, + 236, + 56, + 20, + 39, + 141, + 42, + 183 + ] + }, + { + "name": "OracleMappings", + "discriminator": [ + 40, + 244, + 110, + 80, + 255, + 214, + 243, + 188 + ] + }, + { + "name": "OraclePrices", + "discriminator": [ + 89, + 128, + 118, + 221, + 6, + 72, + 180, + 146 + ] + }, + { + "name": "OracleTwaps", + "discriminator": [ + 192, + 139, + 27, + 250, + 53, + 166, + 101, + 61 + ] + }, + { + "name": "TokenMetadatas", + "discriminator": [ + 221, + 107, + 64, + 103, + 67, + 0, + 165, + 22 + ] + } + ], + "types": [ + { + "name": "UpdateOracleMappingAndMetadataEntriesWithId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "entry_id", + "type": "u16" + }, + { + "name": "updates", + "type": { + "vec": { + "defined": { + "name": "UpdateOracleMappingAndMetadataEntry" + } + } + } + } + ] + } + }, + { + "name": "CappedFlooredData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entry", + "type": "u16" + }, + { + "name": "cap_entry", + "type": { + "option": "u16" + } + }, + { + "name": "floor_entry", + "type": { + "option": "u16" + } + } + ] + } + }, + { + "name": "CappedMostRecentOfData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entries", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "max_divergence_bps", + "type": "u16" + }, + { + "name": "sources_max_age_s", + "type": "u64" + }, + { + "name": "cap_entry", + "type": "u16" + } + ] + } + }, + { + "name": "V3", + "type": { + "kind": "struct", + "fields": [ + { + "name": "confidence_factor", + "type": "u32" + } + ] + } + }, + { + "name": "V8V10", + "type": { + "kind": "struct", + "fields": [ + { + "name": "market_status_behavior", + "type": { + "defined": { + "name": "MarketStatusBehavior" + } + } + } + ] + } + }, + { + "name": "ChainlinkStandardPriceData", + "docs": [ + "Price data for standard Chainlink types (v3, v7, v8, v9)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "observations_timestamp", + "type": "u64" + } + ] + } + }, + { + "name": "ChainlinkXPriceData", + "docs": [ + "Price data for ChainlinkX type (v10)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "observations_timestamp", + "type": "u64" + }, + { + "name": "suspended", + "type": "bool" + }, + { + "name": "activation_date_time", + "type": "u64" + } + ] + } + }, + { + "name": "ConditionalData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "condition", + "type": "u8" + }, + { + "name": "tolerance_bps", + "type": "u16" + }, + { + "name": "sources", + "docs": [ + "Extension-prone source list is stored last so future versioned layouts can", + "add more sources without shifting earlier scalar fields." + ], + "type": { + "array": [ + "u16", + 3 + ] + } + } + ] + } + }, + { + "name": "DiscountToMaturityData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "discount_per_year_bps", + "type": "u16" + }, + { + "name": "maturity_timestamp", + "type": "i64" + } + ] + } + }, + { + "name": "MostRecentOfData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entries", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "max_divergence_bps", + "type": "u16" + }, + { + "name": "sources_max_age_s", + "type": "u64" + } + ] + } + }, + { + "name": "Fee", + "type": { + "kind": "struct", + "fields": [ + { + "name": "basis_points", + "type": "u32" + } + ] + } + }, + { + "name": "LiqPool", + "type": { + "kind": "struct", + "fields": [ + { + "name": "lp_mint", + "type": "pubkey" + }, + { + "name": "lp_mint_authority_bump_seed", + "type": "u8" + }, + { + "name": "sol_leg_bump_seed", + "type": "u8" + }, + { + "name": "msol_leg_authority_bump_seed", + "type": "u8" + }, + { + "name": "msol_leg", + "type": "pubkey" + }, + { + "name": "lp_liquidity_target", + "docs": [ + "Liquidity target. If the Liquidity reach this amount, the fee reaches lp_min_discount_fee" + ], + "type": "u64" + }, + { + "name": "lp_max_fee", + "docs": [ + "Liquidity pool max fee" + ], + "type": { + "defined": { + "name": "Fee" + } + } + }, + { + "name": "lp_min_fee", + "docs": [ + "SOL/mSOL Liquidity pool min fee" + ], + "type": { + "defined": { + "name": "Fee" + } + } + }, + { + "name": "treasury_cut", + "docs": [ + "Treasury cut" + ], + "type": { + "defined": { + "name": "Fee" + } + } + }, + { + "name": "lp_supply", + "type": "u64" + }, + { + "name": "lent_from_sol_leg", + "type": "u64" + }, + { + "name": "liquidity_sol_cap", + "type": "u64" + } + ] + } + }, + { + "name": "List", + "type": { + "kind": "struct", + "fields": [ + { + "name": "account", + "type": "pubkey" + }, + { + "name": "item_size", + "type": "u32" + }, + { + "name": "count", + "type": "u32" + }, + { + "name": "new_account", + "type": "pubkey" + }, + { + "name": "copied_count", + "type": "u32" + } + ] + } + }, + { + "name": "StakeSystem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "stake_list", + "type": { + "defined": { + "name": "List" + } + } + }, + { + "name": "delayed_unstake_cooling_down", + "type": "u64" + }, + { + "name": "stake_deposit_bump_seed", + "type": "u8" + }, + { + "name": "stake_withdraw_bump_seed", + "type": "u8" + }, + { + "name": "slots_for_stake_delta", + "docs": [ + "set by admin, how much slots before the end of the epoch, stake-delta can start" + ], + "type": "u64" + }, + { + "name": "last_stake_delta_epoch", + "docs": [ + "Marks the start of stake-delta operations, meaning that if somebody starts a delayed-unstake ticket", + "after this var is set with epoch_num the ticket will have epoch_created = current_epoch+1", + "(the user must wait one more epoch, because their unstake-delta will be execute in this epoch)" + ], + "type": "u64" + }, + { + "name": "min_stake", + "type": "u64" + }, + { + "name": "extra_stake_delta_runs", + "docs": [ + "can be set by validator-manager-auth to allow a second run of stake-delta to stake late stakers in the last minute of the epoch", + "so we maximize user's rewards" + ], + "type": "u32" + } + ] + } + }, + { + "name": "ValidatorSystem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "validator_list", + "type": { + "defined": { + "name": "List" + } + } + }, + { + "name": "manager_authority", + "type": "pubkey" + }, + { + "name": "total_validator_score", + "type": "u32" + }, + { + "name": "total_active_balance", + "docs": [ + "sum of all active lamports staked" + ], + "type": "u64" + }, + { + "name": "auto_add_validator_enabled", + "docs": [ + "allow & auto-add validator when a user deposits a stake-account of a non-listed validator" + ], + "type": "u8" + } + ] + } + }, + { + "name": "State", + "type": { + "kind": "struct", + "fields": [ + { + "name": "msol_mint", + "type": "pubkey" + }, + { + "name": "admin_authority", + "type": "pubkey" + }, + { + "name": "operational_sol_account", + "type": "pubkey" + }, + { + "name": "treasury_msol_account", + "type": "pubkey" + }, + { + "name": "reserve_bump_seed", + "type": "u8" + }, + { + "name": "msol_mint_authority_bump_seed", + "type": "u8" + }, + { + "name": "rent_exempt_for_token_acc", + "type": "u64" + }, + { + "name": "reward_fee", + "type": { + "defined": { + "name": "Fee" + } + } + }, + { + "name": "stake_system", + "type": { + "defined": { + "name": "StakeSystem" + } + } + }, + { + "name": "validator_system", + "type": { + "defined": { + "name": "ValidatorSystem" + } + } + }, + { + "name": "liq_pool", + "type": { + "defined": { + "name": "LiqPool" + } + } + }, + { + "name": "available_reserve_balance", + "type": "u64" + }, + { + "name": "msol_supply", + "type": "u64" + }, + { + "name": "msol_price", + "type": "u64" + }, + { + "name": "circulating_ticket_count", + "docs": [ + "count tickets for delayed-unstake" + ], + "type": "u64" + }, + { + "name": "circulating_ticket_balance", + "docs": [ + "total lamports amount of generated and not claimed yet tickets" + ], + "type": "u64" + }, + { + "name": "lent_from_reserve", + "type": "u64" + }, + { + "name": "min_deposit", + "type": "u64" + }, + { + "name": "min_withdraw", + "type": "u64" + }, + { + "name": "staking_sol_cap", + "type": "u64" + }, + { + "name": "emergency_cooling_down", + "type": "u64" + } + ] + } + }, + { + "name": "MultiplicationChainData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entries", + "type": { + "array": [ + "u16", + 6 + ] + } + }, + { + "name": "sources_max_age_s", + "type": "u64" + } + ] + } + }, + { + "name": "PythLazerData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "feed_id", + "type": "u16" + }, + { + "name": "exponent", + "type": "u8" + }, + { + "name": "bid_ask_spread_factor", + "docs": [ + "Tolerance factor for the bid/ask spread check (`ask - bid` against the", + "price). `0` disables the spread check entirely, in which case the payload", + "is not required to carry `BestBidPrice`/`BestAskPrice`." + ], + "type": "u32" + }, + { + "name": "ema_enabled", + "type": "bool" + }, + { + "name": "ema_confidence_factor", + "type": "u32" + }, + { + "name": "price_confidence_factor", + "docs": [ + "Tolerance factor for the native Lazer `Confidence` check; `0` disables it." + ], + "type": "u32" + } + ] + } + }, + { + "name": "PythLazerEmaRefData", + "docs": [ + "Reference-oracle config for `OracleType::PythLazerEMA`.", + "", + "Stored in `oracle_mappings.generic[index]` for an EMA entry. The EMA value", + "itself lives in the source `PythLazer` entry's `dated_price.generic_data`,", + "populated by `update_price` whenever the spot refresh payload includes an", + "`EmaPrice` property. See `get_ema_price`." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entry", + "docs": [ + "Token index of the source `PythLazer` entry to read the EMA from." + ], + "type": "u16" + } + ] + } + }, + { + "name": "PythLazerStoredData", + "docs": [ + "Layout of `DatedPrice.generic_data` (24 bytes) for `PythLazer` entries.", + "", + "`update_price` writes the spot feed timestamp on every refresh, and the EMA", + "fields whenever the payload carries an `EmaPrice`. `ema_feed_update_timestamp_us == 0`", + "is the \"EMA never received\" sentinel consumed by `get_ema_price`." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "spot_feed_update_timestamp_us", + "type": "u64" + }, + { + "name": "ema_price_value", + "type": "u64" + }, + { + "name": "ema_feed_update_timestamp_us", + "type": "u64" + } + ] + } + }, + { + "name": "Price", + "type": { + "kind": "struct", + "fields": [ + { + "name": "value", + "type": "u64" + }, + { + "name": "exp", + "type": "u64" + } + ] + } + }, + { + "name": "DatedPrice", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price", + "type": { + "defined": { + "name": "Price" + } + } + }, + { + "name": "last_updated_slot", + "type": "u64" + }, + { + "name": "unix_timestamp", + "type": "u64" + }, + { + "name": "generic_data", + "type": { + "array": [ + "u8", + 24 + ] + } + } + ] + } + }, + { + "name": "MintToScopeChain", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "scope_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + } + ] + } + }, + { + "name": "EmaTwap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_update_slot", + "type": "u64" + }, + { + "name": "last_update_unix_timestamp", + "type": "u64" + }, + { + "name": "current_ema1h", + "type": "u128" + }, + { + "name": "updates_tracker1h", + "docs": [ + "The sample tracker is a 64 bit number where each bit represents a point in time." + ], + "type": "u64" + }, + { + "name": "updates_tracker7d", + "type": "u64" + }, + { + "name": "current_ema8h", + "type": "u128" + }, + { + "name": "current_ema24h", + "type": "u128" + }, + { + "name": "updates_tracker8h", + "type": "u64" + }, + { + "name": "updates_tracker24h", + "type": "u64" + }, + { + "name": "current_ema7d", + "type": "u128" + }, + { + "name": "padding1", + "type": { + "array": [ + "u128", + 35 + ] + } + } + ] + } + }, + { + "name": "TwapEnabledBitmask", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bitmask", + "type": "u8" + } + ] + } + }, + { + "name": "TokenMetadata", + "type": { + "kind": "struct", + "fields": [ + { + "name": "name", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "max_age_price_slots", + "type": "u64" + }, + { + "name": "group_ids_bitset", + "type": "u64" + }, + { + "name": "reserved", + "type": { + "array": [ + "u64", + 15 + ] + } + } + ] + } + }, + { + "name": "UpdateOracleMappingAndMetadataEntry", + "type": { + "kind": "enum", + "variants": [ + { + "name": "RemoveEntry" + }, + { + "name": "MappingConfig", + "fields": [ + { + "name": "price_type", + "type": { + "defined": { + "name": "OracleType" + } + } + }, + { + "name": "generic_data", + "type": { + "array": [ + "u8", + 20 + ] + } + } + ] + }, + { + "name": "MappingTwapEntry", + "fields": [ + { + "name": "price_type", + "type": { + "defined": { + "name": "OracleType" + } + } + }, + { + "name": "twap_source", + "type": "u16" + } + ] + }, + { + "name": "MappingTwapEnabledBitmask", + "fields": [ + "u8" + ] + }, + { + "name": "MappingRefPrice", + "fields": [ + { + "name": "ref_price_index", + "type": { + "option": "u16" + } + }, + { + "name": "ref_price_tolerance_bps", + "type": { + "option": "u16" + } + } + ] + }, + { + "name": "MetadataName", + "fields": [ + "string" + ] + }, + { + "name": "MetadataMaxPriceAgeSlots", + "fields": [ + "u64" + ] + }, + { + "name": "MetadataGroupIdsBitset", + "fields": [ + "u64" + ] + } + ] + } + }, + { + "name": "ReportDataMarketStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Unknown" + }, + { + "name": "Closed" + }, + { + "name": "Open" + } + ] + } + }, + { + "name": "MarketStatusBehavior", + "type": { + "kind": "enum", + "variants": [ + { + "name": "AllUpdates" + }, + { + "name": "Open" + }, + { + "name": "OpenAndPrePost" + } + ] + } + }, + { + "name": "ReportDataV9RipcordFlag", + "docs": [ + "# Ripcord Flag", + "- `0` (false): Feed's data provider is OK. Fund's data provider and accuracy is as expected.", + "- `1` (true): Feed's data provider is flagging a pause. Data provider detected outliers,", + "deviated thresholds, or operational issues. **DO NOT consume NAV data when ripcord=1.**" + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "Normal" + }, + { + "name": "Paused" + } + ] + } + }, + { + "name": "PriceUpdateResult", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Updated" + }, + { + "name": "SuspendExistingPrice" + } + ] + } + }, + { + "name": "Condition", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Gt" + }, + { + "name": "Gte" + }, + { + "name": "Lt" + }, + { + "name": "Lte" + }, + { + "name": "Eq" + }, + { + "name": "Neq" + }, + { + "name": "WithinRangeAbs" + }, + { + "name": "OutsideRangeAbs" + }, + { + "name": "WithinRangeBps" + }, + { + "name": "OutsideRangeBps" + }, + { + "name": "NonZero" + } + ] + } + }, + { + "name": "TokenTypes", + "type": { + "kind": "enum", + "variants": [ + { + "name": "TokenA" + }, + { + "name": "TokenB" + } + ] + } + }, + { + "name": "RefPriceToleranceOrTwapSource", + "type": { + "kind": "enum", + "variants": [ + { + "name": "None" + }, + { + "name": "RefPriceToleranceBps", + "fields": [ + "u16" + ] + }, + { + "name": "TwapSource", + "fields": [ + "u16" + ] + } + ] + } + }, + { + "name": "EmaType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Ema1h" + }, + { + "name": "Ema8h" + }, + { + "name": "Ema24h" + }, + { + "name": "Ema7d" + } + ] + } + }, + { + "name": "OracleType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Unused" + }, + { + "name": "DeprecatedPlaceholder1" + }, + { + "name": "DeprecatedPlaceholder2" + }, + { + "name": "DeprecatedPlaceholder3" + }, + { + "name": "DeprecatedPlaceholder4" + }, + { + "name": "SplStake" + }, + { + "name": "KToken" + }, + { + "name": "DeprecatedPlaceholder5" + }, + { + "name": "MsolStake" + }, + { + "name": "KTokenToTokenA" + }, + { + "name": "KTokenToTokenB" + }, + { + "name": "JupiterLpFetch" + }, + { + "name": "ScopeTwap1h" + }, + { + "name": "OrcaWhirlpoolAtoB" + }, + { + "name": "OrcaWhirlpoolBtoA" + }, + { + "name": "RaydiumAmmV3AtoB" + }, + { + "name": "RaydiumAmmV3BtoA" + }, + { + "name": "DeprecatedPlaceholder6" + }, + { + "name": "MeteoraDlmmAtoB" + }, + { + "name": "MeteoraDlmmBtoA" + }, + { + "name": "DeprecatedPlaceholder7" + }, + { + "name": "PythPull" + }, + { + "name": "PythPullEMA" + }, + { + "name": "FixedPrice" + }, + { + "name": "SwitchboardOnDemand" + }, + { + "name": "JitoRestaking" + }, + { + "name": "Chainlink" + }, + { + "name": "DiscountToMaturity" + }, + { + "name": "MostRecentOf" + }, + { + "name": "PythLazer" + }, + { + "name": "RedStone" + }, + { + "name": "AdrenaLp" + }, + { + "name": "Securitize" + }, + { + "name": "CappedFloored" + }, + { + "name": "ChainlinkRWA" + }, + { + "name": "ChainlinkNAV" + }, + { + "name": "FlashtradeLp" + }, + { + "name": "ChainlinkX" + }, + { + "name": "ChainlinkExchangeRate" + }, + { + "name": "CappedMostRecentOf" + }, + { + "name": "ScopeTwap8h" + }, + { + "name": "ScopeTwap24h" + }, + { + "name": "ScopeTwap7d" + }, + { + "name": "MultiplicationChain" + }, + { + "name": "SplBalance" + }, + { + "name": "StakedSolBalance" + }, + { + "name": "TotalMintSupply" + }, + { + "name": "Conditional" + }, + { + "name": "PythLazerEMA" + } + ] + } + }, + { + "name": "ScopeChainError", + "docs": [ + "Errors that can be raised while creating or manipulating a scope chain" + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "PriceChainTooLong" + }, + { + "name": "PriceChainConversionFailure" + }, + { + "name": "NoChainForToken" + }, + { + "name": "InvalidPricesInChain" + }, + { + "name": "MathOverflow" + }, + { + "name": "IntegerConversionOverflow" + } + ] + } + }, + { + "name": "Configuration", + "type": { + "kind": "struct", + "fields": [ + { + "name": "admin", + "type": "pubkey" + }, + { + "name": "oracle_mappings", + "type": "pubkey" + }, + { + "name": "oracle_prices", + "type": "pubkey" + }, + { + "name": "tokens_metadata", + "type": "pubkey" + }, + { + "name": "oracle_twaps", + "type": "pubkey" + }, + { + "name": "admin_cached", + "type": "pubkey" + }, + { + "name": "emergency_council", + "type": "pubkey" + }, + { + "name": "resume_authority", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 1247 + ] + } + } + ] + } + }, + { + "name": "MintsToScopeChains", + "docs": [ + "Map of mints to scope chain only valid for a given price feed" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "oracle_prices", + "type": "pubkey" + }, + { + "name": "seed_pk", + "type": "pubkey" + }, + { + "name": "seed_id", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "mapping", + "type": { + "vec": { + "defined": { + "name": "MintToScopeChain" + } + } + } + } + ] + } + }, + { + "name": "OracleMappings", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price_info_accounts", + "type": { + "array": [ + "pubkey", + 512 + ] + } + }, + { + "name": "price_types", + "type": { + "array": [ + "u8", + 512 + ] + } + }, + { + "name": "twap_source_or_ref_price_tolerance_bps", + "type": { + "array": [ + "u16", + 512 + ] + } + }, + { + "name": "twap_enabled_bitmask", + "type": { + "array": [ + { + "defined": { + "name": "TwapEnabledBitmask" + } + }, + 512 + ] + } + }, + { + "name": "ref_price", + "type": { + "array": [ + "u16", + 512 + ] + } + }, + { + "name": "generic", + "type": { + "array": [ + { + "array": [ + "u8", + 20 + ] + }, + 512 + ] + } + } + ] + } + }, + { + "name": "OraclePrices", + "type": { + "kind": "struct", + "fields": [ + { + "name": "oracle_mappings", + "type": "pubkey" + }, + { + "name": "prices", + "type": { + "array": [ + { + "defined": { + "name": "DatedPrice" + } + }, + 512 + ] + } + } + ] + } + }, + { + "name": "OracleTwaps", + "type": { + "kind": "struct", + "fields": [ + { + "name": "oracle_prices", + "type": "pubkey" + }, + { + "name": "oracle_mappings", + "type": "pubkey" + }, + { + "name": "twaps", + "type": { + "array": [ + { + "defined": { + "name": "EmaTwap" + } + }, + 512 + ] + } + } + ] + } + }, + { + "name": "TokenMetadatas", + "type": { + "kind": "struct", + "fields": [ + { + "name": "metadatas_array", + "type": { + "array": [ + { + "defined": { + "name": "TokenMetadata" + } + }, + 512 + ] + } + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml new file mode 100644 index 000000000..6e155d8e8 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml @@ -0,0 +1,127 @@ +protocol: kamino-scope +version: v0.39.0 +account_type: OraclePrices +idl_file_path: idl.json + +tags: + - oracle + - price-feed + - lending + - defi + +templates: + - id: kamino-scope-price + name: Override Scope Price + description: Override a price in Kamino's Scope oracle + idl_account_name: OraclePrices + properties: + - path: prices.0.price.value + label: Price value + description: "The price mantissa. Example: 12550000000" + - path: prices.0.price.exp + label: Price exponent + description: "Decimal exponent for `value`. Example: 8" + - path: prices.0.last_updated_slot + label: Last updated slot + description: "Slot at which this price was published. Example: 370000000" + - path: prices.0.unix_timestamp + label: Last updated time + description: "Publication time (unix seconds). Example: 1800000000" + address: + type: pubkey + # Main Market's price account. Kamino runs several OraclePrices accounts and a reserve + # names its own in `config.token_info.scope_configuration.price_feed` - check there before + # assuming this one. Captured 2026-08-06. + value: 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH + llm_context: | + CRITICAL: This is the correct way to move a Kamino price. A Reserve's + liquidity.market_price_sf is only a cache that refresh_reserve recomputes from Scope. + + HOW TO USE THIS TEMPLATE: + 1. Read the target Reserve's config.token_info.scope_configuration.price_feed and use that + account as the address (the default serves the Main Market) + 2. Read its config.token_info.scope_configuration.price_chain - up to 4 indices, 65535 = unused + 3. Replace the index 0 in the property paths with the entry you want to move. A chain of + [210, 3] means price = prices[210] * prices[3] + 4. Set price.value = usd_price * 10^exp, keeping exp as you found it + 5. Set last_updated_slot and unix_timestamp to now, or Kamino rejects the price as stale + 6. Set persist: true if the scenario runs past one slot, so a transaction that writes + this account cannot restore the real price. Safe here: nothing in a fork cranks Scope + + SCOPE INDICES (verified 2026-08-06, do not guess these): + - 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH (Main Market): + SOL=3, USDC=13, PYUSD=148, cbBTC=175, JitoSOL=[210,3] + - 3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C (JLP Market): SOL=0, JLP=416 + + EXAMPLE - "SOL crashes to $45" on the Main Market: + prices.3.price.value: 4500000000 + prices.3.price.exp: 8 + + - id: kamino-scope-price-source + name: Override Scope Price Source Mapping + description: Override where a Scope index reads its price from + idl_account_name: OracleMappings + properties: + - path: price_info_accounts.0 + label: Upstream oracle account + description: "Upstream feed Scope reads this index from. Example: a Pyth price account" + - path: price_types.0 + label: Source type + description: How Scope interprets the upstream account; unlabelled in the IDL, keep as found + - path: twap_source_or_ref_price_tolerance_bps.0 + label: Deviation tolerance + description: "Max deviation from the reference before Scope rejects a price, in bps. Example: 5000" + - path: ref_price.0 + label: Reference index + description: "Index of the entry used as this one's sanity reference; 65535 means none. Example: 65535" + address: + type: pubkey + llm_context: | + Use this template to change WHERE Scope reads a price from on its next refresh. + Use kamino-scope-price instead to change a stored price directly. + + HOW TO USE THIS TEMPLATE: + 1. Find this account via the oracle_mappings field on the OraclePrices account you target + 2. Replace the index 0 in the property paths with your entry (0-511) + 3. Point price_info_accounts at an upstream feed you control, or raise + twap_source_or_ref_price_tolerance_bps to let an extreme simulated price through + + EXAMPLE - let a 50% price move past the anomaly guard on entry 3: + twap_source_or_ref_price_tolerance_bps.3: 5000 + + - id: kamino-scope-twap + name: Override Scope TWAP + description: Override a Kamino Scope TWAP entry + idl_account_name: OracleTwaps + properties: + - path: twaps.0.current_ema1h + label: 1h EMA + description: "1h EMA (scaled fraction, x2^60). Example: 0.15 x 2^60" + - path: twaps.0.current_ema8h + label: 8h EMA + description: "8h EMA (scaled fraction, x2^60). Example: 0.15 x 2^60" + - path: twaps.0.current_ema24h + label: 24h EMA + description: "24h EMA (scaled fraction, x2^60). Example: 0.15 x 2^60" + - path: twaps.0.current_ema7d + label: 7d EMA + description: "7d EMA (scaled fraction, x2^60). Example: 0.15 x 2^60" + - path: twaps.0.last_update_slot + label: Last updated slot + description: "Slot at which this TWAP entry was last updated. Example: 370000000" + - path: twaps.0.last_update_unix_timestamp + label: Last updated time + description: "When this TWAP entry was last updated (unix seconds). Example: 1800000000" + address: + type: pubkey + llm_context: | + Use this template when a Scope price override is rejected for diverging from its TWAP. + + HOW TO USE THIS TEMPLATE: + 1. Find this account via the oracle_twaps field on the Scope Configuration account + 2. Replace the index 0 in the property paths with the same entry you moved in kamino-scope-price + 3. Move the EMA to match your new spot price, or raise max_twap_divergence_bps on + kamino-reserve-oracle instead + + EXAMPLE - move the 1h EMA of entry 3 to $45 (EMAs are scaled by 2^60): + twaps.3.current_ema1h: 51879434184388608000 \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/kamino/swap/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/swap/v1/idl.json new file mode 100644 index 000000000..5dd531b43 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/swap/v1/idl.json @@ -0,0 +1,546 @@ +{ + "address": "LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF", + "metadata": { + "name": "limo", + "version": "0.1.0", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "Order", + "discriminator": [ + 134, + 173, + 223, + 185, + 77, + 86, + 28, + 51 + ] + }, + { + "name": "UserSwapBalancesState", + "discriminator": [ + 140, + 228, + 152, + 62, + 231, + 27, + 245, + 198 + ] + }, + { + "name": "GlobalConfig", + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] + } + ], + "types": [ + { + "name": "OrderStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Active" + }, + { + "name": "Filled" + }, + { + "name": "Cancelled" + } + ] + } + }, + { + "name": "OrderType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Vanilla" + } + ] + } + }, + { + "name": "UpdateGlobalConfigMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "UpdateEmergencyMode" + }, + { + "name": "UpdateFlashTakeOrderBlocked" + }, + { + "name": "UpdateBlockNewOrders" + }, + { + "name": "UpdateBlockOrderTaking" + }, + { + "name": "UpdateHostFeeBps" + }, + { + "name": "UpdateAdminAuthorityCached" + }, + { + "name": "UpdateOrderTakingPermissionless" + }, + { + "name": "UpdateOrderCloseDelaySeconds" + }, + { + "name": "UpdateTxnFeeCost" + }, + { + "name": "UpdateAtaCreationCost" + } + ] + } + }, + { + "name": "UpdateGlobalConfigValue", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Bool", + "fields": [ + "bool" + ] + }, + { + "name": "U16", + "fields": [ + "u16" + ] + }, + { + "name": "U64", + "fields": [ + "u64" + ] + }, + { + "name": "Pubkey", + "fields": [ + "pubkey" + ] + } + ] + } + }, + { + "name": "UpdateOrderMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "UpdatePermissionless" + }, + { + "name": "UpdateCounterparty" + } + ] + } + }, + { + "name": "Order", + "type": { + "kind": "struct", + "fields": [ + { + "name": "global_config", + "type": "pubkey" + }, + { + "name": "maker", + "type": "pubkey" + }, + { + "name": "input_mint", + "type": "pubkey" + }, + { + "name": "input_mint_program_id", + "type": "pubkey" + }, + { + "name": "output_mint", + "type": "pubkey" + }, + { + "name": "output_mint_program_id", + "type": "pubkey" + }, + { + "name": "initial_input_amount", + "docs": [ + "The amount of input token the maker wants to swap" + ], + "type": "u64" + }, + { + "name": "expected_output_amount", + "docs": [ + "The amount of output token the maker wants to receive" + ], + "type": "u64" + }, + { + "name": "remaining_input_amount", + "docs": [ + "The amount of input token remaining to be swapped" + ], + "type": "u64" + }, + { + "name": "filled_output_amount", + "docs": [ + "The amount of output token that the maker has received so far" + ], + "type": "u64" + }, + { + "name": "tip_amount", + "docs": [ + "The amount of tips the maker is due to receive for this order -", + "in lamports, stored in the pda_authority account" + ], + "type": "u64" + }, + { + "name": "number_of_fills", + "docs": [ + "The number of times the order has been filled" + ], + "type": "u64" + }, + { + "name": "order_type", + "type": "u8" + }, + { + "name": "status", + "type": "u8" + }, + { + "name": "in_vault_bump", + "type": "u8" + }, + { + "name": "flash_ix_lock", + "docs": [ + "This is normally set to 0, but can be set to 1 to indicate that the", + "order is part of a flash operation, in whcih case the order can not be", + "modified until the flash operation is completed." + ], + "type": "u8" + }, + { + "name": "permissionless", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 3 + ] + } + }, + { + "name": "last_updated_timestamp", + "type": "u64" + }, + { + "name": "flash_start_taker_output_balance", + "docs": [ + "This is only used for flash operations, and is set to the blanance on the start", + "operation, and than back to 0 on the end operation. It is used to compute the difference", + "between start and end balances in order to compute the amount received from a potential swap" + ], + "type": "u64" + }, + { + "name": "counterparty", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 15 + ] + } + } + ] + } + }, + { + "name": "UserSwapBalancesState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user_lamports", + "type": "u64" + }, + { + "name": "input_ta_balance", + "type": "u64" + }, + { + "name": "output_ta_balance", + "type": "u64" + } + ] + } + }, + { + "name": "GlobalConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "emergency_mode", + "type": "u8" + }, + { + "name": "flash_take_order_blocked", + "type": "u8" + }, + { + "name": "new_orders_blocked", + "type": "u8" + }, + { + "name": "orders_taking_blocked", + "type": "u8" + }, + { + "name": "host_fee_bps", + "type": "u16" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 2 + ] + } + }, + { + "name": "order_close_delay_seconds", + "docs": [ + "The number of seconds after an order has been updated before it can be closed" + ], + "type": "u64" + }, + { + "name": "padding1", + "type": { + "array": [ + "u64", + 9 + ] + } + }, + { + "name": "pda_authority_previous_lamports_balance", + "docs": [ + "The total amount of lamports that were present in the pda_authority last", + "time a program instructions which alters the pda_authority account was", + "executed" + ], + "type": "u64" + }, + { + "name": "total_tip_amount", + "docs": [ + "The total amount of tips that have been paid out - should be at least", + "as much as the total lamports present in the pda_authority account" + ], + "type": "u64" + }, + { + "name": "host_tip_amount", + "docs": [ + "The amount of tips the host is due to receive -", + "in lamports, stored in the pda_authority account" + ], + "type": "u64" + }, + { + "name": "pda_authority", + "type": "pubkey" + }, + { + "name": "pda_authority_bump", + "type": "u64" + }, + { + "name": "admin_authority", + "type": "pubkey" + }, + { + "name": "admin_authority_cached", + "type": "pubkey" + }, + { + "name": "txn_fee_cost", + "type": "u64" + }, + { + "name": "ata_creation_cost", + "type": "u64" + }, + { + "name": "padding2", + "type": { + "array": [ + "u64", + 241 + ] + } + } + ] + } + }, + { + "name": "OrderDisplay", + "type": { + "kind": "struct", + "fields": [ + { + "name": "initial_input_amount", + "type": "u64" + }, + { + "name": "expected_output_amount", + "type": "u64" + }, + { + "name": "remaining_input_amount", + "type": "u64" + }, + { + "name": "filled_output_amount", + "type": "u64" + }, + { + "name": "tip_amount", + "type": "u64" + }, + { + "name": "number_of_fills", + "type": "u64" + }, + { + "name": "on_event_output_amount_filled", + "type": "u64" + }, + { + "name": "on_event_tip_amount", + "type": "u64" + }, + { + "name": "order_type", + "type": "u8" + }, + { + "name": "status", + "type": "u8" + }, + { + "name": "last_updated_timestamp", + "type": "u64" + } + ] + } + }, + { + "name": "UserSwapBalanceDiffs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user_lamports_before", + "type": "u64" + }, + { + "name": "input_ta_balance_before", + "type": "u64" + }, + { + "name": "output_ta_balance_before", + "type": "u64" + }, + { + "name": "user_lamports_after", + "type": "u64" + }, + { + "name": "input_ta_balance_after", + "type": "u64" + }, + { + "name": "output_ta_balance_after", + "type": "u64" + }, + { + "name": "swap_program", + "type": "pubkey" + }, + { + "name": "simulated_swap_amount_out", + "type": "u64" + }, + { + "name": "simulated_ts", + "type": "u64" + }, + { + "name": "minimum_amount_out", + "type": "u64" + }, + { + "name": "swap_amount_in", + "type": "u64" + }, + { + "name": "simulated_amount_out_next_best", + "type": "u64" + }, + { + "name": "aggregator", + "type": "u8" + }, + { + "name": "next_best_aggregator", + "type": "u8" + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml new file mode 100644 index 000000000..9e1e474bd --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml @@ -0,0 +1,114 @@ +protocol: kamino-swap +version: v0.1.0 +account_type: Order +idl_file_path: idl.json + +tags: + - swap + - limit-orders + - defi + +templates: + - id: kamino-swap-order + name: Override Limit Order + description: Override a Kamino limit order's amounts and fill progress + idl_account_name: Order + properties: + - path: maker + label: Maker + description: "Wallet that placed the order and deposited the input tokens. Example: your test wallet" + - path: input_mint + label: Input token + description: >- + Token the maker is giving away. Example: So11111111111111111111111111111111111111112 (wSOL) + - path: output_mint + label: Output token + description: >- + Token the maker wants to receive. Example: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (USDC) + - "initial_input_amount" + - "expected_output_amount" + - "remaining_input_amount" + - "filled_output_amount" + - "tip_amount" + - "number_of_fills" + - path: order_type + label: Order type + description: Order behaviour; unlabelled in the IDL, keep as found + - path: status + label: Order status + description: Strategy lifecycle state; unlabelled in the IDL, keep as found + - path: permissionless + label: Anyone can fill + description: "1 lets any taker fill the order, 0 restricts it to `counterparty`. Example: 1" + - path: counterparty + label: Allowed taker + description: >- + The only wallet permitted to fill when `permissionless` is 0. Example: the taker's wallet + - path: last_updated_timestamp + label: Last updated + description: "When the order last changed (unix seconds). Example: 1800000000" + address: + type: pubkey + llm_context: | + Kamino's Swap tab is powered by LIMO, an on-chain limit order book. + + HOW TO USE THIS TEMPLATE: + 1. Set remaining_input_amount to a fraction of initial_input_amount to simulate a PARTIALLY + filled order, or 0 to make it fully consumed + 2. The implied limit price is expected_output_amount / initial_input_amount - lower the + expected output to make the order fillable at a worse market price + 3. Raise tip_amount to make filling attractive to a bot + 4. Amounts are in each mint's smallest unit, so check the mint's decimals first + + EXAMPLE - "1 SOL order, half filled, cheap for the taker": + initial_input_amount: 1000000000 + remaining_input_amount: 500000000 + expected_output_amount: 100000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-swap-global-config + name: Override Swap Global Config + description: Override Kamino limit order global switches and fees + idl_account_name: GlobalConfig + properties: + - path: emergency_mode + label: Emergency mode + description: "1 blocks deposits, borrows and withdrawals; liquidations still allowed. Example: 1" + - path: new_orders_blocked + label: New orders blocked + description: >- + 1 stops order creation while still allowing existing orders to be filled and cancelled. + Example: 1 + - path: orders_taking_blocked + label: Filling blocked + description: "1 stops orders being filled while still allowing new ones to be placed. Example: 1" + - path: flash_take_order_blocked + label: Flash fills blocked + description: "1 blocks flash fills, the arbitrage path. Example: 1" + - path: host_fee_bps + label: Host fee + description: "The integrator's cut of each fill in bps. Example: 0" + - "order_close_delay_seconds" + - "total_tip_amount" + - "host_tip_amount" + - path: txn_fee_cost + label: Assumed tx fee + description: "Transaction cost the program reimburses a filler, in lamports. Example: 5000" + - path: ata_creation_cost + label: Assumed ATA rent + description: "Token-account rent the program reimburses a filler, in lamports. Example: 2039280" + address: + type: pubkey + llm_context: | + flash_take_order_blocked controls flash fills, where a taker borrows the maker's input inside + one transaction, swaps it elsewhere and returns the output. That is the arbitrage path. + + HOW TO USE THIS TEMPLATE: + 1. Set flash_take_order_blocked: 1 to test the rejection + 2. Or leave it at 0 and pair this with a DEX pool override (whirlpool-*, raydium-*, + meteora-*) to build a profitable route + + EXAMPLE - "halt the order book": + emergency_mode: 1 \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/kamino/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/v1/idl.json index 38ff8c8d8..4c270e49a 100644 --- a/crates/core/src/scenarios/protocols/kamino/v1/idl.json +++ b/crates/core/src/scenarios/protocols/kamino/v1/idl.json @@ -2,49 +2,226 @@ "address": "KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD", "metadata": { "name": "kamino_lending", - "version": "1.12.6", + "version": "1.23.0", "spec": "0.1.0" }, "instructions": [], "accounts": [ { "name": "UserState", - "discriminator": [72, 177, 85, 249, 76, 167, 186, 126] + "discriminator": [ + 72, + 177, + 85, + 249, + 76, + 167, + 186, + 126 + ] }, { "name": "GlobalConfig", - "discriminator": [149, 8, 156, 202, 160, 252, 176, 217] + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] }, { "name": "LendingMarket", - "discriminator": [246, 114, 50, 98, 72, 157, 28, 120] + "discriminator": [ + 246, + 114, + 50, + 98, + 72, + 157, + 28, + 120 + ] }, { "name": "Obligation", - "discriminator": [168, 206, 141, 106, 88, 76, 172, 167] + "discriminator": [ + 168, + 206, + 141, + 106, + 88, + 76, + 172, + 167 + ] }, { "name": "ReferrerState", - "discriminator": [194, 81, 217, 103, 12, 19, 12, 66] + "discriminator": [ + 194, + 81, + 217, + 103, + 12, + 19, + 12, + 66 + ] }, { "name": "ReferrerTokenState", - "discriminator": [39, 15, 208, 77, 32, 195, 105, 56] + "discriminator": [ + 39, + 15, + 208, + 77, + 32, + 195, + 105, + 56 + ] }, { "name": "ShortUrl", - "discriminator": [28, 89, 174, 25, 226, 124, 126, 212] + "discriminator": [ + 28, + 89, + 174, + 25, + 226, + 124, + 126, + 212 + ] }, { "name": "UserMetadata", - "discriminator": [157, 214, 220, 235, 98, 135, 171, 28] + "discriminator": [ + 157, + 214, + 220, + 235, + 98, + 135, + 171, + 28 + ] }, { "name": "Reserve", - "discriminator": [43, 242, 204, 202, 26, 247, 59, 127] + "discriminator": [ + 43, + 242, + 204, + 202, + 26, + 247, + 59, + 127 + ] + }, + { + "name": "WithdrawTicket", + "discriminator": [ + 237, + 23, + 164, + 58, + 53, + 248, + 240, + 94 + ] } ], "types": [ + { + "name": "ReserveConfigCustomizationArgs", + "docs": [ + "A definition of optional customizations that should be applied after cloning the config." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "override_fixed_rate_bps", + "docs": [ + "A gate for [Self::fixed_borrow_rate_bps]." + ], + "type": "u8" + }, + { + "name": "fixed_borrow_rate_bps", + "docs": [ + "If [Self::override_fixed_rate_bps] is non-zero, this borrow rate will be used to override", + "the [ReserveConfig::borrow_rate_curve] with a fixed one." + ], + "type": "u32" + }, + { + "name": "override_debt_term_seconds", + "docs": [ + "A gate for [Self::debt_term_seconds]." + ], + "type": "u8" + }, + { + "name": "debt_term_seconds", + "docs": [ + "If [Self::override_debt_term_seconds] is non-zero, this value will be used to override the", + "[ReserveConfig::debt_term_seconds]." + ], + "type": "u64" + }, + { + "name": "clear_elevation_groups", + "docs": [ + "Whether the target reserve should have zeroed [ReserveConfig::elevation_groups] (i.e. not", + "cloned from source).", + "", + "This customization is mandatory when cloning a reserve (with some elevation groups) into a", + "different market (where those elevation group indices would have different meaning)." + ], + "type": "u8" + } + ] + } + }, + { + "name": "BorrowOrderConfigArgs", + "docs": [ + "A subset of [BorrowOrderConfig] excluding the accounts passed via [SetBorrowOrder]." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "remaining_debt_amount", + "type": "u64" + }, + { + "name": "max_borrow_rate_bps", + "type": "u32" + }, + { + "name": "min_debt_term_seconds", + "type": "u64" + }, + { + "name": "fillable_until_timestamp", + "type": "u64" + }, + { + "name": "enable_auto_rollover_on_filled_borrows", + "type": "bool" + } + ] + } + }, { "name": "UpdateConfigMode", "type": { @@ -123,7 +300,7 @@ "name": "UpdateBorrowRateCurve" }, { - "name": "UpdateEntireReserveConfig" + "name": "DeprecatedUpdateEntireReserveConfig" }, { "name": "UpdateDebtWithdrawalCap" @@ -150,7 +327,7 @@ "name": "UpdateBorrowFactor" }, { - "name": "UpdateAssetTier" + "name": "DeprecatedUpdateAssetTier" }, { "name": "UpdateElevationGroup" @@ -208,6 +385,24 @@ }, { "name": "UpdateBlockCTokenUsage" + }, + { + "name": "UpdateDebtMaturityTimestamp" + }, + { + "name": "UpdateDebtTermSeconds" + }, + { + "name": "UpdateEarlyRepayRemainingInterestPct" + }, + { + "name": "UpdateReserveEmergencyMode" + }, + { + "name": "UpdateRewardsAmountPerSlot" + }, + { + "name": "UpdateReservePermissionedOps" } ] } @@ -219,35 +414,50 @@ "variants": [ { "name": "Bool", - "fields": ["bool"] + "fields": [ + "bool" + ] }, { "name": "U8", - "fields": ["u8"] + "fields": [ + "u8" + ] }, { "name": "U8Array", "fields": [ { - "array": ["u8", 8] + "array": [ + "u8", + 8 + ] } ] }, { "name": "U16", - "fields": ["u16"] + "fields": [ + "u16" + ] }, { "name": "U64", - "fields": ["u64"] + "fields": [ + "u64" + ] }, { "name": "U128", - "fields": ["u128"] + "fields": [ + "u128" + ] }, { "name": "Pubkey", - "fields": ["pubkey"] + "fields": [ + "pubkey" + ] }, { "name": "ElevationGroup", @@ -263,7 +473,10 @@ "name": "Name", "fields": [ { - "array": ["u8", 32] + "array": [ + "u8", + 32 + ] } ] } @@ -294,7 +507,7 @@ "name": "UpdateGlobalAllowedBorrow" }, { - "name": "UpdateRiskCouncil" + "name": "UpdateEmergencyCouncil" }, { "name": "UpdateMinFullLiquidationThreshold" @@ -355,6 +568,63 @@ }, { "name": "UpdatePriceTriggeredLiquidationDisabled" + }, + { + "name": "UpdateMatureReserveDebtLiquidationEnabled" + }, + { + "name": "UpdateObligationBorrowDebtTermLiquidationEnabled" + }, + { + "name": "UpdateBorrowOrderCreationEnabled" + }, + { + "name": "UpdateBorrowOrderExecutionEnabled" + }, + { + "name": "UpdateMinBorrowOrderFillValue" + }, + { + "name": "UpdateWithdrawTicketIssuanceEnabled" + }, + { + "name": "UpdateWithdrawTicketRedemptionEnabled" + }, + { + "name": "UpdateMinWithdrawQueuedLiquidityValue" + }, + { + "name": "UpdateFixedTermRolloverWindowDurationSeconds" + }, + { + "name": "UpdateOpenTermRolloverWindowDurationSeconds" + }, + { + "name": "UpdateObligationBorrowRolloverConfigurationEnabled" + }, + { + "name": "UpdateTermBasedFullLiquidationDurationSecs" + }, + { + "name": "UpdateObligationBorrowMigrationToFixedExecutionEnabled" + }, + { + "name": "UpdateMinPartialRolloverValue" + }, + { + "name": "UpdateWithdrawTicketCancellationEnabled" + }, + { + "name": "UpdatePermissioningAuthority" + }, + { + "name": "UpdatePermissionedOps" + }, + { + "name": "DeprecatedUpdateReserveRewardsMaxAprPct" + }, + { + "name": "UpdateReserveRewardsMaxAprBps" } ] } @@ -375,29 +645,40 @@ }, { "name": "LastUpdate", - "docs": ["Last update state"], + "docs": [ + "Last update state" + ], "type": { "kind": "struct", "fields": [ { "name": "slot", - "docs": ["Last slot when updated"], + "docs": [ + "Last slot when updated" + ], "type": "u64" }, { "name": "stale", - "docs": ["True when marked stale, false when slot updated"], + "docs": [ + "True when marked stale, false when slot updated" + ], "type": "u8" }, { "name": "price_status", - "docs": ["Status of the prices used to calculate the last update"], + "docs": [ + "Status of the prices used to calculate the last update" + ], "type": "u8" }, { "name": "placeholder", "type": { - "array": ["u8", 6] + "array": [ + "u8", + 6 + ] } } ] @@ -438,164 +719,477 @@ }, { "name": "debt_reserve", - "docs": ["Mandatory debt reserve for this elevation group"], + "docs": [ + "Mandatory debt reserve for this elevation group" + ], "type": "pubkey" }, { "name": "padding1", "type": { - "array": ["u64", 4] + "array": [ + "u64", + 4 + ] } } ] } }, { - "name": "InitObligationArgs", - "type": { - "kind": "struct", - "fields": [ - { - "name": "tag", - "type": "u8" - }, - { - "name": "id", - "type": "u8" - } - ] - } - }, - { - "name": "ObligationCollateral", - "docs": ["Obligation collateral state"], + "name": "BorrowOrder", + "docs": [ + "A borrow order.", + "", + "When the [Obligation::borrow_order] is populated (i.e. non-zeroed) on an Obligation, then the", + "permissionless \"fill\" operations may borrow liquidity to the owner according to this", + "specification." + ], "type": { "kind": "struct", "fields": [ { - "name": "deposit_reserve", - "docs": ["Reserve collateral is deposited to"], + "name": "debt_liquidity_mint", + "docs": [ + "The asset to be borrowed.", + "The reserves used for [Obligation::borrows] *must* all provide exactly this asset." + ], "type": "pubkey" }, { - "name": "deposited_amount", - "docs": ["Amount of collateral deposited"], + "name": "remaining_debt_amount", + "docs": [ + "The amount of debt that still needs to be filled, in lamports." + ], "type": "u64" }, { - "name": "market_value_sf", + "name": "filled_debt_destination", "docs": [ - "Collateral market value in quote currency (scaled fraction)" + "The token account owned by the [Obligation::owner] and holding [Self::debt_liquidity_mint],", + "where the filled funds should be transferred to." ], - "type": "u128" + "type": "pubkey" }, { - "name": "borrowed_amount_against_this_collateral_in_elevation_group", + "name": "min_debt_term_seconds", "docs": [ - "Debt amount (lamport) taken against this collateral.", - "(only meaningful if this obligation is part of an elevation group, otherwise 0)", - "This is only indicative of the debt computed on the last refresh obligation.", - "If the obligation have multiple collateral this value is the same for all of them." + "The minimum allowed debt term that the obligation owner agrees to.", + "The reserves used to fill this order *cannot* define their debt term *lower* than this.", + "", + "If zeroed, then only open-term reserves may be used." ], "type": "u64" }, { - "name": "padding", - "type": { - "array": ["u64", 9] - } - } - ] - } - }, - { - "name": "ObligationLiquidity", - "docs": ["Obligation liquidity state"], - "type": { - "kind": "struct", - "fields": [ + "name": "fillable_until_timestamp", + "docs": [ + "The time until which the borrow order can still be filled." + ], + "type": "u64" + }, { - "name": "borrow_reserve", - "docs": ["Reserve liquidity is borrowed from"], - "type": "pubkey" + "name": "placed_at_timestamp", + "docs": [ + "The time at which this order was placed.", + "Currently, this is only a piece of metadata." + ], + "type": "u64" }, { - "name": "cumulative_borrow_rate_bsf", + "name": "last_updated_at_timestamp", "docs": [ - "Borrow rate used for calculating interest (big scaled fraction)" + "The time at which this order was most-recently updated (including: created).", + "Currently, this is only a piece of metadata." ], - "type": { - "defined": { - "name": "BigFractionBytes" - } - } + "type": "u64" }, { - "name": "padding", + "name": "requested_debt_amount", + "docs": [ + "The amount of debt that was originally requested when this order was most-recently updated.", + "In other words: this field holds a value of [Self::remaining_debt_amount] captured at", + "[Self::last_updated_at_timestamp].", + "Currently, this is only a piece of metadata." + ], "type": "u64" }, { - "name": "borrowed_amount_sf", + "name": "max_borrow_rate_bps", "docs": [ - "Amount of liquidity borrowed plus interest (scaled fraction)" + "The maximum borrow rate that the obligation owner agrees to.", + "The reserves used for [Obligation::borrows] *cannot* define their maximum borrow rate", + "*higher* than this." ], - "type": "u128" + "type": "u32" }, { - "name": "market_value_sf", + "name": "active", "docs": [ - "Liquidity market value in quote currency (scaled fraction)" + "Whether the [Self::remaining_debt_amount] is non-zero.", + "", + "This field is *not* used by smart contract logic (which prefers to treat the above", + "[Self::remaining_debt_amount]-based definition as the single source of truth). However, it", + "is useful for off-chain bots (order-searchers) to efficiently list (i.e. `memcmp` filter)", + "just the obligations that have active borrow orders." ], - "type": "u128" + "type": "u8" }, { - "name": "borrow_factor_adjusted_market_value_sf", + "name": "enable_auto_rollover_on_filled_borrows", "docs": [ - "Risk adjusted liquidity market value in quote currency - DEBUG ONLY - use market_value instead" + "When `1`, all [Obligation::borrows] that get filled by this order will have their", + "[FixedTermBorrowRolloverConfig::auto_rollover_enabled] flag set.", + "", + "Additionally, their rollover customizations:", + "- will exactly match this order's constraints regarding [Self::min_debt_term_seconds] and", + "[Self::max_borrow_rate_bps];", + "- will use the [FixedTermBorrowRolloverConfig::open_term_allowed] fallback.", + "", + "See [BorrowOrder::get_rollover_config_for_filled_borrow()].", + "", + "Clarification note: when `0`, this setting has no effect on any borrow (i.e. if an existing", + "borrow was independently marked for auto-rollover, it will *not* be unmarked when filled by", + "this order).", + "", + "Feature flag note: when [LendingMarket::obligation_borrow_rollover_configuration_enabled] is", + "disabled, this setting has no effect on any borrow (i.e. the fill will be successful, but", + "the borrow will not be marked for auto-rollover." ], - "type": "u128" + "type": "u8" }, { - "name": "borrowed_amount_outside_elevation_groups", + "name": "padding1", "docs": [ - "Amount of liquidity borrowed outside of an elevation group" + "Alignment padding." ], - "type": "u64" + "type": { + "array": [ + "u8", + 2 + ] + } }, { - "name": "padding2", + "name": "end_padding", + "docs": [ + "End padding." + ], "type": { - "array": ["u64", 7] + "array": [ + "u64", + 5 + ] } } ] } }, { - "name": "ObligationOrder", - "docs": ["A single obligation order.", "See [Obligation::orders]."], + "name": "FixedTermBorrowRolloverConfig", + "docs": [ + "Settings driving the auto-rollover (or migration) of an [ObligationLiquidity]'s borrow.", + "", + "This covers three flavors:", + "- *fixed-to-fixed*: a fixed-term borrow rolling into another fixed-term reserve,", + "- *fixed-to-open*: a fixed-term borrow rolling into an open-term reserve,", + "- *open-to-fixed*: an open-term borrow migrating into a fixed-term reserve.", + "", + "By its nature (not a special case), the zeroed struct means \"no auto-rollover/migration\"." + ], "type": { "kind": "struct", "fields": [ { - "name": "condition_threshold_sf", - "docs": [ - "A threshold value used by the condition (scaled [Fraction]).", - "The exact meaning depends on the specific [Self::condition_type].", + "name": "auto_rollover_enabled", + "docs": [ + "Whether this *fixed-term* borrow can be permissionlessly prolonged. The funds used to roll", + "over can come:", + "- either from a *fixed-term* reserve (same or a different one):", + "- This can only happen within [LendingMarket::fixed_term_rollover_window_duration_seconds].", + "- The target reserve must meet all the criteria defined in this config (see", + "[Self::max_borrow_rate_bps] and [Self::min_debt_term_seconds]).", + "- Note: not possible when [Self::min_debt_term_seconds] is `0` (open-term only).", + "- or from an *open-term* reserve:", + "- This can only happen within [LendingMarket::open_term_rollover_window_duration_seconds].", + "- The user must explicitly set [Self::open_term_allowed] here.", "", - "Examples:", - "- when `condition_type == 2 (UserLtvBelow)`:", - "then a value of `0.455` here means that the order is active only when the obligation's", - "user LTV is less than `0.455` (i.e. < 45.5%).", - "- when `condition_type == 3 (DebtCollPriceRatioAbove)`:", - "assuming the obligation uses BTC collateral for SOL debt, then a value of `491.3` here", - "means that the order is active only when the BTC-SOL price is greater than `491.3` (i.e.", - "> 491.3 SOL per BTC)." + "This setting is not effective when the borrow is currently using an *open-term* reserve." ], - "type": "u128" + "type": "u8" }, { - "name": "opportunity_parameter_sf", + "name": "open_term_allowed", + "docs": [ + "When `1`, then [Self::auto_rollover_enabled] is allowed to roll this borrow over into any", + "open-term reserve.", + "", + "Please note that if such rollover actually happens, then [Self::max_borrow_rate_bps]", + "condition does not apply - technically, it could be evaluated, but open-term reserves", + "typically use float-rate (utilization-driven borrow rate curve) which has very high maximum", + "(when at 100% utilization) that would not meet any practical criteria here." + ], + "type": "u8" + }, + { + "name": "migration_to_fixed_enabled", + "docs": [ + "Whether this *open-term* borrow can be permissionlessly migrated into a fixed-term reserve:", + "- This can happen at any moment (as soon as liquidity becomes available).", + "- The target fixed-term reserve must meet all the criteria defined in this config (see", + "[Self::max_borrow_rate_bps] and [Self::min_debt_term_seconds]).", + "", + "This setting is not effective when the borrow is currently using a *fixed-term* reserve.", + "", + "Cannot be enabled when [Self::min_debt_term_seconds] is `0` (open-term only), because", + "migrating into a fixed-term reserve contradicts the open-term-only intent." + ], + "type": "u8" + }, + { + "name": "alignment_padding", + "docs": [ + "Internal alignment padding (free to reuse)." + ], + "type": { + "array": [ + "u8", + 1 + ] + } + }, + { + "name": "max_borrow_rate_bps", + "docs": [ + "A maximum allowed borrow rate of a reserve that can be used for a rollover/migration.", + "", + "Note: this must be set (i.e. non-zero) when enabling any rollover/migration flavor, but is", + "of course not effective when rollover/migration is not enabled." + ], + "type": "u32" + }, + { + "name": "min_debt_term_seconds", + "docs": [ + "A minimum debt term (in seconds) of a fixed-term reserve that can be used for a", + "rollover/migration.", + "", + "When `0`, the owner only accepts open-term reserves as rollover targets \u2014 i.e. rolling over", + "(or migrating) into a fixed-term reserve is not allowed. This is consistent with the", + "semantics of [BorrowOrder::min_debt_term_seconds].", + "", + "This means that `0` is incompatible with [Self::migration_to_fixed_enabled] (which requires", + "a fixed-term target) \u2014 this combination is rejected at configuration time." + ], + "type": "u64" + } + ] + } + }, + { + "name": "InitObligationArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "tag", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + } + ] + } + }, + { + "name": "ObligationCollateral", + "docs": [ + "Obligation collateral state" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "deposit_reserve", + "docs": [ + "Reserve collateral is deposited to" + ], + "type": "pubkey" + }, + { + "name": "deposited_amount", + "docs": [ + "Amount of collateral deposited" + ], + "type": "u64" + }, + { + "name": "market_value_sf", + "docs": [ + "Collateral market value in quote currency (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "borrowed_amount_against_this_collateral_in_elevation_group", + "docs": [ + "Debt amount (lamport) taken against this collateral.", + "(only meaningful if this obligation is part of an elevation group, otherwise 0)", + "This is only indicative of the debt computed on the last refresh obligation.", + "If the obligation have multiple collateral this value is the same for all of them." + ], + "type": "u64" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 9 + ] + } + } + ] + } + }, + { + "name": "ObligationLiquidity", + "docs": [ + "Obligation liquidity state" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "borrow_reserve", + "docs": [ + "Reserve liquidity is borrowed from" + ], + "type": "pubkey" + }, + { + "name": "cumulative_borrow_rate_bsf", + "docs": [ + "Borrow rate used for calculating interest (big scaled fraction)" + ], + "type": { + "defined": { + "name": "BigFractionBytes" + } + } + }, + { + "name": "last_borrowed_at_timestamp", + "docs": [ + "The timestamp at which this debt was taken.", + "", + "Conceptually, every borrow can be interpreted as \"closing the previous loan and starting a", + "new one\" (which would make a plain ` borrowed_at ` an even better name). But in terms of", + "implementation, this fields records when the *last* borrow operation from this reserve", + "happened (i.e. adding debt of the same reserve *does* move this timestamp).", + "", + "Note: this field is *not* only metadata: it is used in the logic, e.g. for enforcing the", + "fixed-term borrows (i.e. those induced by [ReserveConfig::debt_term_seconds])." + ], + "type": "u64" + }, + { + "name": "borrowed_amount_sf", + "docs": [ + "Amount of liquidity borrowed plus interest (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "market_value_sf", + "docs": [ + "Liquidity market value in quote currency (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "borrow_factor_adjusted_market_value_sf", + "docs": [ + "Risk adjusted liquidity market value in quote currency - DEBUG ONLY - use market_value instead" + ], + "type": "u128" + }, + { + "name": "borrowed_amount_outside_elevation_groups", + "docs": [ + "Amount of liquidity borrowed outside of an elevation group" + ], + "type": "u64" + }, + { + "name": "fixed_term_borrow_rollover_config", + "docs": [ + "The user's auto-rollover/migration opt-ins. Some settings are effective only for fixed-term", + "borrows, while others only for open-term borrows - see individual field docs." + ], + "type": { + "defined": { + "name": "FixedTermBorrowRolloverConfig" + } + } + }, + { + "name": "borrowed_amount_at_expiration", + "docs": [ + "An amount of liquidity that was borrowed when this fixed-term borrow expired (i.e. zeroed if", + "this borrow is not fixed-term, or if it did not yet expire).", + "", + "Needed to honor the [LendingMarket::term_based_full_liquidation_duration_secs].", + "", + "This value is captured by [Self::capture_borrowed_amount_at_expiration] during obligation's", + "refresh - please see the method's docs for gotchas.", + "", + "Note on precision: we use a `u64` field, since the remaining space within this struct is", + "rather scarce, and we do not need sub-lamport precision for the liquidation throttling rate." + ], + "type": "u64" + }, + { + "name": "padding2", + "type": { + "array": [ + "u64", + 4 + ] + } + } + ] + } + }, + { + "name": "ObligationOrder", + "docs": [ + "A single obligation order.", + "See [Obligation::obligation_orders]." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "condition_threshold_sf", + "docs": [ + "A threshold value used by the condition (scaled [Fraction]).", + "The exact meaning depends on the specific [Self::condition_type].", + "", + "Examples:", + "- when `condition_type == 2 (UserLtvBelow)`:", + "then a value of `0.455` here means that the order is active only when the obligation's", + "user LTV is less than `0.455` (i.e. < 45.5%).", + "- when `condition_type == 3 (DebtCollPriceRatioAbove)`:", + "assuming the obligation uses BTC collateral for SOL debt, then a value of `491.3` here", + "means that the order is active only when the BTC-SOL price is greater than `491.3` (i.e.", + "> 491.3 SOL per BTC)." + ], + "type": "u128" + }, + { + "name": "opportunity_parameter_sf", "docs": [ "A configuration parameter used by the opportunity (scaled [Fraction]).", "The exact meaning depends on the specific [Self::opportunity_type].", @@ -669,12 +1263,15 @@ { "name": "padding1", "docs": [ - "Internal padding.", + "Alignment padding.", "The fields above take up 2+2+1+1 bytes = 48 bits, which means we need 80 bits = 10 bytes to", "align with `u128`s." ], "type": { - "array": ["u8", 10] + "array": [ + "u8", + 10 + ] } }, { @@ -684,25 +1281,44 @@ "The total size of a single instance is 8*u128 = 128 bytes." ], "type": { - "array": ["u128", 5] + "array": [ + "u128", + 5 + ] } } ] } }, { - "name": "AssetTier", + "name": "UpdateObligationConfigMode", + "docs": [ + "A discriminator of a user-configurable piece of [Obligation].", + "", + "Implementation note: due to TS-side codegen quirks (and a \"convention\" currently seen e.g.", + "within reserve and market update operations), this is not a true Rust enum. The new value of", + "a config item is provided in a separate handler argument (borsh-serialized), and its expected", + "type is defined by each discriminator here. Additionally, each update mode acts on a specific", + "[ObligationConfigUpdateSubject] (e.g. the auto-rollover of fixed-term borrows is configured on", + "a per-borrow basis), which is also specified by separate handler arguments." + ], "type": { "kind": "enum", "variants": [ { - "name": "Regular" + "name": "FixedTermRolloverEnabled" + }, + { + "name": "FixedTermRolloverMaxBorrowRateBps" }, { - "name": "IsolatedCollateral" + "name": "FixedTermRolloverMinDebtTermSeconds" }, { - "name": "IsolatedDebt" + "name": "FixedTermRolloverOpenTermAllowed" + }, + { + "name": "MigrationToFixedEnabled" } ] } @@ -715,13 +1331,19 @@ { "name": "value", "type": { - "array": ["u64", 4] + "array": [ + "u64", + 4 + ] } }, { "name": "padding", "type": { - "array": ["u64", 2] + "array": [ + "u64", + 2 + ] } } ] @@ -729,7 +1351,9 @@ }, { "name": "FeeCalculation", - "docs": ["Calculate fees exlusive or inclusive of an amount"], + "docs": [ + "Calculate fees exlusive or inclusive of an amount" + ], "type": { "kind": "enum", "variants": [ @@ -744,35 +1368,49 @@ }, { "name": "ReserveCollateral", - "docs": ["Reserve collateral"], + "docs": [ + "Reserve collateral" + ], "type": { "kind": "struct", "fields": [ { "name": "mint_pubkey", - "docs": ["Reserve collateral mint address"], + "docs": [ + "Reserve collateral mint address" + ], "type": "pubkey" }, { "name": "mint_total_supply", - "docs": ["Reserve collateral mint supply, used for exchange rate"], + "docs": [ + "Reserve collateral mint supply, used for exchange rate" + ], "type": "u64" }, { "name": "supply_vault", - "docs": ["Reserve collateral supply address"], + "docs": [ + "Reserve collateral supply address" + ], "type": "pubkey" }, { "name": "padding1", "type": { - "array": ["u128", 32] + "array": [ + "u128", + 32 + ] } }, { "name": "padding2", "type": { - "array": ["u128", 32] + "array": [ + "u128", + 32 + ] } } ] @@ -780,17 +1418,21 @@ }, { "name": "ReserveConfig", - "docs": ["Reserve configuration values"], + "docs": [ + "Reserve configuration values" + ], "type": { "kind": "struct", "fields": [ { "name": "status", - "docs": ["Status of the reserve Active/Obsolete/Hidden"], + "docs": [ + "Status of the reserve Active/Obsolete/Hidden" + ], "type": "u8" }, { - "name": "asset_tier", + "name": "padding_deprecated_asset_tier", "docs": [ "Asset tier -> 0 - regular (collateral & debt), 1 - isolated collateral, 2 - isolated debt" ], @@ -798,7 +1440,9 @@ }, { "name": "host_fixed_interest_rate_bps", - "docs": ["Flat rate that goes to the host"], + "docs": [ + "Flat rate that goes to the host" + ], "type": "u16" }, { @@ -817,11 +1461,35 @@ ], "type": "u8" }, + { + "name": "early_repay_remaining_interest_pct", + "docs": [ + "The percentage of remaining interest over the debt term that is charged as early repay penalty.", + "Only meaningful when `debt_term_seconds > 0`." + ], + "type": "u8" + }, + { + "name": "emergency_mode", + "docs": [ + "Whether the reserve is in emergency mode.", + "Blocks most user operations involving this reserve, similar to [LendingMarket::emergency_mode]", + "but scoped to a single reserve. Also cascades to obligations using this reserve as", + "collateral or debt, blocking borrows and withdrawals on other reserves but still", + "allowing repays and deposits." + ], + "type": "u8" + }, { "name": "reserved1", - "docs": ["Past reserved space - feel free to reuse."], + "docs": [ + "Past reserved space - feel free to reuse." + ], "type": { - "array": ["u8", 6] + "array": [ + "u8", + 4 + ] } }, { @@ -911,7 +1579,9 @@ }, { "name": "borrow_rate_curve", - "docs": ["Borrow rate curve based on utilization"], + "docs": [ + "Borrow rate curve based on utilization" + ], "type": { "defined": { "name": "BorrowRateCurve" @@ -920,7 +1590,9 @@ }, { "name": "borrow_factor_pct", - "docs": ["Borrow factor in percentage - used for risk adjustment"], + "docs": [ + "Borrow factor in percentage - used for risk adjustment" + ], "type": "u64" }, { @@ -939,7 +1611,9 @@ }, { "name": "token_info", - "docs": ["Token id from TokenInfos struct"], + "docs": [ + "Token id from TokenInfos struct" + ], "type": { "defined": { "name": "TokenInfo" @@ -948,7 +1622,9 @@ }, { "name": "deposit_withdrawal_cap", - "docs": ["Deposit withdrawal caps - deposit & redeem"], + "docs": [ + "Deposit withdrawal caps - deposit & redeem" + ], "type": { "defined": { "name": "WithdrawalCaps" @@ -957,7 +1633,9 @@ }, { "name": "debt_withdrawal_cap", - "docs": ["Debt withdrawal caps - borrow & repay"], + "docs": [ + "Debt withdrawal caps - borrow & repay" + ], "type": { "defined": { "name": "WithdrawalCaps" @@ -967,7 +1645,10 @@ { "name": "elevation_groups", "type": { - "array": ["u8", 20] + "array": [ + "u8", + 20 + ] } }, { @@ -987,8 +1668,7 @@ "Whether this reserve should be subject to auto-deleveraging after deposit or borrow limit is", "crossed.", "Besides this flag, the lending market's flag also needs to be enabled (logical `AND`).", - "**NOTE:** the manual \"target LTV\" deleveraging (enabled by the risk council for individual", - "obligations) is NOT affected by this flag." + "**NOTE:** the manual \"target LTV\" deleveraging is NOT affected by this flag." ], "type": "u8" }, @@ -1021,7 +1701,10 @@ "- 0 to disable borrows in this elevation group (expected value for the debt asset)" ], "type": { - "array": ["u64", 32] + "array": [ + "u64", + 32 + ] } }, { @@ -1031,6 +1714,53 @@ "Only relevant when `autodeleverage_enabled == 1`, and must not be 0 in such case." ], "type": "u64" + }, + { + "name": "debt_maturity_timestamp", + "docs": [ + "The timestamp at which all [Obligation::borrows] using this reserve become liquidatable", + "(on the same terms as reserve-wide deleveraging).", + "Inactive when zeroed (i.e. debt never matures).", + "", + "Note: this feature is independent of [Self::debt_term_seconds] - the liquidation mechanism", + "is based directly on the timestamp defined here, on Reserve's level." + ], + "type": "u64" + }, + { + "name": "debt_term_seconds", + "docs": [ + "The duration after which any debt coming from this Reserve must be repaid.", + "Inactive when zeroed (i.e. funds can be borrowed indefinitely).", + "", + "Note: this feature is independent of [Self::debt_maturity_timestamp] - the liquidation", + "mechanism is based on the [ObligationLiquidity::last_borrowed_at_timestamp]." + ], + "type": "u64" + }, + { + "name": "rewards_amount_per_slot", + "docs": [ + "Rewards distributed per slot to depositors. Drained from", + "[ReserveLiquidity::rewards_amount_available] into", + "[ReserveLiquidity::total_available_amount] at each refresh, capped by the", + "market-level [LendingMarket::reserve_rewards_max_apr_bps]. `0` disables.", + "", + "**Note:** because rewards inflate `total_available_amount`, a non-zero RPS on a", + "reserve with [Self::autodeleverage_enabled] and a finite [Self::deposit_limit]", + "will eventually cross the cap and arm the autodeleverage countdown. Size", + "`deposit_limit` and RPS together." + ], + "type": "u64" + }, + { + "name": "permissioned_ops", + "docs": [ + "Bitmask of [PermissionedOp]s gated by the parent market's `permissioning_authority`", + "when this reserve is the operation's target. `0` = no operation is restricted at the", + "reserve level. Use [Reserve::get_permissioned_ops] for a typed view." + ], + "type": "u64" } ] } @@ -1083,9 +1813,14 @@ }, { "name": "padding", - "docs": ["Used for allignment"], + "docs": [ + "Used for allignment" + ], "type": { - "array": ["u8", 8] + "array": [ + "u8", + 8 + ] } } ] @@ -1093,33 +1828,49 @@ }, { "name": "ReserveLiquidity", - "docs": ["Reserve liquidity"], + "docs": [ + "Reserve liquidity" + ], "type": { "kind": "struct", "fields": [ { "name": "mint_pubkey", - "docs": ["Reserve liquidity mint address"], + "docs": [ + "Reserve liquidity mint address" + ], "type": "pubkey" }, { "name": "supply_vault", - "docs": ["Reserve liquidity supply address"], + "docs": [ + "Reserve liquidity supply address" + ], "type": "pubkey" }, { "name": "fee_vault", - "docs": ["Reserve liquidity fee collection address"], + "docs": [ + "Reserve liquidity fee collection address" + ], "type": "pubkey" }, { - "name": "available_amount", - "docs": ["Reserve liquidity available"], + "name": "total_available_amount", + "docs": [ + "Total reserve liquidity available.", + "", + "Note: not all of this liquidity can be freely used for any purpose. Production code should", + "use the specialized getters - see e.g. [Reserve::total_available_liquidity_amount()],", + "[Reserve::freely_available_liquidity_amount()]." + ], "type": "u64" }, { "name": "borrowed_amount_sf", - "docs": ["Reserve liquidity borrowed (scaled fraction)"], + "docs": [ + "Reserve liquidity borrowed (scaled fraction)" + ], "type": "u128" }, { @@ -1131,12 +1882,16 @@ }, { "name": "market_price_last_updated_ts", - "docs": ["Unix timestamp of the market price (from the oracle)"], + "docs": [ + "Unix timestamp of the market price (from the oracle)" + ], "type": "u64" }, { "name": "mint_decimals", - "docs": ["Reserve liquidity mint decimals"], + "docs": [ + "Reserve liquidity mint decimals" + ], "type": "u64" }, { @@ -1168,12 +1923,16 @@ }, { "name": "accumulated_protocol_fees_sf", - "docs": ["Reserve cumulative protocol fees (scaled fraction)"], + "docs": [ + "Reserve cumulative protocol fees (scaled fraction)" + ], "type": "u128" }, { "name": "accumulated_referrer_fees_sf", - "docs": ["Reserve cumulative referrer fees (scaled fraction)"], + "docs": [ + "Reserve cumulative referrer fees (scaled fraction)" + ], "type": "u128" }, { @@ -1192,19 +1951,40 @@ }, { "name": "token_program", - "docs": ["Token program of the liquidity mint"], + "docs": [ + "Token program of the liquidity mint" + ], "type": "pubkey" }, + { + "name": "rewards_amount_available", + "docs": [ + "Reserve rewards budget remaining for distribution.", + "", + "Tokens are deposited via `topup_reserve_rewards` and increase this counter (without", + "touching [Self::total_available_amount]). On every `refresh_reserve`, up to", + "`rewards_amount_per_slot * slots_elapsed` tokens are moved from this counter into", + "[Self::total_available_amount], inflating the cToken exchange rate, capped by the", + "market-level `reserve_rewards_max_apr_bps` cap." + ], + "type": "u64" + }, { "name": "padding2", "type": { - "array": ["u64", 51] + "array": [ + "u64", + 50 + ] } }, { "name": "padding3", "type": { - "array": ["u128", 32] + "array": [ + "u128", + 32 + ] } } ] @@ -1227,9 +2007,46 @@ ] } }, + { + "name": "WithdrawQueue", + "docs": [ + "A tracker of ticket-based withdrawals." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "queued_collateral_amount", + "docs": [ + "The part of [ReserveLiquidity::total_available_amount] locked for ticketed withdrawals." + ], + "type": "u64" + }, + { + "name": "next_issued_ticket_sequence_number", + "docs": [ + "The sequence number of the next ticket to be issued when enqueueing to withdraw.", + "Note: it is also a number of tickets issued so far." + ], + "type": "u64" + }, + { + "name": "next_withdrawable_ticket_sequence_number", + "docs": [ + "The sequence number of the next ticket to be used for actually transferring the withdrawn", + "liquidity (assuming it is available in the reserve).", + "Note: it is also a number of fully-consumed tickets so far." + ], + "type": "u64" + } + ] + } + }, { "name": "WithdrawalCaps", - "docs": ["Reserve Withdrawal Caps State"], + "docs": [ + "Reserve Withdrawal Caps State" + ], "type": { "kind": "struct", "fields": [ @@ -1259,17 +2076,23 @@ "fields": [ { "name": "lower", - "docs": ["Lower value of acceptable price"], + "docs": [ + "Lower value of acceptable price" + ], "type": "u64" }, { "name": "upper", - "docs": ["Upper value of acceptable price"], + "docs": [ + "Upper value of acceptable price" + ], "type": "u64" }, { "name": "exp", - "docs": ["Number of decimals of the previously defined values"], + "docs": [ + "Number of decimals of the previously defined values" + ], "type": "u64" } ] @@ -1308,14 +2131,22 @@ "This is the scope_id price chain that results in a price for the token" ], "type": { - "array": ["u16", 4] + "array": [ + "u16", + 4 + ] } }, { "name": "twap_chain", - "docs": ["This is the scope_id price chain for the twap"], + "docs": [ + "This is the scope_id price chain for the twap" + ], "type": { - "array": ["u16", 4] + "array": [ + "u16", + 4 + ] } } ] @@ -1347,14 +2178,21 @@ "fields": [ { "name": "name", - "docs": ["UTF-8 encoded name of the token (null-terminated)"], + "docs": [ + "UTF-8 encoded name of the token (null-terminated)" + ], "type": { - "array": ["u8", 32] + "array": [ + "u8", + 32 + ] } }, { "name": "heuristic", - "docs": ["Heuristics limits of acceptable price"], + "docs": [ + "Heuristics limits of acceptable price" + ], "type": { "defined": { "name": "PriceHeuristic" @@ -1363,7 +2201,9 @@ }, { "name": "max_twap_divergence_bps", - "docs": ["Max divergence between twap and price in bps"], + "docs": [ + "Max divergence between twap and price in bps" + ], "type": "u64" }, { @@ -1376,7 +2216,9 @@ }, { "name": "scope_configuration", - "docs": ["Scope price configuration"], + "docs": [ + "Scope price configuration" + ], "type": { "defined": { "name": "ScopeConfiguration" @@ -1385,7 +2227,9 @@ }, { "name": "switchboard_configuration", - "docs": ["Switchboard configuration"], + "docs": [ + "Switchboard configuration" + ], "type": { "defined": { "name": "SwitchboardConfiguration" @@ -1394,7 +2238,9 @@ }, { "name": "pyth_configuration", - "docs": ["Pyth configuration"], + "docs": [ + "Pyth configuration" + ], "type": { "defined": { "name": "PythConfiguration" @@ -1408,18 +2254,49 @@ { "name": "reserved", "type": { - "array": ["u8", 7] + "array": [ + "u8", + 7 + ] } }, { "name": "padding", "type": { - "array": ["u64", 19] + "array": [ + "u64", + 19 + ] } } ] } }, + { + "name": "ProgressCallbackType", + "docs": [ + "A callback to be notified when the ticket is being processed.", + "", + "## Why an enum?", + "", + "Only reliable programs may be used for callbacks (since any error or panic returned from a CPI", + "aborts an entire transaction, which would stall the queue progress). Hence, we need a whitelist,", + "and the simplest initial implementation is a hardcoded enum. If we want to be able to add new", + "whitelist items without SC updates, we can implement such support using a special enum value", + "(e.g. `SPECIFIED_BY_PDA = 255`)." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "None" + }, + { + "name": "KlendQueueAccountingHandlerOnKvault" + } + ] + } + }, { "name": "BorrowRateCurve", "type": { @@ -1481,25 +2358,37 @@ { "name": "padding0", "type": { - "array": ["u8", 7] + "array": [ + "u8", + 7 + ] } }, { "name": "rewards_tally_scaled", "type": { - "array": ["u128", 10] + "array": [ + "u128", + 10 + ] } }, { "name": "rewards_issued_unclaimed", "type": { - "array": ["u64", 10] + "array": [ + "u64", + 10 + ] } }, { "name": "last_claim_ts", "type": { - "array": ["u64", 10] + "array": [ + "u64", + 10 + ] } }, { @@ -1537,7 +2426,10 @@ { "name": "padding1", "type": { - "array": ["u64", 50] + "array": [ + "u64", + 50 + ] } } ] @@ -1550,7 +2442,9 @@ "fields": [ { "name": "global_admin", - "docs": ["Global admin of the program"], + "docs": [ + "Global admin of the program" + ], "type": "pubkey" }, { @@ -1569,9 +2463,14 @@ }, { "name": "padding", - "docs": ["Padding to make the struct size 1024 bytes"], + "docs": [ + "Padding to make the struct size 1024 bytes" + ], "type": { - "array": ["u8", 928] + "array": [ + "u8", + 928 + ] } } ] @@ -1584,17 +2483,23 @@ "fields": [ { "name": "version", - "docs": ["Version of lending market"], + "docs": [ + "Version of lending market" + ], "type": "u64" }, { "name": "bump_seed", - "docs": ["Bump seed for derived authority address"], + "docs": [ + "Bump seed for derived authority address" + ], "type": "u64" }, { "name": "lending_market_owner", - "docs": ["Owner authority which can add new reserves"], + "docs": [ + "Owner authority which can add new reserves" + ], "type": "pubkey" }, { @@ -1611,7 +2516,10 @@ "e.g. \"USD\" null padded (`*b\"USD\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\"`) or a SPL token mint pubkey" ], "type": { - "array": ["u8", 32] + "array": [ + "u8", + 32 + ] } }, { @@ -1665,13 +2573,16 @@ { "name": "min_full_liquidation_value_threshold", "docs": [ - "Minimum liquidation value threshold triggering full liquidation for an obligation" + "Minimum liquidation value threshold triggering full liquidation for an obligation, in full", + "units of the quote currency (e.g. `2` means \"$2\", not \"2 lamports of USDC\")." ], "type": "u64" }, { "name": "max_liquidatable_debt_market_value_at_once", - "docs": ["Max allowed liquidation value in one ix call"], + "docs": [ + "Max allowed liquidation value in one ix call" + ], "type": "u64" }, { @@ -1680,7 +2591,10 @@ "[DEPRECATED] Global maximum unhealthy borrow value allowed for any obligation" ], "type": { - "array": ["u8", 8] + "array": [ + "u8", + 8 + ] } }, { @@ -1691,9 +2605,9 @@ "type": "u64" }, { - "name": "risk_council", + "name": "emergency_council", "docs": [ - "The address of the risk council, in charge of making parameter and risk decisions on behalf of the protocol" + "The address of the emergency council, in charge of taking emergency actions on the market (e.g., enabling emergency mode)" ], "type": "pubkey" }, @@ -1703,7 +2617,10 @@ "[DEPRECATED] Reward points multiplier per obligation type" ], "type": { - "array": ["u8", 8] + "array": [ + "u8", + 8 + ] } }, { @@ -1725,7 +2642,10 @@ { "name": "elevation_group_padding", "type": { - "array": ["u64", 90] + "array": [ + "u64", + 90 + ] } }, { @@ -1744,9 +2664,14 @@ }, { "name": "name", - "docs": ["Market name, zero-padded."], + "docs": [ + "Market name, zero-padded." + ], "type": { - "array": ["u8", 32] + "array": [ + "u8", + 32 + ] } }, { @@ -1760,7 +2685,7 @@ "name": "individual_autodeleverage_margin_call_period_secs", "docs": [ "Time (in seconds) that must pass before liquidation is allowed on an obligation that has", - "been individually marked for auto-deleveraging (by the risk council)." + "been individually marked for auto-deleveraging." ], "type": "u64" }, @@ -1781,7 +2706,9 @@ }, { "name": "immutable", - "docs": ["Whether the lending market is set as immutable."], + "docs": [ + "Whether the lending market is set as immutable." + ], "type": "u8" }, { @@ -1804,23 +2731,211 @@ ], "type": "u8" }, + { + "name": "mature_reserve_debt_liquidation_enabled", + "docs": [ + "Whether the debts that reached their reserve's [ReserveConfig::debt_maturity_timestamp] can", + "be liquidated." + ], + "type": "u8" + }, + { + "name": "obligation_borrow_debt_term_liquidation_enabled", + "docs": [ + "Whether the [Obligation::borrows] that reached their [ReserveConfig::debt_term_seconds] can", + "be liquidated." + ], + "type": "u8" + }, + { + "name": "borrow_order_creation_enabled", + "docs": [ + "Whether new borrow orders can be created.", + "Note: updating or cancelling existing orders is *not* affected by this flag." + ], + "type": "u8" + }, + { + "name": "borrow_order_execution_enabled", + "docs": [ + "Whether the existing borrow orders can be filled." + ], + "type": "u8" + }, + { + "name": "proposer_authority", + "docs": [ + "Authority that can propose creating of new reserves but cannot enable them." + ], + "type": "pubkey" + }, + { + "name": "min_borrow_order_fill_value", + "docs": [ + "Minimum value that can be filled in a single `fill_borrow_order()` call, in full units of", + "the quote currency (e.g. `2` means \"$2\", not \"2 lamports of USDC\")." + ], + "type": "u64" + }, + { + "name": "withdraw_ticket_issuance_enabled", + "docs": [ + "Whether any new withdraw tickets can be issued (i.e. whether new requests can enter the", + "withdraw queue)." + ], + "type": "u8" + }, + { + "name": "withdraw_ticket_redemption_enabled", + "docs": [ + "Whether the existing withdraw tickets can be redeemed (i.e. whether the tickets can be used", + "to transfer accumulated pending liquidity to destination accounts)." + ], + "type": "u8" + }, + { + "name": "obligation_borrow_rollover_configuration_enabled", + "docs": [ + "Whether the owners can enable the borrow rollover/migration on their obligations.", + "", + "*Note 1:* the actual execution of (different kinds of) rollovers are enabled/disabled by:", + "- [Self::fixed_term_rollover_window_duration_seconds],", + "- [Self::open_term_rollover_window_duration_seconds],", + "- [Self::obligation_borrow_migration_to_fixed_execution_enabled].", + "", + "*Note 2:* when this configuration is disabled, the obligation owners can still disable their", + "rollover (i.e. set the obligation's flags to zeroes)." + ], + "type": "u8" + }, + { + "name": "obligation_borrow_migration_to_fixed_execution_enabled", + "docs": [ + "Whether the actual execution of a \"migration to fixed\" rollover flavor is allowed.", + "", + "See [FixedTermBorrowRolloverConfig::migration_to_fixed_enabled]." + ], + "type": "u8" + }, + { + "name": "withdraw_ticket_cancellation_enabled", + "docs": [ + "Whether the ticket owners can cancel their withdraw tickets (i.e. recover ctokens from the", + "queued collateral vault back to their wallet)." + ], + "type": "u8" + }, { "name": "padding2", "type": { - "array": ["u8", 4] + "array": [ + "u8", + 1 + ] } }, { - "name": "proposer_authority", + "name": "reserve_rewards_max_apr_bps", + "docs": [ + "Maximum APR (in basis points; `FULL_BPS = 10_000` = 100%) at which reserves on this market", + "may distribute their `rewards_amount_per_slot`. `0` disables rewards on this market", + "entirely (`topup_reserve_rewards` is rejected). Bounded by `FULL_BPS` (100% APR) when set.", + "See [ReserveConfig::rewards_amount_per_slot] for the depositor-cap interaction." + ], + "type": "u16" + }, + { + "name": "min_withdraw_queued_liquidity_value", + "docs": [ + "Minimum value that can be withdrawn in a single `withdraw_queued_liquidity()` call, in full", + "units of the quote currency (e.g. `2` means \"$2\", not \"2 lamports of USDC\")." + ], + "type": "u64" + }, + { + "name": "fixed_term_rollover_window_duration_seconds", + "docs": [ + "A configurable time window (right before the end of a fixed debt term) during which an", + "auto-rollover into another *fixed* rate/term can happen.", + "", + "When zeroed, this rollover mode is effectively disabled.", + "Can only be enabled when [Self::min_partial_rollover_value] is configured.", + "", + "See [FixedTermBorrowRolloverConfig]." + ], + "type": "u64" + }, + { + "name": "open_term_rollover_window_duration_seconds", + "docs": [ + "A configurable time window (right before the end of a fixed debt term) during which an", + "auto-rollover into a *variable* (indefinite) rate/term can happen.", + "", + "When zeroed, this rollover mode is effectively disabled.", + "Can only be enabled when [Self::min_partial_rollover_value] is configured.", + "", + "This will typically be shorter than [Self::fixed_term_rollover_window_duration_seconds],", + "acting as a fallback if a fixed reserve liquidity remains unavailable for considerable time." + ], + "type": "u64" + }, + { + "name": "min_partial_rollover_value", + "docs": [ + "Minimum dollar value for a partial rollover into a different reserve.", + "When the achievable rollover amount is below this threshold (and it's not a full rollover),", + "the rollover is rejected.", + "", + "In full units of the quote currency (e.g. `2` means \"$2\")." + ], + "type": "u64" + }, + { + "name": "term_based_full_liquidation_duration_secs", + "docs": [ + "The time that must pass before an entire expired debt becomes liquidatable.", + "", + "For example:", + "Let's assume this duration is configured as 100 seconds; then:", + "- right after fixed-term debt expiration, effectively no debt can be liquidated.", + "- 30 seconds after expiration, we allow to 30% of the expired debt to be liquidated", + "- to be specific: at this point in time, we \"protect\" from liquidation 70% of the", + "[ObligationLiquidity::borrowed_amount_at_expiration] (regardless of how much interest", + "was accrued or how much debt was repaid while expired).", + "- 100 seconds after expiration we allow the entire debt to be liquidated.", + "", + "Only effective when [Self::obligation_borrow_debt_term_liquidation_enabled].", + "", + "Motivation note: this throttling feature gives an opportunity to execute a configured", + "auto-rollover (after a partial liquidation brings the debt size down so that there is enough", + "available liquidity in some compatible reserve).", + "", + "When zeroed, an entire expired debt can be liquidated right after expiration (i.e. no", + "throttling)." + ], + "type": "u64" + }, + { + "name": "permissioning_authority", + "docs": [ + "If not NULL, operations encoded in permissioned_ops require a signature from this authority" + ], + "type": "pubkey" + }, + { + "name": "permissioned_ops", "docs": [ - "Authority that can propose creating of new reserves but cannot enable them." + "Bitmap of operations that require permissioning authority signature" ], - "type": "pubkey" + "type": "u64" }, { "name": "padding1", "type": { - "array": ["u64", 165] + "array": [ + "u64", + 153 + ] } } ] @@ -1828,13 +2943,17 @@ }, { "name": "Obligation", - "docs": ["Lending market obligation state"], + "docs": [ + "Lending market obligation state" + ], "type": { "kind": "struct", "fields": [ { "name": "tag", - "docs": ["Version of the struct"], + "docs": [ + "Version of the struct" + ], "type": "u64" }, { @@ -1850,12 +2969,16 @@ }, { "name": "lending_market", - "docs": ["Lending market address"], + "docs": [ + "Lending market address" + ], "type": "pubkey" }, { "name": "owner", - "docs": ["Owner authority which can borrow liquidity"], + "docs": [ + "Owner authority which can borrow liquidity" + ], "type": "pubkey" }, { @@ -1883,7 +3006,9 @@ }, { "name": "deposited_value_sf", - "docs": ["Market value of deposits (scaled fraction)"], + "docs": [ + "Market value of deposits (scaled fraction)" + ], "type": "u128" }, { @@ -1931,22 +3056,22 @@ "type": "u128" }, { - "name": "deposits_asset_tiers", - "docs": ["The asset tier of the deposits"], - "type": { - "array": ["u8", 8] - } - }, - { - "name": "borrows_asset_tiers", - "docs": ["The asset tier of the borrows"], + "name": "padding_deprecated_asset_tiers", + "docs": [ + "The asset tier of the deposits" + ], "type": { - "array": ["u8", 5] + "array": [ + "u8", + 13 + ] } }, { "name": "elevation_group", - "docs": ["The elevation group id the obligation opted into."], + "docs": [ + "The elevation group id the obligation opted into." + ], "type": "u8" }, { @@ -1965,18 +3090,22 @@ }, { "name": "referrer", - "docs": ["Wallet address of the referrer"], + "docs": [ + "Wallet address of the referrer" + ], "type": "pubkey" }, { "name": "borrowing_disabled", - "docs": ["Marked = 1 if borrowing disabled, 0 = borrowing enabled"], + "docs": [ + "Marked = 1 if borrowing disabled, 0 = borrowing enabled" + ], "type": "u8" }, { "name": "autodeleverage_target_ltv_pct", "docs": [ - "A target LTV set by the risk council when marking this obligation for deleveraging.", + "A target LTV set by the market owner when marking this obligation for deleveraging.", "Only effective when `deleveraging_margin_call_started_slot != 0`." ], "type": "u8" @@ -1995,10 +3124,20 @@ ], "type": "u8" }, + { + "name": "ownership_transfer_state", + "docs": [ + "State of ownership transfer, see [OwnershipTransferState]" + ], + "type": "u8" + }, { "name": "reserved", "type": { - "array": ["u8", 4] + "array": [ + "u8", + 3 + ] } }, { @@ -2008,15 +3147,15 @@ { "name": "autodeleverage_margin_call_started_timestamp", "docs": [ - "A timestamp at which the risk council most-recently marked this obligation for deleveraging.", + "A timestamp at which the market owner most-recently marked this obligation for deleveraging.", "Zero if not currently subject to deleveraging." ], "type": "u64" }, { - "name": "orders", + "name": "obligation_orders", "docs": [ - "Owner-defined, liquidator-executed orders applicable to this obligation.", + "Owner-defined, permissionlessly-executed repay orders.", "Typical use-cases would be a stop-loss and a take-profit (possibly co-existing)." ], "type": { @@ -2030,10 +3169,33 @@ ] } }, + { + "name": "borrow_order", + "docs": [ + "Owner-defined, permissionlessly-executed borrow order applicable to this obligation.", + "Non-zeroed only on a newly-initialized fixed-rate, fixed-term obligation." + ], + "type": { + "defined": { + "name": "BorrowOrder" + } + } + }, + { + "name": "pending_owner", + "docs": [ + "Pending owner during ownership transfer process.", + "Pubkey::default() means no pending owner (similar to Option::None)" + ], + "type": "pubkey" + }, { "name": "padding3", "type": { - "array": ["u64", 93] + "array": [ + "u64", + 69 + ] } } ] @@ -2065,12 +3227,16 @@ "fields": [ { "name": "referrer", - "docs": ["Pubkey of the referrer/owner"], + "docs": [ + "Pubkey of the referrer/owner" + ], "type": "pubkey" }, { "name": "mint", - "docs": ["Token mint for the account"], + "docs": [ + "Token mint for the account" + ], "type": "pubkey" }, { @@ -2089,13 +3255,18 @@ }, { "name": "bump", - "docs": ["Referrer token state bump, used for address validation"], + "docs": [ + "Referrer token state bump, used for address validation" + ], "type": "u64" }, { "name": "padding", "type": { - "array": ["u64", 31] + "array": [ + "u64", + 31 + ] } } ] @@ -2134,7 +3305,9 @@ }, { "name": "bump", - "docs": ["Bump used for validation of account address"], + "docs": [ + "Bump used for validation of account address" + ], "type": "u64" }, { @@ -2146,19 +3319,27 @@ }, { "name": "owner", - "docs": ["User metadata account owner"], + "docs": [ + "User metadata account owner" + ], "type": "pubkey" }, { "name": "padding1", "type": { - "array": ["u64", 51] + "array": [ + "u64", + 51 + ] } }, { "name": "padding2", "type": { - "array": ["u64", 64] + "array": [ + "u64", + 64 + ] } } ] @@ -2171,12 +3352,16 @@ "fields": [ { "name": "version", - "docs": ["Version of the reserve"], + "docs": [ + "Version of the reserve" + ], "type": "u64" }, { "name": "last_update", - "docs": ["Last slot when supply and rates updated"], + "docs": [ + "Last slot when supply and rates updated" + ], "type": { "defined": { "name": "LastUpdate" @@ -2185,7 +3370,9 @@ }, { "name": "lending_market", - "docs": ["Lending market address"], + "docs": [ + "Lending market address" + ], "type": "pubkey" }, { @@ -2198,7 +3385,9 @@ }, { "name": "liquidity", - "docs": ["Reserve liquidity"], + "docs": [ + "Reserve liquidity" + ], "type": { "defined": { "name": "ReserveLiquidity" @@ -2208,12 +3397,17 @@ { "name": "reserve_liquidity_padding", "type": { - "array": ["u64", 150] + "array": [ + "u64", + 150 + ] } }, { "name": "collateral", - "docs": ["Reserve collateral"], + "docs": [ + "Reserve collateral" + ], "type": { "defined": { "name": "ReserveCollateral" @@ -2223,12 +3417,17 @@ { "name": "reserve_collateral_padding", "type": { - "array": ["u64", 150] + "array": [ + "u64", + 150 + ] } }, { "name": "config", - "docs": ["Reserve configuration values"], + "docs": [ + "Reserve configuration values" + ], "type": { "defined": { "name": "ReserveConfig" @@ -2238,7 +3437,10 @@ { "name": "config_padding", "type": { - "array": ["u64", 116] + "array": [ + "u64", + 112 + ] } }, { @@ -2252,13 +3454,253 @@ "elevation group when this reserve is part of the collaterals." ], "type": { - "array": ["u64", 32] + "array": [ + "u64", + 32 + ] + } + }, + { + "name": "withdraw_queue", + "docs": [ + "The tracker of ticket-based withdrawals." + ], + "type": { + "defined": { + "name": "WithdrawQueue" + } } }, { "name": "padding", "type": { - "array": ["u64", 207] + "array": [ + "u64", + 204 + ] + } + } + ] + } + }, + { + "name": "WithdrawTicket", + "docs": [ + "A finite-lifecycle account representing a specific depositor's place in the withdraw queue of", + "a specific reserve.", + "", + "The lifecycle:", + "1. The depositor holding ctokens wants to withdraw funds from the reserve, and finds out that", + "the required amount is not available (due to high utilization).", + "2. The depositor calls the `enqueue_to_withdraw` handler.", + "3. The handler transfers the depositor's ctokens to the reserve's internal \"pending\" vault.", + "4. The handler initializes a new [WithdrawTicket] account, with the next available sequence", + "number.", + "5. The depositor waits until his ticket is the next expected one for actual withdraw, and until", + "the reserve has enough liquidity.", + "6. Anyone (the depositor or a bot) calls the permissionless `withdraw_queued_liquidity`", + "handler. If the ticket became invalid (e.g. destination account no longer exists), then the", + "depositor can call the `recover_invalid_ticket_collateral` handler instead.", + "7. The handler transfers the liquidity amount according to the current exchange rate.", + "8. The handler closes the ticket account." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "sequence_number", + "docs": [ + "This ticket's place in the queue; the same as used for PDA derivation." + ], + "type": "u64" + }, + { + "name": "owner", + "docs": [ + "The funds' owner (the user who called the `enqueue_to_withdraw` handler)." + ], + "type": "pubkey" + }, + { + "name": "reserve", + "docs": [ + "The reserve to withdraw from." + ], + "type": "pubkey" + }, + { + "name": "user_destination_liquidity_ta", + "docs": [ + "The token account to which the finally-available liquidity should be transferred (by the", + "`withdraw_queued_liquidity` handler)." + ], + "type": "pubkey" + }, + { + "name": "queued_collateral_amount", + "docs": [ + "The amount of collateral still waiting to be withdrawn using this ticket." + ], + "type": "u64" + }, + { + "name": "created_at_timestamp", + "docs": [ + "The timestamp at which the queue was entered.", + "", + "This is currently only a piece of metadata, not used by the logic." + ], + "type": "u64" + }, + { + "name": "invalid", + "docs": [ + "Whether the ticket has been found to be invalid (e.g. the [Self::user_destination_liquidity]", + "has been repurposed) by the `withdraw_queued_liquidity` handler.", + "To be specific: valid = `0`, invalid = `1`.", + "", + "An invalid ticket cannot be made valid again, and can only be passed to the", + "`recover_invalid_ticket_collateral` handler." + ], + "type": "u8" + }, + { + "name": "progress_callback_type", + "docs": [ + "One of the valid [ProgressCallbackType] representations." + ], + "type": "u8" + }, + { + "name": "alignment_padding", + "docs": [ + "Inner padding, for alignment." + ], + "type": { + "array": [ + "u8", + 6 + ] + } + }, + { + "name": "progress_callback_custom_accounts", + "docs": [ + "The (optional) accounts to be used by [Self::progress_callback_type]s." + ], + "type": { + "array": [ + "pubkey", + 2 + ] + } + }, + { + "name": "end_padding", + "docs": [ + "Trailing padding, for future developments." + ], + "type": { + "array": [ + "u64", + 40 + ] + } + } + ] + } + }, + { + "name": "BorrowOrderCancelEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "before", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + } + ] + } + }, + { + "name": "BorrowOrderFullFillEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "before", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + } + ] + } + }, + { + "name": "BorrowOrderPartialFillEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "before", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + }, + { + "name": "after", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + } + ] + } + }, + { + "name": "BorrowOrderPlaceEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "after", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + } + ] + } + }, + { + "name": "BorrowOrderUpdateEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "before", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + }, + { + "name": "after", + "type": { + "defined": { + "name": "BorrowOrder" + } } } ] diff --git a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml index d4a69d21e..39db9aa26 100644 --- a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml @@ -1,5 +1,5 @@ protocol: kamino -version: v1.12.6 +version: v1.23.0 account_type: Reserve idl_file_path: idl.json @@ -9,52 +9,595 @@ tags: - defi templates: + # ========================================== + # Reserve + # ========================================== - id: kamino-reserve-state name: Override Reserve Liquidity & Rates - description: Override Kamino Reserve liquidity and interest rate data + description: Override Kamino Reserve liquidity, accrued fees and cached price idl_account_name: Reserve properties: - [ - "liquidity.available_amount", - "liquidity.borrowed_amount_sf", - "liquidity.market_price_sf", - "liquidity.cumulative_borrow_rate_bsf", - ] + - "liquidity.total_available_amount" + - "liquidity.borrowed_amount_sf" + - "liquidity.market_price_sf" + - "liquidity.market_price_last_updated_ts" + - "liquidity.cumulative_borrow_rate_bsf" + - "liquidity.accumulated_protocol_fees_sf" + - "liquidity.accumulated_referrer_fees_sf" + - "liquidity.pending_referrer_fees_sf" + - "last_update.slot" + - "last_update.stale" + - "last_update.price_status" address: type: pubkey + llm_context: | + CRITICAL: market_price_sf is a CACHE. refresh_reserve recomputes it from the configured + oracle, so any transaction that refreshes the reserve overwrites it. Use kamino-scope-price + for a price that survives. + + HOW TO USE THIS TEMPLATE: + 1. Set fetchBeforeUse: true so the real reserve is forked first + 2. To make the reserve look freshly refreshed, set last_update.stale: 0 and + last_update.slot to the current slot + 3. To drain a reserve, set liquidity.total_available_amount: 0 + + liquidity.cumulative_borrow_rate_bsf is a struct - supply it whole as + {"value": [u64 x 4], "padding": [u64 x 2]}, or set one limb with + liquidity.cumulative_borrow_rate_bsf.value.0 + + EXAMPLE - "reserve has run dry" (forces the withdrawal queue): + liquidity.total_available_amount: 0 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. - id: kamino-reserve-config name: Override Reserve Risk Configuration - description: Override Kamino Reserve risk parameters and liquidation settings + description: Override Kamino Reserve LTV, liquidation thresholds and bonuses idl_account_name: Reserve properties: - [ - "config.loan_to_value_pct", - "config.liquidation_threshold_pct", - "config.min_liquidation_bonus_bps", - "config.max_liquidation_bonus_bps", - ] + - "config.loan_to_value_pct" + - "config.liquidation_threshold_pct" + - "config.min_liquidation_bonus_bps" + - "config.max_liquidation_bonus_bps" + - "config.bad_debt_liquidation_bonus_bps" + - "config.protocol_liquidation_fee_pct" + - "config.borrow_factor_pct" + - "config.min_deleveraging_bonus_bps" + - "config.deleveraging_margin_call_period_secs" + - "config.deleveraging_threshold_decrease_bps_per_day" + - "config.deleveraging_bonus_increase_bps_per_day" address: type: pubkey + llm_context: | + Use this template to make a position liquidatable in a way that survives refresh_obligation, + unlike the Obligation health fields. + + HOW TO USE THIS TEMPLATE: + 1. Set fetchBeforeUse: true + 2. Lower config.liquidation_threshold_pct below the borrower's current LTV + 3. Keep it >= config.loan_to_value_pct, which gates new borrows + + EXAMPLE - "liquidate anything above 50% LTV": + config.liquidation_threshold_pct: 50 + config.max_liquidation_bonus_bps: 1000 + + - id: kamino-reserve-status + name: Override Reserve Status & Usage Flags + description: Override Kamino Reserve status and usage restrictions + idl_account_name: Reserve + properties: + - "config.status" + - "config.block_ctoken_usage" + - path: config.disable_usage_as_coll_outside_emode + label: Collateral only in e-mode + description: "1 stops this asset being used as collateral outside an elevation group. Example: 1" + - "config.emergency_mode" + - "config.utilization_limit_block_borrowing_above_pct" + - "config.autodeleverage_enabled" + - "config.proposer_authority_locked" + - path: config.elevation_groups + label: Elevation groups + description: "The 20 elevation-group ids this reserve may join; 0 is empty. Example: 1" + address: + type: pubkey + llm_context: | + Use this template to disable a reserve or change its elevation-group membership. + + config.status: 0 = Active, 1 = Obsolete, 2 = Hidden. Marking a reserve Obsolete exercises the + num_of_obsolete_deposit_reserves / num_of_obsolete_borrow_reserves paths on an Obligation. + + config.elevation_groups is a fixed [u8; 20] array - supply all 20 entries, or one slot with + config.elevation_groups.0 + + EXAMPLE - "reserve is deprecated": + config.status: 1 + + - id: kamino-reserve-limits + name: Override Reserve Deposit & Borrow Limits + description: Override Kamino Reserve caps and the withdrawal queue + idl_account_name: Reserve + properties: + - "config.deposit_limit" + - "config.borrow_limit" + - "config.borrow_limit_outside_elevation_group" + - path: config.deposit_withdrawal_cap.config_capacity + label: Deposit cap per interval + description: "Maximum that may be deposited per interval, in the token's smallest unit. Example: -1" + - path: config.deposit_withdrawal_cap.current_total + label: Deposited this interval + description: "Running total deposited in the current interval. Example: 0" + - path: config.deposit_withdrawal_cap.config_interval_length_seconds + label: Deposit cap window + description: "Length of the deposit cap window, in seconds. Example: 86400" + - path: config.deposit_withdrawal_cap.last_interval_start_timestamp + label: Deposit window start + description: "When the current deposit window opened (unix seconds). Example: 1800000000" + - path: config.debt_withdrawal_cap.config_capacity + label: Borrow cap per interval + description: "Maximum that may be borrowed per interval, smallest unit. Example: -1" + - path: config.debt_withdrawal_cap.current_total + label: Borrowed this interval + description: "Running total borrowed in the current interval. Example: 0" + - path: config.debt_withdrawal_cap.config_interval_length_seconds + label: Borrow cap window + description: "Length of the borrow cap window, in seconds. Example: 86400" + - path: config.debt_withdrawal_cap.last_interval_start_timestamp + label: Borrow window start + description: "When the current borrow window opened (unix seconds). Example: 1800000000" + - "liquidity.deposit_limit_crossed_timestamp" + - "liquidity.borrow_limit_crossed_timestamp" + - path: borrowed_amount_outside_elevation_group + label: Borrowed outside e-mode + description: >- + Amount borrowed against this reserve by obligations not in an elevation group, smallest unit. + Example: 0 + - "withdraw_queue.queued_collateral_amount" + - "withdraw_queue.next_issued_ticket_sequence_number" + - "withdraw_queue.next_withdrawable_ticket_sequence_number" + address: + type: pubkey + llm_context: | + Use this template for borrow/deposit caps and for the queued-withdrawal feature. + + HOW TO USE THIS TEMPLATE (queued withdrawals, klend 1.23.0): + 1. Drain the reserve with kamino-reserve-state (liquidity.total_available_amount: 0) + 2. Enable the feature on kamino-lending-market-risk (withdraw_ticket_issuance_enabled: 1) + 3. Set withdraw_queue.next_withdrawable_ticket_sequence_number to serve a ticket + 4. Build the ticket itself with kamino-withdraw-ticket + + Set a config_capacity of -1 to disable a withdrawal cap. + + EXAMPLE - "no new borrows against this reserve": + config.borrow_limit: 0 + + - id: kamino-reserve-fees + name: Override Reserve Fees + description: Override Kamino Reserve origination, flash-loan and protocol fees + idl_account_name: Reserve + properties: + - "config.fees.origination_fee_sf" + - "config.fees.flash_loan_fee_sf" + - "config.host_fixed_interest_rate_bps" + - "config.protocol_take_rate_pct" + - "config.protocol_order_execution_fee_pct" + address: + type: pubkey + llm_context: | + Use this template to remove fee noise from an arbitrage simulation. + + Fees ending in _sf are scaled fractions: a 0.3% flash-loan fee is 0.003 * 2^60. + + EXAMPLE - "free flash loans" so only the swap legs decide profitability: + config.fees.flash_loan_fee_sf: 0 + config.fees.origination_fee_sf: 0 + + - id: kamino-reserve-interest-rate + name: Override Reserve Borrow Rate Curve + description: Override the Kamino Reserve borrow-rate curve + idl_account_name: Reserve + properties: + - "config.borrow_rate_curve" + address: + type: pubkey + llm_context: | + config.borrow_rate_curve is a struct with one field, points, a fixed array of EXACTLY 11 + CurvePoint entries sorted by ascending utilization_rate_bps. Pad the tail by repeating the + final point, which Kamino treats as the end of the curve. + + HOW TO USE THIS TEMPLATE: + 1. Prefer an element path to change one point, e.g. + config.borrow_rate_curve.points.3.borrow_rate_bps + 2. Only supply the whole struct if you are replacing the entire curve + + EXAMPLE - raise the borrow rate at the 4th curve point to 50%: + config.borrow_rate_curve.points.3.borrow_rate_bps: 5000 + + - id: kamino-reserve-oracle + name: Override Reserve Oracle Configuration + description: Override which oracle a Kamino Reserve reads, and its staleness guards + idl_account_name: Reserve + properties: + - "config.token_info.scope_configuration.price_feed" + - "config.token_info.scope_configuration.price_chain" + - "config.token_info.scope_configuration.twap_chain" + - "config.token_info.pyth_configuration.price" + - "config.token_info.switchboard_configuration.price_aggregator" + - path: config.token_info.switchboard_configuration.twap_aggregator + label: Switchboard TWAP feed + description: >- + Switchboard aggregator supplying a TWAP for this token. Example: the aggregator address, or + the default pubkey to disable + - path: config.token_info.max_age_price_seconds + label: Max price age + description: "How old the oracle price may be before Kamino refuses it, in seconds. Example: 600" + - path: config.token_info.max_age_twap_seconds + label: Max TWAP age + description: "How old the TWAP may be before Kamino refuses it, in seconds. Example: 600" + - "config.token_info.max_twap_divergence_bps" + - path: config.token_info.block_price_usage + label: Block price usage + description: >- + 1 stops this token's price being used at all, which freezes borrowing against it. Example: 1 + - "config.token_info.heuristic.lower" + - "config.token_info.heuristic.upper" + - "config.token_info.heuristic.exp" + address: + type: pubkey + llm_context: | + Use this template to change WHICH oracle a reserve reads, rather than the price itself. + + HOW TO USE THIS TEMPLATE: + 1. To drive the price from a Pyth feed you already control, set + config.token_info.pyth_configuration.price to that feed and use the pyth-price-feed-v2 + template to move it - this survives refresh_reserve + 2. To fix a stale-price rejection, raise config.token_info.max_age_price_seconds + 3. To fix a TWAP divergence rejection, raise config.token_info.max_twap_divergence_bps + + price_chain and twap_chain are fixed [u16; 4] arrays - supply all 4, or one entry with + config.token_info.scope_configuration.price_chain.0 (65535 = unused) + + EXAMPLE - "accept prices up to an hour old": + config.token_info.max_age_price_seconds: 3600 + + - id: kamino-reserve-rewards + name: Override Reserve Reward Emissions + description: Override Kamino Reserve reward emissions + idl_account_name: Reserve + properties: + - "config.rewards_amount_per_slot" + - "liquidity.rewards_amount_available" + address: + type: pubkey + llm_context: | + Reserve-level rewards (klend 1.23.0) are separate from Kamino Farms - use the kamino-farms-* + templates for those. + + HOW TO USE THIS TEMPLATE: + 1. Set config.rewards_amount_per_slot to the emission rate (smallest unit per slot, ~2.5 + slots per second) + 2. Raise liquidity.rewards_amount_available too, or emissions stop when the budget empties + 3. Check reserve_rewards_max_apr_bps on kamino-lending-market-risk is not capping you + + EXAMPLE - "emit 1 USDC per second to depositors" (6 decimals, ~2.5 slots/sec): + config.rewards_amount_per_slot: 400000 + liquidity.rewards_amount_available: 1000000000 + + - id: kamino-reserve-debt-term + name: Override Reserve Fixed-Term Debt Settings + description: Override Kamino Reserve fixed-term debt settings + idl_account_name: Reserve + properties: + - "config.debt_term_seconds" + - "config.debt_maturity_timestamp" + - "config.early_repay_remaining_interest_pct" + address: + type: pubkey + llm_context: | + Fixed-term borrowing arrived in klend 1.23.0. A debt_term_seconds of 0 means the reserve uses + open-term (perpetual) loans. + + HOW TO USE THIS TEMPLATE: + 1. Set config.debt_maturity_timestamp to a unix timestamp in the past so outstanding + fixed-term debt matures immediately + 2. Enable mature_reserve_debt_liquidation_enabled on kamino-lending-market-risk, or the + maturity liquidation path stays inactive + EXAMPLE - "this debt matured yesterday": + config.debt_maturity_timestamp: 1799913600 + + - id: kamino-withdraw-ticket + name: Override Withdraw Ticket + description: Override a Kamino queued-withdrawal ticket + idl_account_name: WithdrawTicket + properties: + - "sequence_number" + - "owner" + - "reserve" + - "user_destination_liquidity_ta" + - "queued_collateral_amount" + - "created_at_timestamp" + - "invalid" + - "progress_callback_type" + address: + type: pubkey + llm_context: | + CRITICAL: No live WithdrawTicket existed on mainnet when this template was written. Build one + with surfnet_setAccount rather than expecting to fork one. + + HOW TO USE THIS TEMPLATE: + 1. Set owner and user_destination_liquidity_ta - the destination must be a real token account + for the reserve's liquidity mint + 2. To make the ticket redeemable, set sequence_number at or below the reserve's + withdraw_queue.next_withdrawable_ticket_sequence_number (kamino-reserve-limits) + 3. To test the not-yet-your-turn rejection, set it above + + EXAMPLE - "ticket 7 is next in line, waiting on 500 collateral": + sequence_number: 7 + queued_collateral_amount: 500 + invalid: 0 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + # ========================================== + # Named reserves - addresses pre-filled + # ========================================== + # Kamino reserves are NOT PDAs (see `init_reserve` in the IDL: the reserve account is a plain + # keypair account), so an address cannot be derived from a token mint. The only way to spare a + # user the lookup is to bake in known addresses, the same approach the whirlpool templates take. + # + # These are facts about mainnet as captured on 2026-08-06, verified by decoding each account + # with the bundled IDL: every address below is an active Reserve owned by + # KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD, and every Scope index below was checked to + # produce the reserve's own cached price. Re-verify if Kamino migrates a market. + # + # Only the canonical demo pair is baked in; six near-duplicates implied a "supported set" that + # does not exist. For any other reserve use the generic `kamino-reserve-*` templates and supply + # the address - see their llm_context for how to find one. + + - id: kamino-reserve-main-sol + name: Override SOL Reserve (Main Market) + description: Override the SOL reserve of Kamino's Main Market + idl_account_name: Reserve + properties: + - "config.loan_to_value_pct" + - "config.liquidation_threshold_pct" + - "config.min_liquidation_bonus_bps" + - "config.max_liquidation_bonus_bps" + - path: config.token_info.max_age_price_seconds + label: Max price age + description: "How old the oracle price may be before Kamino refuses it, in seconds. Example: 600" + - "liquidity.market_price_sf" + - "liquidity.total_available_amount" + - "last_update.slot" + - "last_update.stale" + address: + type: pubkey + value: d4A2prbA2whesmvHaL88BH6Ewn5N4bTSU2Ze8P6Bc4Q + llm_context: | + The SOL reserve of Kamino's Main Market, address already filled in - no lookup needed. + + HOW TO USE THIS TEMPLATE: + 1. Set fetchBeforeUse: true + 2. Lower config.liquidation_threshold_pct below the borrower's LTV - this survives + refresh_obligation, unlike the Obligation's own health fields + 3. To move the price, use kamino-scope-price on account + 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH with prices.3.price.value (SOL is index 3) + + EXAMPLE - "liquidate SOL collateral above 50% LTV": + config.liquidation_threshold_pct: 50 + + persist: true is safe for the config.* fields only. liquidity.* and last_update.* are + rewritten by refresh_reserve, so pinning them fights every transaction that touches the reserve. + - id: kamino-reserve-main-usdc + name: Override USDC Reserve (Main Market) + description: Override the USDC reserve of Kamino's Main Market + idl_account_name: Reserve + properties: + - "config.loan_to_value_pct" + - "config.liquidation_threshold_pct" + - "config.min_liquidation_bonus_bps" + - "config.max_liquidation_bonus_bps" + - path: config.token_info.max_age_price_seconds + label: Max price age + description: "How old the oracle price may be before Kamino refuses it, in seconds. Example: 600" + - "liquidity.market_price_sf" + - "liquidity.total_available_amount" + - "last_update.slot" + - "last_update.stale" + address: + type: pubkey + value: D6q6wuQSrifJKZYpR1M8R4YawnLDtDsMmWM1NbBmgJ59 + llm_context: | + The USDC reserve of Kamino's Main Market, address already filled in - no lookup needed. + + HOW TO USE THIS TEMPLATE: + 1. Set fetchBeforeUse: true + 2. Lower config.liquidation_threshold_pct below the borrower's LTV - this survives + refresh_obligation, unlike the Obligation's own health fields + 3. To move the price, use kamino-scope-price on account + 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH with prices.13.price.value (USDC is index 13) + + EXAMPLE - "USDC depegs to $0.90": + use kamino-scope-price with prices.13.price.value: 90000000 and prices.13.price.exp: 8 + + persist: true is safe for the config.* fields only. liquidity.* and last_update.* are + rewritten by refresh_reserve, so pinning them fights every transaction that touches the reserve. + # ========================================== + # Obligation + # ========================================== - id: kamino-obligation-health name: Override Obligation Health - description: Override Kamino Obligation health metrics for testing liquidation scenarios. An obligation becomes unhealthy (liquidatable) when borrowed_value_sf exceeds unhealthy_borrow_value_sf. Use deposits/borrows arrays to set actual positions. + description: Override Kamino Obligation health metrics + idl_account_name: Obligation + properties: + - "last_update.slot" + - "last_update.stale" + - "deposited_value_sf" + - "borrow_factor_adjusted_debt_value_sf" + - "borrowed_assets_market_value_sf" + - "allowed_borrow_value_sf" + - "unhealthy_borrow_value_sf" + - "lowest_reserve_deposit_liquidation_ltv" + - "lowest_reserve_deposit_max_ltv_pct" + - path: highest_borrow_factor_pct + label: Highest borrow factor + description: "The largest borrow factor across this obligation's debts, as a percent. Example: 100" + - "borrowing_disabled" + - "num_of_obsolete_deposit_reserves" + - "num_of_obsolete_borrow_reserves" + - "autodeleverage_target_ltv_pct" + address: + type: pubkey + llm_context: | + CRITICAL: These are DERIVED values. refresh_obligation recomputes every one of them from the + positions and reserves, and liquidation instructions reject a stale obligation - so a + realistic liquidation transaction discards these overrides. + + TO MAKE A POSITION LIQUIDATABLE DURABLY, use one of these instead: + - kamino-reserve-config: lower config.liquidation_threshold_pct on the deposit reserve + - kamino-scope-price: move the price the reserve reads + + Use this template only for assertions that do not refresh. All *_sf values are scaled + fractions: usd_value * 2^60. + + EXAMPLE - force an unhealthy obligation for a direct state check ($1000 debt vs $500 limit): + borrow_factor_adjusted_debt_value_sf: 1152921504606846976000 + unhealthy_borrow_value_sf: 576460752303423488000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-obligation-positions + name: Override Obligation Positions + description: Override the deposits and borrows of a Kamino Obligation + idl_account_name: Obligation + properties: + - "tag" + - "lending_market" + - "owner" + - "referrer" + - "deposits" + - "borrows" + - "has_debt" + - "elevation_group" + address: + type: pubkey + llm_context: | + CRITICAL: Prefer element paths. Supplying a whole array requires it COMPLETE - every field of + every element including padding, with unused slots all-zero and the reserve set to + 11111111111111111111111111111111 + + HOW TO USE THIS TEMPLATE: + 1. Set one position with deposits.0.deposit_reserve and deposits.0.deposited_amount + 2. Set the matching debt with borrows.0.borrow_reserve and borrows.0.borrowed_amount_sf + 3. Set has_debt: 1 whenever any borrow slot is populated + + Array sizes: deposits = 8 slots, borrows = 5 slots. + + EXAMPLE - "10 SOL deposited against the Main Market SOL reserve": + deposits.0.deposit_reserve: d4A2prbA2whesmvHaL88BH6Ewn5N4bTSU2Ze8P6Bc4Q + deposits.0.deposited_amount: 10000000000 + has_debt: 1 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-obligation-orders + name: Override Obligation Orders + description: Override Kamino Obligation stop-loss and take-profit orders idl_account_name: Obligation properties: - [ - "last_update_slot", - "lending_market", - "owner", - "deposits", - "borrows", - "deposited_value_sf", - "borrowed_value_sf", - "allowed_borrow_value_sf", - "unhealthy_borrow_value_sf", - "borrowing_disabled", - "highest_borrow_factor_pct", - "num_of_obsolete_reserves", - ] + - "obligation_orders" + - "autodeleverage_margin_call_started_timestamp" + - "autodeleverage_target_ltv_pct" + address: + type: pubkey + llm_context: | + obligation_orders is a fixed array of EXACTLY 2 entries. Prefer element paths for a single + order. An all-zero entry is an empty slot. + + HOW TO USE THIS TEMPLATE: + 1. Set obligation_orders.0.condition_threshold_sf and obligation_orders.0.condition_type + 2. Enable obligation_order_execution_enabled on kamino-lending-market-risk, or the order + never executes + + EXAMPLE - arm a stop-loss on the first order slot: + obligation_orders.0.condition_threshold_sf: 576460752303423488000 + obligation_orders.0.min_execution_bonus_bps: 100 + + # ========================================== + # LendingMarket + # ========================================== + - id: kamino-lending-market-risk + name: Override Lending Market Risk Controls + description: Override Kamino market-wide switches and liquidation limits + idl_account_name: LendingMarket + properties: + - path: emergency_mode + label: Emergency mode + description: "1 blocks deposits, borrows and withdrawals; liquidations still allowed. Example: 1" + - path: borrow_disabled + label: Borrowing disabled + description: >- + 1 blocks all new borrows market-wide without touching deposits or withdrawals. Example: 1 + - "autodeleverage_enabled" + - "price_refresh_trigger_to_max_age_pct" + - "liquidation_max_debt_close_factor_pct" + - "insolvency_risk_unhealthy_ltv_pct" + - "min_full_liquidation_value_threshold" + - "max_liquidatable_debt_market_value_at_once" + - "global_allowed_borrow_value" + - "referral_fee_bps" + - "min_value_skip_liquidation_ltv_checks" + - "min_value_skip_liquidation_bf_checks" + - "min_net_value_in_obligation_sf" + - "min_initial_deposit_amount" + - "reserve_rewards_max_apr_bps" + - "obligation_order_execution_enabled" + - "obligation_order_creation_enabled" + - "price_triggered_liquidation_disabled" + - "withdraw_ticket_issuance_enabled" + - "withdraw_ticket_redemption_enabled" + - "withdraw_ticket_cancellation_enabled" + - "min_withdraw_queued_liquidity_value" + - "mature_reserve_debt_liquidation_enabled" + - "term_based_full_liquidation_duration_secs" + - "individual_autodeleverage_margin_call_period_secs" + address: + type: pubkey + llm_context: | + Use this template for market-wide switches, including the gates for two klend 1.23.0 features + that are otherwise configured but never active: + - withdraw_ticket_issuance_enabled / _redemption_enabled / _cancellation_enabled gate the + queued withdrawals set up by kamino-reserve-limits and kamino-withdraw-ticket + - mature_reserve_debt_liquidation_enabled gates the maturity liquidation set up by + kamino-reserve-debt-term + + EXAMPLE - "allow a full position to be closed in one liquidation": + liquidation_max_debt_close_factor_pct: 100 + + EXAMPLE - "wind-down mode" (blocks deposits, borrows and withdrawals, still allows liquidation): + emergency_mode: 1 + + - id: kamino-lending-market-elevation-groups + name: Override Lending Market Elevation Groups + description: Override Kamino e-mode elevation groups + idl_account_name: LendingMarket + properties: + - "elevation_groups" address: type: pubkey + llm_context: | + elevation_groups is a fixed array of EXACTLY 32 entries. Index 0 is the reserved + no-elevation-group slot and its id must stay 0. Prefer element paths for a single group. + + An obligation opts in via elevation_group on kamino-obligation-positions, and the group's + values then override the per-reserve ones. + + EXAMPLE - "e-mode group 1 allows 90% LTV, liquidating at 95%": + elevation_groups.1.ltv_pct: 90 + elevation_groups.1.liquidation_threshold_pct: 95 + elevation_groups.1.allow_new_loans: 1 + diff --git a/crates/core/src/scenarios/protocols/kamino/vault/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/vault/v1/idl.json new file mode 100644 index 000000000..83b32eaeb --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/vault/v1/idl.json @@ -0,0 +1,1781 @@ +{ + "address": "KvauGMspG5k6rtzrqqn7WNn3oZdyKqLKwK2XWQ8FLjd", + "metadata": { + "name": "kamino_vault", + "version": "2.2.2", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "Reserve", + "discriminator": [ + 43, + 242, + 204, + 202, + 26, + 247, + 59, + 127 + ] + }, + { + "name": "GlobalConfig", + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] + }, + { + "name": "ReserveWhitelistEntry", + "discriminator": [ + 135, + 130, + 156, + 210, + 58, + 58, + 91, + 170 + ] + }, + { + "name": "VaultState", + "discriminator": [ + 228, + 196, + 82, + 165, + 98, + 210, + 235, + 152 + ] + } + ], + "types": [ + { + "name": "LastUpdate", + "docs": [ + "Last update state" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "slot", + "docs": [ + "Last slot when updated" + ], + "type": "u64" + }, + { + "name": "stale", + "docs": [ + "True when marked stale, false when slot updated" + ], + "type": "u8" + }, + { + "name": "price_status", + "docs": [ + "Status of the prices used to calculate the last update" + ], + "type": "u8" + }, + { + "name": "placeholder", + "type": { + "array": [ + "u8", + 6 + ] + } + } + ] + } + }, + { + "name": "BigFractionBytes", + "type": { + "kind": "struct", + "fields": [ + { + "name": "value", + "type": { + "array": [ + "u64", + 4 + ] + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 2 + ] + } + } + ] + } + }, + { + "name": "ReserveCollateral", + "docs": [ + "Reserve collateral" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint_pubkey", + "docs": [ + "Reserve collateral mint address" + ], + "type": "pubkey" + }, + { + "name": "mint_total_supply", + "docs": [ + "Reserve collateral mint supply, used for exchange rate" + ], + "type": "u64" + }, + { + "name": "supply_vault", + "docs": [ + "Reserve collateral supply address" + ], + "type": "pubkey" + }, + { + "name": "padding1", + "type": { + "array": [ + "u128", + 32 + ] + } + }, + { + "name": "padding2", + "type": { + "array": [ + "u128", + 32 + ] + } + } + ] + } + }, + { + "name": "ReserveConfig", + "docs": [ + "Reserve configuration values" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "status", + "docs": [ + "Status of the reserve Active/Obsolete/Hidden" + ], + "type": "u8" + }, + { + "name": "padding_deprecated_asset_tier", + "docs": [ + "Asset tier -> 0 - regular (collateral & debt), 1 - isolated collateral, 2 - isolated debt" + ], + "type": "u8" + }, + { + "name": "host_fixed_interest_rate_bps", + "docs": [ + "Flat rate that goes to the host" + ], + "type": "u16" + }, + { + "name": "min_deleveraging_bonus_bps", + "docs": [ + "Starting bonus for deleveraging-related liquidations, in bps." + ], + "type": "u16" + }, + { + "name": "block_ctoken_usage", + "docs": [ + "Boolean flag to block minting/redeeming of ctokens", + "Blocks usage of ctokens (minting or withdrawing from obligation)", + "Effectively blocks deposit_reserve_liquidity and withdraw_obligation_collateral" + ], + "type": "u8" + }, + { + "name": "early_repay_remaining_interest_pct", + "docs": [ + "The percentage of remaining interest over the debt term that is charged as early repay penalty.", + "Only meaningful when `debt_term_seconds > 0`." + ], + "type": "u8" + }, + { + "name": "emergency_mode", + "docs": [ + "Whether the reserve is in emergency mode.", + "Blocks most user operations involving this reserve, similar to [LendingMarket::emergency_mode]", + "but scoped to a single reserve. Also cascades to obligations using this reserve as", + "collateral or debt, blocking borrows and withdrawals on other reserves but still", + "allowing repays and deposits." + ], + "type": "u8" + }, + { + "name": "reserved1", + "docs": [ + "Past reserved space - feel free to reuse." + ], + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "protocol_order_execution_fee_pct", + "docs": [ + "Cut of the order execution bonus that the protocol receives, as a percentage" + ], + "type": "u8" + }, + { + "name": "protocol_take_rate_pct", + "docs": [ + "Protocol take rate is the amount borrowed interest protocol receives, as a percentage" + ], + "type": "u8" + }, + { + "name": "protocol_liquidation_fee_pct", + "docs": [ + "Cut of the liquidation bonus that the protocol receives, as a percentage" + ], + "type": "u8" + }, + { + "name": "loan_to_value_pct", + "docs": [ + "Target ratio of the value of borrows to deposits, as a percentage", + "0 if use as collateral is disabled" + ], + "type": "u8" + }, + { + "name": "liquidation_threshold_pct", + "docs": [ + "Loan to value ratio at which an obligation can be liquidated, as percentage" + ], + "type": "u8" + }, + { + "name": "min_liquidation_bonus_bps", + "docs": [ + "Minimum bonus a liquidator receives when repaying part of an unhealthy obligation, as bps" + ], + "type": "u16" + }, + { + "name": "max_liquidation_bonus_bps", + "docs": [ + "Maximum bonus a liquidator receives when repaying part of an unhealthy obligation, as bps" + ], + "type": "u16" + }, + { + "name": "bad_debt_liquidation_bonus_bps", + "docs": [ + "Bad debt liquidation bonus for an undercollateralized obligation, as bps" + ], + "type": "u16" + }, + { + "name": "deleveraging_margin_call_period_secs", + "docs": [ + "Time in seconds that must pass before redemptions are enabled after the deposit limit is", + "crossed.", + "Only relevant when `autodeleverage_enabled == 1`, and must not be 0 in such case." + ], + "type": "u64" + }, + { + "name": "deleveraging_threshold_decrease_bps_per_day", + "docs": [ + "The rate at which the deleveraging threshold decreases, in bps per day.", + "Only relevant when `autodeleverage_enabled == 1`, and must not be 0 in such case." + ], + "type": "u64" + }, + { + "name": "fees", + "docs": [ + "Program owner fees assessed, separate from gains due to interest accrual" + ], + "type": { + "defined": { + "name": "ReserveFees" + } + } + }, + { + "name": "borrow_rate_curve", + "docs": [ + "Borrow rate curve based on utilization" + ], + "type": { + "defined": { + "name": "BorrowRateCurve" + } + } + }, + { + "name": "borrow_factor_pct", + "docs": [ + "Borrow factor in percentage - used for risk adjustment" + ], + "type": "u64" + }, + { + "name": "deposit_limit", + "docs": [ + "Maximum deposit limit of liquidity in native units, u64::MAX for inf" + ], + "type": "u64" + }, + { + "name": "borrow_limit", + "docs": [ + "Maximum amount borrowed, u64::MAX for inf, 0 to disable borrows (protected deposits)" + ], + "type": "u64" + }, + { + "name": "token_info", + "docs": [ + "Token id from TokenInfos struct" + ], + "type": { + "defined": { + "name": "TokenInfo" + } + } + }, + { + "name": "deposit_withdrawal_cap", + "docs": [ + "Deposit withdrawal caps - deposit & redeem" + ], + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "debt_withdrawal_cap", + "docs": [ + "Debt withdrawal caps - borrow & repay" + ], + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "elevation_groups", + "type": { + "array": [ + "u8", + 20 + ] + } + }, + { + "name": "disable_usage_as_coll_outside_emode", + "type": "u8" + }, + { + "name": "utilization_limit_block_borrowing_above_pct", + "docs": [ + "Utilization (in percentage) above which borrowing is blocked. 0 to disable." + ], + "type": "u8" + }, + { + "name": "autodeleverage_enabled", + "docs": [ + "Whether this reserve should be subject to auto-deleveraging after deposit or borrow limit is", + "crossed.", + "Besides this flag, the lending market's flag also needs to be enabled (logical `AND`).", + "**NOTE:** the manual \"target LTV\" deleveraging is NOT affected by this flag." + ], + "type": "u8" + }, + { + "name": "proposer_authority_locked", + "docs": [ + "Boolean flag indicating whether the reserve is locked for the proposer authority.", + "", + "Once the proposer have finished preparing the reserve, it must be locked to prevent", + "further changes to the reserve configuration allowing review and voting on the proposal", + "without alteration during the voting period." + ], + "type": "u8" + }, + { + "name": "borrow_limit_outside_elevation_group", + "docs": [ + "Maximum amount liquidity of this reserve borrowed outside all elevation groups", + "- u64::MAX for inf", + "- 0 to disable borrows outside elevation groups" + ], + "type": "u64" + }, + { + "name": "borrow_limit_against_this_collateral_in_elevation_group", + "docs": [ + "Defines the maximum amount (in lamports of elevation group debt asset)", + "that can be borrowed when this reserve is used as collateral.", + "- u64::MAX for inf", + "- 0 to disable borrows in this elevation group (expected value for the debt asset)" + ], + "type": { + "array": [ + "u64", + 32 + ] + } + }, + { + "name": "deleveraging_bonus_increase_bps_per_day", + "docs": [ + "The rate at which the deleveraging-related liquidation bonus increases, in bps per day.", + "Only relevant when `autodeleverage_enabled == 1`, and must not be 0 in such case." + ], + "type": "u64" + }, + { + "name": "debt_maturity_timestamp", + "docs": [ + "The timestamp at which all [Obligation::borrows] using this reserve become liquidatable", + "(on the same terms as reserve-wide deleveraging).", + "Inactive when zeroed (i.e. debt never matures).", + "", + "Note: this feature is independent of [Self::debt_term_seconds] - the liquidation mechanism", + "is based directly on the timestamp defined here, on Reserve's level." + ], + "type": "u64" + }, + { + "name": "debt_term_seconds", + "docs": [ + "The duration after which any debt coming from this Reserve must be repaid.", + "Inactive when zeroed (i.e. funds can be borrowed indefinitely).", + "", + "Note: this feature is independent of [Self::debt_maturity_timestamp] - the liquidation", + "mechanism is based on the [ObligationLiquidity::last_borrowed_at_timestamp]." + ], + "type": "u64" + }, + { + "name": "rewards_amount_per_slot", + "docs": [ + "Rewards distributed per slot to depositors. Drained from", + "[ReserveLiquidity::rewards_amount_available] into", + "[ReserveLiquidity::total_available_amount] at each refresh, capped by the", + "market-level [LendingMarket::reserve_rewards_max_apr_bps]. `0` disables.", + "", + "**Note:** because rewards inflate `total_available_amount`, a non-zero RPS on a", + "reserve with [Self::autodeleverage_enabled] and a finite [Self::deposit_limit]", + "will eventually cross the cap and arm the autodeleverage countdown. Size", + "`deposit_limit` and RPS together." + ], + "type": "u64" + }, + { + "name": "permissioned_ops", + "docs": [ + "Bitmask of [PermissionedOp]s gated by the parent market's `permissioning_authority`", + "when this reserve is the operation's target. `0` = no operation is restricted at the", + "reserve level. Use [Reserve::get_permissioned_ops] for a typed view." + ], + "type": "u64" + } + ] + } + }, + { + "name": "ReserveFees", + "docs": [ + "Additional fee information on a reserve", + "", + "These exist separately from interest accrual fees, and are specifically for the program owner", + "and referral fee. The fees are paid out as a percentage of liquidity token amounts during", + "repayments and liquidations." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "origination_fee_sf", + "docs": [ + "Fee assessed on `BorrowObligationLiquidity`, as scaled fraction (60 bits fractional part)", + "Must be between `0` and `2^60`, such that `2^60 = 1`. A few examples for", + "clarity:", + "1% = (1 << 60) / 100 = 11529215046068470", + "0.01% (1 basis point) = 115292150460685", + "0.00001% (Aave origination fee) = 115292150461" + ], + "type": "u64" + }, + { + "name": "flash_loan_fee_sf", + "docs": [ + "Fee for flash loan, expressed as scaled fraction.", + "0.3% (Aave flash loan fee) = 0.003 * 2^60 = 3458764513820541" + ], + "type": "u64" + }, + { + "name": "padding", + "docs": [ + "Used for allignment" + ], + "type": { + "array": [ + "u8", + 8 + ] + } + } + ] + } + }, + { + "name": "ReserveLiquidity", + "docs": [ + "Reserve liquidity" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint_pubkey", + "docs": [ + "Reserve liquidity mint address" + ], + "type": "pubkey" + }, + { + "name": "supply_vault", + "docs": [ + "Reserve liquidity supply address" + ], + "type": "pubkey" + }, + { + "name": "fee_vault", + "docs": [ + "Reserve liquidity fee collection address" + ], + "type": "pubkey" + }, + { + "name": "total_available_amount", + "docs": [ + "Total reserve liquidity available.", + "", + "Note: not all of this liquidity can be freely used for any purpose. Production code should", + "use the specialized getters - see e.g. [Reserve::total_available_liquidity_amount()],", + "[Reserve::freely_available_liquidity_amount()]." + ], + "type": "u64" + }, + { + "name": "borrowed_amount_sf", + "docs": [ + "Reserve liquidity borrowed (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "market_price_sf", + "docs": [ + "Reserve liquidity market price in quote currency (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "market_price_last_updated_ts", + "docs": [ + "Unix timestamp of the market price (from the oracle)" + ], + "type": "u64" + }, + { + "name": "mint_decimals", + "docs": [ + "Reserve liquidity mint decimals" + ], + "type": "u64" + }, + { + "name": "deposit_limit_crossed_timestamp", + "docs": [ + "Timestamp when the last refresh reserve detected that the liquidity amount is above the deposit cap. When this threshold is crossed, then redemptions (auto-deleverage) are enabled.", + "If the threshold is not crossed, then the timestamp is set to 0" + ], + "type": "u64" + }, + { + "name": "borrow_limit_crossed_timestamp", + "docs": [ + "Timestamp when the last refresh reserve detected that the borrowed amount is above the borrow cap. When this threshold is crossed, then redemptions (auto-deleverage) are enabled.", + "If the threshold is not crossed, then the timestamp is set to 0" + ], + "type": "u64" + }, + { + "name": "cumulative_borrow_rate_bsf", + "docs": [ + "Reserve liquidity cumulative borrow rate (scaled fraction)" + ], + "type": { + "defined": { + "name": "BigFractionBytes" + } + } + }, + { + "name": "accumulated_protocol_fees_sf", + "docs": [ + "Reserve cumulative protocol fees (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "accumulated_referrer_fees_sf", + "docs": [ + "Reserve cumulative referrer fees (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "pending_referrer_fees_sf", + "docs": [ + "Reserve pending referrer fees, to be claimed in refresh_obligation by referrer or protocol (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "absolute_referral_rate_sf", + "docs": [ + "Reserve referrer fee absolute rate calculated at each refresh_reserve operation (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "token_program", + "docs": [ + "Token program of the liquidity mint" + ], + "type": "pubkey" + }, + { + "name": "rewards_amount_available", + "docs": [ + "Reserve rewards budget remaining for distribution.", + "", + "Tokens are deposited via `topup_reserve_rewards` and increase this counter (without", + "touching [Self::total_available_amount]). On every `refresh_reserve`, up to", + "`rewards_amount_per_slot * slots_elapsed` tokens are moved from this counter into", + "[Self::total_available_amount], inflating the cToken exchange rate, capped by the", + "market-level `reserve_rewards_max_apr_bps` cap." + ], + "type": "u64" + }, + { + "name": "padding2", + "type": { + "array": [ + "u64", + 50 + ] + } + }, + { + "name": "padding3", + "type": { + "array": [ + "u128", + 32 + ] + } + } + ] + } + }, + { + "name": "WithdrawQueue", + "docs": [ + "A tracker of ticket-based withdrawals." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "queued_collateral_amount", + "docs": [ + "The part of [ReserveLiquidity::total_available_amount] locked for ticketed withdrawals." + ], + "type": "u64" + }, + { + "name": "next_issued_ticket_sequence_number", + "docs": [ + "The sequence number of the next ticket to be issued when enqueueing to withdraw.", + "Note: it is also a number of tickets issued so far." + ], + "type": "u64" + }, + { + "name": "next_withdrawable_ticket_sequence_number", + "docs": [ + "The sequence number of the next ticket to be used for actually transferring the withdrawn", + "liquidity (assuming it is available in the reserve).", + "Note: it is also a number of fully-consumed tickets so far." + ], + "type": "u64" + } + ] + } + }, + { + "name": "WithdrawalCaps", + "docs": [ + "Reserve Withdrawal Caps State" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "config_capacity", + "type": "i64" + }, + { + "name": "current_total", + "type": "i64" + }, + { + "name": "last_interval_start_timestamp", + "type": "u64" + }, + { + "name": "config_interval_length_seconds", + "type": "u64" + } + ] + } + }, + { + "name": "PriceHeuristic", + "type": { + "kind": "struct", + "fields": [ + { + "name": "lower", + "docs": [ + "Lower value of acceptable price" + ], + "type": "u64" + }, + { + "name": "upper", + "docs": [ + "Upper value of acceptable price" + ], + "type": "u64" + }, + { + "name": "exp", + "docs": [ + "Number of decimals of the previously defined values" + ], + "type": "u64" + } + ] + } + }, + { + "name": "PythConfiguration", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price", + "docs": [ + "Pubkey of the base price feed (disabled if `null` or `default`)" + ], + "type": "pubkey" + } + ] + } + }, + { + "name": "ScopeConfiguration", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price_feed", + "docs": [ + "Pubkey of the scope price feed (disabled if `null` or `default`)" + ], + "type": "pubkey" + }, + { + "name": "price_chain", + "docs": [ + "This is the scope_id price chain that results in a price for the token" + ], + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "twap_chain", + "docs": [ + "This is the scope_id price chain for the twap" + ], + "type": { + "array": [ + "u16", + 4 + ] + } + } + ] + } + }, + { + "name": "SwitchboardConfiguration", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price_aggregator", + "docs": [ + "Pubkey of the base price feed (disabled if `null` or `default`)" + ], + "type": "pubkey" + }, + { + "name": "twap_aggregator", + "type": "pubkey" + } + ] + } + }, + { + "name": "TokenInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "name", + "docs": [ + "UTF-8 encoded name of the token (null-terminated)" + ], + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "heuristic", + "docs": [ + "Heuristics limits of acceptable price" + ], + "type": { + "defined": { + "name": "PriceHeuristic" + } + } + }, + { + "name": "max_twap_divergence_bps", + "docs": [ + "Max divergence between twap and price in bps" + ], + "type": "u64" + }, + { + "name": "max_age_price_seconds", + "type": "u64" + }, + { + "name": "max_age_twap_seconds", + "type": "u64" + }, + { + "name": "scope_configuration", + "docs": [ + "Scope price configuration" + ], + "type": { + "defined": { + "name": "ScopeConfiguration" + } + } + }, + { + "name": "switchboard_configuration", + "docs": [ + "Switchboard configuration" + ], + "type": { + "defined": { + "name": "SwitchboardConfiguration" + } + } + }, + { + "name": "pyth_configuration", + "docs": [ + "Pyth configuration" + ], + "type": { + "defined": { + "name": "PythConfiguration" + } + } + }, + { + "name": "block_price_usage", + "type": "u8" + }, + { + "name": "reserved", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 19 + ] + } + } + ] + } + }, + { + "name": "BorrowRateCurve", + "type": { + "kind": "struct", + "fields": [ + { + "name": "points", + "type": { + "array": [ + { + "defined": { + "name": "CurvePoint" + } + }, + 11 + ] + } + } + ] + } + }, + { + "name": "CurvePoint", + "type": { + "kind": "struct", + "fields": [ + { + "name": "utilization_rate_bps", + "type": "u32" + }, + { + "name": "borrow_rate_bps", + "type": "u32" + } + ] + } + }, + { + "name": "UpdateReserveWhitelistMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Invest", + "fields": [ + "u8" + ] + }, + { + "name": "AddAllocation", + "fields": [ + "u8" + ] + } + ] + } + }, + { + "name": "VaultConfigField", + "type": { + "kind": "enum", + "variants": [ + { + "name": "PerformanceFeeBps" + }, + { + "name": "ManagementFeeBps" + }, + { + "name": "MinDepositAmount" + }, + { + "name": "MinWithdrawAmount" + }, + { + "name": "MinInvestAmount" + }, + { + "name": "MinInvestDelaySlots" + }, + { + "name": "CrankFundFeePerReserve" + }, + { + "name": "PendingVaultAdmin" + }, + { + "name": "Name" + }, + { + "name": "LookupTable" + }, + { + "name": "Farm" + }, + { + "name": "AllocationAdmin" + }, + { + "name": "UnallocatedWeight" + }, + { + "name": "UnallocatedTokensCap" + }, + { + "name": "WithdrawalPenaltyLamports" + }, + { + "name": "WithdrawalPenaltyBps" + }, + { + "name": "FirstLossCapitalFarm" + }, + { + "name": "AllowAllocationsInWhitelistedReservesOnly" + }, + { + "name": "AllowInvestInWhitelistedReservesOnly" + }, + { + "name": "RewardPerSecond" + }, + { + "name": "DepositCap" + } + ] + } + }, + { + "name": "VaultAllocation", + "type": { + "kind": "struct", + "fields": [ + { + "name": "reserve", + "type": "pubkey" + }, + { + "name": "ctoken_vault", + "type": "pubkey" + }, + { + "name": "target_allocation_weight", + "type": "u64" + }, + { + "name": "token_allocation_cap", + "docs": [ + "Maximum token invested in this reserve" + ], + "type": "u64" + }, + { + "name": "ctoken_vault_bump", + "type": "u64" + }, + { + "name": "ctoken_allocation_cap", + "type": "u64" + }, + { + "name": "config_padding", + "type": { + "array": [ + "u64", + 126 + ] + } + }, + { + "name": "ctoken_allocation", + "type": "u64" + }, + { + "name": "last_invest_slot", + "type": "u64" + }, + { + "name": "token_target_allocation_sf", + "type": "u128" + }, + { + "name": "state_padding", + "type": { + "array": [ + "u64", + 128 + ] + } + } + ] + } + }, + { + "name": "VaultRewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "reward_per_second", + "type": "u64" + }, + { + "name": "last_issuance_ts", + "type": "u64" + }, + { + "name": "rewards_available", + "docs": [ + "Rewards available to distribute (topped up but not yet moved to vault.token_available)" + ], + "type": "u64" + }, + { + "name": "cumulative_rewards_distributed_analytics", + "type": "u64" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 8 + ] + } + } + ] + } + }, + { + "name": "UpdateGlobalConfigMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "PendingAdmin", + "fields": [ + "pubkey" + ] + }, + { + "name": "MinWithdrawalPenaltyLamports", + "fields": [ + "u64" + ] + }, + { + "name": "MinWithdrawalPenaltyBPS", + "fields": [ + "u64" + ] + } + ] + } + }, + { + "name": "Reserve", + "type": { + "kind": "struct", + "fields": [ + { + "name": "version", + "docs": [ + "Version of the reserve" + ], + "type": "u64" + }, + { + "name": "last_update", + "docs": [ + "Last slot when supply and rates updated" + ], + "type": { + "defined": { + "name": "LastUpdate" + } + } + }, + { + "name": "lending_market", + "docs": [ + "Lending market address" + ], + "type": "pubkey" + }, + { + "name": "farm_collateral", + "type": "pubkey" + }, + { + "name": "farm_debt", + "type": "pubkey" + }, + { + "name": "liquidity", + "docs": [ + "Reserve liquidity" + ], + "type": { + "defined": { + "name": "ReserveLiquidity" + } + } + }, + { + "name": "reserve_liquidity_padding", + "type": { + "array": [ + "u64", + 150 + ] + } + }, + { + "name": "collateral", + "docs": [ + "Reserve collateral" + ], + "type": { + "defined": { + "name": "ReserveCollateral" + } + } + }, + { + "name": "reserve_collateral_padding", + "type": { + "array": [ + "u64", + 150 + ] + } + }, + { + "name": "config", + "docs": [ + "Reserve configuration values" + ], + "type": { + "defined": { + "name": "ReserveConfig" + } + } + }, + { + "name": "config_padding", + "type": { + "array": [ + "u64", + 112 + ] + } + }, + { + "name": "borrowed_amount_outside_elevation_group", + "type": "u64" + }, + { + "name": "borrowed_amounts_against_this_reserve_in_elevation_groups", + "docs": [ + "Amount of token borrowed in lamport of debt asset in the given", + "elevation group when this reserve is part of the collaterals." + ], + "type": { + "array": [ + "u64", + 32 + ] + } + }, + { + "name": "withdraw_queue", + "docs": [ + "The tracker of ticket-based withdrawals." + ], + "type": { + "defined": { + "name": "WithdrawQueue" + } + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 204 + ] + } + } + ] + } + }, + { + "name": "GlobalConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "global_admin", + "type": "pubkey" + }, + { + "name": "pending_admin", + "type": "pubkey" + }, + { + "name": "withdrawal_penalty_lamports", + "type": "u64" + }, + { + "name": "withdrawal_penalty_bps", + "type": "u64" + }, + { + "name": "padding", + "type": { + "array": [ + "u8", + 944 + ] + } + } + ] + } + }, + { + "name": "ReserveWhitelistEntry", + "type": { + "kind": "struct", + "fields": [ + { + "name": "token_mint", + "docs": [ + "The token mint is stored to solve the problem of finding all the whitelisted reserves for a particular token mint:", + "when storing the token mint inside the PDA, finding all the whitelisted reserves becomes a `getProgramAccounts` with", + "a filter on discriminator + the mint field", + "The reserve pubkey, as seed of the reserve whitelist PDA account, is stored so you can link back the PDA to its seeds", + "(for instance, in the operation above we easily find the reserve corresponding to the PDA)" + ], + "type": "pubkey" + }, + { + "name": "reserve", + "type": "pubkey" + }, + { + "name": "whitelist_add_allocation", + "type": "u8" + }, + { + "name": "whitelist_invest", + "type": "u8" + }, + { + "name": "padding", + "type": { + "array": [ + "u8", + 62 + ] + } + } + ] + } + }, + { + "name": "VaultState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "vault_admin_authority", + "type": "pubkey" + }, + { + "name": "base_vault_authority", + "type": "pubkey" + }, + { + "name": "base_vault_authority_bump", + "type": "u64" + }, + { + "name": "token_mint", + "type": "pubkey" + }, + { + "name": "token_mint_decimals", + "type": "u64" + }, + { + "name": "token_vault", + "type": "pubkey" + }, + { + "name": "token_program", + "type": "pubkey" + }, + { + "name": "shares_mint", + "type": "pubkey" + }, + { + "name": "shares_mint_decimals", + "type": "u64" + }, + { + "name": "token_available", + "type": "u64" + }, + { + "name": "shares_issued", + "type": "u64" + }, + { + "name": "available_crank_funds", + "type": "u64" + }, + { + "name": "unallocated_weight", + "type": "u64" + }, + { + "name": "performance_fee_bps", + "type": "u64" + }, + { + "name": "management_fee_bps", + "type": "u64" + }, + { + "name": "last_fee_charge_timestamp", + "type": "u64" + }, + { + "name": "prev_aum_sf", + "type": "u128" + }, + { + "name": "pending_fees_sf", + "type": "u128" + }, + { + "name": "vault_allocation_strategy", + "type": { + "array": [ + { + "defined": { + "name": "VaultAllocation" + } + }, + 25 + ] + } + }, + { + "name": "padding1", + "type": { + "array": [ + "u128", + 256 + ] + } + }, + { + "name": "min_deposit_amount", + "type": "u64" + }, + { + "name": "min_withdraw_amount", + "type": "u64" + }, + { + "name": "min_invest_amount", + "type": "u64" + }, + { + "name": "min_invest_delay_slots", + "type": "u64" + }, + { + "name": "crank_fund_fee_per_reserve", + "type": "u64" + }, + { + "name": "pending_admin", + "type": "pubkey" + }, + { + "name": "cumulative_earned_interest_sf", + "type": "u128" + }, + { + "name": "cumulative_mgmt_fees_sf", + "type": "u128" + }, + { + "name": "cumulative_perf_fees_sf", + "type": "u128" + }, + { + "name": "name", + "type": { + "array": [ + "u8", + 40 + ] + } + }, + { + "name": "vault_lookup_table", + "type": "pubkey" + }, + { + "name": "vault_farm", + "type": "pubkey" + }, + { + "name": "creation_timestamp", + "type": "u64" + }, + { + "name": "unallocated_tokens_cap", + "type": "u64" + }, + { + "name": "allocation_admin", + "type": "pubkey" + }, + { + "name": "withdrawal_penalty_lamports", + "type": "u64" + }, + { + "name": "withdrawal_penalty_bps", + "type": "u64" + }, + { + "name": "first_loss_capital_farm", + "type": "pubkey" + }, + { + "name": "allow_allocations_in_whitelisted_reserves_only", + "type": "u8" + }, + { + "name": "allow_invest_in_whitelisted_reserves_only", + "type": "u8" + }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 6 + ] + } + }, + { + "name": "deposit_cap", + "docs": [ + "total vault deposit cap; 0 means uncapped for backward compatibility reasons; this is a soft cap that just blocks new deposits but the vault AUM can go above this cap because of the earned interest" + ], + "type": "u64" + }, + { + "name": "reward_info", + "type": { + "defined": { + "name": "VaultRewardInfo" + } + } + }, + { + "name": "padding3", + "type": { + "array": [ + "u128", + 232 + ] + } + } + ] + } + }, + { + "name": "DepositResultEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "shares_to_mint", + "type": "u64" + }, + { + "name": "token_to_deposit", + "type": "u64" + }, + { + "name": "crank_funds_to_deposit", + "type": "u64" + } + ] + } + }, + { + "name": "DepositUserAtaBalanceEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user_ata_balance", + "type": "u64" + } + ] + } + }, + { + "name": "RedeemInKindResultEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "shares_to_burn", + "type": "u64" + }, + { + "name": "ctokens_to_send_to_user", + "type": "u64" + } + ] + } + }, + { + "name": "SharesToWithdrawEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "shares_amount", + "type": "u64" + }, + { + "name": "user_shares_before", + "type": "u64" + } + ] + } + }, + { + "name": "WithdrawResultEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "shares_to_burn", + "type": "u64" + }, + { + "name": "available_to_send_to_user", + "type": "u64" + }, + { + "name": "invested_to_disinvest_ctokens", + "type": "u64" + }, + { + "name": "invested_liquidity_to_send_to_user", + "type": "u64" + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml new file mode 100644 index 000000000..f8d190d83 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml @@ -0,0 +1,224 @@ +protocol: kamino-vault +version: v2.2.2 +account_type: VaultState +idl_file_path: idl.json + +tags: + - vault + - yield + - lending + - defi + +templates: + - id: kamino-vault-state + name: Override Earn Vault Balances + description: Override Kamino Earn vault balances and deposit limits + idl_account_name: VaultState + # Do not add `token_mint`, `shares_mint` or their `*_decimals` here. The mints are wiring + # for token accounts that must exist and be funded, so repointing them only produces a + # broken vault - fork a real one instead. The decimals are cached copies of the SPL mints + # and changing them silently desyncs every amount. + properties: + - path: token_available + label: Idle tokens + description: "Tokens idle in the vault, not yet lent out. Example: 1000000000" + - path: shares_issued + label: Shares outstanding + description: "Total shares held by depositors. Example: 1000000000" + - path: prev_aum_sf + label: AUM at last fee charge + description: "AUM recorded at the last fee charge (scaled fraction, x2^60). Example: 1000 x 2^60" + - "deposit_cap" + - path: min_deposit_amount + label: Minimum deposit + description: "Smallest accepted deposit, in the token's smallest unit. Example: 1000000" + - path: min_withdraw_amount + label: Minimum withdrawal + description: "Smallest accepted withdrawal, smallest unit. Example: 1000000" + - path: min_invest_amount + label: Minimum invest + description: "Least the vault will deploy into a reserve in one go, smallest unit. Example: 1000000" + - path: min_invest_delay_slots + label: Invest cooldown + description: "Slots that must pass between deployments. Example: 0" + - path: unallocated_tokens_cap + label: Idle ceiling + description: "Most the vault will deliberately leave idle, smallest unit. Example: 1000000000" + - path: unallocated_weight + label: Idle weight + description: >- + The idle bucket's share of the vault, expressed relative to the reserve weights rather than as + a percentage. Example: 100 + address: + type: pubkey + llm_context: | + Share price = total assets (token_available plus what is deployed into reserves) / + shares_issued. + + HOW TO USE THIS TEMPLATE: + 1. Raise token_available alone to simulate the vault earning yield + 2. Raise shares_issued alone to dilute every holder + 3. Set deposit_cap: 0 to block new deposits + + EXAMPLE - "the vault earned 1000 USDC of yield" (6 decimals): + token_available: 1000000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-vault-fees + name: Override Earn Vault Fees + description: Override Kamino Earn vault performance, management and exit fees + idl_account_name: VaultState + properties: + - path: performance_fee_bps + label: Performance fee + description: "Charged on yield the vault earns in bps. Example: 0" + - path: management_fee_bps + label: Management fee + description: "Charged annually on assets held in bps. Example: 0" + - path: last_fee_charge_timestamp + label: Last fee charge + description: "When fees were last taken (unix seconds). Example: 1780000000" + - path: pending_fees_sf + label: Accrued fees + description: "Fees accrued but not yet taken (scaled fraction, x2^60). Example: 0" + - path: withdrawal_penalty_bps + label: Withdrawal penalty + description: "Exit fee charged on withdrawal, in bps. Example: 100" + - path: withdrawal_penalty_lamports + label: Withdrawal penalty (lamports) + description: "Flat SOL charge on withdrawal, in lamports. Example: 0" + address: + type: pubkey + llm_context: | + Use this template to isolate depositor returns from fees, or to stress the fee maths. + + HOW TO USE THIS TEMPLATE: + 1. Set both fee rates to 0 to remove fees from a share-price assertion + 2. Move last_fee_charge_timestamp into the past so the next charge covers a longer period, + which simulates elapsed time without waiting + + EXAMPLE - "no fees": + performance_fee_bps: 0 + management_fee_bps: 0 + + - id: kamino-vault-allocation + name: Override Earn Vault Allocation + description: Override how a Kamino Earn vault spreads deposits across reserves + idl_account_name: VaultState + properties: + - path: vault_allocation_strategy.0.reserve + label: Target reserve + description: >- + The Kamino Lend reserve this slot lends into. Example: + D6q6wuQSrifJKZYpR1M8R4YawnLDtDsMmWM1NbBmgJ59 + - path: vault_allocation_strategy.0.target_allocation_weight + label: Target weight + description: "This reserve's share of the vault, a proportion not a percentage. Example: 0" + - "vault_allocation_strategy.0.token_allocation_cap" + - path: vault_allocation_strategy.0.ctoken_allocation + label: Collateral held + description: >- + How many of the reserve's collateral tokens the vault currently holds there, smallest unit. + Example: 1000000000 + - path: vault_allocation_strategy.0.ctoken_allocation_cap + label: Collateral cap + description: "Ceiling on collateral tokens held in this reserve, smallest unit. Example: 1000000000" + - path: vault_allocation_strategy.0.token_target_allocation_sf + label: Target amount + description: "Target holding for this reserve (scaled fraction, x2^60). Example: 1000 x 2^60" + - path: vault_allocation_strategy.0.last_invest_slot + label: Last invest slot + description: "Slot at which the vault last deployed into this reserve. Example: 370000000" + - path: allow_allocations_in_whitelisted_reserves_only + label: Whitelist allocations + description: "1 restricts which reserves may be given a weight to whitelisted ones. Example: 1" + - path: allow_invest_in_whitelisted_reserves_only + label: Whitelist investing + description: "1 restricts actual deployment to whitelisted reserves. Example: 1" + address: + type: pubkey + llm_context: | + vault_allocation_strategy has 25 slots - replace the 0 in the property paths to target slots + 0-24. Weights are PROPORTIONS relative to each other plus unallocated_weight, not percentages. + + HOW TO USE THIS TEMPLATE: + 1. Set vault_allocation_strategy.N.reserve to the Kamino Lend reserve for that slot + 2. Set target_allocation_weight: 0 to make the vault withdraw from it on the next crank + 3. To set up a withdrawal failure, concentrate the full weight into one reserve and then make + that reserve illiquid with kamino-reserve-limits + + EXAMPLE - "pull out of this reserve": + vault_allocation_strategy.0.target_allocation_weight: 0 + + - id: kamino-vault-rewards + name: Override Earn Vault Rewards + description: Override Kamino Earn vault reward emissions + idl_account_name: VaultState + properties: + - path: reward_info.reward_per_second + label: Emission rate + description: >- + Rewards paid to vault depositors per second, in the reward token's smallest unit. Example: + 1000 + - "reward_info.rewards_available" + - path: reward_info.last_issuance_ts + label: Last issuance time + description: "When vault rewards last accrued (unix seconds). Example: 1780000000" + - path: vault_farm + label: Linked farm + description: >- + Kamino Farms account if the vault also distributes through Farms. Example: the farm's address + - path: first_loss_capital_farm + label: First-loss farm + description: >- + Farm holding first-loss capital that absorbs losses before depositors. Example: the farm's + address + address: + type: pubkey + llm_context: | + A THIRD reward mechanism, separate from reserve rewards (kamino-reserve-rewards) and Kamino + Farms (kamino-farms-*). This one pays vault depositors directly. + + HOW TO USE THIS TEMPLATE: + 1. Set reward_info.reward_per_second to the emission rate + 2. Raise reward_info.rewards_available too, or emissions stop when the budget empties + 3. Move reward_info.last_issuance_ts backwards to accrue a longer period without waiting + 4. When vault_farm is set, the vault also distributes through Farms - use the kamino-farms-* + templates for the per-user side + + EXAMPLE - "emit 1 USDC per second" (6 decimals): + reward_info.reward_per_second: 1000000 + reward_info.rewards_available: 1000000000 + + - id: kamino-vault-reserve-whitelist + name: Override Earn Vault Reserve Whitelist + description: Override a Kamino Earn vault reserve whitelist entry + idl_account_name: ReserveWhitelistEntry + properties: + - "token_mint" + - path: reserve + label: Reserve + description: >- + The Kamino Lend reserve this entry whitelists. Example: + D6q6wuQSrifJKZYpR1M8R4YawnLDtDsMmWM1NbBmgJ59 (Main Market USDC) + - path: whitelist_add_allocation + label: May be allocated + description: "1 permits the vault to give this reserve a target weight, 0 refuses it. Example: 1" + - path: whitelist_invest + label: May be invested in + description: "1 permits the vault to actually deploy funds into this reserve, 0 refuses it. Example: 1" + address: + type: pubkey + llm_context: | + CRITICAL: Without an entry here, the whitelist switches on kamino-vault-allocation can only be + turned on - with nothing whitelisted, every allocation is refused. Build one with + surfnet_setAccount, since a vault that has never used whitelisting has no entries. + + One account per (vault, reserve) pair. + + EXAMPLE - "this reserve is approved for both allocation and investment": + reserve: D6q6wuQSrifJKZYpR1M8R4YawnLDtDsMmWM1NbBmgJ59 + whitelist_add_allocation: 1 + whitelist_invest: 1 \ No newline at end of file diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 9d69b0eee..533bf9d63 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -23,6 +23,27 @@ pub const METEORA_DLMM_OVERRIDES_CONTENT: &str = pub const KAMINO_V1_IDL_CONTENT: &str = include_str!("./protocols/kamino/v1/idl.json"); pub const KAMINO_V1_OVERRIDES_CONTENT: &str = include_str!("./protocols/kamino/v1/overrides.yaml"); +pub const KAMINO_SCOPE_IDL_CONTENT: &str = include_str!("./protocols/kamino/scope/v1/idl.json"); +pub const KAMINO_SCOPE_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/scope/v1/overrides.yaml"); + +pub const KAMINO_FARMS_IDL_CONTENT: &str = include_str!("./protocols/kamino/farms/v1/idl.json"); +pub const KAMINO_FARMS_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/farms/v1/overrides.yaml"); + +pub const KAMINO_SWAP_IDL_CONTENT: &str = include_str!("./protocols/kamino/swap/v1/idl.json"); +pub const KAMINO_SWAP_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/swap/v1/overrides.yaml"); + +pub const KAMINO_VAULT_IDL_CONTENT: &str = include_str!("./protocols/kamino/vault/v1/idl.json"); +pub const KAMINO_VAULT_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/vault/v1/overrides.yaml"); + +pub const KAMINO_LIQUIDITY_IDL_CONTENT: &str = + include_str!("./protocols/kamino/liquidity/v1/idl.json"); +pub const KAMINO_LIQUIDITY_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/liquidity/v1/overrides.yaml"); + pub const DRIFT_V2_IDL_CONTENT: &str = include_str!("./protocols/drift/v2/idl.json"); pub const DRIFT_V2_OVERRIDES_CONTENT: &str = include_str!("./protocols/drift/v2/overrides.yaml"); @@ -89,6 +110,36 @@ impl TemplateRegistry { pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); + + self.load_protocol_overrides( + KAMINO_SCOPE_IDL_CONTENT, + KAMINO_SCOPE_OVERRIDES_CONTENT, + "kamino-scope", + ); + + self.load_protocol_overrides( + KAMINO_FARMS_IDL_CONTENT, + KAMINO_FARMS_OVERRIDES_CONTENT, + "kamino-farms", + ); + + self.load_protocol_overrides( + KAMINO_SWAP_IDL_CONTENT, + KAMINO_SWAP_OVERRIDES_CONTENT, + "kamino-swap", + ); + + self.load_protocol_overrides( + KAMINO_VAULT_IDL_CONTENT, + KAMINO_VAULT_OVERRIDES_CONTENT, + "kamino-vault", + ); + + self.load_protocol_overrides( + KAMINO_LIQUIDITY_IDL_CONTENT, + KAMINO_LIQUIDITY_OVERRIDES_CONTENT, + "kamino-liquidity", + ); } pub fn load_drift_overrides(&mut self) { @@ -182,13 +233,35 @@ impl TemplateRegistry { #[cfg(test)] mod tests { - use std::{collections::HashMap, str::FromStr}; + use anchor_lang_idl::types::IdlType; + use std::{collections::HashMap, collections::BTreeSet, str::FromStr}; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, PdaSeed}; use super::*; + /// A valid JSON value for a scalar IDL type, or `None` for composites. + fn sample_scalar_value(ty: &IdlType) -> Option { + match ty { + IdlType::Bool => Some(serde_json::json!(true)), + IdlType::U8 + | IdlType::U16 + | IdlType::U32 + | IdlType::U64 + | IdlType::U128 + | IdlType::I8 + | IdlType::I16 + | IdlType::I32 + | IdlType::I64 + | IdlType::I128 => Some(serde_json::json!(1)), + IdlType::Pubkey => Some(serde_json::json!( + "11111111111111111111111111111111".to_string() + )), + _ => None, + } + } + #[test] fn raydium_config_index_options_derive_their_documented_address() { let registry = TemplateRegistry::new(); @@ -329,11 +402,11 @@ mod tests { fn test_registry_loads_all_protocols() { let registry = TemplateRegistry::new(); - // Should have Pyth (1 template) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift(4) + Meteora (2) + Kamino(3) + Whirlpool(6) + SPL Token (2) = 24 total + // Should have Pyth (1 template) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift(4) + Meteora (2) + Kamino(Lend 17, Scope 3, Farms 5, Swap 2, Vault 5, Liquidity 4) + Whirlpool(6) + SPL Token (2) = 57 total assert_eq!( registry.count(), - 24, - "Registry should load 24 templates total" + 57, + "Registry should load 57 templates total" ); assert!(registry.contains("pyth-price-feed-v2")); @@ -352,7 +425,36 @@ mod tests { assert!(registry.contains("kamino-reserve-state")); assert!(registry.contains("kamino-reserve-config")); + assert!(registry.contains("kamino-reserve-status")); + assert!(registry.contains("kamino-reserve-limits")); + assert!(registry.contains("kamino-reserve-fees")); + assert!(registry.contains("kamino-reserve-interest-rate")); + assert!(registry.contains("kamino-reserve-oracle")); assert!(registry.contains("kamino-obligation-health")); + assert!(registry.contains("kamino-obligation-positions")); + assert!(registry.contains("kamino-obligation-orders")); + assert!(registry.contains("kamino-lending-market-risk")); + assert!(registry.contains("kamino-lending-market-elevation-groups")); + assert!(registry.contains("kamino-reserve-rewards")); + assert!(registry.contains("kamino-reserve-debt-term")); + assert!(registry.contains("kamino-withdraw-ticket")); + assert!(registry.contains("kamino-scope-price")); + assert!(registry.contains("kamino-scope-price-source")); + assert!(registry.contains("kamino-scope-twap")); + assert!(registry.contains("kamino-farms-reward-emissions")); + assert!(registry.contains("kamino-farms-reward-accumulator")); + assert!(registry.contains("kamino-farms-user-rewards")); + assert!(registry.contains("kamino-farms-farm-config")); + assert!(registry.contains("kamino-farms-global-config")); + assert!(registry.contains("kamino-swap-order")); + assert!(registry.contains("kamino-swap-global-config")); + assert!(registry.contains("kamino-vault-state")); + assert!(registry.contains("kamino-vault-allocation")); + assert!(registry.contains("kamino-vault-rewards")); + assert!(registry.contains("kamino-vault-reserve-whitelist")); + assert!(registry.contains("kamino-liquidity-strategy-balances")); + assert!(registry.contains("kamino-liquidity-strategy-rewards")); + assert!(registry.contains("kamino-liquidity-strategy-guards")); assert!(registry.contains("drift-perp-market")); assert!(registry.contains("drift-spot-market")); @@ -409,8 +511,70 @@ mod tests { "Should have 5 Raydium templates (1 CLMM + 4 AMM v4)" ); - let kamino_templates = registry.by_protocol("Kamino"); - assert_eq!(kamino_templates.len(), 3, "Should have 3 Kamino templates"); + let kamino_templates = registry.by_protocol("kamino"); + assert_eq!( + kamino_templates.len(), + 17, + "Should have 17 Kamino Lend templates" + ); + assert_eq!( + registry.by_protocol("kamino-scope").len(), + 3, + "Should have 3 Kamino Scope templates" + ); + assert_eq!( + registry.by_protocol("kamino-farms").len(), + 5, + "Should have 5 Kamino Farms templates" + ); + assert_eq!( + registry.by_protocol("kamino-swap").len(), + 2, + "Should have 2 Kamino Swap templates" + ); + assert_eq!( + registry.by_protocol("kamino-vault").len(), + 5, + "Should have 5 Kamino Earn vault templates" + ); + assert_eq!( + registry.by_protocol("kamino-liquidity").len(), + 4, + "Should have 4 Kamino Liquidity templates" + ); + + // Each Kamino-family protocol must cover the accounts worth overriding + for (protocol, expected_accounts) in [ + ( + "kamino", + vec!["Reserve", "Obligation", "LendingMarket", "WithdrawTicket"], + ), + ( + "kamino-scope", + vec!["OraclePrices", "OracleMappings", "OracleTwaps"], + ), + ( + "kamino-farms", + vec!["FarmState", "UserState", "GlobalConfig"], + ), + ("kamino-swap", vec!["Order", "GlobalConfig"]), + ("kamino-vault", vec!["VaultState", "ReserveWhitelistEntry"]), + ("kamino-liquidity", vec!["WhirlpoolStrategy"]), + ] { + let account_types: BTreeSet<&str> = registry + .by_protocol(protocol) + .iter() + .map(|t| t.account_type.as_str()) + .collect(); + for expected in expected_accounts { + assert!( + account_types.contains(expected), + "{} should have at least one template for the {} account", + protocol, + expected + ); + } + } let whirlpool_templates = registry.by_protocol("Whirlpool"); assert_eq!( @@ -427,8 +591,15 @@ mod tests { let oracle_templates = registry.by_tags(&[vec!["oracle".to_string()]].concat()); assert_eq!( oracle_templates.len(), - 1, - "Should find 1 oracle template (Pyth)" + 4, + "Should find 4 oracle templates (Pyth + 3 Kamino Scope)" + ); + + let rewards_templates = registry.by_tags(&[vec!["rewards".to_string()]].concat()); + assert_eq!( + rewards_templates.len(), + 5, + "Should find 5 rewards templates (Kamino Farms)" ); let dex_templates = registry.by_tags(&[vec!["dex".to_string()]].concat()); @@ -473,6 +644,11 @@ mod tests { assert!(ids.contains(&"kamino-reserve-state".to_string())); assert!(ids.contains(&"kamino-reserve-config".to_string())); assert!(ids.contains(&"kamino-obligation-health".to_string())); + assert!(ids.contains(&"kamino-obligation-positions".to_string())); + assert!(ids.contains(&"kamino-reserve-oracle".to_string())); + assert!(ids.contains(&"kamino-lending-market-risk".to_string())); + assert!(ids.contains(&"kamino-scope-price".to_string())); + assert!(ids.contains(&"kamino-farms-user-rewards".to_string())); assert!(ids.contains(&"drift-perp-market".to_string())); assert!(ids.contains(&"whirlpool-sol-usdc".to_string())); assert!(ids.contains(&"whirlpool-sol-usdt".to_string())); @@ -877,4 +1053,1089 @@ mod tests { resolved_address, expected_address ); } + + /// A property that does not exist in the IDL is dropped at materialization time with only + /// a warning, so the scenario appears to run while changing nothing. + #[test] + fn test_all_template_property_paths_exist_in_idl() { + let registry = TemplateRegistry::new(); + let mut errors = Vec::new(); + + for template in registry.all() { + for property in &template.properties { + // constant_ref properties are UI dropdowns (e.g. token pickers), not + // account fields, so they are not expected to resolve against the IDL. + if property.is_constant_ref() { + continue; + } + if let Err(e) = surfpool_types::resolve_idl_type( + &template.idl, + &template.account_type, + &property.path, + ) { + errors.push(format!("[{}] {}: {}", template.id, property.path, e)); + } + } + } + + assert!( + errors.is_empty(), + "{} template propert(ies) do not exist in their IDL:\n {}", + errors.len(), + errors.join("\n ") + ); + } + + #[test] + fn test_kamino_templates_round_trip_through_forge() { + use std::collections::HashMap; + + use solana_pubkey::Pubkey; + + use crate::surfnet::svm::SurfnetSvm; + + // Live mainnet sizes. Keyed by (protocol, account) because `GlobalConfig` is a + // different struct in four of these programs. + const ACCOUNT_SIZES: &[(&str, &str, usize)] = &[ + // Kamino Lend (KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD) + ("kamino", "Reserve", 8624), + ("kamino", "Obligation", 3344), + ("kamino", "LendingMarket", 4664), + // No WithdrawTicket existed on mainnet when this was written (the feature is new + // in klend 1.23.0), so this size is derived from the IDL rather than observed. + ("kamino", "WithdrawTicket", 520), + // Scope (HFn8GnPADiny6XqUoWE8uRPPxb29ikn4yTuPa9MF2fWJ) + ("kamino-scope", "OraclePrices", 28712), + ("kamino-scope", "OracleMappings", 29704), + ("kamino-scope", "OracleTwaps", 344136), + // Kamino Farms (FarmsPZpWu9i7Kky8tPN37rs2TpmMrAZrC7S7vJa91Hr) + ("kamino-farms", "FarmState", 8336), + ("kamino-farms", "UserState", 920), + ("kamino-farms", "GlobalConfig", 2136), + // LIMO / Kamino Swap (LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF) + ("kamino-swap", "Order", 424), + ("kamino-swap", "GlobalConfig", 2168), + // Kamino Vaults / Earn (KvauGMspG5k6rtzrqqn7WNn3oZdyKqLKwK2XWQ8FLjd) + ("kamino-vault", "VaultState", 62552), + ("kamino-vault", "ReserveWhitelistEntry", 136), + // Kamino Liquidity / yvaults (6LtLpnUFNByNXLyCoK9wA2MykKAmQNZKBdY8s47dehDc) + ("kamino-liquidity", "WhirlpoolStrategy", 4064), + ]; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + let mut checked = 0; + + for protocol in [ + "kamino", + "kamino-scope", + "kamino-farms", + "kamino-swap", + "kamino-vault", + "kamino-liquidity", + ] { + let templates = registry.by_protocol(protocol); + assert!( + !templates.is_empty(), + "expected templates for protocol {}", + protocol + ); + + for template in templates { + let (_, _, size) = ACCOUNT_SIZES + .iter() + .find(|(proto, name, _)| *proto == protocol && *name == template.account_type) + .unwrap_or_else(|| { + panic!( + "template {} targets {}/{} with no known size; add it to ACCOUNT_SIZES", + template.id, protocol, template.account_type + ) + }); + + let account_def = template + .idl + .accounts + .iter() + .find(|a| a.name == template.account_type) + .unwrap_or_else(|| { + panic!( + "account '{}' not found in the {} IDL (template {})", + template.account_type, protocol, template.id + ) + }); + + let mut data = vec![0u8; *size]; + data[..8].copy_from_slice(&account_def.discriminator); + + // A zeroed account with no overrides must survive the decode/re-encode cycle + // byte-for-byte, otherwise the pipeline is silently rewriting account state. + let identity = surfnet_svm + .get_forged_account_data(&pubkey, &data, &template.idl, &HashMap::new()) + .unwrap_or_else(|e| { + panic!("identity round-trip failed for {}: {}", template.id, e) + }); + assert_eq!( + identity, data, + "identity round-trip changed bytes for {}", + template.id + ); + + // Now write every scalar property the template advertises, in one pass. + let mut overrides: HashMap = HashMap::new(); + for property in &template.properties { + let ty = surfpool_types::resolve_idl_type( + &template.idl, + &template.account_type, + &property.path, + ) + .unwrap_or_else(|e| panic!("[{}] {}: {}", template.id, property.path, e)); + if let Some(value) = sample_scalar_value(ty) { + overrides.insert(property.path.clone(), value); + } + } + + if overrides.is_empty() { + // Composite-only template (e.g. kamino-reserve-interest-rate exposes a + // single struct); its llm_context documents the required full shape. + continue; + } + + let forged = surfnet_svm + .get_forged_account_data(&pubkey, &data, &template.idl, &overrides) + .unwrap_or_else(|e| { + panic!( + "forge failed for {} with {} scalar override(s): {}", + template.id, + overrides.len(), + e + ) + }); + + assert_eq!( + forged.len(), + data.len(), + "forged account size changed for {}", + template.id + ); + assert_ne!( + forged, data, + "overrides for {} did not change any bytes", + template.id + ); + checked += 1; + } + } + + assert!( + checked >= 25, + "expected to exercise at least 25 Kamino-family templates, got {}", + checked + ); + } + + /// The default pubkey "1111...1111" is all hex characters, which the encoder used to + /// misread as hex bytes and panic on. + #[test] + fn test_kamino_obligation_array_index_and_pubkey_overrides() { + use std::collections::HashMap; + + use solana_pubkey::Pubkey; + + use crate::surfnet::svm::SurfnetSvm; + + // Obligation offsets incl. discriminator: header is 88 bytes, then 136 per deposit. + const DEPOSIT_0_RESERVE: usize = 8 + 88; + const DEPOSIT_0_AMOUNT: usize = DEPOSIT_0_RESERVE + 32; + const DEPOSIT_1_RESERVE: usize = 8 + 88 + 136; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let template = registry + .get("kamino-obligation-positions") + .expect("kamino-obligation-positions template should exist"); + + let account_def = template + .idl + .accounts + .iter() + .find(|a| a.name == "Obligation") + .expect("Obligation account in Kamino IDL"); + let mut data = vec![0u8; 3344]; + data[..8].copy_from_slice(&account_def.discriminator); + + let wsol = "So11111111111111111111111111111111111111112"; + let overrides: HashMap = HashMap::from([ + ( + "deposits.0.deposit_reserve".to_string(), + serde_json::json!("11111111111111111111111111111111"), + ), + ( + "deposits.0.deposited_amount".to_string(), + serde_json::json!(4_200_000_000u64), + ), + ( + "deposits.1.deposit_reserve".to_string(), + serde_json::json!(wsol), + ), + ("has_debt".to_string(), serde_json::json!(1)), + ]); + + let forged = surfnet_svm + .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .expect("array-index and pubkey overrides should apply"); + + assert_eq!(forged.len(), data.len(), "account size must be preserved"); + + assert_eq!( + &forged[DEPOSIT_0_RESERVE..DEPOSIT_0_RESERVE + 32], + Pubkey::default().as_ref(), + "deposits[0].deposit_reserve should be the default pubkey" + ); + assert_eq!( + u64::from_le_bytes( + forged[DEPOSIT_0_AMOUNT..DEPOSIT_0_AMOUNT + 8] + .try_into() + .unwrap() + ), + 4_200_000_000u64, + "deposits[0].deposited_amount should be written at its array index" + ); + assert_eq!( + &forged[DEPOSIT_1_RESERVE..DEPOSIT_1_RESERVE + 32], + Pubkey::from_str_const(wsol).as_ref(), + "deposits[1].deposit_reserve should be the wSOL mint" + ); + } + + #[test] + fn test_array_index_override_path_errors() { + use txtx_addon_kit::{indexmap::IndexMap, types::types::Value}; + + use crate::surfnet::svm::apply_override_to_decoded_account; + + let mut decoded = Value::Object(IndexMap::from([( + "deposits".to_string(), + Value::Array(Box::new(vec![Value::Integer(1), Value::Integer(2)])), + )])); + + assert!( + apply_override_to_decoded_account(&mut decoded, "deposits.1", &serde_json::json!(9)) + .is_ok() + ); + match &decoded { + Value::Object(map) => match map.get("deposits") { + Some(Value::Array(items)) => assert_eq!(items[1], Value::Integer(9)), + _ => panic!("expected deposits array"), + }, + _ => panic!("expected object"), + } + + // out-of-bounds index + let err = + apply_override_to_decoded_account(&mut decoded, "deposits.7", &serde_json::json!(1)) + .expect_err("index 7 is out of bounds for a 2-element array"); + assert!( + format!("{err}").contains("out of bounds"), + "unexpected error: {err}" + ); + + // non-numeric segment on an array + let err = apply_override_to_decoded_account( + &mut decoded, + "deposits.first", + &serde_json::json!(1), + ) + .expect_err("'first' is not an array index"); + assert!( + format!("{err}").contains("zero-based array index"), + "unexpected error: {err}" + ); + + // empty segment + assert!( + apply_override_to_decoded_account(&mut decoded, "deposits..0", &serde_json::json!(1)) + .is_err() + ); + } + + #[test] + fn test_kamino_scope_price_override_writes_expected_bytes() { + use std::collections::HashMap; + + use solana_pubkey::Pubkey; + + use crate::surfnet::svm::SurfnetSvm; + + // OraclePrices: discriminator + oracle_mappings pubkey, then 56 bytes per entry. + const PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + + // A mechanical target; real per-token indices differ per price account. + const SOL_INDEX: usize = 0; + // $125.50 with exp = 8 + const SOL_VALUE: u64 = 12_550_000_000; + const SOL_EXP: u64 = 8; + const AT_SLOT: u64 = 370_000_000; + const AT_TS: u64 = 1_800_000_000; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let template = registry + .get("kamino-scope-price") + .expect("kamino-scope-price template should exist"); + + assert_eq!( + template.address, + surfpool_types::AccountAddress::Pubkey( + "3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH".to_string() + ), + "template should default to the Main Market's Scope prices account" + ); + + let account_def = template + .idl + .accounts + .iter() + .find(|a| a.name == "OraclePrices") + .expect("OraclePrices in the Scope IDL"); + let mut data = vec![0u8; 28712]; + data[..8].copy_from_slice(&account_def.discriminator); + + let overrides: HashMap = HashMap::from([ + ( + format!("prices.{SOL_INDEX}.price.value"), + serde_json::json!(SOL_VALUE), + ), + ( + format!("prices.{SOL_INDEX}.price.exp"), + serde_json::json!(SOL_EXP), + ), + ( + format!("prices.{SOL_INDEX}.last_updated_slot"), + serde_json::json!(AT_SLOT), + ), + ( + format!("prices.{SOL_INDEX}.unix_timestamp"), + serde_json::json!(AT_TS), + ), + ]); + + let forged = surfnet_svm + .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .expect("scope price override should apply"); + + assert_eq!(forged.len(), data.len(), "account size must be preserved"); + + let base = PRICES_BASE + SOL_INDEX * DATED_PRICE_SIZE; + let read = |off: usize| u64::from_le_bytes(forged[off..off + 8].try_into().unwrap()); + assert_eq!(read(base), SOL_VALUE, "price.value"); + assert_eq!(read(base + 8), SOL_EXP, "price.exp"); + assert_eq!(read(base + 16), AT_SLOT, "last_updated_slot"); + assert_eq!(read(base + 24), AT_TS, "unix_timestamp"); + + // price = value / 10^exp + assert_eq!(SOL_VALUE as f64 / 10f64.powi(SOL_EXP as i32), 125.50); + + // Neighbouring entries must be untouched. + let next = PRICES_BASE + (SOL_INDEX + 1) * DATED_PRICE_SIZE; + assert!( + forged[next..next + DATED_PRICE_SIZE] + .iter() + .all(|b| *b == 0), + "writing one price index must not disturb the next entry" + ); + } + + /// A reward accrues from the gap between the farm accumulator and the user's tally, so + /// both halves must be writable. + #[test] + fn test_kamino_farms_reward_override_writes_both_halves() { + use std::collections::HashMap; + + use solana_pubkey::Pubkey; + + use crate::surfnet::svm::SurfnetSvm; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + let farm = registry + .get("kamino-farms-reward-accumulator") + .expect("kamino-farms-reward-accumulator template"); + let farm_def = farm + .idl + .accounts + .iter() + .find(|a| a.name == "FarmState") + .expect("FarmState in the Farms IDL"); + let mut farm_data = vec![0u8; 8336]; + farm_data[..8].copy_from_slice(&farm_def.discriminator); + + let farm_overrides: HashMap = HashMap::from([ + ( + "reward_infos.0.reward_per_share_scaled".to_string(), + serde_json::json!(5_000_000u64), + ), + ( + "total_active_stake_scaled".to_string(), + serde_json::json!(1_000_000u64), + ), + ]); + let forged_farm = surfnet_svm + .get_forged_account_data(&pubkey, &farm_data, &farm.idl, &farm_overrides) + .expect("farm accumulator override should apply"); + assert_eq!(forged_farm.len(), farm_data.len()); + assert_ne!(forged_farm, farm_data); + + let user = registry + .get("kamino-farms-user-rewards") + .expect("kamino-farms-user-rewards template"); + let user_def = user + .idl + .accounts + .iter() + .find(|a| a.name == "UserState") + .expect("UserState in the Farms IDL"); + let mut user_data = vec![0u8; 920]; + user_data[..8].copy_from_slice(&user_def.discriminator); + + // UserState offsets incl. discriminator: 80-byte header, then the [u128; 10] tally. + const TALLY_0: usize = 88; + const UNCLAIMED_0: usize = TALLY_0 + 160; + + let user_overrides: HashMap = HashMap::from([ + ( + "rewards_issued_unclaimed.0".to_string(), + serde_json::json!(777_000u64), + ), + ( + "rewards_tally_scaled.0".to_string(), + serde_json::json!(0u64), + ), + ( + "active_stake_scaled".to_string(), + serde_json::json!(1_000u64), + ), + ]); + let forged_user = surfnet_svm + .get_forged_account_data(&pubkey, &user_data, &user.idl, &user_overrides) + .expect("user reward override should apply"); + + assert_eq!(forged_user.len(), user_data.len()); + assert_eq!( + u64::from_le_bytes( + forged_user[UNCLAIMED_0..UNCLAIMED_0 + 8] + .try_into() + .unwrap() + ), + 777_000u64, + "rewards_issued_unclaimed[0] should be written at its array index" + ); + } + + /// The two overrides that survive `refresh_obligation`: crash the Scope price, then + /// tighten the deposit reserve's liquidation threshold. + #[test] + fn test_kamino_liquidation_setup_writes_durable_inputs() { + use std::collections::HashMap; + + use solana_pubkey::Pubkey; + + use crate::surfnet::svm::SurfnetSvm; + + const LTV_PCT: usize = 4872; + const LIQ_THRESHOLD_PCT: usize = 4873; + const SCOPE_PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + // Crash the Scope price the reserve prices from. + let scope = registry.get("kamino-scope-price").expect("scope template"); + let scope_disc = &scope + .idl + .accounts + .iter() + .find(|a| a.name == "OraclePrices") + .expect("OraclePrices") + .discriminator; + let mut scope_data = vec![0u8; 28712]; + scope_data[..8].copy_from_slice(scope_disc); + + const IDX: usize = 45; + const CRASHED: u64 = 15_000_000; + let scope_overrides: HashMap = HashMap::from([ + ( + format!("prices.{IDX}.price.value"), + serde_json::json!(CRASHED), + ), + (format!("prices.{IDX}.price.exp"), serde_json::json!(8u64)), + ]); + let forged_scope = surfnet_svm + .get_forged_account_data(&pubkey, &scope_data, &scope.idl, &scope_overrides) + .expect("scope crash should apply"); + + let off = SCOPE_PRICES_BASE + IDX * DATED_PRICE_SIZE; + assert_eq!( + u64::from_le_bytes(forged_scope[off..off + 8].try_into().unwrap()), + CRASHED, + "crashed price must land at the Scope entry the reserve names" + ); + assert_eq!( + CRASHED as f64 / 10f64.powi(8), + 0.15, + "value/exp must decode to $0.15" + ); + + // Tighten the deposit reserve's liquidation threshold. + let reserve = registry + .get("kamino-reserve-config") + .expect("reserve config template"); + let reserve_disc = &reserve + .idl + .accounts + .iter() + .find(|a| a.name == "Reserve") + .expect("Reserve") + .discriminator; + let mut reserve_data = vec![0u8; 8624]; + reserve_data[..8].copy_from_slice(reserve_disc); + // A healthy 70/75 configuration. + reserve_data[LTV_PCT] = 70; + reserve_data[LIQ_THRESHOLD_PCT] = 75; + + let reserve_overrides: HashMap = HashMap::from([ + ( + "config.liquidation_threshold_pct".to_string(), + serde_json::json!(50u8), + ), + ( + "config.max_liquidation_bonus_bps".to_string(), + serde_json::json!(1000u16), + ), + ]); + let forged_reserve = surfnet_svm + .get_forged_account_data(&pubkey, &reserve_data, &reserve.idl, &reserve_overrides) + .expect("reserve config override should apply"); + + assert_eq!( + forged_reserve[LIQ_THRESHOLD_PCT], 50, + "liquidation threshold must be lowered" + ); + assert_eq!( + forged_reserve[LTV_PCT], 70, + "loan-to-value must be left untouched, so a position at 70% LTV is now above the \ + 50% liquidation threshold and therefore liquidatable" + ); + assert_eq!( + forged_reserve.len(), + reserve_data.len(), + "reserve size must be preserved" + ); + } + + /// A ticket becomes redeemable once the reserve's queue cursor reaches its sequence number. + #[test] + fn test_kamino_withdraw_ticket_and_queue_cursor() { + use std::collections::HashMap; + + use solana_pubkey::Pubkey; + + use crate::surfnet::svm::SurfnetSvm; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + let ticket = registry + .get("kamino-withdraw-ticket") + .expect("withdraw ticket template"); + let ticket_disc = &ticket + .idl + .accounts + .iter() + .find(|a| a.name == "WithdrawTicket") + .expect("WithdrawTicket") + .discriminator; + let mut ticket_data = vec![0u8; 520]; + ticket_data[..8].copy_from_slice(ticket_disc); + + let ticket_overrides: HashMap = HashMap::from([ + ("sequence_number".to_string(), serde_json::json!(7u64)), + ( + "queued_collateral_amount".to_string(), + serde_json::json!(500u64), + ), + ("invalid".to_string(), serde_json::json!(0u8)), + ]); + let forged_ticket = surfnet_svm + .get_forged_account_data(&pubkey, &ticket_data, &ticket.idl, &ticket_overrides) + .expect("withdraw ticket override should apply"); + assert_eq!( + u64::from_le_bytes(forged_ticket[8..16].try_into().unwrap()), + 7, + "ticket sequence number" + ); + + // Advance the reserve's cursor to 7, making ticket 7 serveable. + let limits = registry + .get("kamino-reserve-limits") + .expect("reserve limits template"); + let reserve_disc = &limits + .idl + .accounts + .iter() + .find(|a| a.name == "Reserve") + .expect("Reserve") + .discriminator; + let mut reserve_data = vec![0u8; 8624]; + reserve_data[..8].copy_from_slice(reserve_disc); + + let queue_overrides: HashMap = HashMap::from([ + ( + "withdraw_queue.queued_collateral_amount".to_string(), + serde_json::json!(500u64), + ), + ( + "withdraw_queue.next_withdrawable_ticket_sequence_number".to_string(), + serde_json::json!(7u64), + ), + ( + "withdraw_queue.next_issued_ticket_sequence_number".to_string(), + serde_json::json!(8u64), + ), + ( + "liquidity.total_available_amount".to_string(), + serde_json::json!(0u64), + ), + ]); + let forged_reserve = surfnet_svm + .get_forged_account_data(&pubkey, &reserve_data, &limits.idl, &queue_overrides) + .expect("withdraw queue override should apply"); + + assert_eq!(forged_reserve.len(), reserve_data.len()); + assert_ne!(forged_reserve, reserve_data); + } + + // Unmodified mainnet account data, captured 2026-08-06, with the source address of each so + // it can be re-captured. Zeroed accounts never exercise real enum discriminants or non-zero + // padding; these do. The reserve and Scope prices accounts are a matched pair - + // test_reserve_price_is_derived_from_scope depends on it. + // 14sqx2pLioXamoBFxE6CvHNth6uEAvJhXuJ2iwZMccAS + const FIXTURE_RESERVE: &[u8] = include_bytes!("./fixtures/kamino_reserve.bin"); + // 3iprSGrEQdBxhmqV399tYQQPG8Z1Hh2aYFrBwgqFXjGS + const FIXTURE_OBLIGATION: &[u8] = include_bytes!("./fixtures/kamino_obligation.bin"); + // 3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C + const FIXTURE_SCOPE_PRICES: &[u8] = include_bytes!("./fixtures/kamino_scope_oracle_prices.bin"); + // 18DizwAbBuuNGwfav3v6yWMbunnye4RnMLwLp67jAtj + const FIXTURE_FARM_STATE: &[u8] = include_bytes!("./fixtures/kamino_farms_farm_state.bin"); + // 14Buhfy7WBpiv2e6RMZNN5R7w3ua8MY1ZJ3WQyd29uJ + const FIXTURE_SWAP_ORDER: &[u8] = include_bytes!("./fixtures/kamino_swap_order.bin"); + // 1EXN5b1z7wucGb2uZoQmqjHdPoK1PNfUNWuwq8AqLTV + const FIXTURE_STRATEGY: &[u8] = include_bytes!("./fixtures/kamino_liquidity_strategy.bin"); + + /// Byte indices at which two buffers differ. + fn diff_indices(a: &[u8], b: &[u8]) -> Vec { + a.iter() + .zip(b.iter()) + .enumerate() + .filter(|(_, (x, y))| x != y) + .map(|(i, _)| i) + .collect() + } + + /// A failure here means a bundled IDL disagrees with the live on-chain layout. + #[test] + fn test_real_mainnet_accounts_round_trip_unchanged() { + use std::collections::HashMap; + + use solana_pubkey::Pubkey; + + use crate::surfnet::svm::SurfnetSvm; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + let cases: &[(&str, &str, &[u8])] = &[ + ("kamino-reserve-config", "Reserve", FIXTURE_RESERVE), + ("kamino-obligation-health", "Obligation", FIXTURE_OBLIGATION), + ("kamino-scope-price", "OraclePrices", FIXTURE_SCOPE_PRICES), + ( + "kamino-farms-reward-accumulator", + "FarmState", + FIXTURE_FARM_STATE, + ), + ("kamino-swap-order", "Order", FIXTURE_SWAP_ORDER), + ( + "kamino-liquidity-strategy-balances", + "WhirlpoolStrategy", + FIXTURE_STRATEGY, + ), + ]; + + for (template_id, account_name, data) in cases { + let template = registry + .get(template_id) + .unwrap_or_else(|| panic!("template {} should exist", template_id)); + + let account_def = template + .idl + .accounts + .iter() + .find(|a| a.name == *account_name) + .unwrap_or_else(|| panic!("{} not in the IDL", account_name)); + assert_eq!( + &data[..8], + account_def.discriminator.as_slice(), + "{} fixture discriminator does not match the IDL - wrong account type?", + account_name + ); + + let forged = surfnet_svm + .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .unwrap_or_else(|e| { + panic!( + "real mainnet {} failed to decode/re-encode with the bundled IDL: {}", + account_name, e + ) + }); + + assert_eq!( + forged.len(), + data.len(), + "{} changed size on round-trip", + account_name + ); + let diffs = diff_indices(&forged, data); + assert!( + diffs.is_empty(), + "real mainnet {} was altered by a no-op round-trip at {} byte(s), first at {:?}", + account_name, + diffs.len(), + diffs.first() + ); + } + } + + /// Catches collateral damage from the Borsh re-encode that a zeroed fixture would hide. + #[test] + fn test_override_on_real_account_touches_only_target_bytes() { + use std::collections::HashMap; + + use solana_pubkey::Pubkey; + + use crate::surfnet::svm::SurfnetSvm; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + // Reserve: one u8 at a known offset. + const LIQ_THRESHOLD_PCT: usize = 4873; + let reserve = registry.get("kamino-reserve-config").unwrap(); + let original_threshold = FIXTURE_RESERVE[LIQ_THRESHOLD_PCT]; + assert!( + original_threshold > 50, + "fixture should start above the value we set, got {}", + original_threshold + ); + + let forged = surfnet_svm + .get_forged_account_data( + &pubkey, + FIXTURE_RESERVE, + &reserve.idl, + &HashMap::from([( + "config.liquidation_threshold_pct".to_string(), + serde_json::json!(50u8), + )]), + ) + .expect("threshold override on real reserve"); + + assert_eq!( + diff_indices(&forged, FIXTURE_RESERVE), + vec![LIQ_THRESHOLD_PCT], + "exactly one byte should change, and only the liquidation threshold" + ); + assert_eq!(forged[LIQ_THRESHOLD_PCT], 50); + + // Scope: one u64 inside a 512-element array. + const PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + const IDX: usize = 0; + let scope = registry.get("kamino-scope-price").unwrap(); + let value_off = PRICES_BASE + IDX * DATED_PRICE_SIZE; + + let original_value = u64::from_le_bytes( + FIXTURE_SCOPE_PRICES[value_off..value_off + 8] + .try_into() + .unwrap(), + ); + assert!( + original_value > 0, + "fixture SOL price should be non-zero, got {}", + original_value + ); + let new_value = original_value / 2; // halve SOL + + let forged = surfnet_svm + .get_forged_account_data( + &pubkey, + FIXTURE_SCOPE_PRICES, + &scope.idl, + &HashMap::from([( + format!("prices.{IDX}.price.value"), + serde_json::json!(new_value), + )]), + ) + .expect("price override on real Scope account"); + + let diffs = diff_indices(&forged, FIXTURE_SCOPE_PRICES); + assert!(!diffs.is_empty(), "the price should have changed"); + assert!( + diffs.iter().all(|i| (value_off..value_off + 8).contains(i)), + "only the 8 bytes of prices[{}].price.value should change, got {:?}", + IDX, + diffs + ); + assert_eq!( + u64::from_le_bytes(forged[value_off..value_off + 8].try_into().unwrap()), + new_value + ); + + let next = PRICES_BASE + DATED_PRICE_SIZE; + assert_eq!( + &forged[next..next + DATED_PRICE_SIZE], + &FIXTURE_SCOPE_PRICES[next..next + DATED_PRICE_SIZE], + "neighbouring Scope entry must not move" + ); + } + + /// These addresses are hardcoded facts about mainnet, so guard their shape and uniqueness. + /// A liveness check would need network access. + #[test] + fn test_named_kamino_reserve_templates_have_baked_addresses() { + use std::{collections::BTreeSet, str::FromStr}; + + use solana_pubkey::Pubkey; + + let registry = TemplateRegistry::new(); + + const NAMED: &[&str] = &["kamino-reserve-main-sol", "kamino-reserve-main-usdc"]; + + let mut addresses = BTreeSet::new(); + for id in NAMED { + let template = registry + .get(id) + .unwrap_or_else(|| panic!("named reserve template {} should exist", id)); + + assert_eq!( + template.account_type, "Reserve", + "{} should target a Reserve", + id + ); + + let surfpool_types::AccountAddress::Pubkey(address) = &template.address else { + panic!("{} should carry a plain pubkey address, not a PDA", id); + }; + assert!( + Pubkey::from_str(address).is_ok(), + "{} has an unparseable address: {}", + id, + address + ); + assert!( + addresses.insert(address.clone()), + "{} reuses an address already used by another named template", + id + ); + + let paths: Vec<&str> = template.property_paths(); + for required in [ + "config.liquidation_threshold_pct", + "liquidity.market_price_sf", + ] { + assert!( + paths.contains(&required), + "{} should expose {}", + id, + required + ); + } + + // Each must point at the template that moves its price, and name its Scope index - + // the lookup a user would otherwise do by hand. + let context = template.llm_context.as_deref().unwrap_or_default(); + assert!( + context.contains("kamino-scope-price"), + "{} should point at kamino-scope-price for moving its price", + id + ); + assert!( + context.contains("index"), + "{} should name the Scope index its price comes from", + id + ); + } + + assert_eq!( + addresses.len(), + NAMED.len(), + "all addresses must be distinct" + ); + } + + /// Evidence that a Reserve's cached price is derived from Scope, which is why + /// `kamino-scope-price` is the durable lever. The two fixtures are a matched pair: the + /// reserve names this Scope account, and its `price_chain` product reproduces the cache. + #[test] + fn test_reserve_price_is_derived_from_scope() { + use solana_pubkey::Pubkey; + + // Reserve offsets incl. discriminator. + const MARKET_PRICE_SF: usize = 248; // u128 scaled fraction (value << 60) + const SCOPE_PRICE_FEED: usize = 5112; + const SCOPE_PRICE_CHAIN: usize = 5144; // [u16; 4], 65535 = unused + const PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + const UNUSED_CHAIN_ENTRY: u16 = 65535; + + let scope_account = Pubkey::from_str_const("3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C"); + + assert_eq!( + &FIXTURE_RESERVE[SCOPE_PRICE_FEED..SCOPE_PRICE_FEED + 32], + scope_account.as_ref(), + "the reserve fixture must price through the Scope account the other fixture holds" + ); + + let chain: Vec = (0..4) + .map(|i| { + let off = SCOPE_PRICE_CHAIN + i * 2; + u16::from_le_bytes(FIXTURE_RESERVE[off..off + 2].try_into().unwrap()) + }) + .take_while(|entry| *entry != UNUSED_CHAIN_ENTRY) + .collect(); + assert!( + !chain.is_empty(), + "the reserve fixture should name at least one Scope index" + ); + + // A chained price is the product of its entries, each value / 10^exp. + let mut scope_price = 1.0f64; + for index in &chain { + let base = PRICES_BASE + (*index as usize) * DATED_PRICE_SIZE; + let value = + u64::from_le_bytes(FIXTURE_SCOPE_PRICES[base..base + 8].try_into().unwrap()); + let exp = u64::from_le_bytes( + FIXTURE_SCOPE_PRICES[base + 8..base + 16] + .try_into() + .unwrap(), + ); + assert!( + value > 0 && exp < 30, + "Scope entry {} looks unpopulated (value {}, exp {})", + index, + value, + exp + ); + scope_price *= value as f64 / 10f64.powi(exp as i32); + } + + let cached_sf = u128::from_le_bytes( + FIXTURE_RESERVE[MARKET_PRICE_SF..MARKET_PRICE_SF + 16] + .try_into() + .unwrap(), + ); + let cached_price = cached_sf as f64 / 2f64.powi(60); + assert!(cached_price > 0.0, "reserve fixture should have a price"); + + // Captured together, so this is exact rather than approximate. + let relative_error = (scope_price - cached_price).abs() / cached_price; + assert!( + relative_error < 1e-6, + "reserve cached price ${cached_price} should equal the Scope chain {chain:?} product \ + ${scope_price} - if these have diverged, either the scaled-fraction interpretation \ + (value << 60), the price_chain semantics (a product), or an offset is wrong. \ + Relative error {relative_error}" + ); + } + + /// A path ending on an index must resolve to the array's ELEMENT type. Resolving it to the + /// array instead sends the value down the untyped conversion, where an all-hex base58 pubkey + /// such as the default one is mistaken for hex and panics the request. + #[test] + fn test_terminal_array_index_resolves_to_the_element_type() { + use anchor_lang_idl::types::IdlType; + + let registry = TemplateRegistry::new(); + let template = registry + .get("kamino-scope-price-source") + .expect("kamino-scope-price-source should exist"); + + for (path, expected) in [ + ("price_info_accounts.0", IdlType::Pubkey), + ("price_types.0", IdlType::U8), + ("ref_price.0", IdlType::U16), + ] { + let resolved = + surfpool_types::resolve_idl_type(&template.idl, &template.account_type, path) + .unwrap_or_else(|e| panic!("{path} should resolve: {e}")); + assert_eq!( + *resolved, expected, + "{path} should resolve to its element type, not the array" + ); + } + + // An index mid-path already worked; keep it that way. + let obligation = registry + .get("kamino-obligation-positions") + .expect("kamino-obligation-positions should exist"); + let resolved = surfpool_types::resolve_idl_type( + &obligation.idl, + &obligation.account_type, + "deposits.0.deposit_reserve", + ) + .expect("deposits.0.deposit_reserve should resolve"); + assert_eq!(*resolved, IdlType::Pubkey); + } + + /// Descriptions come from the IDL's own `docs`, or from an explicit `description` in the + /// YAML. Studio and any LLM reading a template rely on them. + #[test] + fn test_every_kamino_property_has_a_description() { + let registry = TemplateRegistry::new(); + let mut missing = Vec::new(); + let mut described = 0; + + for protocol in [ + "kamino", + "kamino-scope", + "kamino-farms", + "kamino-swap", + "kamino-vault", + "kamino-liquidity", + ] { + for template in registry.by_protocol(protocol) { + for property in &template.properties { + match property.description.as_deref() { + Some(text) if !text.trim().is_empty() => described += 1, + _ => missing.push(format!("{}:{}", template.id, property.path)), + } + } + } + } + + assert!( + missing.is_empty(), + "{} Kamino propert(ies) have no description ({} do):\n {}", + missing.len(), + described, + missing.join("\n ") + ); + } } diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index e412fd7b1..12e176d5f 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -7,6 +7,7 @@ use std::{ }; use agave_feature_set::FeatureSet; +use anchor_lang_idl::types::{IdlDefinedFields, IdlGenericArg, IdlType, IdlTypeDef, IdlTypeDefTy}; use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::Utc; use convert_case::Casing; @@ -122,50 +123,148 @@ pub fn apply_override_to_decoded_account( decoded_value: &mut Value, path: &str, value: &serde_json::Value, +) -> SurfpoolResult<()> { + let txtx_value = json_to_txtx_value(value)?; + set_decoded_account_value(decoded_value, path, txtx_value) +} + +/// Same as [`apply_override_to_decoded_account`], but takes an already-converted [`Value`]. +pub fn apply_typed_override_to_decoded_account( + decoded_value: &mut Value, + path: &str, + value: Value, +) -> SurfpoolResult<()> { + set_decoded_account_value(decoded_value, path, value) +} + +fn set_decoded_account_value( + decoded_value: &mut Value, + path: &str, + new_value: Value, ) -> SurfpoolResult<()> { let parts: Vec<&str> = path.split('.').collect(); - if parts.is_empty() { - return Err(SurfpoolError::internal("Empty path provided for override")); + if parts.iter().any(|part| part.is_empty()) { + return Err(SurfpoolError::internal(format!( + "Invalid path '{}' provided for override - contains an empty segment", + path + ))); } // Navigate to the parent of the target field let mut current = decoded_value; for part in &parts[..parts.len() - 1] { - match current { - Value::Object(map) => { - current = map.get_mut(&part.to_string()).ok_or_else(|| { + current = match current { + Value::Object(map) => map.get_mut(&part.to_string()).ok_or_else(|| { + SurfpoolError::internal(format!( + "Path segment '{}' not found in decoded account", + part + )) + })?, + Value::Array(items) => { + let index = parse_decoded_account_index(part, path)?; + let len = items.len(); + items.get_mut(index).ok_or_else(|| { SurfpoolError::internal(format!( - "Path segment '{}' not found in decoded account", - part + "Index {} is out of bounds for array of length {} in path '{}'", + index, len, path )) - })?; + })? } _ => { return Err(SurfpoolError::internal(format!( - "Cannot navigate through field '{}' - not an object", + "Cannot navigate through field '{}' - not an object or array", part ))); } - } + }; } - // Set the final field let final_key = parts[parts.len() - 1]; match current { Value::Object(map) => { - // Convert serde_json::Value to txtx Value - let txtx_value = json_to_txtx_value(value)?; - map.insert(final_key.to_string(), txtx_value); + map.insert(final_key.to_string(), new_value); + Ok(()) + } + Value::Array(items) => { + let index = parse_decoded_account_index(final_key, path)?; + let len = items.len(); + let slot = items.get_mut(index).ok_or_else(|| { + SurfpoolError::internal(format!( + "Index {} is out of bounds for array of length {} in path '{}'", + index, len, path + )) + })?; + *slot = new_value; Ok(()) } _ => Err(SurfpoolError::internal(format!( - "Cannot set field '{}' - parent is not an object", + "Cannot set field '{}' - parent is not an object or array", final_key ))), } } +fn parse_decoded_account_index(segment: &str, path: &str) -> SurfpoolResult { + segment.parse::().map_err(|_| { + SurfpoolError::internal(format!( + "Path segment '{}' in '{}' must be a zero-based array index", + segment, path + )) + }) +} + +/// Converts JSON into a txtx [`Value`] using the expected IDL type +fn json_to_txtx_value_for_idl_type( + json: &serde_json::Value, + idl_type: &IdlType, + idl_types: &[IdlTypeDef], +) -> SurfpoolResult { + match (idl_type, json) { + (IdlType::Pubkey, serde_json::Value::String(address)) => { + let pubkey = Pubkey::from_str(address).map_err(|e| { + SurfpoolError::internal(format!( + "Invalid pubkey '{}' in account override: {}", + address, e + )) + })?; + Ok(txtx_addon_network_svm_types::SvmValue::pubkey( + pubkey.to_bytes().to_vec(), + )) + } + (IdlType::Option(inner), _) if !json.is_null() => { + json_to_txtx_value_for_idl_type(json, inner, idl_types) + } + (IdlType::Vec(inner), serde_json::Value::Array(items)) + | (IdlType::Array(inner, _), serde_json::Value::Array(items)) => { + let converted = items + .iter() + .map(|item| json_to_txtx_value_for_idl_type(item, inner, idl_types)) + .collect::>>()?; + Ok(Value::Array(Box::new(converted))) + } + (IdlType::Defined { name, .. }, serde_json::Value::Object(fields)) => { + let Some(IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(named_fields)), + }) = idl_types.iter().find(|t| &t.name == name).map(|t| &t.ty) + else { + return json_to_txtx_value(json); + }; + + let mut object = IndexMap::new(); + for (key, value) in fields.iter() { + let converted = match named_fields.iter().find(|f| &f.name == key) { + Some(field) => json_to_txtx_value_for_idl_type(value, &field.ty, idl_types)?, + None => json_to_txtx_value(value)?, + }; + object.insert(key.clone(), converted); + } + Ok(Value::Object(object)) + } + _ => json_to_txtx_value(json), + } +} + /// Helper function to convert serde_json::Value to txtx Value fn json_to_txtx_value(json: &serde_json::Value) -> SurfpoolResult { match json { @@ -2707,6 +2806,10 @@ impl SurfnetSvm { } } + if override_instance.persist { + self.reschedule_override_for_next_slot(&override_instance, target_slot); + } + // Apply the override values to the account data if !override_instance.values.is_empty() { // Filter out values that are only used for PDA derivation (not account data) @@ -2838,6 +2941,34 @@ impl SurfnetSvm { Ok(()) } + /// Re-queues `instance` for the slot after `target_slot`. Idempotent, so an override + /// cannot be applied twice to one slot. + fn reschedule_override_for_next_slot( + &mut self, + instance: &OverrideInstance, + target_slot: Slot, + ) { + let next_slot = target_slot + 1; + let mut next = self + .scheduled_overrides + .get(&next_slot) + .ok() + .flatten() + .unwrap_or_default(); + + if next.iter().any(|existing| existing.id == instance.id) { + return; + } + + next.push(instance.clone()); + if let Err(e) = self.scheduled_overrides.store(next_slot, next) { + warn!( + "Failed to reschedule override {} for slot {}: {}", + instance.id, next_slot, e + ); + } + } + /// Forges account data by applying overrides to existing account data /// /// This function: @@ -2928,12 +3059,15 @@ impl SurfnetSvm { // Apply overrides to the decoded value for (path, value) in overrides { - apply_override_to_decoded_account(&mut parsed_value, path, value)?; + let converted = match surfpool_types::resolve_idl_type(idl, &account_type.name, path) { + Ok(idl_type) => json_to_txtx_value_for_idl_type(value, idl_type, &idl.types)?, + Err(_) => json_to_txtx_value(value)?, + }; + apply_typed_override_to_decoded_account(&mut parsed_value, path, converted)?; } // Construct an IdlType::Defined that references the account type // This is needed because borsh_encode_value_to_idl_type expects IdlType, not IdlTypeDefTy - use anchor_lang_idl::types::{IdlGenericArg, IdlType}; let defined_type = IdlType::Defined { name: account_type.name.clone(), generics: account_type @@ -6870,4 +7004,198 @@ mod tests { .expect("Valid account should be restored"); assert_eq!(restored_account.lamports, 1_000_000); } + + /// `Obligation.unhealthy_borrow_value_sf` (u128), counting the discriminator. + const UNHEALTHY_OFFSET: usize = 2256; + + /// A zeroed Kamino `Obligation` owned by klend. `SurfnetSvm::default()` already registers + /// the bundled template IDLs, so klend's is resolvable by owner program. + fn scheduled_persist_fixture( + persist: bool, + ) -> (SurfnetSvm, Pubkey, surfpool_types::OverrideInstance) { + let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + + let klend = Pubkey::from_str_const("KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD"); + let idl: Idl = serde_json::from_str(crate::scenarios::registry::KAMINO_V1_IDL_CONTENT) + .expect("kamino idl"); + let obligation_disc = &idl + .accounts + .iter() + .find(|a| a.name == "Obligation") + .expect("Obligation account") + .discriminator; + + let mut data = vec![0u8; 3344]; + data[..8].copy_from_slice(obligation_disc); + + let account_pubkey = Pubkey::new_unique(); + surfnet_svm + .inner + .set_account( + account_pubkey, + Account { + lamports: 1_000_000, + data, + owner: klend, + executable: false, + rent_epoch: 0, + }, + ) + .expect("set obligation account"); + + let mut instance = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ) + .with_values(HashMap::from([( + "unhealthy_borrow_value_sf".to_string(), + serde_json::json!(1_234u64), + )])); + instance.persist = persist; + + (surfnet_svm, account_pubkey, instance) + } + + #[tokio::test] + async fn test_persisted_override_is_rescheduled_for_the_next_slot() { + const SLOT: u64 = 500; + + let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); + let instance_id = instance.id.clone(); + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let account = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("account present"); + let unhealthy = u128::from_le_bytes( + account.data[UNHEALTHY_OFFSET..UNHEALTHY_OFFSET + 16] + .try_into() + .expect("16 bytes"), + ); + assert_eq!(unhealthy, 1_234, "override should have been applied"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot should have queued overrides"); + assert_eq!( + next.len(), + 1, + "exactly one override queued for the next slot" + ); + assert_eq!(next[0].id, instance_id); + assert!(next[0].persist, "persist flag must survive rescheduling"); + + assert!( + svm.scheduled_overrides + .get(&SLOT) + .expect("storage read") + .is_none(), + "materialized slot should be drained" + ); + } + + #[tokio::test] + async fn test_non_persisted_override_is_not_rescheduled() { + const SLOT: u64 = 500; + + let (mut svm, _account_pubkey, instance) = scheduled_persist_fixture(false); + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + assert!( + svm.scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .is_none(), + "a one-shot override must not be rescheduled" + ); + } + + #[tokio::test] + async fn test_persisted_override_survives_a_run_of_slots() { + const FIRST_SLOT: u64 = 900; + const SLOTS: u64 = 5; + + let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); + svm.scheduled_overrides + .store(FIRST_SLOT, vec![instance]) + .expect("schedule override"); + + for slot in FIRST_SLOT..FIRST_SLOT + SLOTS { + // Clobber the field, the way `refresh_obligation` would. + let mut account = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("account present"); + account.data[UNHEALTHY_OFFSET..UNHEALTHY_OFFSET + 16] + .copy_from_slice(&0u128.to_le_bytes()); + svm.inner + .set_account(account_pubkey, account) + .expect("clobber account"); + + svm.materialize_overrides_for_slot(&None, slot) + .await + .expect("materialize"); + + let account = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("account present"); + let unhealthy = u128::from_le_bytes( + account.data[UNHEALTHY_OFFSET..UNHEALTHY_OFFSET + 16] + .try_into() + .expect("16 bytes"), + ); + assert_eq!( + unhealthy, 1_234, + "persisted override should be re-applied on slot {slot} after being clobbered" + ); + } + } + + #[tokio::test] + async fn test_persisted_override_does_not_duplicate_itself() { + const SLOT: u64 = 700; + + let (mut svm, _account_pubkey, instance) = scheduled_persist_fixture(true); + svm.scheduled_overrides + .store(SLOT + 1, vec![instance.clone()]) + .expect("pre-queue next slot"); + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot queue"); + assert_eq!( + next.len(), + 1, + "override must not be queued twice for one slot" + ); + } } diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index fb3859572..728bc55df 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -500,6 +500,12 @@ pub struct OverrideInstance { #[serde(default)] #[cfg_attr(feature = "ts-bindings", ts(as = "Option", optional))] pub fetch_before_use: bool, + /// Whether to re-apply this override on every subsequent slot, rather than only once + #[schemars( + description = "If true, re-applies this override every following slot. Use only for values no transaction writes: it reverts transaction writes to the same fields." + )] + #[serde(default)] + pub persist: bool, /// Account address to override - use pubkey for known addresses or pda for derived addresses #[schemars( description = "Account address: either {\"pubkey\": \"base58_address\"} or {\"pda\": {\"programId\": \"...\", \"seeds\": [...]}}" @@ -517,6 +523,7 @@ impl OverrideInstance { label: None, enabled: true, fetch_before_use: false, + persist: false, account, } } @@ -530,6 +537,11 @@ impl OverrideInstance { self.label = Some(label); self } + + pub fn with_persist(mut self, persist: bool) -> Self { + self.persist = persist; + self + } } /// A scenario containing a timeline of overrides @@ -931,6 +943,119 @@ pub struct YamlOverrideTemplateEntry { pub llm_context: Option, } +/// Walks a dot-notation property path the way overrides are applied: struct fields by name, +/// array elements by index. The `Err` says where the path stopped. +/// +/// Returns the named field the path passed through last *and* the type at the path's end. Those +/// differ when the path ends on an index: `price_info_accounts.0` is documented by the array +/// field, but its value is one Pubkey element, so callers must pick the one they need. +fn resolve_idl_path<'a>( + idl: &'a Idl, + account_type: &str, + path: &str, +) -> Result< + ( + &'a anchor_lang_idl::types::IdlField, + &'a anchor_lang_idl::types::IdlType, + ), + String, +> { + use anchor_lang_idl::types::{IdlDefinedFields, IdlType, IdlTypeDefTy}; + + fn named_fields<'a>( + idl: &'a Idl, + type_name: &str, + ) -> Result<&'a Vec, String> { + let def = idl + .types + .iter() + .find(|t| t.name == type_name) + .ok_or_else(|| format!("type '{}' not found in IDL types", type_name))?; + match &def.ty { + IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(fields)), + } => Ok(fields), + _ => Err(format!("'{}' is not a struct with named fields", type_name)), + } + } + + let mut segments = path.split('.'); + let first = segments + .next() + .ok_or_else(|| format!("empty property path for '{}'", account_type))?; + let mut field = named_fields(idl, account_type)? + .iter() + .find(|f| f.name == first) + .ok_or_else(|| format!("field '{}' not found in '{}'", first, account_type))?; + let mut ty: &IdlType = &field.ty; + + for segment in segments { + match ty { + // An index descends into the element type while `field` stays on the array, + // which is what documents it. + IdlType::Array(inner, _) | IdlType::Vec(inner) => { + segment.parse::().map_err(|_| { + format!("'{}' is an array; '{}' is not an index", path, segment) + })?; + ty = inner.as_ref(); + } + IdlType::Defined { name, .. } => { + field = named_fields(idl, name)? + .iter() + .find(|f| f.name == segment) + .ok_or_else(|| format!("field '{}' not found in type '{}'", segment, name))?; + ty = &field.ty; + } + other => { + return Err(format!( + "cannot descend into '{}': leaf type {:?} has no fields", + segment, other + )); + } + } + } + + Ok((field, ty)) +} + +/// The IDL type of the value a property path writes. For a path ending on an index this is the +/// array's element type, not the array - the conversion needs the element to encode it. +pub fn resolve_idl_type<'a>( + idl: &'a Idl, + account_type: &str, + path: &str, +) -> Result<&'a anchor_lang_idl::types::IdlType, String> { + resolve_idl_path(idl, account_type, path).map(|(_, ty)| ty) +} + +fn idl_field_docs(idl: &Idl, account_type: &str, path: &str) -> Option { + // The containing field, deliberately: an array element carries no docs of its own. + let docs = &resolve_idl_path(idl, account_type, path).ok()?.0.docs; + if docs.is_empty() { + return None; + } + Some(docs.join(" ")) +} + +/// Fills in each property's `description` from the IDL's own `docs` when the template did not +/// supply one, so field guidance is not written twice. +fn describe_properties_from_idl( + properties: Vec, + idl: &Idl, + account_type: &str, +) -> Vec { + properties + .into_iter() + .map(|yaml| { + let mut property: Property = yaml.into(); + if property.description.is_none() { + property.description = idl_field_docs(idl, account_type, &property.path); + } + property + }) + .collect() +} + impl YamlOverrideTemplateCollection { /// Convert collection to runtime OverrideTemplates with loaded IDL pub fn to_override_templates(self, idl: Idl) -> Vec { @@ -945,20 +1070,23 @@ impl YamlOverrideTemplateCollection { self.templates .into_iter() - .map(|entry| OverrideTemplate { - id: entry.id, - name: entry.name, - description: entry.description, - protocol: self.protocol.clone(), - idl: idl.clone(), - address: entry.address.into(), - account_type: entry + .map(|entry| { + let account_type = entry .idl_account_name - .unwrap_or_else(|| default_account_type.clone()), - properties: entry.properties.into_iter().map(Into::into).collect(), - constants: constants.clone(), - tags: self.tags.clone(), - llm_context: entry.llm_context, + .unwrap_or_else(|| default_account_type.clone()); + OverrideTemplate { + id: entry.id, + name: entry.name, + description: entry.description, + protocol: self.protocol.clone(), + idl: idl.clone(), + address: entry.address.into(), + properties: describe_properties_from_idl(entry.properties, &idl, &account_type), + account_type, + constants: constants.clone(), + tags: self.tags.clone(), + llm_context: entry.llm_context, + } }) .collect() } From 883a01ae56efb8faf0501701e2968a08b9ebba75 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 13 Aug 2026 13:16:10 +0300 Subject: [PATCH 2/3] fix(scenarios): stop persisted overrides re-fetching the account every slot Addresses two review comments. A persisted override was re-queued with fetch_before_use intact, so every following slot pulled the whole account from mainnet again: one RPC per slot per override, and any field the override does not write was reset to mainnet's value, discarding what local transactions had written to it. fetch_before_use is now cleared on the re-queue, but only after the write succeeds, so a failed apply still retries next slot with the fetch. The re-queue replaces a copy of itself already queued for that slot instead of bailing out, which keeps one entry per id. persist also gains the ts-bindings attribute its sibling fetch_before_use already had, and the regenerated OverrideInstance.ts exposes it - the field was previously absent from the TS SDK entirely. --- crates/core/src/scenarios/README.md | 5 +- crates/core/src/surfnet/svm.rs | 48 ++++++++++++++++--- .../kit/generated/OverrideInstance.ts | 4 ++ crates/types/src/scenarios.rs | 9 ++-- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 04043dc21..9a4181157 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -38,8 +38,9 @@ every following slot, which is needed when something else writes the account in transaction, or another override fetching it fresh. Persist inputs nothing in the scenario writes (an oracle price, a disabled switch, a risk parameter), never state the transactions under test mutate: re-applying reverts their writes at the start of the next slot, so a pool would refill -itself after every swap. Re-queuing is idempotent, so an override is never applied twice to one -slot. +itself after every swap. Only one entry is queued per override, so it is never applied twice to +one slot, and `fetchBeforeUse` applies to the first slot only - once the account is forked, later +slots re-pin the fields without re-fetching it. ### Override Templates Directly using the `surfnet_registerScenario` endpoint requires building out a map of account keys that are specific to the schema of the account that is being written to. diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 12e176d5f..5863e4f2c 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -2806,6 +2806,7 @@ impl SurfnetSvm { } } + // Queued before the write so a failed apply is retried next slot, still fetching. if override_instance.persist { self.reschedule_override_for_next_slot(&override_instance, target_slot); } @@ -2934,6 +2935,14 @@ impl SurfnetSvm { account_pubkey, override_instance.id ); + // The account is forked now. Re-fetching it every slot would cost one RPC + // per slot and overwrite whatever local transactions wrote to the fields + // this override leaves alone, so later slots re-pin without fetching. + if override_instance.persist && override_instance.fetch_before_use { + let mut requeued = override_instance.clone(); + requeued.fetch_before_use = false; + self.reschedule_override_for_next_slot(&requeued, target_slot); + } } } } @@ -2941,8 +2950,8 @@ impl SurfnetSvm { Ok(()) } - /// Re-queues `instance` for the slot after `target_slot`. Idempotent, so an override - /// cannot be applied twice to one slot. + /// Re-queues `instance` for the slot after `target_slot`, replacing any copy of itself + /// already queued there. One entry per id, so an override cannot be applied twice to one slot. fn reschedule_override_for_next_slot( &mut self, instance: &OverrideInstance, @@ -2956,11 +2965,11 @@ impl SurfnetSvm { .flatten() .unwrap_or_default(); - if next.iter().any(|existing| existing.id == instance.id) { - return; + if let Some(existing) = next.iter_mut().find(|queued| queued.id == instance.id) { + *existing = instance.clone(); + } else { + next.push(instance.clone()); } - - next.push(instance.clone()); if let Err(e) = self.scheduled_overrides.store(next_slot, next) { warn!( "Failed to reschedule override {} for slot {}: {}", @@ -7105,6 +7114,33 @@ mod tests { ); } + #[tokio::test] + async fn test_persisted_override_stops_refetching_once_the_account_is_forked() { + const SLOT: u64 = 500; + + let (mut svm, _account_pubkey, mut instance) = scheduled_persist_fixture(true); + instance.fetch_before_use = true; + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot should have queued overrides"); + assert_eq!(next.len(), 1, "one entry per override id"); + assert!(next[0].persist, "persist must survive rescheduling"); + assert!( + !next[0].fetch_before_use, + "the account is forked, so later slots must not re-fetch it and discard local writes" + ); + } + #[tokio::test] async fn test_non_persisted_override_is_not_rescheduled() { const SLOT: u64 = 500; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts index 348ea2ae5..80a87f241 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts @@ -35,6 +35,10 @@ enabled: boolean, * Whether to fetch fresh account data just before transaction execution */ fetchBeforeUse?: boolean, +/** + * Whether to re-apply this override on every subsequent slot, rather than only once + */ +persist?: boolean, /** * Account address to override - use pubkey for known addresses or pda for derived addresses */ diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index 728bc55df..7df74404a 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -505,6 +505,7 @@ pub struct OverrideInstance { description = "If true, re-applies this override every following slot. Use only for values no transaction writes: it reverts transaction writes to the same fields." )] #[serde(default)] + #[cfg_attr(feature = "ts-bindings", ts(as = "Option", optional))] pub persist: bool, /// Account address to override - use pubkey for known addresses or pda for derived addresses #[schemars( @@ -943,12 +944,10 @@ pub struct YamlOverrideTemplateEntry { pub llm_context: Option, } -/// Walks a dot-notation property path the way overrides are applied: struct fields by name, -/// array elements by index. The `Err` says where the path stopped. +/// Walks a dot-notation path: struct fields by name, array elements by index. /// -/// Returns the named field the path passed through last *and* the type at the path's end. Those -/// differ when the path ends on an index: `price_info_accounts.0` is documented by the array -/// field, but its value is one Pubkey element, so callers must pick the one they need. +/// Returns the last named field and the type at the path's end. They differ on a trailing index: +/// `price_info_accounts.0` is documented by the array but its value is one Pubkey. fn resolve_idl_path<'a>( idl: &'a Idl, account_type: &str, From 82e0c73c805247266290621ac237b1cc5cb5def2 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Fri, 14 Aug 2026 18:16:36 +0300 Subject: [PATCH 3/3] Move to integration tests and fix final comments --- crates/core/Cargo.toml | 2 + crates/core/src/scenarios/README.md | 12 + .../fixtures/kamino_farms_farm_state.bin | Bin 8336 -> 0 bytes .../fixtures/kamino_liquidity_strategy.bin | Bin 4064 -> 0 bytes .../scenarios/fixtures/kamino_obligation.bin | Bin 3344 -> 0 bytes .../src/scenarios/fixtures/kamino_reserve.bin | Bin 8624 -> 0 bytes .../fixtures/kamino_scope_oracle_prices.bin | Bin 28712 -> 0 bytes .../scenarios/fixtures/kamino_swap_order.bin | Bin 424 -> 0 bytes .../protocols/kamino/scope/v1/overrides.yaml | 2 +- .../protocols/kamino/v1/overrides.yaml | 6 +- crates/core/src/scenarios/registry.rs | 869 +----------------- crates/core/src/surfnet/svm.rs | 85 +- crates/core/src/tests/kamino/mod.rs | 754 +++++++++++++++ crates/core/src/tests/mod.rs | 2 + 14 files changed, 862 insertions(+), 870 deletions(-) delete mode 100644 crates/core/src/scenarios/fixtures/kamino_farms_farm_state.bin delete mode 100644 crates/core/src/scenarios/fixtures/kamino_liquidity_strategy.bin delete mode 100644 crates/core/src/scenarios/fixtures/kamino_obligation.bin delete mode 100644 crates/core/src/scenarios/fixtures/kamino_reserve.bin delete mode 100644 crates/core/src/scenarios/fixtures/kamino_scope_oracle_prices.bin delete mode 100644 crates/core/src/scenarios/fixtures/kamino_swap_order.bin create mode 100644 crates/core/src/tests/kamino/mod.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 36e43e16b..caa72f83f 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -125,5 +125,7 @@ sbpf-debugger = ["litesvm/sbpf-debugger"] sqlite = ["surfpool-db/sqlite"] postgres = ["surfpool-db/postgres"] ignore_tests_ci = [] +# Tests that fetch live mainnet accounts; off by default because they need a network. +integration-tests = [] register-tracing = ["litesvm/register-tracing"] prometheus = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-prometheus", "dep:prometheus", "dep:axum"] diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 9a4181157..f468626ed 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -42,6 +42,18 @@ itself after every swap. Only one entry is queued per override, so it is never a one slot, and `fetchBeforeUse` applies to the first slot only - once the account is forked, later slots re-pin the fields without re-fetching it. +### Kamino integration tests + +Byte-level Kamino coverage lives in `crates/core/src/tests/kamino/`. Those tests fetch the real +accounts from mainnet, so they need a network connection and are compiled only behind a feature: + +``` +cargo test -p surfpool-core --features integration-tests kamino +``` + +Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint instead of the public one. The default test +run needs no network. + ### Override Templates Directly using the `surfnet_registerScenario` endpoint requires building out a map of account keys that are specific to the schema of the account that is being written to. This is a cumbersome process in most cases. diff --git a/crates/core/src/scenarios/fixtures/kamino_farms_farm_state.bin b/crates/core/src/scenarios/fixtures/kamino_farms_farm_state.bin deleted file mode 100644 index 7f78f5701097cb1c9ac2d889e9771920913a2bb7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8336 zcmX?>cEiiwY4N_fi5{;^i#Oh2Ou2s9D0|YBfJx0M!RI%q_xSS6D_SBU8JKzFVND1_ z?BZ9)1Rq{r+wX4#OQdtFV+yZ zZ7Fhiv%k!hjR6AK?tXiCJ$2#ao2MVToqJumMrKRv+xTeniB>&dTkHNatb{5fno191 zjZxVA@N0+{?L9})t%!UHC*B+8iqV()Xx{$dqK6Ny<73o((n0_|en$ED(=%zI zJF1%2AuyU=@uyex@S$~ljG9kc2%yK$C?9`%CM|SFRns~IM$;?)^okxnw2qH~HlGpd zfIui+lDCN!#GLjpYQ?A7ZPB0B{dj)tRiLhn%jU@~6@AS)t8Jubef3&!{`7+@*AHHp z_m=IG`aZum|4sUwm0L>mZ>Mw5|9th(a)W<()oNM3)Lq8Z}}x1n3q509vWP+5i9m diff --git a/crates/core/src/scenarios/fixtures/kamino_liquidity_strategy.bin b/crates/core/src/scenarios/fixtures/kamino_liquidity_strategy.bin deleted file mode 100644 index 5280d59b4eff2eb0a4764b5cdd201c9f4b66478f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4064 zcmdnD>G=-BUFp25B%du;nyZk@Z6ICxLt4NyVAbVj9bxXy?H4nP1Bw3TJ3;&C@xRz(xRXn)4{KWg$txe8O&X-=^ zDlFaeCE@KFMw80eQ+v-VXhm`0H@E@9a z;D%;dnfbT)&(Hjqy?lOu>yopp1j8(+%b(D>v1atu_vLGly+L_PY%GYflbj zct4Q3XPv$4vTg(8g;jz_McL*}@Cos+{C|7)gwPrpj>hDhr)MxQYwB>_n$;C~I$rKY zAA^uM$9uNWY0+gmK37FJqw_nDUYhk||4fffbvKr>y=PI(j3{&CJpDPVc#q`X>n~e_ zQ{vxNT#~*1x#%~~rmlm{i}RB9`v)06(EOHubVCr^WVN(capxn<*4Y;y!r2o z*^>06r6VRXD(sonQ~o(kHruQ3^V~hb4)50X*zDtW<(%R0Ld~{ab!+>EVm7Eb98ema z5{C-7Kxt*2Oy&nGSspj#G=8pQSa@(zhK6!Wj7swJdDdl=(P+_ggO{z__OIyFe*b;J z0?)@V_ZS8l6sZ2nJHJrzxK-ls{+44qPfT}~u+Dq8U+gH0`>vI+aDcg&5ypVhgu)R` zIh;#3eJ{#9U||cl93Mk3vP=-4JdtuQn2A$(2pXWc==2{8Qi(HZR24NtfKd8`p&L}vY&byHR5Ea$u!vwm0G zhS!`jdvBfXU%d3C`dSZN)p6mw1)^R7IfS-`O?%`x6cWQNjj~0(~|$W_KT^B<$1=r zo6A=|%H#YY_40Ms(GO6=zbV#RC2HnqGBq<3{=VCV9aKek`sfS82N&kd|hhf0xZ)u{T>5Eu;s0wI8&-_ZG2 zkEm_-t@~>J1DXjp&AR_?&)<*pzj8wj{tp2RAPU&5Az&R)S`Mrvg|Na=6{8_A8Un*C F1OR>?kih@| diff --git a/crates/core/src/scenarios/fixtures/kamino_reserve.bin b/crates/core/src/scenarios/fixtures/kamino_reserve.bin deleted file mode 100644 index c61702e793cf550be8fedf133a48e732092cd231..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8624 zcmdP?bmo-Qck6ma1_+ST6PE>+%2tBW&#y-O(4e%UrbbiL@s4!>@(L-REkybE2> z_0DSbL#Q^gDUPC(Ut{0s9dojXU*onh@N4|@FoEgz53;0eA{gV|UMYc}#U=#aoVEiI= zdCb=rBCi~_c`f0_?K%ONRAB*c7KqIT6F{Rgmi_Sdt7m|#N0X*Hm+kJihu2dVPQH2i zq1(CFm1|_Sw7!jxHlJwK^R>0^Kf_9@TR3XqXb6mkz-S1JhQMeDjE2C-34!O^)4ww- zZqz-Ua{gn)x-B`ss~-Hlq8&X|Lr&1rMkxl?ewx6#XrHRkclBAiZP%x^e@^gUy7}hh zi|fCx_OGtpd-3-CkrQB}J{=8#(GVC7fzc2c4S~@R7#<-YViXYdh4BS*GDCwEC)i60 zGK75w8Z&c{XJF6)GE5X180IlCFlZ<69yh~=iDlIGj z6-j7bW}z~Lq?AKc7CQbME<`-}~?RJ#XjE3;+6d zuW32kj@)bU_1jmYeK@UZ{Qi_a?Sotzch+hwn|Q%0f|ruM;6F82BJ-38f>0Oy2|w}k zIflYxzt-hn2#>Npv)r>bjSzfE;x+)B77MO${h42`tD)lv-K1*`0(yFYp;|!ChK1f2 zhFK~?2+8MRfHP#l;nK5yblz84f?gUVz2eH|bLw|hh_`@z?VbUe!ha91xb6)uz2wlF zP(3r@Tpz(Vqy`9p-;=4B%X*M}IFpI?;FLEB-2yM|D`L_MLkY&0GyMB4G!BkW4iR%;S?6d0$E02^0Q(JPu^(dcWKj#75^C$B2AlKVSC?2KH zRDfg4FaAO4ql7rpucM4SL~(m+{W>{uJAnAvD8_ZnX;i%!5{73_xBMALe1BAYpK>xaH)Js5m3Swx98LripZ(fb-8mM3LEY%j0`c=@}Dy?Z!Xh$I>`BdV`6= zr$>d&A7wvlpqE*4^Flskmh%VbRzQOa*fsgsjmh7dVd z#yK8sY7 zDIv?(*L%{b(-^og?$p)*!(b&*Vx_R{zochzpy~C|s!mm|9UMgp4$1Z;ocE zOZ;*4Zi!4!<`)fst6;%>)OiyN_r11HsAPLv3Fy5G>K8}nmAq-6id>4}lCcgxE{F3^ ztBX2ef^QrE?hh6|_vWbd7CwlSuNl6%zRx*6!7p>s4-G;QE=j2~e~hVCiwx@o@7j zo&?H^cT#^n$F9}D7r_zREnLytF+WUP(YIoG$asgXmkKa(k5737nzh=ik^Mi|e%$KG zqTy94FF}vx8&vxdVvvpbM`aJ8r{;R&GPQ6h53|pa?z$ll!7xzCyZ;vp^5qT?m$3?8}R3-HS z8}CO9EUQwFwIkyO8k?_P#2=3w?+a@;&h#u_LDdDY{$dw0HYFUxU$n9v{patrNA?%f zs^^W*+R?{fyliedO7{C<>(2{%84ftOb>RuznVe ziHkdwxm0ZqIz1jH4mbXKaIaPa+_$wD<8`ogLHr4IEZnQ*p8aVBzxJ+)j786bCoysC z9KHOG(x~I_OXelm{z}G+4dZZdFX!U*ySf0g9ee#G>EQ@P15uAoT-;%ltAsk974dP- zpr5?aaVWfX$9tQ2)r@gd*m;W?vzWLPFb>AHp5e(|=DY=LyV73I#FYYiBUt7|6>SrB zovR{}WIYI5pIMt-!NQHrytk~)WDfGYw)42iYmpA?*~JA^13MUJ5QIk5fIfu&5nz~-}vBY3zL?r-mG&O;w( z`0Kd-nN6Rg<6gCW*q&FH6~<^EJ_dQ0ZM~alv6RHaVe_B_L?Qp?m<+oT@j^{oyt;%S zVEt^b#m>8~7`U8g;Y;rf;cAfcN3itLJ5n$F{Q){oAxiu|BC^ar@5cC_dB}MwBYyA) zrN_{5-6f9RbUu~(G;lJeqt72xPnY-6@7`$0Ugt@H2aUZvs8Lytd2+d-FiWV^SnRUcG4L^d0XR7&QF zG@uu@{GMHfhqL7s@qUQ-5IG)j>j_2M;Z3K9+in`wb;gv{pvMtzyerxc28mPFSIj~m zUs^XfHnihDx?U7r@8aq+NopM~Y#mZBwGI~+hs$So!6npL_16|`njrU0!1i;(^S|qh zgX=DO=`MU`|HS8QKaU>oxc%bpqL=Q%XUQ3HizHgqE?`=^Uo1Rv@&0i47V&!zvci>15~PbG!Tr*gVA}`x=UZbW z_qD=~hv1IeSO!?naPg|A#ReY-=fR8q%IJJ{Gz|%-ar*$D)n~B)TF zYxc$2$MXGJCcEKNiCvfJID*y7+@OmVU;jYk;O#If>+(%Hj$rATUl*@HBI^m*_m!lU z;^8`5<;S~BeG+wU`$9C4{rdU9FZG4bB;|vxdyEJBGH`Vu@3OtWF2Z2Kn7XYrpYZlm zZ<_L1Y#(yo7uJ4+APKheshF-q}JO0vDe$n$> zG!0nlUmt$-G5x+7So@tB89w&cr|397cPwLFI&58_t~3k_hr7QYO^ZEuzit}vpX-FJ^Y|)Ah`14&S=4)GU~b~|GKMKI5h75_q&Hr7mv)7;~Ms$ zE?|5A*}55cxM7W9y5}#_@5_U2S9ETiU%B`aIu2JuzAKsQ`C#|){k@xsgWcC^xxw5e zOya%nU0s)7_^nJdRYKYwR=&;~4!Vs3rIPy%VeQBDPT7+|$6*FK4%hCu zxaP!xA}Fa(*!m@E0TTzi@7nL4m=Y?z`*lnlEWOt4?=s|r^%v(nChi=_FKoX%uxYE< z6689he1J0r^J=o}J;-{w8ZbI!1_y*58n8a>70{(ow0!y!KPkh|m0o9bhz~=dhMB<+(Mx(c58JNF^@(@{#M7F!%9$rsfIQi!3hi>OySFVxS()u<&+I*r_&)3$v{|qb7G4ds>f4fOt zd~dR(euzkv{j@0K-A$K{GtJv{PEMgIg^+%QCBo|&fWSzZ2~307AOPd9h-U)x8Nnow RVEhkeO?RA{0TUX`GywU?a{>SW diff --git a/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml index 6e155d8e8..9cb81179b 100644 --- a/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml @@ -124,4 +124,4 @@ templates: kamino-reserve-oracle instead EXAMPLE - move the 1h EMA of entry 3 to $45 (EMAs are scaled by 2^60): - twaps.3.current_ema1h: 51879434184388608000 \ No newline at end of file + twaps.3.current_ema1h: "51879434184388608000" diff --git a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml index 39db9aa26..7ab89e5f4 100644 --- a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml @@ -463,8 +463,8 @@ templates: fractions: usd_value * 2^60. EXAMPLE - force an unhealthy obligation for a direct state check ($1000 debt vs $500 limit): - borrow_factor_adjusted_debt_value_sf: 1152921504606846976000 - unhealthy_borrow_value_sf: 576460752303423488000 + borrow_factor_adjusted_debt_value_sf: "1152921504606846976000" + unhealthy_borrow_value_sf: "576460752303423488000" DO NOT set persist: true here - transactions write these fields, and re-applying the override reverts their writes at the start of every following slot. @@ -524,7 +524,7 @@ templates: never executes EXAMPLE - arm a stop-loss on the first order slot: - obligation_orders.0.condition_threshold_sf: 576460752303423488000 + obligation_orders.0.condition_threshold_sf: "576460752303423488000" obligation_orders.0.min_execution_bonus_bps: 100 # ========================================== diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 533bf9d63..5650564d9 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -241,27 +241,6 @@ mod tests { use super::*; - /// A valid JSON value for a scalar IDL type, or `None` for composites. - fn sample_scalar_value(ty: &IdlType) -> Option { - match ty { - IdlType::Bool => Some(serde_json::json!(true)), - IdlType::U8 - | IdlType::U16 - | IdlType::U32 - | IdlType::U64 - | IdlType::U128 - | IdlType::I8 - | IdlType::I16 - | IdlType::I32 - | IdlType::I64 - | IdlType::I128 => Some(serde_json::json!(1)), - IdlType::Pubkey => Some(serde_json::json!( - "11111111111111111111111111111111".to_string() - )), - _ => None, - } - } - #[test] fn raydium_config_index_options_derive_their_documented_address() { let registry = TemplateRegistry::new(); @@ -1086,228 +1065,6 @@ mod tests { ); } - #[test] - fn test_kamino_templates_round_trip_through_forge() { - use std::collections::HashMap; - - use solana_pubkey::Pubkey; - - use crate::surfnet::svm::SurfnetSvm; - - // Live mainnet sizes. Keyed by (protocol, account) because `GlobalConfig` is a - // different struct in four of these programs. - const ACCOUNT_SIZES: &[(&str, &str, usize)] = &[ - // Kamino Lend (KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD) - ("kamino", "Reserve", 8624), - ("kamino", "Obligation", 3344), - ("kamino", "LendingMarket", 4664), - // No WithdrawTicket existed on mainnet when this was written (the feature is new - // in klend 1.23.0), so this size is derived from the IDL rather than observed. - ("kamino", "WithdrawTicket", 520), - // Scope (HFn8GnPADiny6XqUoWE8uRPPxb29ikn4yTuPa9MF2fWJ) - ("kamino-scope", "OraclePrices", 28712), - ("kamino-scope", "OracleMappings", 29704), - ("kamino-scope", "OracleTwaps", 344136), - // Kamino Farms (FarmsPZpWu9i7Kky8tPN37rs2TpmMrAZrC7S7vJa91Hr) - ("kamino-farms", "FarmState", 8336), - ("kamino-farms", "UserState", 920), - ("kamino-farms", "GlobalConfig", 2136), - // LIMO / Kamino Swap (LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF) - ("kamino-swap", "Order", 424), - ("kamino-swap", "GlobalConfig", 2168), - // Kamino Vaults / Earn (KvauGMspG5k6rtzrqqn7WNn3oZdyKqLKwK2XWQ8FLjd) - ("kamino-vault", "VaultState", 62552), - ("kamino-vault", "ReserveWhitelistEntry", 136), - // Kamino Liquidity / yvaults (6LtLpnUFNByNXLyCoK9wA2MykKAmQNZKBdY8s47dehDc) - ("kamino-liquidity", "WhirlpoolStrategy", 4064), - ]; - - let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - let registry = TemplateRegistry::new(); - let pubkey = Pubkey::new_unique(); - let mut checked = 0; - - for protocol in [ - "kamino", - "kamino-scope", - "kamino-farms", - "kamino-swap", - "kamino-vault", - "kamino-liquidity", - ] { - let templates = registry.by_protocol(protocol); - assert!( - !templates.is_empty(), - "expected templates for protocol {}", - protocol - ); - - for template in templates { - let (_, _, size) = ACCOUNT_SIZES - .iter() - .find(|(proto, name, _)| *proto == protocol && *name == template.account_type) - .unwrap_or_else(|| { - panic!( - "template {} targets {}/{} with no known size; add it to ACCOUNT_SIZES", - template.id, protocol, template.account_type - ) - }); - - let account_def = template - .idl - .accounts - .iter() - .find(|a| a.name == template.account_type) - .unwrap_or_else(|| { - panic!( - "account '{}' not found in the {} IDL (template {})", - template.account_type, protocol, template.id - ) - }); - - let mut data = vec![0u8; *size]; - data[..8].copy_from_slice(&account_def.discriminator); - - // A zeroed account with no overrides must survive the decode/re-encode cycle - // byte-for-byte, otherwise the pipeline is silently rewriting account state. - let identity = surfnet_svm - .get_forged_account_data(&pubkey, &data, &template.idl, &HashMap::new()) - .unwrap_or_else(|e| { - panic!("identity round-trip failed for {}: {}", template.id, e) - }); - assert_eq!( - identity, data, - "identity round-trip changed bytes for {}", - template.id - ); - - // Now write every scalar property the template advertises, in one pass. - let mut overrides: HashMap = HashMap::new(); - for property in &template.properties { - let ty = surfpool_types::resolve_idl_type( - &template.idl, - &template.account_type, - &property.path, - ) - .unwrap_or_else(|e| panic!("[{}] {}: {}", template.id, property.path, e)); - if let Some(value) = sample_scalar_value(ty) { - overrides.insert(property.path.clone(), value); - } - } - - if overrides.is_empty() { - // Composite-only template (e.g. kamino-reserve-interest-rate exposes a - // single struct); its llm_context documents the required full shape. - continue; - } - - let forged = surfnet_svm - .get_forged_account_data(&pubkey, &data, &template.idl, &overrides) - .unwrap_or_else(|e| { - panic!( - "forge failed for {} with {} scalar override(s): {}", - template.id, - overrides.len(), - e - ) - }); - - assert_eq!( - forged.len(), - data.len(), - "forged account size changed for {}", - template.id - ); - assert_ne!( - forged, data, - "overrides for {} did not change any bytes", - template.id - ); - checked += 1; - } - } - - assert!( - checked >= 25, - "expected to exercise at least 25 Kamino-family templates, got {}", - checked - ); - } - - /// The default pubkey "1111...1111" is all hex characters, which the encoder used to - /// misread as hex bytes and panic on. - #[test] - fn test_kamino_obligation_array_index_and_pubkey_overrides() { - use std::collections::HashMap; - - use solana_pubkey::Pubkey; - - use crate::surfnet::svm::SurfnetSvm; - - // Obligation offsets incl. discriminator: header is 88 bytes, then 136 per deposit. - const DEPOSIT_0_RESERVE: usize = 8 + 88; - const DEPOSIT_0_AMOUNT: usize = DEPOSIT_0_RESERVE + 32; - const DEPOSIT_1_RESERVE: usize = 8 + 88 + 136; - - let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - let registry = TemplateRegistry::new(); - let template = registry - .get("kamino-obligation-positions") - .expect("kamino-obligation-positions template should exist"); - - let account_def = template - .idl - .accounts - .iter() - .find(|a| a.name == "Obligation") - .expect("Obligation account in Kamino IDL"); - let mut data = vec![0u8; 3344]; - data[..8].copy_from_slice(&account_def.discriminator); - - let wsol = "So11111111111111111111111111111111111111112"; - let overrides: HashMap = HashMap::from([ - ( - "deposits.0.deposit_reserve".to_string(), - serde_json::json!("11111111111111111111111111111111"), - ), - ( - "deposits.0.deposited_amount".to_string(), - serde_json::json!(4_200_000_000u64), - ), - ( - "deposits.1.deposit_reserve".to_string(), - serde_json::json!(wsol), - ), - ("has_debt".to_string(), serde_json::json!(1)), - ]); - - let forged = surfnet_svm - .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) - .expect("array-index and pubkey overrides should apply"); - - assert_eq!(forged.len(), data.len(), "account size must be preserved"); - - assert_eq!( - &forged[DEPOSIT_0_RESERVE..DEPOSIT_0_RESERVE + 32], - Pubkey::default().as_ref(), - "deposits[0].deposit_reserve should be the default pubkey" - ); - assert_eq!( - u64::from_le_bytes( - forged[DEPOSIT_0_AMOUNT..DEPOSIT_0_AMOUNT + 8] - .try_into() - .unwrap() - ), - 4_200_000_000u64, - "deposits[0].deposited_amount should be written at its array index" - ); - assert_eq!( - &forged[DEPOSIT_1_RESERVE..DEPOSIT_1_RESERVE + 32], - Pubkey::from_str_const(wsol).as_ref(), - "deposits[1].deposit_reserve should be the wSOL mint" - ); - } - #[test] fn test_array_index_override_path_errors() { use txtx_addon_kit::{indexmap::IndexMap, types::types::Value}; @@ -1359,560 +1116,17 @@ mod tests { ); } + /// The Scope template must default to the Main Market's prices account, since every price + /// recipe in the docs is written against its indices. #[test] - fn test_kamino_scope_price_override_writes_expected_bytes() { - use std::collections::HashMap; - - use solana_pubkey::Pubkey; - - use crate::surfnet::svm::SurfnetSvm; - - // OraclePrices: discriminator + oracle_mappings pubkey, then 56 bytes per entry. - const PRICES_BASE: usize = 8 + 32; - const DATED_PRICE_SIZE: usize = 56; - - // A mechanical target; real per-token indices differ per price account. - const SOL_INDEX: usize = 0; - // $125.50 with exp = 8 - const SOL_VALUE: u64 = 12_550_000_000; - const SOL_EXP: u64 = 8; - const AT_SLOT: u64 = 370_000_000; - const AT_TS: u64 = 1_800_000_000; - - let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + fn test_kamino_scope_template_defaults_to_the_main_market() { let registry = TemplateRegistry::new(); let template = registry .get("kamino-scope-price") .expect("kamino-scope-price template should exist"); - assert_eq!( template.address, - surfpool_types::AccountAddress::Pubkey( - "3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH".to_string() - ), - "template should default to the Main Market's Scope prices account" - ); - - let account_def = template - .idl - .accounts - .iter() - .find(|a| a.name == "OraclePrices") - .expect("OraclePrices in the Scope IDL"); - let mut data = vec![0u8; 28712]; - data[..8].copy_from_slice(&account_def.discriminator); - - let overrides: HashMap = HashMap::from([ - ( - format!("prices.{SOL_INDEX}.price.value"), - serde_json::json!(SOL_VALUE), - ), - ( - format!("prices.{SOL_INDEX}.price.exp"), - serde_json::json!(SOL_EXP), - ), - ( - format!("prices.{SOL_INDEX}.last_updated_slot"), - serde_json::json!(AT_SLOT), - ), - ( - format!("prices.{SOL_INDEX}.unix_timestamp"), - serde_json::json!(AT_TS), - ), - ]); - - let forged = surfnet_svm - .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) - .expect("scope price override should apply"); - - assert_eq!(forged.len(), data.len(), "account size must be preserved"); - - let base = PRICES_BASE + SOL_INDEX * DATED_PRICE_SIZE; - let read = |off: usize| u64::from_le_bytes(forged[off..off + 8].try_into().unwrap()); - assert_eq!(read(base), SOL_VALUE, "price.value"); - assert_eq!(read(base + 8), SOL_EXP, "price.exp"); - assert_eq!(read(base + 16), AT_SLOT, "last_updated_slot"); - assert_eq!(read(base + 24), AT_TS, "unix_timestamp"); - - // price = value / 10^exp - assert_eq!(SOL_VALUE as f64 / 10f64.powi(SOL_EXP as i32), 125.50); - - // Neighbouring entries must be untouched. - let next = PRICES_BASE + (SOL_INDEX + 1) * DATED_PRICE_SIZE; - assert!( - forged[next..next + DATED_PRICE_SIZE] - .iter() - .all(|b| *b == 0), - "writing one price index must not disturb the next entry" - ); - } - - /// A reward accrues from the gap between the farm accumulator and the user's tally, so - /// both halves must be writable. - #[test] - fn test_kamino_farms_reward_override_writes_both_halves() { - use std::collections::HashMap; - - use solana_pubkey::Pubkey; - - use crate::surfnet::svm::SurfnetSvm; - - let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - let registry = TemplateRegistry::new(); - let pubkey = Pubkey::new_unique(); - - let farm = registry - .get("kamino-farms-reward-accumulator") - .expect("kamino-farms-reward-accumulator template"); - let farm_def = farm - .idl - .accounts - .iter() - .find(|a| a.name == "FarmState") - .expect("FarmState in the Farms IDL"); - let mut farm_data = vec![0u8; 8336]; - farm_data[..8].copy_from_slice(&farm_def.discriminator); - - let farm_overrides: HashMap = HashMap::from([ - ( - "reward_infos.0.reward_per_share_scaled".to_string(), - serde_json::json!(5_000_000u64), - ), - ( - "total_active_stake_scaled".to_string(), - serde_json::json!(1_000_000u64), - ), - ]); - let forged_farm = surfnet_svm - .get_forged_account_data(&pubkey, &farm_data, &farm.idl, &farm_overrides) - .expect("farm accumulator override should apply"); - assert_eq!(forged_farm.len(), farm_data.len()); - assert_ne!(forged_farm, farm_data); - - let user = registry - .get("kamino-farms-user-rewards") - .expect("kamino-farms-user-rewards template"); - let user_def = user - .idl - .accounts - .iter() - .find(|a| a.name == "UserState") - .expect("UserState in the Farms IDL"); - let mut user_data = vec![0u8; 920]; - user_data[..8].copy_from_slice(&user_def.discriminator); - - // UserState offsets incl. discriminator: 80-byte header, then the [u128; 10] tally. - const TALLY_0: usize = 88; - const UNCLAIMED_0: usize = TALLY_0 + 160; - - let user_overrides: HashMap = HashMap::from([ - ( - "rewards_issued_unclaimed.0".to_string(), - serde_json::json!(777_000u64), - ), - ( - "rewards_tally_scaled.0".to_string(), - serde_json::json!(0u64), - ), - ( - "active_stake_scaled".to_string(), - serde_json::json!(1_000u64), - ), - ]); - let forged_user = surfnet_svm - .get_forged_account_data(&pubkey, &user_data, &user.idl, &user_overrides) - .expect("user reward override should apply"); - - assert_eq!(forged_user.len(), user_data.len()); - assert_eq!( - u64::from_le_bytes( - forged_user[UNCLAIMED_0..UNCLAIMED_0 + 8] - .try_into() - .unwrap() - ), - 777_000u64, - "rewards_issued_unclaimed[0] should be written at its array index" - ); - } - - /// The two overrides that survive `refresh_obligation`: crash the Scope price, then - /// tighten the deposit reserve's liquidation threshold. - #[test] - fn test_kamino_liquidation_setup_writes_durable_inputs() { - use std::collections::HashMap; - - use solana_pubkey::Pubkey; - - use crate::surfnet::svm::SurfnetSvm; - - const LTV_PCT: usize = 4872; - const LIQ_THRESHOLD_PCT: usize = 4873; - const SCOPE_PRICES_BASE: usize = 8 + 32; - const DATED_PRICE_SIZE: usize = 56; - - let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - let registry = TemplateRegistry::new(); - let pubkey = Pubkey::new_unique(); - - // Crash the Scope price the reserve prices from. - let scope = registry.get("kamino-scope-price").expect("scope template"); - let scope_disc = &scope - .idl - .accounts - .iter() - .find(|a| a.name == "OraclePrices") - .expect("OraclePrices") - .discriminator; - let mut scope_data = vec![0u8; 28712]; - scope_data[..8].copy_from_slice(scope_disc); - - const IDX: usize = 45; - const CRASHED: u64 = 15_000_000; - let scope_overrides: HashMap = HashMap::from([ - ( - format!("prices.{IDX}.price.value"), - serde_json::json!(CRASHED), - ), - (format!("prices.{IDX}.price.exp"), serde_json::json!(8u64)), - ]); - let forged_scope = surfnet_svm - .get_forged_account_data(&pubkey, &scope_data, &scope.idl, &scope_overrides) - .expect("scope crash should apply"); - - let off = SCOPE_PRICES_BASE + IDX * DATED_PRICE_SIZE; - assert_eq!( - u64::from_le_bytes(forged_scope[off..off + 8].try_into().unwrap()), - CRASHED, - "crashed price must land at the Scope entry the reserve names" - ); - assert_eq!( - CRASHED as f64 / 10f64.powi(8), - 0.15, - "value/exp must decode to $0.15" - ); - - // Tighten the deposit reserve's liquidation threshold. - let reserve = registry - .get("kamino-reserve-config") - .expect("reserve config template"); - let reserve_disc = &reserve - .idl - .accounts - .iter() - .find(|a| a.name == "Reserve") - .expect("Reserve") - .discriminator; - let mut reserve_data = vec![0u8; 8624]; - reserve_data[..8].copy_from_slice(reserve_disc); - // A healthy 70/75 configuration. - reserve_data[LTV_PCT] = 70; - reserve_data[LIQ_THRESHOLD_PCT] = 75; - - let reserve_overrides: HashMap = HashMap::from([ - ( - "config.liquidation_threshold_pct".to_string(), - serde_json::json!(50u8), - ), - ( - "config.max_liquidation_bonus_bps".to_string(), - serde_json::json!(1000u16), - ), - ]); - let forged_reserve = surfnet_svm - .get_forged_account_data(&pubkey, &reserve_data, &reserve.idl, &reserve_overrides) - .expect("reserve config override should apply"); - - assert_eq!( - forged_reserve[LIQ_THRESHOLD_PCT], 50, - "liquidation threshold must be lowered" - ); - assert_eq!( - forged_reserve[LTV_PCT], 70, - "loan-to-value must be left untouched, so a position at 70% LTV is now above the \ - 50% liquidation threshold and therefore liquidatable" - ); - assert_eq!( - forged_reserve.len(), - reserve_data.len(), - "reserve size must be preserved" - ); - } - - /// A ticket becomes redeemable once the reserve's queue cursor reaches its sequence number. - #[test] - fn test_kamino_withdraw_ticket_and_queue_cursor() { - use std::collections::HashMap; - - use solana_pubkey::Pubkey; - - use crate::surfnet::svm::SurfnetSvm; - - let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - let registry = TemplateRegistry::new(); - let pubkey = Pubkey::new_unique(); - - let ticket = registry - .get("kamino-withdraw-ticket") - .expect("withdraw ticket template"); - let ticket_disc = &ticket - .idl - .accounts - .iter() - .find(|a| a.name == "WithdrawTicket") - .expect("WithdrawTicket") - .discriminator; - let mut ticket_data = vec![0u8; 520]; - ticket_data[..8].copy_from_slice(ticket_disc); - - let ticket_overrides: HashMap = HashMap::from([ - ("sequence_number".to_string(), serde_json::json!(7u64)), - ( - "queued_collateral_amount".to_string(), - serde_json::json!(500u64), - ), - ("invalid".to_string(), serde_json::json!(0u8)), - ]); - let forged_ticket = surfnet_svm - .get_forged_account_data(&pubkey, &ticket_data, &ticket.idl, &ticket_overrides) - .expect("withdraw ticket override should apply"); - assert_eq!( - u64::from_le_bytes(forged_ticket[8..16].try_into().unwrap()), - 7, - "ticket sequence number" - ); - - // Advance the reserve's cursor to 7, making ticket 7 serveable. - let limits = registry - .get("kamino-reserve-limits") - .expect("reserve limits template"); - let reserve_disc = &limits - .idl - .accounts - .iter() - .find(|a| a.name == "Reserve") - .expect("Reserve") - .discriminator; - let mut reserve_data = vec![0u8; 8624]; - reserve_data[..8].copy_from_slice(reserve_disc); - - let queue_overrides: HashMap = HashMap::from([ - ( - "withdraw_queue.queued_collateral_amount".to_string(), - serde_json::json!(500u64), - ), - ( - "withdraw_queue.next_withdrawable_ticket_sequence_number".to_string(), - serde_json::json!(7u64), - ), - ( - "withdraw_queue.next_issued_ticket_sequence_number".to_string(), - serde_json::json!(8u64), - ), - ( - "liquidity.total_available_amount".to_string(), - serde_json::json!(0u64), - ), - ]); - let forged_reserve = surfnet_svm - .get_forged_account_data(&pubkey, &reserve_data, &limits.idl, &queue_overrides) - .expect("withdraw queue override should apply"); - - assert_eq!(forged_reserve.len(), reserve_data.len()); - assert_ne!(forged_reserve, reserve_data); - } - - // Unmodified mainnet account data, captured 2026-08-06, with the source address of each so - // it can be re-captured. Zeroed accounts never exercise real enum discriminants or non-zero - // padding; these do. The reserve and Scope prices accounts are a matched pair - - // test_reserve_price_is_derived_from_scope depends on it. - // 14sqx2pLioXamoBFxE6CvHNth6uEAvJhXuJ2iwZMccAS - const FIXTURE_RESERVE: &[u8] = include_bytes!("./fixtures/kamino_reserve.bin"); - // 3iprSGrEQdBxhmqV399tYQQPG8Z1Hh2aYFrBwgqFXjGS - const FIXTURE_OBLIGATION: &[u8] = include_bytes!("./fixtures/kamino_obligation.bin"); - // 3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C - const FIXTURE_SCOPE_PRICES: &[u8] = include_bytes!("./fixtures/kamino_scope_oracle_prices.bin"); - // 18DizwAbBuuNGwfav3v6yWMbunnye4RnMLwLp67jAtj - const FIXTURE_FARM_STATE: &[u8] = include_bytes!("./fixtures/kamino_farms_farm_state.bin"); - // 14Buhfy7WBpiv2e6RMZNN5R7w3ua8MY1ZJ3WQyd29uJ - const FIXTURE_SWAP_ORDER: &[u8] = include_bytes!("./fixtures/kamino_swap_order.bin"); - // 1EXN5b1z7wucGb2uZoQmqjHdPoK1PNfUNWuwq8AqLTV - const FIXTURE_STRATEGY: &[u8] = include_bytes!("./fixtures/kamino_liquidity_strategy.bin"); - - /// Byte indices at which two buffers differ. - fn diff_indices(a: &[u8], b: &[u8]) -> Vec { - a.iter() - .zip(b.iter()) - .enumerate() - .filter(|(_, (x, y))| x != y) - .map(|(i, _)| i) - .collect() - } - - /// A failure here means a bundled IDL disagrees with the live on-chain layout. - #[test] - fn test_real_mainnet_accounts_round_trip_unchanged() { - use std::collections::HashMap; - - use solana_pubkey::Pubkey; - - use crate::surfnet::svm::SurfnetSvm; - - let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - let registry = TemplateRegistry::new(); - let pubkey = Pubkey::new_unique(); - - let cases: &[(&str, &str, &[u8])] = &[ - ("kamino-reserve-config", "Reserve", FIXTURE_RESERVE), - ("kamino-obligation-health", "Obligation", FIXTURE_OBLIGATION), - ("kamino-scope-price", "OraclePrices", FIXTURE_SCOPE_PRICES), - ( - "kamino-farms-reward-accumulator", - "FarmState", - FIXTURE_FARM_STATE, - ), - ("kamino-swap-order", "Order", FIXTURE_SWAP_ORDER), - ( - "kamino-liquidity-strategy-balances", - "WhirlpoolStrategy", - FIXTURE_STRATEGY, - ), - ]; - - for (template_id, account_name, data) in cases { - let template = registry - .get(template_id) - .unwrap_or_else(|| panic!("template {} should exist", template_id)); - - let account_def = template - .idl - .accounts - .iter() - .find(|a| a.name == *account_name) - .unwrap_or_else(|| panic!("{} not in the IDL", account_name)); - assert_eq!( - &data[..8], - account_def.discriminator.as_slice(), - "{} fixture discriminator does not match the IDL - wrong account type?", - account_name - ); - - let forged = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) - .unwrap_or_else(|e| { - panic!( - "real mainnet {} failed to decode/re-encode with the bundled IDL: {}", - account_name, e - ) - }); - - assert_eq!( - forged.len(), - data.len(), - "{} changed size on round-trip", - account_name - ); - let diffs = diff_indices(&forged, data); - assert!( - diffs.is_empty(), - "real mainnet {} was altered by a no-op round-trip at {} byte(s), first at {:?}", - account_name, - diffs.len(), - diffs.first() - ); - } - } - - /// Catches collateral damage from the Borsh re-encode that a zeroed fixture would hide. - #[test] - fn test_override_on_real_account_touches_only_target_bytes() { - use std::collections::HashMap; - - use solana_pubkey::Pubkey; - - use crate::surfnet::svm::SurfnetSvm; - - let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - let registry = TemplateRegistry::new(); - let pubkey = Pubkey::new_unique(); - - // Reserve: one u8 at a known offset. - const LIQ_THRESHOLD_PCT: usize = 4873; - let reserve = registry.get("kamino-reserve-config").unwrap(); - let original_threshold = FIXTURE_RESERVE[LIQ_THRESHOLD_PCT]; - assert!( - original_threshold > 50, - "fixture should start above the value we set, got {}", - original_threshold - ); - - let forged = surfnet_svm - .get_forged_account_data( - &pubkey, - FIXTURE_RESERVE, - &reserve.idl, - &HashMap::from([( - "config.liquidation_threshold_pct".to_string(), - serde_json::json!(50u8), - )]), - ) - .expect("threshold override on real reserve"); - - assert_eq!( - diff_indices(&forged, FIXTURE_RESERVE), - vec![LIQ_THRESHOLD_PCT], - "exactly one byte should change, and only the liquidation threshold" - ); - assert_eq!(forged[LIQ_THRESHOLD_PCT], 50); - - // Scope: one u64 inside a 512-element array. - const PRICES_BASE: usize = 8 + 32; - const DATED_PRICE_SIZE: usize = 56; - const IDX: usize = 0; - let scope = registry.get("kamino-scope-price").unwrap(); - let value_off = PRICES_BASE + IDX * DATED_PRICE_SIZE; - - let original_value = u64::from_le_bytes( - FIXTURE_SCOPE_PRICES[value_off..value_off + 8] - .try_into() - .unwrap(), - ); - assert!( - original_value > 0, - "fixture SOL price should be non-zero, got {}", - original_value - ); - let new_value = original_value / 2; // halve SOL - - let forged = surfnet_svm - .get_forged_account_data( - &pubkey, - FIXTURE_SCOPE_PRICES, - &scope.idl, - &HashMap::from([( - format!("prices.{IDX}.price.value"), - serde_json::json!(new_value), - )]), - ) - .expect("price override on real Scope account"); - - let diffs = diff_indices(&forged, FIXTURE_SCOPE_PRICES); - assert!(!diffs.is_empty(), "the price should have changed"); - assert!( - diffs.iter().all(|i| (value_off..value_off + 8).contains(i)), - "only the 8 bytes of prices[{}].price.value should change, got {:?}", - IDX, - diffs - ); - assert_eq!( - u64::from_le_bytes(forged[value_off..value_off + 8].try_into().unwrap()), - new_value - ); - - let next = PRICES_BASE + DATED_PRICE_SIZE; - assert_eq!( - &forged[next..next + DATED_PRICE_SIZE], - &FIXTURE_SCOPE_PRICES[next..next + DATED_PRICE_SIZE], - "neighbouring Scope entry must not move" + AccountAddress::Pubkey("3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH".to_string()) ); } @@ -1990,81 +1204,6 @@ mod tests { ); } - /// Evidence that a Reserve's cached price is derived from Scope, which is why - /// `kamino-scope-price` is the durable lever. The two fixtures are a matched pair: the - /// reserve names this Scope account, and its `price_chain` product reproduces the cache. - #[test] - fn test_reserve_price_is_derived_from_scope() { - use solana_pubkey::Pubkey; - - // Reserve offsets incl. discriminator. - const MARKET_PRICE_SF: usize = 248; // u128 scaled fraction (value << 60) - const SCOPE_PRICE_FEED: usize = 5112; - const SCOPE_PRICE_CHAIN: usize = 5144; // [u16; 4], 65535 = unused - const PRICES_BASE: usize = 8 + 32; - const DATED_PRICE_SIZE: usize = 56; - const UNUSED_CHAIN_ENTRY: u16 = 65535; - - let scope_account = Pubkey::from_str_const("3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C"); - - assert_eq!( - &FIXTURE_RESERVE[SCOPE_PRICE_FEED..SCOPE_PRICE_FEED + 32], - scope_account.as_ref(), - "the reserve fixture must price through the Scope account the other fixture holds" - ); - - let chain: Vec = (0..4) - .map(|i| { - let off = SCOPE_PRICE_CHAIN + i * 2; - u16::from_le_bytes(FIXTURE_RESERVE[off..off + 2].try_into().unwrap()) - }) - .take_while(|entry| *entry != UNUSED_CHAIN_ENTRY) - .collect(); - assert!( - !chain.is_empty(), - "the reserve fixture should name at least one Scope index" - ); - - // A chained price is the product of its entries, each value / 10^exp. - let mut scope_price = 1.0f64; - for index in &chain { - let base = PRICES_BASE + (*index as usize) * DATED_PRICE_SIZE; - let value = - u64::from_le_bytes(FIXTURE_SCOPE_PRICES[base..base + 8].try_into().unwrap()); - let exp = u64::from_le_bytes( - FIXTURE_SCOPE_PRICES[base + 8..base + 16] - .try_into() - .unwrap(), - ); - assert!( - value > 0 && exp < 30, - "Scope entry {} looks unpopulated (value {}, exp {})", - index, - value, - exp - ); - scope_price *= value as f64 / 10f64.powi(exp as i32); - } - - let cached_sf = u128::from_le_bytes( - FIXTURE_RESERVE[MARKET_PRICE_SF..MARKET_PRICE_SF + 16] - .try_into() - .unwrap(), - ); - let cached_price = cached_sf as f64 / 2f64.powi(60); - assert!(cached_price > 0.0, "reserve fixture should have a price"); - - // Captured together, so this is exact rather than approximate. - let relative_error = (scope_price - cached_price).abs() / cached_price; - assert!( - relative_error < 1e-6, - "reserve cached price ${cached_price} should equal the Scope chain {chain:?} product \ - ${scope_price} - if these have diverged, either the scaled-fraction interpretation \ - (value << 60), the price_chain semantics (a product), or an offset is wrong. \ - Relative error {relative_error}" - ); - } - /// A path ending on an index must resolve to the array's ELEMENT type. Resolving it to the /// array instead sends the value down the untyped conversion, where an all-hex base58 pubkey /// such as the default one is mistaken for hex and panics the request. diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 5863e4f2c..d63818a17 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -214,6 +214,22 @@ fn parse_decoded_account_index(segment: &str, path: &str) -> SurfpoolResult SurfpoolResult { + match json { + serde_json::Value::Number(n) if n.as_u64().is_none() && n.as_i64().is_none() => { + Err(SurfpoolError::internal(format!( + "{n} exceeds what a JSON number can hold exactly; pass this {target} as a decimal \ + string instead, e.g. \"1152921504606846976000\"" + ))) + } + serde_json::Value::Number(n) => Ok(n.to_string()), + serde_json::Value::String(s) => Ok(s.trim().to_string()), + other => Err(SurfpoolError::internal(format!( + "Expected a number or decimal string for {target}, found {other}" + ))), + } +} + /// Converts JSON into a txtx [`Value`] using the expected IDL type fn json_to_txtx_value_for_idl_type( json: &serde_json::Value, @@ -235,6 +251,20 @@ fn json_to_txtx_value_for_idl_type( (IdlType::Option(inner), _) if !json.is_null() => { json_to_txtx_value_for_idl_type(json, inner, idl_types) } + (IdlType::U128, _) => { + let digits = json_integer_digits(json, "u128")?; + let value = digits.parse::().map_err(|e| { + SurfpoolError::internal(format!("Invalid u128 '{digits}': {e}")) + })?; + Ok(txtx_addon_network_svm_types::SvmValue::u128(value)) + } + (IdlType::I128, _) => { + let digits = json_integer_digits(json, "i128")?; + let value = digits.parse::().map_err(|e| { + SurfpoolError::internal(format!("Invalid i128 '{digits}': {e}")) + })?; + Ok(txtx_addon_network_svm_types::SvmValue::i128(value)) + } (IdlType::Vec(inner), serde_json::Value::Array(items)) | (IdlType::Array(inner, _), serde_json::Value::Array(items)) => { let converted = items @@ -2720,6 +2750,8 @@ impl SurfnetSvm { target_slot ); + let mut settled_this_slot: HashSet = HashSet::new(); + for override_instance in overrides { if !override_instance.enabled { debug!("Skipping disabled override: {}", override_instance.id); @@ -2758,7 +2790,7 @@ impl SurfnetSvm { ); // Fetch fresh account data from remote if requested - if override_instance.fetch_before_use { + if override_instance.fetch_before_use && !settled_this_slot.contains(&account_pubkey) { if let Some((client, _)) = remote_ctx { debug!( "Fetching fresh account data for {} from remote", @@ -2783,6 +2815,8 @@ impl SurfnetSvm { "Failed to set account {} from remote: {}", account_pubkey, e ); + } else { + settled_this_slot.insert(account_pubkey); } } Ok(GetAccountResult::None(_)) => { @@ -2935,6 +2969,7 @@ impl SurfnetSvm { account_pubkey, override_instance.id ); + settled_this_slot.insert(account_pubkey); // The account is forked now. Re-fetching it every slot would cost one RPC // per slot and overwrite whatever local transactions wrote to the fields // this override leaves alone, so later slots re-pin without fetching. @@ -7141,6 +7176,54 @@ mod tests { ); } + /// Guards the ordering invariant only. The re-fetch that used to clobber the first override + /// needs a remote client, so `remote_ctx: &None` cannot reproduce it here - that path is + /// covered against a live fork. + #[tokio::test] + async fn test_two_fetching_overrides_on_one_account_both_apply() { + const SLOT: u64 = 500; + // immediately precedes unhealthy_borrow_value_sf in the Obligation layout + const ALLOWED_OFFSET: usize = UNHEALTHY_OFFSET - 16; + + let (mut svm, account_pubkey, first) = scheduled_persist_fixture(false); + let mut first = first; + first.fetch_before_use = true; + + let mut second = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ) + .with_values(HashMap::from([( + "allowed_borrow_value_sf".to_string(), + serde_json::json!(5_678u64), + )])); + second.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![first, second]) + .expect("schedule overrides"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let account = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("account present"); + let read = |off: usize| { + u128::from_le_bytes(account.data[off..off + 16].try_into().expect("16 bytes")) + }; + assert_eq!( + read(UNHEALTHY_OFFSET), + 1_234, + "the first override must survive the second override's fetch" + ); + assert_eq!(read(ALLOWED_OFFSET), 5_678, "the second override must apply"); + } + #[tokio::test] async fn test_non_persisted_override_is_not_rescheduled() { const SLOT: u64 = 500; diff --git a/crates/core/src/tests/kamino/mod.rs b/crates/core/src/tests/kamino/mod.rs new file mode 100644 index 000000000..6a1c6c2d8 --- /dev/null +++ b/crates/core/src/tests/kamino/mod.rs @@ -0,0 +1,754 @@ +//! Kamino integration tests. +//! +//! These fetch the real accounts from mainnet rather than embedding captured copies, so they need +//! a network connection and are compiled only behind a feature: +//! +//! ```text +//! cargo test -p surfpool-core --features integration-tests kamino +//! ``` +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. +//! +//! What these cover that the unit tests cannot: a synthetic account is built *by* the bundled IDL, +//! so it can never disagree with it. Real accounts carry non-zero padding, live enum +//! discriminants and populated arrays, so an IDL that has drifted from the on-chain layout shows +//! up as a byte diff here and nowhere else. + +use std::collections::HashMap; + +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; + +use crate::{ + scenarios::TemplateRegistry, + surfnet::{GetAccountResult, remote::SurfnetRemoteClient, svm::SurfnetSvm}, +}; + +const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +const RESERVE: &str = "14sqx2pLioXamoBFxE6CvHNth6uEAvJhXuJ2iwZMccAS"; +const OBLIGATION: &str = "3iprSGrEQdBxhmqV399tYQQPG8Z1Hh2aYFrBwgqFXjGS"; +const SCOPE_PRICES: &str = "3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C"; +const FARM_STATE: &str = "18DizwAbBuuNGwfav3v6yWMbunnye4RnMLwLp67jAtj"; +const SWAP_ORDER: &str = "14Buhfy7WBpiv2e6RMZNN5R7w3ua8MY1ZJ3WQyd29uJ"; +const STRATEGY: &str = "1EXN5b1z7wucGb2uZoQmqjHdPoK1PNfUNWuwq8AqLTV"; +const LENDING_MARKET: &str = "13iJ9S8qW8VGG94qUapfe3zbjvfig8PPgbDyfgHY6UHL"; +const ORACLE_MAPPINGS: &str = "4zh6bmb77qX2CL7t5AJYCqa6YqFafbz3QJNeFvZjLowg"; +const ORACLE_TWAPS: &str = "6L6vUts9tYqxHVUCEFVc2mzZw6yxMn8C6a44cp5ga7e9"; +const FARMS_USER_STATE: &str = "1142jwhL6evoo2Ziqe6FJaj49USXA4JNXHcMH9bUFHz"; +const FARMS_GLOBAL_CONFIG: &str = "3UQ2HX2VtY2tuVycTEintP3SSkbH5UkNes3QkG577iYz"; +const SWAP_GLOBAL_CONFIG: &str = "3Lvo5giazx2Gyz9a2WWmDWj6eFeugKkcKSNK3qrPu46Y"; +const VAULT_STATE: &str = "2BEYDYJFQWHkfVHrA4r9fPnfBm1nguqmgoMBfzrWnBDP"; +const VAULT_WHITELIST_ENTRY: &str = "2GYjQAagrcmWDYZAjkeMZsDuT7jDyuiVqjxXuKvHEtcm"; + +/// Fetches the accounts in one request, so every account returned is from the same slot. +async fn fetch(addresses: &[&str]) -> Vec> { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + let pubkeys: Vec = addresses + .iter() + .map(|a| Pubkey::from_str_const(a)) + .collect(); + + client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + .unwrap_or_else(|e| panic!("failed to fetch {addresses:?} from mainnet: {e}")) + .into_iter() + .zip(addresses) + .map(|(result, address)| match result { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundProgramAccount((_, account), _) + | GetAccountResult::FoundTokenAccount((_, account), _) => account.data, + GetAccountResult::None(_) => { + panic!("{address} no longer exists on mainnet; the test needs a new address") + } + }) + .collect() +} + +/// Byte indices at which two buffers differ. +fn diff_indices(a: &[u8], b: &[u8]) -> Vec { + a.iter() + .zip(b.iter()) + .enumerate() + .filter(|(_, (x, y))| x != y) + .map(|(i, _)| i) + .collect() +} + +/// A failure here means a bundled IDL disagrees with the live on-chain layout. +#[tokio::test] +async fn real_mainnet_accounts_round_trip_unchanged() { + let cases: &[(&str, &str, &str)] = &[ + ("kamino-reserve-config", "Reserve", RESERVE), + ("kamino-obligation-health", "Obligation", OBLIGATION), + ("kamino-scope-price", "OraclePrices", SCOPE_PRICES), + ("kamino-farms-reward-accumulator", "FarmState", FARM_STATE), + ("kamino-swap-order", "Order", SWAP_ORDER), + ( + "kamino-liquidity-strategy-balances", + "WhirlpoolStrategy", + STRATEGY, + ), + ]; + + let addresses: Vec<&str> = cases.iter().map(|(_, _, a)| *a).collect(); + let accounts = fetch(&addresses).await; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + for ((template_id, account_name, _), data) in cases.iter().zip(&accounts) { + let template = registry + .get(template_id) + .unwrap_or_else(|| panic!("template {template_id} should exist")); + + let account_def = template + .idl + .accounts + .iter() + .find(|a| a.name == *account_name) + .unwrap_or_else(|| panic!("{account_name} not in the IDL")); + assert_eq!( + &data[..8], + account_def.discriminator.as_slice(), + "{account_name} discriminator does not match the IDL - wrong account type?" + ); + + let forged = surfnet_svm + .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .unwrap_or_else(|e| { + panic!( + "live mainnet {account_name} failed to decode/re-encode with the bundled \ + IDL: {e}" + ) + }); + + assert_eq!( + forged.len(), + data.len(), + "{account_name} changed size on round-trip" + ); + let diffs = diff_indices(&forged, data); + assert!( + diffs.is_empty(), + "live mainnet {} was altered by a no-op round-trip at {} byte(s), first at {:?}", + account_name, + diffs.len(), + diffs.first() + ); + } +} + +/// Catches collateral damage from the Borsh re-encode against real padding and live enum +/// discriminants, which a synthetic account cannot exercise. +#[tokio::test] +async fn override_on_real_account_touches_only_target_bytes() { + let accounts = fetch(&[RESERVE, SCOPE_PRICES]).await; + let (reserve_data, scope_data) = (&accounts[0], &accounts[1]); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + // Reserve: one u8 at a known offset. + const LIQ_THRESHOLD_PCT: usize = 4873; + let reserve = registry.get("kamino-reserve-config").unwrap(); + let original_threshold = reserve_data[LIQ_THRESHOLD_PCT]; + assert!( + original_threshold > 50, + "the live reserve should start above the value we set, got {original_threshold}" + ); + + let forged = surfnet_svm + .get_forged_account_data( + &pubkey, + reserve_data, + &reserve.idl, + &HashMap::from([( + "config.liquidation_threshold_pct".to_string(), + serde_json::json!(50u8), + )]), + ) + .expect("threshold override on live reserve"); + + assert_eq!( + diff_indices(&forged, reserve_data), + vec![LIQ_THRESHOLD_PCT], + "exactly one byte should change, and only the liquidation threshold" + ); + assert_eq!(forged[LIQ_THRESHOLD_PCT], 50); + + // Scope: one u64 inside a 512-element array. + const PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + const IDX: usize = 0; + let scope = registry.get("kamino-scope-price").unwrap(); + let value_off = PRICES_BASE + IDX * DATED_PRICE_SIZE; + + let original_value = + u64::from_le_bytes(scope_data[value_off..value_off + 8].try_into().unwrap()); + assert!( + original_value > 0, + "live Scope index {IDX} should be populated, got {original_value}" + ); + let new_value = original_value / 2; + + let forged = surfnet_svm + .get_forged_account_data( + &pubkey, + scope_data, + &scope.idl, + &HashMap::from([( + format!("prices.{IDX}.price.value"), + serde_json::json!(new_value), + )]), + ) + .expect("price override on live Scope account"); + + let diffs = diff_indices(&forged, scope_data); + assert!(!diffs.is_empty(), "the price should have changed"); + assert!( + diffs.iter().all(|i| (value_off..value_off + 8).contains(i)), + "only the 8 bytes of prices[{IDX}].price.value should change, got {diffs:?}" + ); + assert_eq!( + u64::from_le_bytes(forged[value_off..value_off + 8].try_into().unwrap()), + new_value + ); + + let next = PRICES_BASE + DATED_PRICE_SIZE; + assert_eq!( + &forged[next..next + DATED_PRICE_SIZE], + &scope_data[next..next + DATED_PRICE_SIZE], + "neighbouring Scope entry must not move" + ); +} + +/// Evidence that a Reserve's cached price is derived from Scope, which is why +/// `kamino-scope-price` is the durable lever rather than the Reserve's own cache. Only checkable +/// against a genuine pair - constructing both sides would test our arithmetic against itself. +#[tokio::test] +async fn reserve_price_is_derived_from_scope() { + // Reserve offsets incl. discriminator. + const MARKET_PRICE_SF: usize = 248; // u128 scaled fraction (value << 60) + const SCOPE_PRICE_FEED: usize = 5112; + const SCOPE_PRICE_CHAIN: usize = 5144; // [u16; 4], 65535 = unused + const PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + const UNUSED_CHAIN_ENTRY: u16 = 65535; + + let accounts = fetch(&[RESERVE, SCOPE_PRICES]).await; + let (reserve_data, scope_data) = (&accounts[0], &accounts[1]); + + let scope_account = Pubkey::from_str_const(SCOPE_PRICES); + assert_eq!( + &reserve_data[SCOPE_PRICE_FEED..SCOPE_PRICE_FEED + 32], + scope_account.as_ref(), + "the reserve must price through the Scope account this test fetches" + ); + + let chain: Vec = (0..4) + .map(|i| { + let off = SCOPE_PRICE_CHAIN + i * 2; + u16::from_le_bytes(reserve_data[off..off + 2].try_into().unwrap()) + }) + .take_while(|entry| *entry != UNUSED_CHAIN_ENTRY) + .collect(); + assert!( + !chain.is_empty(), + "the reserve should name at least one Scope index" + ); + + // A chained price is the product of its entries, each value / 10^exp. + let mut scope_price = 1.0f64; + for index in &chain { + let base = PRICES_BASE + (*index as usize) * DATED_PRICE_SIZE; + let value = u64::from_le_bytes(scope_data[base..base + 8].try_into().unwrap()); + let exp = u64::from_le_bytes(scope_data[base + 8..base + 16].try_into().unwrap()); + assert!( + value > 0 && exp < 30, + "Scope entry {index} looks unpopulated (value {value}, exp {exp})" + ); + scope_price *= value as f64 / 10f64.powi(exp as i32); + } + + let cached_sf = u128::from_le_bytes( + reserve_data[MARKET_PRICE_SF..MARKET_PRICE_SF + 16] + .try_into() + .unwrap(), + ); + let cached_price = cached_sf as f64 / 2f64.powi(60); + assert!(cached_price > 0.0, "the reserve should have a cached price"); + + // The cache is only rewritten when someone calls refresh_reserve, so it lags Scope by however + // long it has been since the last refresh. The tolerance covers that lag; what is being tested + // is the interpretation (value << 60, the chain being a product, the offsets), which a wrong + // reading would miss by orders of magnitude rather than a few percent. + let relative_error = (scope_price - cached_price).abs() / cached_price; + assert!( + relative_error < 0.05, + "reserve cached price ${cached_price} should track the Scope chain {chain:?} product \ + ${scope_price} - if these have diverged, either the scaled-fraction interpretation \ + (value << 60), the price_chain semantics (a product), or an offset is wrong. \ + Relative error {relative_error}" + ); +} + +/// A valid JSON value for a scalar IDL type, or `None` for composites. Mirrors the helper in +/// the registry unit tests; duplicated rather than widening that module's visibility. +fn sample_scalar_value(ty: &anchor_lang_idl::types::IdlType) -> Option { + use anchor_lang_idl::types::IdlType; + match ty { + IdlType::Bool => Some(serde_json::json!(true)), + IdlType::U8 | IdlType::U16 | IdlType::U32 | IdlType::U64 | IdlType::U128 => { + Some(serde_json::json!(7u64)) + } + IdlType::I8 | IdlType::I16 | IdlType::I32 | IdlType::I64 | IdlType::I128 => { + Some(serde_json::json!(7i64)) + } + IdlType::Pubkey => Some(serde_json::json!( + "So11111111111111111111111111111111111111112" + )), + _ => None, + } +} + +/// Every account type our templates target that has a live instance on mainnet. `WithdrawTicket` +/// is absent: the feature is new in klend 1.23.0 and none existed when this was written. +const LIVE_ACCOUNTS: &[(&str, &str, &str)] = &[ + ("kamino", "Reserve", RESERVE), + ("kamino", "Obligation", OBLIGATION), + ("kamino", "LendingMarket", LENDING_MARKET), + ("kamino-scope", "OraclePrices", SCOPE_PRICES), + ("kamino-scope", "OracleMappings", ORACLE_MAPPINGS), + ("kamino-scope", "OracleTwaps", ORACLE_TWAPS), + ("kamino-farms", "FarmState", FARM_STATE), + ("kamino-farms", "UserState", FARMS_USER_STATE), + ("kamino-farms", "GlobalConfig", FARMS_GLOBAL_CONFIG), + ("kamino-swap", "Order", SWAP_ORDER), + ("kamino-swap", "GlobalConfig", SWAP_GLOBAL_CONFIG), + ("kamino-vault", "VaultState", VAULT_STATE), + ("kamino-vault", "ReserveWhitelistEntry", VAULT_WHITELIST_ENTRY), + ("kamino-liquidity", "WhirlpoolStrategy", STRATEGY), +]; + +/// Every template, exercised against a live instance of the account it targets: an identity +/// round-trip must not alter bytes, then writing every scalar it advertises must change some. +#[tokio::test] +async fn every_template_round_trips_over_a_live_account() { + let addresses: Vec<&str> = LIVE_ACCOUNTS.iter().map(|(_, _, a)| *a).collect(); + let fetched = fetch(&addresses).await; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + let mut checked = 0; + + for ((protocol, account_type, address), data) in LIVE_ACCOUNTS.iter().zip(&fetched) { + for template in registry + .by_protocol(protocol) + .into_iter() + .filter(|t| t.account_type == *account_type) + { + let identity = surfnet_svm + .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .unwrap_or_else(|e| { + panic!("identity round-trip failed for {} ({address}): {e}", template.id) + }); + // A live account may be allocated larger than the struct needs, so the re-encode is + // a prefix rather than the whole buffer. + assert!( + identity.len() <= data.len(), + "{} re-encoded larger than the live account", + template.id + ); + assert_eq!( + identity, + data[..identity.len()], + "identity round-trip changed bytes for {} ({address})", + template.id + ); + + let mut overrides: HashMap = HashMap::new(); + for property in &template.properties { + let ty = surfpool_types::resolve_idl_type( + &template.idl, + &template.account_type, + &property.path, + ) + .unwrap_or_else(|e| panic!("[{}] {}: {e}", template.id, property.path)); + if let Some(value) = sample_scalar_value(ty) { + overrides.insert(property.path.clone(), value); + } + } + if overrides.is_empty() { + continue; // composite-only template; its llm_context documents the full shape + } + + let forged = surfnet_svm + .get_forged_account_data(&pubkey, data, &template.idl, &overrides) + .unwrap_or_else(|e| { + panic!( + "forge failed for {} with {} scalar override(s): {e}", + template.id, + overrides.len() + ) + }); + assert_eq!( + forged.len(), + identity.len(), + "forged size changed for {}", + template.id + ); + assert_ne!( + forged, identity, + "overrides for {} did not change any bytes", + template.id + ); + checked += 1; + } + } + + assert!( + checked >= 25, + "expected to exercise at least 25 Kamino templates against live accounts, got {checked}" + ); +} + +/// The default pubkey "1111...1111" is all hex characters, which the encoder used to misread as +/// hex bytes and panic on. +#[tokio::test] +async fn obligation_array_index_and_pubkey_overrides() { + // Obligation offsets incl. discriminator: header is 88 bytes, then 136 per deposit. + const DEPOSIT_0_RESERVE: usize = 8 + 88; + const DEPOSIT_0_AMOUNT: usize = DEPOSIT_0_RESERVE + 32; + const DEPOSIT_1_RESERVE: usize = 8 + 88 + 136; + + let data = fetch(&[OBLIGATION]).await.remove(0); + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let template = registry + .get("kamino-obligation-positions") + .expect("kamino-obligation-positions template should exist"); + + let wsol = "So11111111111111111111111111111111111111112"; + let overrides: HashMap = HashMap::from([ + ( + "deposits.0.deposit_reserve".to_string(), + serde_json::json!("11111111111111111111111111111111"), + ), + ( + "deposits.0.deposited_amount".to_string(), + serde_json::json!(4_200_000_000u64), + ), + ( + "deposits.1.deposit_reserve".to_string(), + serde_json::json!(wsol), + ), + ("has_debt".to_string(), serde_json::json!(1)), + ]); + + let forged = surfnet_svm + .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .expect("array-index and pubkey overrides should apply"); + + assert_eq!(forged.len(), data.len(), "account size must be preserved"); + assert_eq!( + &forged[DEPOSIT_0_RESERVE..DEPOSIT_0_RESERVE + 32], + Pubkey::default().as_ref(), + "deposits[0].deposit_reserve should be the default pubkey" + ); + assert_eq!( + u64::from_le_bytes( + forged[DEPOSIT_0_AMOUNT..DEPOSIT_0_AMOUNT + 8] + .try_into() + .unwrap() + ), + 4_200_000_000u64, + "deposits[0].deposited_amount should be written at its array index" + ); + assert_eq!( + &forged[DEPOSIT_1_RESERVE..DEPOSIT_1_RESERVE + 32], + Pubkey::from_str_const(wsol).as_ref(), + "deposits[1].deposit_reserve should be the wSOL mint" + ); +} + +#[tokio::test] +async fn scope_price_override_writes_expected_bytes() { + // OraclePrices: discriminator + oracle_mappings pubkey, then 56 bytes per entry. + const PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + const SOL_INDEX: usize = 0; + // $125.50 with exp = 8 + const SOL_VALUE: u64 = 12_550_000_000; + const SOL_EXP: u64 = 8; + const AT_SLOT: u64 = 370_000_000; + const AT_TS: u64 = 1_800_000_000; + + let data = fetch(&[SCOPE_PRICES]).await.remove(0); + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let template = registry + .get("kamino-scope-price") + .expect("kamino-scope-price template should exist"); + + let overrides: HashMap = HashMap::from([ + ( + format!("prices.{SOL_INDEX}.price.value"), + serde_json::json!(SOL_VALUE), + ), + ( + format!("prices.{SOL_INDEX}.price.exp"), + serde_json::json!(SOL_EXP), + ), + ( + format!("prices.{SOL_INDEX}.last_updated_slot"), + serde_json::json!(AT_SLOT), + ), + ( + format!("prices.{SOL_INDEX}.unix_timestamp"), + serde_json::json!(AT_TS), + ), + ]); + + let forged = surfnet_svm + .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .expect("scope price override should apply"); + + assert_eq!(forged.len(), data.len(), "account size must be preserved"); + + let base = PRICES_BASE + SOL_INDEX * DATED_PRICE_SIZE; + let read = |off: usize| u64::from_le_bytes(forged[off..off + 8].try_into().unwrap()); + assert_eq!(read(base), SOL_VALUE, "price.value"); + assert_eq!(read(base + 8), SOL_EXP, "price.exp"); + assert_eq!(read(base + 16), AT_SLOT, "last_updated_slot"); + assert_eq!(read(base + 24), AT_TS, "unix_timestamp"); + + // price = value / 10^exp + assert_eq!(SOL_VALUE as f64 / 10f64.powi(SOL_EXP as i32), 125.50); + + // The neighbouring entry is populated on a live account, so require it unchanged rather + // than zero. + let next = PRICES_BASE + (SOL_INDEX + 1) * DATED_PRICE_SIZE; + assert_eq!( + &forged[next..next + DATED_PRICE_SIZE], + &data[next..next + DATED_PRICE_SIZE], + "writing one price index must not disturb the next entry" + ); +} + +/// A reward accrues from the gap between the farm accumulator and the user's tally, so both +/// halves must be writable. +#[tokio::test] +async fn farms_reward_override_writes_both_halves() { + let fetched = fetch(&[FARM_STATE, FARMS_USER_STATE]).await; + let (farm_data, user_data) = (&fetched[0], &fetched[1]); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + let farm = registry + .get("kamino-farms-reward-accumulator") + .expect("kamino-farms-reward-accumulator template"); + let farm_overrides: HashMap = HashMap::from([ + ( + "reward_infos.0.reward_per_share_scaled".to_string(), + serde_json::json!(5_000_000u64), + ), + ( + "total_active_stake_scaled".to_string(), + serde_json::json!(1_000_000u64), + ), + ]); + let forged_farm = surfnet_svm + .get_forged_account_data(&pubkey, farm_data, &farm.idl, &farm_overrides) + .expect("farm accumulator override should apply"); + assert_eq!(forged_farm.len(), farm_data.len()); + assert_ne!(&forged_farm, farm_data); + + // UserState offsets incl. discriminator: 80-byte header, then the [u128; 10] tally. + const TALLY_0: usize = 88; + const UNCLAIMED_0: usize = TALLY_0 + 160; + + let user = registry + .get("kamino-farms-user-rewards") + .expect("kamino-farms-user-rewards template"); + let user_overrides: HashMap = HashMap::from([ + ( + "rewards_issued_unclaimed.0".to_string(), + serde_json::json!(777_000u64), + ), + ("rewards_tally_scaled.0".to_string(), serde_json::json!(0u64)), + ("active_stake_scaled".to_string(), serde_json::json!(1_000u64)), + ]); + let forged_user = surfnet_svm + .get_forged_account_data(&pubkey, user_data, &user.idl, &user_overrides) + .expect("user reward override should apply"); + + assert_eq!(forged_user.len(), user_data.len()); + assert_eq!( + u64::from_le_bytes( + forged_user[UNCLAIMED_0..UNCLAIMED_0 + 8] + .try_into() + .unwrap() + ), + 777_000u64, + "rewards_issued_unclaimed[0] should be written at its array index" + ); +} + +/// The two overrides that survive `refresh_obligation`: crash the Scope price, then tighten the +/// deposit reserve's liquidation threshold. +#[tokio::test] +async fn liquidation_setup_writes_durable_inputs() { + const LTV_PCT: usize = 4872; + const LIQ_THRESHOLD_PCT: usize = 4873; + const SCOPE_PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + + let fetched = fetch(&[SCOPE_PRICES, RESERVE]).await; + let (scope_data, reserve_data) = (&fetched[0], &fetched[1]); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + // Crash the Scope price the reserve prices from. + const IDX: usize = 45; + const CRASHED: u64 = 15_000_000; + let scope = registry.get("kamino-scope-price").expect("scope template"); + let scope_overrides: HashMap = HashMap::from([ + ( + format!("prices.{IDX}.price.value"), + serde_json::json!(CRASHED), + ), + (format!("prices.{IDX}.price.exp"), serde_json::json!(8u64)), + ]); + let forged_scope = surfnet_svm + .get_forged_account_data(&pubkey, scope_data, &scope.idl, &scope_overrides) + .expect("scope crash should apply"); + + let off = SCOPE_PRICES_BASE + IDX * DATED_PRICE_SIZE; + assert_eq!( + u64::from_le_bytes(forged_scope[off..off + 8].try_into().unwrap()), + CRASHED, + "crashed price must land at the Scope entry the reserve names" + ); + assert_eq!( + CRASHED as f64 / 10f64.powi(8), + 0.15, + "value/exp must decode to $0.15" + ); + + // Tighten the live reserve's liquidation threshold, leaving its loan-to-value alone. + let reserve = registry + .get("kamino-reserve-config") + .expect("reserve config template"); + let live_ltv = reserve_data[LTV_PCT]; + let reserve_overrides: HashMap = HashMap::from([ + ( + "config.liquidation_threshold_pct".to_string(), + serde_json::json!(50u8), + ), + ( + "config.max_liquidation_bonus_bps".to_string(), + serde_json::json!(1000u16), + ), + ]); + let forged_reserve = surfnet_svm + .get_forged_account_data(&pubkey, reserve_data, &reserve.idl, &reserve_overrides) + .expect("reserve config override should apply"); + + assert_eq!( + forged_reserve[LIQ_THRESHOLD_PCT], 50, + "liquidation threshold must be lowered" + ); + assert_eq!( + forged_reserve[LTV_PCT], live_ltv, + "loan-to-value must be left untouched, so a position above the new 50% liquidation \ + threshold becomes liquidatable" + ); + assert_eq!( + forged_reserve.len(), + reserve_data.len(), + "reserve size must be preserved" + ); +} + +/// A ticket becomes redeemable once the reserve's queue cursor reaches its sequence number. The +/// ticket half is synthetic because no `WithdrawTicket` exists on mainnet yet; the reserve half +/// uses a live account. +#[tokio::test] +async fn withdraw_ticket_and_queue_cursor() { + let reserve_data = fetch(&[RESERVE]).await.remove(0); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + let ticket = registry + .get("kamino-withdraw-ticket") + .expect("withdraw ticket template"); + let ticket_disc = &ticket + .idl + .accounts + .iter() + .find(|a| a.name == "WithdrawTicket") + .expect("WithdrawTicket") + .discriminator; + let mut ticket_data = vec![0u8; 520]; + ticket_data[..8].copy_from_slice(ticket_disc); + + let ticket_overrides: HashMap = HashMap::from([ + ("sequence_number".to_string(), serde_json::json!(7u64)), + ( + "queued_collateral_amount".to_string(), + serde_json::json!(500u64), + ), + ("invalid".to_string(), serde_json::json!(0u8)), + ]); + let forged_ticket = surfnet_svm + .get_forged_account_data(&pubkey, &ticket_data, &ticket.idl, &ticket_overrides) + .expect("withdraw ticket override should apply"); + assert_eq!( + u64::from_le_bytes(forged_ticket[8..16].try_into().unwrap()), + 7, + "ticket sequence number" + ); + + // Advance the live reserve's cursor to 7, making ticket 7 serveable. + let limits = registry + .get("kamino-reserve-limits") + .expect("reserve limits template"); + let queue_overrides: HashMap = HashMap::from([ + ( + "withdraw_queue.queued_collateral_amount".to_string(), + serde_json::json!(500u64), + ), + ( + "withdraw_queue.next_withdrawable_ticket_sequence_number".to_string(), + serde_json::json!(7u64), + ), + ( + "withdraw_queue.next_issued_ticket_sequence_number".to_string(), + serde_json::json!(8u64), + ), + ( + "liquidity.total_available_amount".to_string(), + serde_json::json!(0u64), + ), + ]); + let forged_reserve = surfnet_svm + .get_forged_account_data(&pubkey, &reserve_data, &limits.idl, &queue_overrides) + .expect("withdraw queue override should apply"); + + assert_eq!(forged_reserve.len(), reserve_data.len()); + assert_ne!(forged_reserve, reserve_data); +} diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index cd28512dd..1a4b048be 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,3 +1,5 @@ pub mod helpers; pub mod integration; +#[cfg(feature = "integration-tests")] +pub mod kamino; pub mod plugin;