From 982f031db15daab740c9a528fc7c0363f86e056c Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 13 Aug 2026 11:51:57 +0300 Subject: [PATCH 01/32] 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 02/32] 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 03/32] 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; From d046a79bd5e6d7e7fa7eec91aea910d8241677ba Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 20 Aug 2026 16:09:09 +0300 Subject: [PATCH 04/32] =?UTF-8?q?=E2=8F=BA=20feat:=20add=20BisonFi=20overr?= =?UTF-8?q?ide=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/core/src/scenarios/README.md | 47 +- .../src/scenarios/protocols/bisonfi/README.md | 144 + .../protocols/bisonfi/overrides.yaml | 264 ++ crates/core/src/scenarios/registry.rs | 60 +- crates/core/src/surfnet/svm.rs | 90 +- crates/core/src/tests/bisonfi/mod.rs | 3707 +++++++++++++++++ crates/core/src/tests/kamino/mod.rs | 78 +- crates/core/src/tests/mod.rs | 2 + crates/types/src/scenarios.rs | 417 +- 9 files changed, 4749 insertions(+), 60 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/bisonfi/README.md create mode 100644 crates/core/src/scenarios/protocols/bisonfi/overrides.yaml create mode 100644 crates/core/src/tests/bisonfi/mod.rs diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index f468626ed..5d8176be6 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -18,6 +18,7 @@ Protocols that are natively supported by Surfpool will have their IDLs included - **Switchboard On-Demand** - On-demand oracle with QuoteAccount override template - **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 +- **BisonFi v3** – Proprietary market maker (no published IDL, not Anchor), with price, depth, spread and freshness templates. See [protocols/bisonfi/README.md](./protocols/bisonfi/README.md) For custom protocols, an IDL can be registered at runtime using the [`surfnet_registerIdl`](https://docs.surfpool.run/rpc/cheatcodes#surfnet-registeridl) RPC cheatcode. @@ -42,18 +43,58 @@ 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 +### On-chain 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: +Byte-level coverage that forks real mainnet state lives in two modules, +`crates/core/src/tests/kamino/` and `crates/core/src/tests/bisonfi/`. Both fetch real accounts, so +they need a network connection and are compiled only behind a feature: ``` +# both suites +cargo test -p surfpool-core --features integration-tests + +# one at a time cargo test -p surfpool-core --features integration-tests kamino +cargo test -p surfpool-core --features integration-tests bisonfi ``` +Note the per-suite filters are substring matches on the full test path, so `kamino` covers only the +Kamino module. It used to sweep up the BisonFi tests as well, back when they lived inside +`tests/kamino/` and were named `tests::kamino::bisonfi_*` - if you are following an older note that +says the `kamino` filter is enough, it no longer is. + Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint instead of the public one. The default test run needs no network. +### Programs with no IDL + +Some programs publish no IDL and are not Anchor at all, so there is no discriminator to resolve an +account type. Those ship a byte layout in their `overrides.yaml` instead: + +```yaml +raw_layout: + account_size: 2048 + magic: { offset: 0, bytes: [80, 79, 79, 76, 83, 84, 65, 84] } # optional + +templates: + - id: bisonfi-fair-value + properties: + - path: fair_value + offset: 832 + encoding: u128 # u8/u16/u32/u64/u128/i64/i128/bytes32/slot +``` + +When a template carries a `raw_layout` the engine writes bytes at each property's offset instead of +decoding through the IDL. `account_size` and `magic` replace the discriminator as the check that +this is the right account - without them a raw write would silently corrupt an unrelated one. + +Make that guard as narrow as the layout actually is. Size and a magic prefix are often not enough: +BisonFi has eighteen accounts that are all 2048 bytes with the same `POOLSTAT` prefix, but one of +them is an older layout version, so the magic is extended to cover the version word that follows it. +Any field the program itself validates before trusting the account is a candidate for the guard. +Values are written little-endian and integer-exact; anything above `u64::MAX` must be passed as a +decimal string, since a JSON number that large has already lost digits. + ### 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/protocols/bisonfi/README.md b/crates/core/src/scenarios/protocols/bisonfi/README.md new file mode 100644 index 000000000..14ad284f7 --- /dev/null +++ b/crates/core/src/scenarios/protocols/bisonfi/README.md @@ -0,0 +1,144 @@ +# BisonFi + +A proprietary market maker (PMM), not an AMM. Four templates: price, depth, spread and freshness. + +Because it is a market maker rather than a curve, it can be put into states no constant-product pool +can reach - quoting wide with deep inventory, or refusing to quote at all. Those are the scenarios +worth reaching for this protocol to test. + +# Template index + +| Template | Overrides | +|---|---| +| `bisonfi-fair-value` | the mid price BisonFi quotes around | +| `bisonfi-depth` | how far a trade moves BisonFi's price | +| `bisonfi-spread` | the spread BisonFi quotes around its mid | +| `bisonfi-freshness` | whether BisonFi's quote is live | + + +## Number formats + +| You'll see | It means | Example | +|---|---|---| +| `fair_value` | price x 2^88, as a decimal **string** | $50 -> `"15474250491067253436239052800"` | +| `tick_offset` | 1/2,560,000 of the mid | `25600` = 1%, `2560` = 10 bps, `256` = 100 ppm | +| reserves | the mint's smallest unit | 1 USDC -> `1000000` | +| `last_update_slot` | an absolute slot number | | + +`fair_value` exceeds what a JSON number holds exactly, so it must be quoted. To convert a spread: +`ticks = percent * 25600`. + +## Picking a market + +The templates default to the live WSOL/USDC market `8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo`. +Other markets are found by reading `base_mint` and `quote_mint` on the accounts the program owns. + +Only version-3 pool accounts are supported and the guard rejects the one remaining version-2 account +rather than write a price into the wrong field. + +# Recipes + +## Set a price + +``` +template: bisonfi-fair-value +fair_value: "15474250491067253436239052800" # $50 x 2^88, as a STRING +``` + +Set `fetchBeforeUse: true` so the live pool is forked first. + +## Make large trades slip + +``` +template: bisonfi-depth +quote_reserve: # makes SELLING the base asset expensive +base_reserve: # makes BUYING it expensive +``` + +The side the pool pays *out* of is the side that constrains the trade. Set both if the scenario does +not fix a direction. + +**Reach for an order of magnitude.** The response is not linear - a trade worth a couple of percent of +a reserve barely notices that reserve being quartered. + +**Lower, never raise.** These fields mirror the balances of the vaults, which this template does not +touch. Lowering is safe. Raising one above the vault's real balance makes the program compute a payout +the vault cannot cover, and the swap fails when it settles. + +## Make a market unable to fill + +The same template, taken further - around `quote_reserve / 10` the swap stops slipping and starts +failing outright with an insufficient-liquidity error. Useful for testing how a router handles a venue +that cannot fill at all. + +## Keep the venue quoting + +**A forked pool goes stale by itself after two slots** - nothing in a fork republishes the mid, and +once stale the price, depth and spread templates are silently ignored. Refresh the timestamp to keep +the venue alive for as long as your scenario needs. + +``` +template: bisonfi-freshness +last_update_slot: +persist: true +``` + +Refreshing resumes the price the venue already held - no new price is needed. Without `persist` the +next slot's state overwrites yours. + +If your scenario executes within a slot of forking you do not need this. If it spends longer than +that on setup, you do. + +## Quote a wide spread + +``` +template: bisonfi-spread +working_levels.0.tick_offset: -25600 # 1% below mid +configured_levels.0.tick_offset: -25600 +continuation_levels.0.tick_offset: -25600 +continuation_source_levels.0.tick_offset: -25600 +``` + +**Set all four properties of a side, or all eight.** The `.0.` paths are the bid side, the `.4.` and +`.5.` paths the ask side. Setting only some of them produces a spread that varies with timing. + +**Signs matter.** Bid offsets are negative and price SELLS of the base token. Ask offsets are positive +and price BUYS. + +Do not use `0` to mean "no offset" - use a small magnitude instead. + +## Reprice or widen mid-flight + +Schedule two steps on the same field a couple of slots apart: the caller prices on one number and +executes against another. Works with `bisonfi-fair-value` (the mid moves) or `bisonfi-spread` (the +maker widens). + +Both **revert**, caught by the caller's own minimum-output bound - the opposite symptom to a dark +maker, which succeeds with zero. Testing the pair is more informative than either alone: one failure +is detectable by a consumer and one is not. + +## Arbitrage against an AMM + +Move `fair_value` away from an AMM's price on the same pair and the two venues disagree by a real, +executable margin - both legs fit in one transaction. Two things to get right: + +- **Use an exact-output swap on the AMM leg.** Instruction amounts are fixed when the transaction is + built, so a leg that buys "whatever N USDC gets" cannot be followed by one that sells exactly that. + Ask the AMM for a known quantity and pay whatever it costs. +- **Expect the undislocated round trip to lose money** - the taker pays a fee on both venues. The + dislocation has to clear that before any profit appears, and a control run showing a profit at the + true mid means you are measuring something other than a round trip. + +# Troubleshooting + +| Symptom | Fix | +|---|---| +| A price, depth or spread override had no effect and nothing errored | The quote is stale, and the freshness gate runs first. Refresh `last_update_slot` - see "Keep the venue quoting" | +| The pool quotes nothing at any size | Probably one of the dormant markets. Check how far `last_update_slot` is behind the chain | +| A spread override does nothing | You set some of a side's four properties but not all, or the trade is too small - very small trades do not consult the ladder. Try a percent or so of `base_reserve`, and try a few sizes | +| A stale market returns 0 instead of reverting | Not a bug: a stale venue returns zero and the transaction SUCCEEDS, and the swap's minimum-output bound is not enforced on that path | +| The override reverts after the next slot | Add `persist: true` | +| The guard rejects the account | Only version-3 pools are supported | +| `Custom(60)` | A Token-2022 mint whose token accounts need matching extension data. Two live markets quote such an asset | +| A swap in a simulated slot returns 0 for no reason | The `LastRestartSlot` sysvar must be at least `246464040`, and the default 200k compute budget cannot finish a large trade - ask for ~1.4M | +| A freshness override does not seem to age the pool | If your harness derives its clock from the pool's own `last_update_slot`, aging the account moves the clock with it. Apply the override after the clock is taken | \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml b/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml new file mode 100644 index 000000000..c05c8560e --- /dev/null +++ b/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml @@ -0,0 +1,264 @@ +protocol: bisonfi +# The pool-account version this layout describes, and the only one supported. BisonFi ships no +# program semver; the guard below rejects the one remaining version-2 account outright. +version: v3 +account_type: PoolStat + +# BisonFi publishes no IDL and none is reconstructed here. Writes go through the byte layout below. +raw_layout: + account_size: 2048 + magic: + offset: 0 + # "POOLSTAT" followed by the u64 version, which must be 3. + # + # The version is part of the guard on purpose: size and magic alone admit an older account whose + # fields sit elsewhere, and a scenario naming it would write a price into an unrelated field. + bytes: [80, 79, 79, 76, 83, 84, 65, 84, 3, 0, 0, 0, 0, 0, 0, 0] + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: bisonfi-fair-value + name: Override BisonFi Fair Value + description: Override the mid price BisonFi quotes around + idl_account_name: PoolStat + address: + type: pubkey + value: 8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo + properties: + - path: fair_value + offset: 832 + encoding: u128 + label: Fair value + description: >- + Mid price as a fixed-point integer scaled by 2^88. Pass it as a decimal string. + Example: $75.45 is "23350643991020486314894032896" + llm_context: | + PRECONDITION - THE QUOTE MUST BE FRESH. If the venue's quote is two or more slots stale this + override is silently ignored: the swap returns zero and the transaction still SUCCEEDS, with + your value sitting correctly in the account. A forked pool goes stale on its own after two + slots, because nothing in a fork republishes the mid. If your scenario spends more than a slot + before executing, refresh last_update_slot with bisonfi-freshness first. + + This is the only price lever. The reserves control depth, not price: BisonFi quotes around a mid + the operator publishes, so changing vault balances will not move the quote. + + HOW TO USE: + 1. Multiply the price by 2^88 (309485009821345068724781056) + 2. Pass the result as a decimal STRING, since it exceeds what a JSON number holds exactly + 3. Set fetchBeforeUse: true so the live pool is forked first + + The default address is the live WSOL/USDC market. Other markets are found by reading base_mint + and quote_mint on the accounts the program owns. + + MID-FLIGHT VARIANT: schedule two steps on this field a couple of slots apart, and a caller + prices on one mid and executes against another. A reprice is caught by the caller's own + minimum-output bound, so the transaction reverts rather than filling at the worse price. + + CROSS-VENUE ARBITRAGE: moving this away from an AMM's price on the same pair creates an + executable arbitrage, with both legs in one transaction. Two practical notes: use an + exact-OUTPUT swap on the AMM leg, because instruction amounts are fixed when the transaction is + built and the second leg needs a known size; and expect the undislocated round trip to LOSE + money, since the taker pays a fee on both venues. A control run showing a profit at the true mid + is measuring something other than a round trip. + + EXAMPLE - "SOL is worth $50": + fair_value: "15474250491067253436239052800" + - id: bisonfi-depth + name: Override BisonFi Depth + description: Make BisonFi shallower so large trades move its price + idl_account_name: PoolStat + address: + type: pubkey + value: 8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo + properties: + - path: base_reserve + offset: 48 + encoding: u64 + label: Base reserve + description: >- + Base tokens the pool can pay out, in the mint's smallest unit. Constrains a BUY of the base + token. Example: 204479526927 + - path: quote_reserve + offset: 56 + encoding: u64 + label: Quote reserve + description: >- + Quote tokens the pool can pay out, in the mint's smallest unit. Constrains a SELL of the base + token. Example: 22008930770 + llm_context: | + PRECONDITION - THE QUOTE MUST BE FRESH. If the venue's quote is two or more slots stale this + override is silently ignored: the swap returns zero and the transaction still SUCCEEDS, with + your value sitting correctly in the account. A forked pool goes stale on its own after two + slots, because nothing in a fork republishes the mid. If your scenario spends more than a slot + before executing, refresh last_update_slot with bisonfi-freshness first. + + The depth lever: how far a trade moves the price. For how wide the venue quotes around its mid, + use bisonfi-spread instead. + + WHICH FIELD TO SET: the pool pays out of one side, and that side's reserve constrains the trade. + To make SELLING the base token expensive, lower quote_reserve. To make BUYING it expensive, + lower base_reserve. Set both if the scenario does not fix a direction. + + REACH FOR AN ORDER OF MAGNITUDE. The response is not linear: a trade worth a couple of percent + of a reserve barely notices that reserve being quartered. A large reduction produces slippage; a + very large one produces an outright "insufficient liquidity" refusal, which is useful in itself + for testing how a router handles a venue that cannot fill. + + LOWER, DO NOT RAISE. These fields mirror the balances of the token accounts named by base_vault + and quote_vault, which this template does not touch. Lowering is safe: the pool quotes and pays + out less than it really holds. Raising one above the vault's real balance makes the program + compute a payout the vault cannot cover, and the swap fails when it settles. + + The two directions are largely but not perfectly independent. Treat them as independent for + slippage-scale testing; do not assert that one is untouched to the byte. + + EXAMPLE - "BisonFi is thin, selling SOL into it slips badly": + quote_reserve: 1100446538524 + - id: bisonfi-spread + name: Override BisonFi Spread + description: Widen or tighten the spread BisonFi quotes around its mid + idl_account_name: PoolStat + address: + type: pubkey + value: 8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo + properties: + - path: working_levels.0.tick_offset + offset: 300 + encoding: + i32_strided: + count: 4 + stride: 16 + label: Bid spread, working book + description: >- + Sell-side offset, outer levels excluded. Units of 1/2,560,000 of the mid, NEGATIVE. -25600 is 1% below mid. + - path: working_levels.4.tick_offset + offset: 364 + encoding: + i32_strided: + count: 4 + stride: 16 + label: Ask spread, working book + description: >- + Buy-side offset, outer levels excluded. POSITIVE. 25600 is 1% above mid. + - path: configured_levels.0.tick_offset + offset: 540 + encoding: + i32_strided: + count: 4 + stride: 16 + label: Bid spread, source book + description: >- + Companion bid run. Set it to the same value as the working bid spread. + - path: configured_levels.4.tick_offset + offset: 604 + encoding: + i32_strided: + count: 4 + stride: 16 + label: Ask spread, source book + description: >- + Companion ask run. Set it to the same value as the working ask spread. + - path: continuation_levels.0.tick_offset + offset: 1048 + encoding: + i32_strided: + count: 5 + stride: 16 + label: Bid spread, working outer book + description: >- + Sell-side offset for the outer levels. NEGATIVE. + - path: continuation_levels.5.tick_offset + offset: 1128 + encoding: + i32_strided: + count: 5 + stride: 16 + label: Ask spread, working outer book + description: >- + Buy-side offset for the outer levels. POSITIVE. + - path: continuation_source_levels.0.tick_offset + offset: 1208 + encoding: + i32_strided: + count: 5 + stride: 16 + label: Bid spread, source outer book + description: >- + Companion outer bid run. Same value as the working outer bid spread. + - path: continuation_source_levels.5.tick_offset + offset: 1288 + encoding: + i32_strided: + count: 5 + stride: 16 + label: Ask spread, source outer book + description: >- + Companion outer ask run. Same value as the working outer ask spread. + llm_context: | + PRECONDITION - THE QUOTE MUST BE FRESH. If the venue's quote is two or more slots stale this + override is silently ignored: the swap returns zero and the transaction still SUCCEEDS, with + your value sitting correctly in the account. A forked pool goes stale on its own after two + slots, because nothing in a fork republishes the mid. If your scenario spends more than a slot + before executing, refresh last_update_slot with bisonfi-freshness first. + + The spread lever: how wide the venue quotes around its mid, independently of how much inventory + it holds. Use bisonfi-depth to make a venue THIN and this one to make it EXPENSIVE. + + THE UNIT IS 1/2,560,000 OF THE MID. So 25600 is 1%, 2560 is 10 bps and 256 is 100 ppm. To + convert a target spread: ticks = percent * 25600. + + SET ALL FOUR BID PROPERTIES TO THE SAME VALUE, or all four ask properties, or all eight. Each + property writes one run of the book and they have to agree; setting only some of them produces a + spread that varies with timing. + + SIGNS MATTER. Bid offsets are negative and price SELLS of the base token. Ask offsets are + positive and price BUYS. + + DO NOT SET A TICK TO ZERO to mean "no offset" - use a small magnitude instead. + + TRADE SIZE MATTERS. Very small trades do not consult the ladder at all, and very large ones stop + paying the full spread. Size a test trade at a percent or so of the pool's base_reserve, and try + a few sizes before concluding the lever did nothing. + + EXAMPLE - "BisonFi is quoting 1% wide on the sell side": + working_levels.0.tick_offset: -25600 + configured_levels.0.tick_offset: -25600 + continuation_levels.0.tick_offset: -25600 + continuation_source_levels.0.tick_offset: -25600 + - id: bisonfi-freshness + name: Override BisonFi Quote Freshness + description: Keep BisonFi's published quote live + idl_account_name: PoolStat + address: + type: pubkey + value: 8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo + properties: + - path: last_update_slot + offset: 72 + encoding: u64 + label: Last update slot + description: "Slot of the most recent mid publication. Example: 439868588" + llm_context: | + This field is the venue's liveness signal. + + A FORKED POOL GOES STALE BY ITSELF. Surfpool takes its starting slot from mainnet and never + re-fetches an account it has already pulled, so nothing republishes the mid. Two slots after the + fork the venue stops quoting and stays that way, and from then on the price, depth and spread + overrides are silently ignored - the swap returns zero and the transaction still SUCCEEDS. That + makes this template the precondition for the other three: if your scenario spends more than a + slot before executing, refresh this field first. + + HOW TO USE THIS TEMPLATE: + 1. Set last_update_slot to the current slot. The venue resumes quoting the price it already + held - a fresh timestamp is enough, no new price is needed + 2. Set persist: true, or the next slot's state overwrites your value + + A scenario that executes within a slot of forking does not need this. One that spends longer on + setup does. + + EXAMPLE - "keep the maker quoting at chain slot 439868588": + last_update_slot: 439868588 diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 5650564d9..a891a5f38 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -23,6 +23,7 @@ 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 BISONFI_OVERRIDES_CONTENT: &str = include_str!("./protocols/bisonfi/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"); @@ -69,6 +70,7 @@ impl TemplateRegistry { default.load_raydium_overrides(); default.load_meteora_overrides(); default.load_kamino_overrides(); + default.load_bisonfi_overrides(); default.load_drift_overrides(); default.load_whirlpool_overrides(); default.load_spl_token_overrides(); @@ -108,6 +110,10 @@ impl TemplateRegistry { ); } + pub fn load_bisonfi_overrides(&mut self) { + self.load_protocol_overrides_without_idl(BISONFI_OVERRIDES_CONTENT, "bisonfi"); + } + pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); @@ -172,7 +178,25 @@ impl TemplateRegistry { Ok(idl) => idl, Err(e) => panic!("unable to load {} idl: {}", protocol_name, e), }; + self.load_collection(Some(idl), overrides_content, protocol_name); + } + + /// For programs that publish no IDL. Their templates must carry a `raw_layout` and spell out + /// every property description, since there is no schema to fall back on. + fn load_protocol_overrides_without_idl( + &mut self, + overrides_content: &str, + protocol_name: &str, + ) { + self.load_collection(None, overrides_content, protocol_name); + } + fn load_collection( + &mut self, + idl: Option, + overrides_content: &str, + protocol_name: &str, + ) { let collection = match serde_yaml::from_str::(overrides_content) { Ok(c) => c, @@ -234,7 +258,7 @@ impl TemplateRegistry { #[cfg(test)] mod tests { use anchor_lang_idl::types::IdlType; - use std::{collections::HashMap, collections::BTreeSet, str::FromStr}; + use std::{collections::BTreeSet, collections::HashMap, str::FromStr}; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, PdaSeed}; @@ -381,11 +405,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(Lend 17, Scope 3, Farms 5, Swap 2, Vault 5, Liquidity 4) + Whirlpool(6) + SPL Token (2) = 57 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) + BisonFi (4) = 61 total assert_eq!( registry.count(), - 57, - "Registry should load 57 templates total" + 61, + "Registry should load 61 templates total" ); assert!(registry.contains("pyth-price-feed-v2")); @@ -601,7 +625,7 @@ mod tests { let registry = TemplateRegistry::new(); let jupiter_template = registry.get("jupiter-token-ledger-override").unwrap(); let has_token_ledger = jupiter_template - .idl + .idl() .accounts .iter() .any(|acc| acc.name == "TokenLedger"); @@ -1040,18 +1064,24 @@ mod tests { let registry = TemplateRegistry::new(); let mut errors = Vec::new(); + let mut checked = 0usize; for template in registry.all() { + // Templates for programs that publish no IDL declare their own byte offsets, so there + // is no schema for their paths to resolve against. Their offsets are covered instead by + // the per-property write tests in `tests/kamino`. + let Some(idl) = template.idl.as_ref() else { + continue; + }; 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, - ) { + checked += 1; + if let Err(e) = + surfpool_types::resolve_idl_type(idl, &template.account_type, &property.path) + { errors.push(format!("[{}] {}: {}", template.id, property.path, e)); } } @@ -1063,6 +1093,12 @@ mod tests { errors.len(), errors.join("\n ") ); + // Without this the skip above could silently swallow every template and the test would pass + // having resolved nothing. + assert!( + checked > 0, + "no property was resolved against an IDL, so this proved nothing" + ); } #[test] @@ -1222,7 +1258,7 @@ mod tests { ("ref_price.0", IdlType::U16), ] { let resolved = - surfpool_types::resolve_idl_type(&template.idl, &template.account_type, path) + surfpool_types::resolve_idl_type(template.idl(), &template.account_type, path) .unwrap_or_else(|e| panic!("{path} should resolve: {e}")); assert_eq!( *resolved, expected, @@ -1235,7 +1271,7 @@ mod tests { .get("kamino-obligation-positions") .expect("kamino-obligation-positions should exist"); let resolved = surfpool_types::resolve_idl_type( - &obligation.idl, + obligation.idl(), &obligation.account_type, "deposits.0.deposit_reserve", ) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index d63818a17..2991228a3 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -230,6 +230,13 @@ fn json_integer_digits(json: &serde_json::Value, target: &str) -> SurfpoolResult } } +/// The bundled template registry, parsed once and reused. +fn template_registry() -> &'static crate::scenarios::TemplateRegistry { + static REGISTRY: std::sync::OnceLock = + std::sync::OnceLock::new(); + REGISTRY.get_or_init(crate::scenarios::TemplateRegistry::new) +} + /// Converts JSON into a txtx [`Value`] using the expected IDL type fn json_to_txtx_value_for_idl_type( json: &serde_json::Value, @@ -253,16 +260,16 @@ fn json_to_txtx_value_for_idl_type( } (IdlType::U128, _) => { let digits = json_integer_digits(json, "u128")?; - let value = digits.parse::().map_err(|e| { - SurfpoolError::internal(format!("Invalid u128 '{digits}': {e}")) - })?; + 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}")) - })?; + 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)) @@ -754,7 +761,11 @@ impl SurfnetSvm { fn register_builtin_template_idls(&mut self) { let registry = TemplateRegistry::new(); for (_, template) in registry.templates.into_iter() { - let _ = self.register_idl(template.idl, None); + // Templates for programs with no IDL have nothing to register; they write through + // `raw_layout` instead. + if let Some(idl) = template.idl { + let _ = self.register_idl(idl, None); + } } } @@ -2881,6 +2892,55 @@ impl SurfnetSvm { continue; }; + // Programs with no usable IDL carry a byte layout instead, and this MUST come + // before the IDL lookup below: those programs have no registered IDL at all, so the + // lookup would `continue` and silently drop the override. + let raw_template = template_registry() + .get(&override_instance.template_id) + .filter(|t| t.raw_layout.is_some()) + .cloned(); + if let Some(template) = raw_template { + let raw_layout = template.raw_layout.expect("filtered above"); + let properties = template.properties; + match raw_layout.materialize( + account.data(), + &properties, + &account_values, + target_slot, + ) { + Ok(new_data) => { + let modified = Account { + lamports: account.lamports(), + data: new_data, + owner: *account.owner(), + executable: account.executable(), + rent_epoch: account.rent_epoch(), + }; + if let Err(e) = self.inner.set_account(account_pubkey, modified) { + warn!("Failed to set raw-layout account {}: {}", account_pubkey, e); + } else { + debug!( + "Raw-layout override {} applied {} field(s) to {}", + override_instance.id, + account_values.len(), + account_pubkey + ); + settled_this_slot.insert(account_pubkey); + 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); + } + } + } + Err(e) => warn!( + "Raw-layout override {} failed on {}: {}", + override_instance.id, account_pubkey, e + ), + } + continue; + } + // Get the account owner (program ID) let owner_program_id = account.owner(); @@ -5275,10 +5335,18 @@ mod tests { assert!(!epoch_schedule.warmup); let registry = TemplateRegistry::new(); + let mut checked = 0usize; for (_, template) in registry.templates { - let program_id = template.idl.address.clone(); + // Templates for programs that publish no IDL have nothing to register. + let Some(idl) = template.idl else { continue }; + let program_id = idl.address.clone(); assert!(svm.registered_idls.get(&program_id).unwrap().is_some()); + checked += 1; } + assert!( + checked > 0, + "no template carried an IDL, so this proved nothing about registration" + ); assert!(svm.skip_blockhash_check); } @@ -7221,7 +7289,11 @@ mod tests { 1_234, "the first override must survive the second override's fetch" ); - assert_eq!(read(ALLOWED_OFFSET), 5_678, "the second override must apply"); + assert_eq!( + read(ALLOWED_OFFSET), + 5_678, + "the second override must apply" + ); } #[tokio::test] diff --git a/crates/core/src/tests/bisonfi/mod.rs b/crates/core/src/tests/bisonfi/mod.rs new file mode 100644 index 000000000..3ac1d753f --- /dev/null +++ b/crates/core/src/tests/bisonfi/mod.rs @@ -0,0 +1,3707 @@ +//! On-chain tests for BisonFi, and for the Orca Whirlpool leg its arbitrage scenario trades +//! against. +//! +//! The account-fetch and byte-diff helpers below are deliberately DUPLICATED from the Kamino suite +//! rather than shared. These suites fork live mainnet state and are the most likely place to need a +//! one-off change to retry behaviour or account synthesis; a shared helper would couple two +//! unrelated protocols' tests together and make such a change risky for both. + +//! +//! Like the Kamino suite these fetch real mainnet accounts 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 bisonfi +//! ``` +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. +//! +//! What these cover that a unit test cannot: BisonFi publishes no IDL, so there is no schema to +//! check a synthetic account against. The only way to know an offset is right is to run the real +//! deployed program over real account state and watch the fill change. + +use std::collections::HashMap; + +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; + +use crate::{ + scenarios::TemplateRegistry, + surfnet::{GetAccountResult, remote::SurfnetRemoteClient, svm::SurfnetSvm}, +}; + + +// ---------------------------------------------------------------- fetch/diff helpers + +const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; + +const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +/// 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(); + + // The public endpoint throttles and intermittently 503s, which has nothing to do with what these + // tests assert. Retry a few times with backoff so a transient refusal is not read as a failure. + let mut attempt = 0; + let results = loop { + match client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + { + Ok(r) => break r, + Err(e) => { + attempt += 1; + if attempt >= 5 { + panic!( + "failed to fetch {addresses:?} from mainnet after {attempt} attempts: {e}" + ); + } + tokio::time::sleep(std::time::Duration::from_millis(750 * attempt)).await; + } + } + }; + results + .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() +} + +/// Like [`fetch`] but reports absence instead of panicking. +/// +/// Needed for PDAs that are only created lazily. A Whirlpool tick array, for instance, does not exist +/// until someone provides liquidity in that range, so "missing" is a real answer about the market +/// rather than a stale address in the test. +async fn fetch_optional(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(); + let results = client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + .expect("get_multiple_accounts"); + results + .into_iter() + .map(|r| match r { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundProgramAccount((_, account), _) + | GetAccountResult::FoundTokenAccount((_, account), _) => Some(account.data), + GetAccountResult::None(_) => None, + }) + .collect() +} + +/// Like [`fetch`] but keeps each account's owner instead of its data. +/// +/// Needed to tell a classic SPL mint from a Token-2022 one. Two of BisonFi's live markets quote a +/// Token-2022 base asset and refuse a swap with `Custom(60)` if handed classic token accounts, so a +/// replay harness that assumes one token program silently cannot exercise them. +async fn fetch_owners(addresses: &[Pubkey]) -> Vec { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + let mut attempt = 0; + let results = loop { + match client + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + { + Ok(r) => break r, + Err(e) => { + attempt += 1; + if attempt >= 5 { + panic!("failed to fetch owners after {attempt} attempts: {e}"); + } + tokio::time::sleep(std::time::Duration::from_millis(750 * attempt)).await; + } + } + }; + results + .into_iter() + .zip(addresses) + .map(|(result, address)| match result { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundProgramAccount((_, account), _) + | GetAccountResult::FoundTokenAccount((_, account), _) => account.owner, + GetAccountResult::None(_) => panic!("{address} no longer exists on mainnet"), + }) + .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() +} + +/// Minimal initialised SPL token account (the 165-byte legacy layout). +fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut d = vec![0u8; 165]; + d[0..32].copy_from_slice(mint.as_ref()); + d[32..64].copy_from_slice(owner.as_ref()); + d[64..72].copy_from_slice(&amount.to_le_bytes()); + d[108] = 1; // AccountState::Initialized + d +} + +fn spl_amount(data: &[u8]) -> u64 { + u64::from_le_bytes(data[64..72].try_into().unwrap()) +} + + +/// Like [`fetch`] but keeps each account's lamports. A wrapped-SOL vault's lamports are part of its +/// state, so overwriting them with a placeholder makes the runtime reject the transaction as +/// unbalanced on any path that pays out the base token. +async fn fetch_with_lamports(addresses: &[&str]) -> Vec<(Vec, u64)> { + 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(); + let mut attempt = 0; + let results = loop { + match client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + { + Ok(r) => break r, + Err(e) => { + attempt += 1; + if attempt >= 5 { + panic!("failed to fetch {addresses:?} after {attempt} attempts: {e}"); + } + tokio::time::sleep(std::time::Duration::from_millis(750 * attempt)).await; + } + } + }; + results + .into_iter() + .zip(addresses) + .map(|(result, address)| match result { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundProgramAccount((_, account), _) + | GetAccountResult::FoundTokenAccount((_, account), _) => { + (account.data, account.lamports) + } + GetAccountResult::None(_) => panic!("{address} no longer exists on mainnet"), + }) + .collect() +} + +const USDC_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + +// ------------------------------------------------------------------ BisonFi / Orca + +/// The token program owning each pool's base and quote mint, resolved for every pool in one request. +async fn bisonfi_token_programs(pools: &[Vec]) -> Vec<(Pubkey, Pubkey)> { + let mut mints: Vec = Vec::new(); + for data in pools { + for range in [184..216, 216..248] { + let m = Pubkey::new_from_array(data[range].try_into().unwrap()); + if !mints.contains(&m) { + mints.push(m); + } + } + } + let owners = fetch_owners(&mints).await; + let map: HashMap = mints.into_iter().zip(owners).collect(); + pools + .iter() + .map(|data| { + let base = Pubkey::new_from_array(data[184..216].try_into().unwrap()); + let quote = Pubkey::new_from_array(data[216..248].try_into().unwrap()); + (map[&base], map["e]) + }) + .collect() +} + +const BISONFI_POOL: &str = "8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo"; + +/// The reconstructed layout must describe every one of the 2048 bytes, or the re-encode silently +/// truncates or reorders the account. +#[tokio::test] +async fn bisonfi_pool_round_trips_unchanged() { + let data = fetch(&[BISONFI_POOL]).await.remove(0); + assert_eq!(data.len(), 2048, "BisonFi pool accounts are 2048 bytes"); + assert_eq!(&data[..8], b"POOLSTAT", "magic prefix"); + + let registry = TemplateRegistry::new(); + let template = registry + .get("bisonfi-fair-value") + .expect("bisonfi-fair-value template"); + let raw_layout = template + .raw_layout + .as_ref() + .expect("bisonfi templates carry a raw layout"); + + let forged = raw_layout + .materialize(&data, &template.properties, &HashMap::new(), 0) + .expect("live BisonFi pool should round-trip through the byte layout"); + + assert_eq!(forged.len(), data.len(), "size changed on round-trip"); + let diffs = diff_indices(&forged, &data); + assert!( + diffs.is_empty(), + "the reconstructed layout altered {} byte(s) on a no-op round-trip, first at {:?} - the \ + program was likely redeployed with a changed layout", + diffs.len(), + diffs.first() + ); +} + +/// The published mid is the only price lever, and it is a u128 far beyond `u64::MAX`, so it can +/// only be written as a decimal string. +#[tokio::test] +async fn bisonfi_fair_value_override_writes_expected_bytes() { + const FAIR_VALUE: usize = 832; + + let data = fetch(&[BISONFI_POOL]).await.remove(0); + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-fair-value").unwrap(); + let raw_layout = template + .raw_layout + .as_ref() + .expect("bisonfi templates carry a raw layout"); + + // $50.00 scaled by 2^88 + let target: u128 = 50u128 * (1u128 << 88); + let forged = raw_layout + .materialize( + &data, + &template.properties, + &HashMap::from([( + "fair_value".to_string(), + serde_json::json!(target.to_string()), + )]), + 0, + ) + .expect("fair value override should apply"); + + assert_eq!( + u128::from_le_bytes(forged[FAIR_VALUE..FAIR_VALUE + 16].try_into().unwrap()), + target, + "the published mid must land at offset 832 as a 2^88 fixed point" + ); + let diffs = diff_indices(&forged, &data); + assert!( + diffs + .iter() + .all(|i| (FAIR_VALUE..FAIR_VALUE + 16).contains(i)), + "only the fair value should change, got {diffs:?}" + ); +} + +/// The size and magic guard is all that stands in for a discriminator, so it has to actually bite. +#[tokio::test] +async fn bisonfi_raw_layout_refuses_the_wrong_account() { + let data = fetch(&[BISONFI_POOL]).await.remove(0); + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-fair-value").expect("template"); + let raw_layout = template.raw_layout.as_ref().expect("raw layout"); + + assert!(raw_layout.guard(&data).is_ok(), "the real pool must pass"); + + let mut wrong_magic = data.clone(); + wrong_magic[0] = b'X'; + let err = raw_layout + .guard(&wrong_magic) + .expect_err("a changed magic must be refused"); + assert!(err.contains("magic"), "unexpected error: {err}"); + + let err = raw_layout + .guard(&data[..2047]) + .expect_err("a differently sized account must be refused"); + assert!(err.contains("bytes"), "unexpected error: {err}"); +} + +/// last_update_slot is what makes the staleness scenario possible, so pin that it really is the +/// chain slot on a live market and that ageing it is a one-field write. +#[tokio::test] +async fn bisonfi_freshness_tracks_the_chain_slot() { + const LAST_UPDATE: usize = 72; + const PREVIOUS_UPDATE: usize = 80; + + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + let slot = client + .get_epoch_info() + .await + .expect("epoch info") + .absolute_slot; + let data = fetch(&[BISONFI_POOL]).await.remove(0); + + let last = u64::from_le_bytes(data[LAST_UPDATE..LAST_UPDATE + 8].try_into().unwrap()); + let prev = u64::from_le_bytes( + data[PREVIOUS_UPDATE..PREVIOUS_UPDATE + 8] + .try_into() + .unwrap(), + ); + assert!( + slot.saturating_sub(last) < 200, + "a live market should have been updated within the last ~200 slots; chain {slot}, \ + last_update {last}. If this market went dormant, pick another." + ); + // Not strict. The operator republishes about ten times a second against ~400ms slots, so two + // publications landing in one slot is normal and leaves these two fields EQUAL. Requiring prev to + // be strictly behind made this test fail intermittently on nothing more than a busy market; the + // property actually worth asserting is that previous never LEADS last. + assert!( + prev <= last, + "previous_update_slot ({prev}) must never lead last_update_slot ({last})" + ); + + let registry = TemplateRegistry::new(); + let template = registry + .get("bisonfi-freshness") + .expect("freshness template"); + let raw_layout = template.raw_layout.as_ref().expect("raw layout"); + + let aged = last - 1000; + let forged = raw_layout + .materialize( + &data, + &template.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::json!(aged))]), + 0, + ) + .expect("ageing the quote should apply"); + assert_eq!( + u64::from_le_bytes(forged[LAST_UPDATE..LAST_UPDATE + 8].try_into().unwrap()), + aged + ); + let diffs = diff_indices(&forged, &data); + assert!(!diffs.is_empty(), "the slot should have changed"); + assert!( + diffs + .iter() + .all(|i| (LAST_UPDATE..LAST_UPDATE + 8).contains(i)), + "only bytes within last_update_slot should change, got {diffs:?}" + ); +} + +/// Every account the program owns, live and dormant, as of program build 3f38e742. The templates +/// default to one market but nothing stops a scenario naming another, so the guard and the write +/// have to behave identically on all of them. +const BISONFI_ALL_POOLS: [&str; 17] = [ + "2vPjbPRnz7V1SLGr56CmLLc7JspzfSfccWp3Th5KbrMJ", + "6b5LxeDVxqCGAhZjjjgieGP71c5GBt2cBwiafCFX6NMU", + "8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo", + "AfaA4CE8C2DWSHANCqvU9RWrxRiXCV7KKVSw4cHi68Wn", + "DSzgmzz1Ms4qshdeCpE2uWenXXyNbkikw3bzfJRAv7JF", + "FJnaiidSLXFweWkgbinxEHRykVHsnkzDcYbNDR3RF5LN", + "GU7Auyn3cMxtuZX8N3ezhKztgJ1bqqpuUk19KWXqnYwv", + "Hv8FoJFsrQhoyrR6Lcz4KFcpqNHU1Kxj2yaFDKU6vJdp", + "7ZTpmqKWeAkRwHHgi74Gu1o6vWrHJRBDNsZTAdkKpohv", + "AWVYnCT2ZdLsWZf1X9KXatZhC2TyruRM22y8KZqVeupr", + "CKc2gypi1feWLboi7PWRgNTCi6NWhkYaGU4v6rhnZDDJ", + "4X3seJERbu4xy7sVAndPBsy4JWZVAGEpXv1NcCQ6zo66", + "Gsu4WmGJf9z4RWiQ9onE9u29rSvh5XsAkVwUJ2bLrGQb", + "51FQwjrvo8J8zXUaKyAznJ5NYpoiTCuqAqCu3HAMB9NZ", + "4XkEAUpmQnuKK2N1H73v68GkTpbNrxZZ37ZyfHfELZve", + "6U1kWANmyBuJRTZGRuPb9o2EJ6KRui3QpqrWDZoZ4bnG", + "FC9pWtfdtbyGZ5WHTLneoMSUx6jmTDgqKaxDcm2trsND", +]; + +/// AVAX-USDC-Pool1. Also 2048 bytes and also carries the POOLSTAT magic, but its version word is 2 +/// and its fields are not where the v3 layout says: offset 832 holds 2^32+1, not a price. It exists +/// to prove the guard refuses it. +const BISONFI_V2_POOL: &str = "9fLzyySS73UnecJRzx2AKcgoSQ1qigzU3b6m9e2iVq6"; + +/// The reconstruction has to describe all 2048 bytes of *every* v3 pool, not just the busy one the +/// templates point at. A dormant pool exercises regions the live pool leaves zeroed, so a field +/// boundary that is wrong in an unused region only shows up here. +#[tokio::test] +async fn bisonfi_every_pool_round_trips_unchanged() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-fair-value").expect("template"); + let raw_layout = template + .raw_layout + .as_ref() + .expect("bisonfi templates carry a raw layout"); + + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + assert_eq!(data.len(), 2048, "{pool} should be 2048 bytes"); + assert_eq!(&data[..8], b"POOLSTAT", "{pool} magic prefix"); + + let forged = raw_layout + .materialize(data, &template.properties, &HashMap::new(), 0) + .unwrap_or_else(|e| panic!("{pool} failed to round-trip through the byte layout: {e}")); + let diffs = diff_indices(&forged, data); + assert!( + diffs.is_empty(), + "{pool}: the layout altered {} byte(s) on a no-op round-trip, first at {:?}", + diffs.len(), + diffs.first() + ); + } +} + +/// The write half of the matrix: each shipping property, against each of the 18 pools. Asserts the +/// guard admits the account, the value lands at the offset the template declares, and nothing +/// outside that field moves. +#[tokio::test] +async fn bisonfi_every_property_writes_cleanly_on_every_pool() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let registry = TemplateRegistry::new(); + + // (template id, property name, offset, width, value to write) + let cases: [(&str, &str, usize, usize, serde_json::Value); 4] = [ + ( + "bisonfi-fair-value", + "fair_value", + 832, + 16, + serde_json::json!((50u128 * (1u128 << 88)).to_string()), + ), + ( + "bisonfi-freshness", + "last_update_slot", + 72, + 8, + serde_json::json!(123_456_789u64), + ), + ( + "bisonfi-depth", + "base_reserve", + 48, + 8, + serde_json::json!(1_000_000_000u64), + ), + ( + "bisonfi-depth", + "quote_reserve", + 56, + 8, + serde_json::json!(2_000_000_000u64), + ), + ]; + + for (id, prop, offset, width, value) in cases { + let template = registry.get(id).unwrap_or_else(|| panic!("{id} template")); + let raw_layout = template + .raw_layout + .as_ref() + .unwrap_or_else(|| panic!("{id} carries a raw layout")); + + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + raw_layout + .guard(data) + .unwrap_or_else(|e| panic!("{id}: guard rejected {pool}: {e}")); + + let forged = raw_layout + .materialize( + data, + &template.properties, + &HashMap::from([(prop.to_string(), value.clone())]), + 0, + ) + .unwrap_or_else(|e| panic!("{id}: {prop} failed on {pool}: {e}")); + + assert_eq!(forged.len(), 2048, "{id} on {pool}: size changed"); + let diffs = diff_indices(&forged, data); + assert!( + diffs.iter().all(|i| (offset..offset + width).contains(i)), + "{id}: writing {prop} on {pool} touched bytes outside {offset}..{}: {diffs:?}", + offset + width + ); + // And the value actually landed. + let mut buf = [0u8; 16]; + buf[..width].copy_from_slice(&forged[offset..offset + width]); + let got = u128::from_le_bytes(buf); + let want: u128 = match &value { + serde_json::Value::String(s) => s.parse().unwrap(), + // A negative tick lands as two's complement in `width` bytes, so compare against + // the same truncation rather than treating the field as unsigned. + v => match v.as_i64() { + Some(n) if n < 0 => (n as i128 as u128) & ((1u128 << (width * 8)) - 1), + _ => v.as_u64().unwrap() as u128, + }, + }; + assert_eq!(got, want, "{id}: {prop} on {pool} did not land"); + } + } +} + +/// Offsets 48 and 56 mirror the vaults exactly, which is why no template writes them. This pins +/// that measurement so the claim in the layout docs cannot rot silently: if a redeploy changes it, +/// the reserve fields mean something else and the docs need revisiting. +#[tokio::test] +async fn bisonfi_reserves_mirror_the_vaults() { + const BASE_RESERVE: usize = 48; + const BASE_VAULT: usize = 120; + const QUOTE_VAULT: usize = 152; + + // The vaults are named in the pool itself, but the balance comparison is only meaningful if + // both are read at the same slot - this market turns over thousands of SOL in a few hundred + // slots. So they are fetched in one batch, which means the addresses have to be known up front + // and then checked against the pool's own fields. + const BASE_VAULT_ADDR: &str = "ATRsNGv2nDw7hSMfkUTBoVUDsFDwN7po7KbecyiGWNB4"; + const QUOTE_VAULT_ADDR: &str = "2Y7HATmn9aJBcxCskE5V2U2epmjvkZmB51zTJBbhj4cU"; + + let batch = fetch(&[BISONFI_POOL, BASE_VAULT_ADDR, QUOTE_VAULT_ADDR]).await; + let data = &batch[0]; + + assert_eq!( + Pubkey::new_from_array(data[BASE_VAULT..BASE_VAULT + 32].try_into().unwrap()), + Pubkey::from_str_const(BASE_VAULT_ADDR), + "base_vault at offset 120 no longer points at the expected token account" + ); + assert_eq!( + Pubkey::new_from_array(data[QUOTE_VAULT..QUOTE_VAULT + 32].try_into().unwrap()), + Pubkey::from_str_const(QUOTE_VAULT_ADDR), + "quote_vault at offset 152 no longer points at the expected token account" + ); + + // SPL token account: amount is a u64 at offset 64. + let base_held = u64::from_le_bytes(batch[1][64..72].try_into().unwrap()); + let cached = u64::from_le_bytes(data[BASE_RESERVE..BASE_RESERVE + 8].try_into().unwrap()); + + // Same slot, so they must agree exactly. This is the measurement that disqualified offset 48 + // as a "quotable slice" of the vaults: it is the whole balance, mirrored. + assert_eq!( + cached, base_held, + "offset 48 is expected to mirror the base vault balance exactly; pool says {cached}, \ + vault holds {base_held}" + ); +} + +/// The pools a behavioural scenario can actually be asserted on, fetched once. +struct BisonfiRig { + elf: Vec, + /// Address, account bytes, and the token program owning each side's mint. + quoting: Vec<(&'static str, Vec, (Pubkey, Pubkey))>, +} + +impl BisonfiRig { + /// Applies one template's values through the real override engine and replays a swap. + fn scenario( + &self, + pool: &str, + data: &[u8], + tp: (Pubkey, Pubkey), + template_id: &str, + values: &[(&str, serde_json::Value)], + amount_in: u64, + direction: u8, + ) -> u64 { + // Materialize INSIDE the replay, not before it. `bisonfi_replay` derives the simnet clock + // from the pool's own last_update_slot, so handing it an already-aged account moves the clock + // back along with the field and the quote never looks stale at all - which is exactly how the + // freshness scenario first appeared to fail. + self.try_scenario(pool, data, tp, template_id, values, amount_in, direction) + .unwrap_or_else(|e| panic!("{template_id} on {pool}: replay failed: {e}")) + } + + /// As [`Self::scenario`] but surfaces a refusal instead of panicking, for the scenarios where the + /// venue declining to fill is the point. + fn try_scenario( + &self, + pool: &str, + data: &[u8], + tp: (Pubkey, Pubkey), + template_id: &str, + values: &[(&str, serde_json::Value)], + amount_in: u64, + direction: u8, + ) -> Result { + let registry = TemplateRegistry::new(); + let template = registry + .get(template_id) + .unwrap_or_else(|| panic!("{template_id} must exist in the registry")); + let raw_layout = template + .raw_layout + .as_ref() + .unwrap_or_else(|| panic!("{template_id} must carry a raw layout")); + let map: HashMap = values + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + let props = template.properties.clone(); + let layout = raw_layout.clone(); + bisonfi_replay(&self.elf, pool, data, tp, amount_in, direction, move |d| { + let forged = layout + .materialize(d.as_slice(), &props, &map, 0) + .unwrap_or_else(|e| panic!("materialize failed: {e}")); + *d = forged; + }) + } + + /// A sell size that every live market fills, at 2% of the base reserve. + fn sell_size(data: &[u8]) -> u64 { + u64::from_le_bytes(data[48..56].try_into().unwrap()) / 50 + } + + /// The quote-side notional matching [`Self::sell_size`], taken from what a control sell actually + /// pays out. + /// + /// An earlier version derived this from the pool's fixed-point mid, which is the price in HUMAN + /// units - so it was out by the market's decimal shift, a thousand-fold on a 9/6 pair. Every buy + /// leg then asked for more than the venue would fill and was quietly skipped. Using the control + /// fill needs no decimal table and cannot drift. + fn buy_size(control_sell_out: u64) -> u64 { + control_sell_out + } +} + +/// One rig per process. Ten tests need it, and each build costs two `getMultipleAccounts` calls +/// against a public endpoint that rate-limits - running them in parallel exhausted it and failed six +/// tests at once, every one of which passed in isolation. +async fn bisonfi_rig() -> std::sync::Arc { + static CACHE: tokio::sync::OnceCell> = + tokio::sync::OnceCell::const_new(); + CACHE + .get_or_init(|| async { std::sync::Arc::new(bisonfi_rig_uncached().await) }) + .await + .clone() +} + +async fn bisonfi_rig_uncached() -> BisonfiRig { + let elf = bisonfi_elf().await; + let all = fetch(&BISONFI_ALL_POOLS).await; + let programs = bisonfi_token_programs(&all).await; + let mut quoting = Vec::new(); + for ((pool, data), tp) in BISONFI_ALL_POOLS + .iter() + .zip(all.iter()) + .zip(programs.iter()) + { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + if base_reserve < 1_000_000 { + continue; + } + let size = BisonfiRig::sell_size(data); + if size == 0 { + continue; + } + if let Ok(out) = bisonfi_replay(&elf, pool, data, *tp, size, 0, |_| {}) { + if out > 0 { + quoting.push((*pool, data.clone(), *tp)); + } + } + } + // Six of the seventeen v3 pools publish a current mid. Two more are live but quote a Token-2022 + // base asset the harness cannot build accounts for; the rest are dormant by 13-30 million slots + // and return no quote whatever is written to them. If this count drops, coverage silently + // narrowed and the scenario assertions below stop meaning anything. + assert!( + quoting.len() >= 6, + "only {} of {} pools can be quoted; scenario coverage has narrowed", + quoting.len(), + BISONFI_ALL_POOLS.len() + ); + BisonfiRig { elf, quoting } +} + +/// SCENARIO: set X mid price for a given market. +/// +/// The template's whole promise is that the number you pass becomes the price the venue quotes +/// around. Asserted as proportionality, on every market that quotes, because that is the property a +/// scenario author relies on: ask for double and the fill doubles. +#[tokio::test] +async fn bisonfi_scenario_set_mid_price() { + let rig = bisonfi_rig().await; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let base = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: control failed: {e}")); + + for (label, num, den) in [("double", 2u128, 1u128), ("half", 1, 2)] { + let out = rig.scenario( + pool, + data, + *tp, + "bisonfi-fair-value", + &[( + "fair_value", + serde_json::json!((mid * num / den).to_string()), + )], + size, + 0, + ); + let want = base as f64 * num as f64 / den as f64; + let err = (out as f64 - want) / want; + assert!( + err.abs() < 0.005, + "{pool}: setting the mid to {label} the live value paid {out}, {:.3}% off the {want:.0} \ + that proportionality requires. A scenario asking for a price would not get it", + err * 100.0 + ); + } + } +} + +/// SCENARIO: thin out a given market so a large trade slips measurably. +#[tokio::test] +async fn bisonfi_scenario_thin_depth_makes_a_trade_slip() { + let rig = bisonfi_rig().await; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + let at = |r: u64| { + rig.scenario( + pool, + data, + *tp, + "bisonfi-depth", + &[("quote_reserve", serde_json::json!(r))], + size, + 0, + ) + }; + // Raising a reserve above the vault balance breaks settlement, which the template warns + // about, so "deep" is the live value and the comparison runs downward from there. + let deep = at(quote_reserve); + let thin = at(quote_reserve / 2); + let thinner = at(quote_reserve / 4); + assert!( + deep > thin && thin > thinner, + "{pool}: halving the payout reserve must make the same sell fill worse each time, got \ + {deep} -> {thin} -> {thinner}" + ); + } +} + +/// Asserts that a depth override moves the direction it is documented to move and leaves the +/// opposite direction alone. +/// +/// `cross` is the untargeted direction (value, control); `own` is the targeted one. Deliberately not +/// an equality check on `cross`. The claim the template makes - and the only one a consumer relies on +/// - is that each reserve constrains one direction. Exact byte-identity of the untargeted quote is a +/// strictly stronger claim, and it is not one this program guarantees: the working ladder at 288/1036 +/// is refreshed from 528/1196 through a watermark-gated memcpy, so a write that tips that gate can +/// shift both directions by a few bps without the documented asymmetry being wrong at all. That was +/// observed once in the wild - quartering base_reserve moved a sell 3.5 bps on DSzgmzz1 - and could +/// not be reproduced across a size sweep from 1/10000 of the reserve up to the whole of it, on any of +/// the six quoting markets, where the cross effect measured exactly 0.0000 bps. +/// +/// So the tolerance below is not slack for a claim we cannot prove. It asserts the asymmetry itself: +/// the untargeted direction must stay within 50 bps, AND the targeted direction must move at least +/// ten times further. A lever that genuinely bled into both directions fails the ratio even when both +/// moves are individually small, which is what exact equality was really there to catch. +fn assert_direction_specific( + pool: &str, + field: &str, + cross: (u64, u64), + own: (Result, u64), +) { + const CROSS_TOLERANCE: f64 = 0.005; // 50 bps + const MIN_RATIO: f64 = 10.0; + + let (cross_val, cross_control) = cross; + let cross_rel = (cross_val as f64 - cross_control as f64).abs() / cross_control as f64; + assert!( + cross_rel <= CROSS_TOLERANCE, + "{pool}: lowering {field} moved the direction it should not constrain by {:.2} bps ({cross_val} vs control {cross_control}). The template's direction guidance would be wrong", + cross_rel * 10_000.0 + ); + + // A refusal is an unboundedly large move on the targeted side, so the ratio is satisfied outright. + let (own_val, own_control) = own; + let own_rel = match own_val { + Err(_) => f64::INFINITY, + Ok(v) => (v as f64 - own_control as f64).abs() / own_control as f64, + }; + assert!( + own_rel >= cross_rel * MIN_RATIO, + "{pool}: lowering {field} moved the direction it constrains by {:.2} bps but moved the other direction by {:.2} bps. The two are within {MIN_RATIO}x, so this is not a direction-specific lever and the template's guidance would mislead", + own_rel * 10_000.0, + cross_rel * 10_000.0 + ); +} + +/// SCENARIO: make a given market expensive in one direction only. +/// +/// The pool pays out of one side, so lowering that side's reserve must hurt trades in that direction +/// and leave the other direction untouched. A router that treats the venue as symmetric fails here. +#[tokio::test] +async fn bisonfi_scenario_one_sided_liquidity() { + let rig = bisonfi_rig().await; + // Starve hard rather than gently. Quartering a reserve barely binds when the trade is only 2% of + // it: on DSzgmzz1 a quartered base_reserve moved the buy it constrains by 0.5 bps while the ladder + // refresh wobbled the sell by 2.7 bps, so the asymmetry was smaller than the noise and the ratio + // below could not see it. Starving by 1000x drives the constrained direction to the point where + // the reserve genuinely limits the fill, which is the regime the template's guidance describes. + const STARVE: u64 = 1000; + let mut checked = 0usize; + for (pool, data, tp) in &rig.quoting { + let sell = BisonfiRig::sell_size(data); + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + let sell_control = bisonfi_replay(&rig.elf, pool, data, *tp, sell, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: sell control failed: {e}")); + let buy = BisonfiRig::buy_size(sell_control); + let buy_control = bisonfi_replay(&rig.elf, pool, data, *tp, buy, 1, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: buy control failed: {e}")); + assert!(buy_control > 0, "{pool}: buy control returned nothing"); + checked += 1; + + // A refusal counts as strictly worse than any fill - the venue declining is the extreme end + // of the same lever, and starving a reserve hard enough reaches it. + let worse_than = |r: Result, control: u64| match r { + Ok(o) => o < control, + Err(_) => true, + }; + + // Starve the quote side: sells get worse, the opposite direction barely moves. + let vals = [("quote_reserve", serde_json::json!(quote_reserve / STARVE))]; + let sell_starved = rig.try_scenario(pool, data, *tp, "bisonfi-depth", &vals, sell, 0); + let buy_cross = rig.scenario(pool, data, *tp, "bisonfi-depth", &vals, buy, 1); + assert!( + worse_than(sell_starved.clone(), sell_control), + "{pool}: lowering quote_reserve must make a SELL worse, got {sell_starved:?} vs \ + {sell_control}" + ); + assert_direction_specific( + pool, + "quote_reserve", + (buy_cross, buy_control), + (sell_starved.clone(), sell_control), + ); + + // And the mirror image on the base side. + let vals = [("base_reserve", serde_json::json!(base_reserve / STARVE))]; + let buy_starved = rig.try_scenario(pool, data, *tp, "bisonfi-depth", &vals, buy, 1); + let sell_cross = rig.scenario(pool, data, *tp, "bisonfi-depth", &vals, sell, 0); + assert!( + worse_than(buy_starved.clone(), buy_control), + "{pool}: lowering base_reserve must make a BUY worse, got {buy_starved:?} vs \ + {buy_control}" + ); + assert_direction_specific( + pool, + "base_reserve", + (sell_cross, sell_control), + (buy_starved.clone(), buy_control), + ); + } + // Both directions must actually have been exercised. The buy leg used to be skipped on every + // market because the notional was computed wrongly, and nothing said so. + assert!( + checked >= 6, + "only {checked} markets exercised both directions of the depth template" + ); +} + +/// SCENARIO: silence a given market maker so it stops quoting entirely, and the boundary case where +/// it is one slot behind and still quotes. +/// +/// This is the behaviour no constant-product AMM can imitate - an AMM always quotes something - so +/// it is the scenario most likely to be untested on the consuming side. +#[tokio::test] +async fn bisonfi_scenario_silence_the_maker() { + let rig = bisonfi_rig().await; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let published = u64::from_le_bytes(data[72..80].try_into().unwrap()); + + // One slot behind: still quoting. This is the boundary, and it is why the template says the + // tolerance is one slot rather than "recent". + let boundary = rig.scenario( + pool, + data, + *tp, + "bisonfi-freshness", + &[("last_update_slot", serde_json::json!(published - 1))], + size, + 0, + ); + assert!( + boundary > 0, + "{pool}: a quote one slot behind must still fill, or the boundary scenario is wrong" + ); + + // Two or more slots behind: silent. Checked well past the cliff as well as just over it, so + // a scenario that ages a market by a thousand slots is covered too. + for back in [2u64, 1_000, 1_000_000] { + let silent = rig.scenario( + pool, + data, + *tp, + "bisonfi-freshness", + &[( + "last_update_slot", + serde_json::json!(published.saturating_sub(back)), + )], + size, + 0, + ); + assert_eq!( + silent, 0, + "{pool}: aged by {back} slots the venue must not fill at all, got {silent}" + ); + } + + // And the sharp part: the swap's minimum-output bound is NOT honoured on the stale path, so + // the caller gets a CONFIRMED transaction that moved nothing and ignored their slippage + // protection. The healthy control below proves the bound is otherwise real, so this is the + // program returning early rather than the harness failing to set the field. + let healthy = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: control failed: {e}")); + assert!( + bisonfi_replay_min_out(&rig.elf, pool, data, *tp, size, healthy + 1, 0, |_| {}) + .is_err(), + "{pool}: a minimum above the fillable amount must revert on a healthy market, or the \ + bound is not a slippage guard at all and the claim below means nothing" + ); + let ignored = bisonfi_replay_min_out( + &rig.elf, + pool, + data, + *tp, + size, + healthy, + 0, + move |d: &mut Vec| { + d[72..80].copy_from_slice(&(published - 2).to_le_bytes()); + }, + ); + assert_eq!( + ignored, + Ok(0), + "{pool}: a silenced market must succeed with zero even when the caller demands \ + {healthy} out - if this ever starts reverting, the scenario's symptom changed from a \ + silent no-op to a failed transaction and every consumer's handling changes with it" + ); + } +} + +/// SCENARIO: make a given market unable to fill a trade at all. +/// +/// The extreme end of the depth lever: starve the payout reserve far enough and the program stops +/// negotiating and refuses, rather than quoting a terrible price. That is a distinct thing for a +/// router to handle - it has to split the trade or fall back to another venue - so it is asserted +/// separately from ordinary slippage. +#[tokio::test] +async fn bisonfi_scenario_market_cannot_fill() { + let rig = bisonfi_rig().await; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + // Escalate until the venue gives up. Which divisor does it depends on how much of the pool's + // depth the trade draws, so the claim is that SOME reachable setting refuses, not a + // particular number. + let mut refused_at = None; + for div in [4u64, 10, 100, 1_000, 100_000] { + let r = rig.try_scenario( + pool, + data, + *tp, + "bisonfi-depth", + &[("quote_reserve", serde_json::json!(quote_reserve / div))], + size, + 0, + ); + match r { + Err(_) => { + refused_at = Some(div); + break; + } + Ok(0) => { + refused_at = Some(div); + break; + } + Ok(_) => {} + } + } + assert!( + refused_at.is_some(), + "{pool}: no reduction of quote_reserve down to a hundred-thousandth made the venue \ + refuse the trade, so the 'cannot fill' scenario is not reachable on this market" + ); + } +} + +/// Every property of the spread template, and the tick offsets each one is supposed to cover. +const BISONFI_SPREAD_PROPS: [(&str, usize, usize); 8] = [ + ("working_levels.0.tick_offset", 300, 4), + ("working_levels.4.tick_offset", 364, 4), + ("configured_levels.0.tick_offset", 540, 4), + ("configured_levels.4.tick_offset", 604, 4), + ("continuation_levels.0.tick_offset", 1048, 5), + ("continuation_levels.5.tick_offset", 1128, 5), + ("continuation_source_levels.0.tick_offset", 1208, 5), + ("continuation_source_levels.5.tick_offset", 1288, 5), +]; + +/// The bid half of the spread template's properties, all set to `v`. +/// +/// The bid runs are the ones starting at rung 0 of each region; the ask runs start mid-region. Both +/// halves are needed to move a two-sided book, but a sell only pays the bid side, so tests that +/// measure a sell set just these. +fn bisonfi_spread_bids(v: i32) -> Vec<(&'static str, serde_json::Value)> { + BISONFI_SPREAD_PROPS + .iter() + .filter(|(path, _, _)| path.contains(".0.")) + .map(|(path, _, _)| (*path, serde_json::json!(v))) + .collect() +} + +/// Builds a mutation closure that applies a shipped template through the real `materialize` path. +/// +/// For tests that iterate the raw pool list directly instead of going through `BisonfiRig`, so that +/// they still exercise the template we ship rather than a hand-written copy of its offsets. +fn bisonfi_apply_template( + template_id: &str, + values: &[(&str, serde_json::Value)], +) -> impl FnOnce(&mut Vec) + use<> { + let id = template_id.to_string(); + let registry = TemplateRegistry::new(); + let template = registry + .get(template_id) + .unwrap_or_else(|| panic!("{template_id} must exist in the registry")); + let layout = template + .raw_layout + .as_ref() + .unwrap_or_else(|| panic!("{template_id} must carry a raw layout")) + .clone(); + let props = template.properties.clone(); + let map: HashMap = values + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + move |d: &mut Vec| { + *d = layout + .materialize(d.as_slice(), &props, &map, 0) + .unwrap_or_else(|e| panic!("{id}: materialize failed: {e}")); + } +} + +/// Values setting the whole book to one magnitude: bid properties negative, ask properties positive. +fn bisonfi_spread_values(magnitude: i32) -> Vec<(&'static str, serde_json::Value)> { + BISONFI_SPREAD_PROPS + .iter() + .map(|(path, _, _)| { + // The bid properties are the ones whose run starts at the first rung of a region. + let is_bid = path.contains(".0."); + let v = if is_bid { + -magnitude.abs() + } else { + magnitude.abs() + }; + (*path, serde_json::json!(v)) + }) + .collect() +} + +/// SCENARIO: set X spread for a given market. +/// +/// The decisive form of the claim, and the one three earlier attempts got wrong by comparing spread +/// *differences* - which is blind to a change that shifts both legs equally. This compares two uniform +/// settings against each other, so the ratio is fully determined by the unit: +/// +/// price(T) = mid * (1 - T/2_560_000) => price(T1)/price(T2) = (1 - T1/u) / (1 - T2/u) +/// +/// Any multiplicative term the venue applies regardless - its base spread, a fee - cancels in that +/// ratio, and no per-market token decimals enter it either. So it tests the unit absolutely with +/// nothing fitted. +#[tokio::test] +async fn bisonfi_scenario_set_spread() { + const UNIT: f64 = 2_560_000.0; + const TIGHT: i32 = 2_560; // 10 bps + const WIDE: i32 = 25_600; // 1% + let predicted = (1.0 - WIDE as f64 / UNIT) / (1.0 - TIGHT as f64 / UNIT); + + let rig = bisonfi_rig().await; + let mut checked = 0usize; + for (pool, data, tp) in &rig.quoting { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + // 5% of the reserve: comfortably above the size below which the ladder is not consulted. + let size = base_reserve / 20; + let at = |magnitude: i32| { + rig.try_scenario( + pool, + data, + *tp, + "bisonfi-spread", + &bisonfi_spread_values(magnitude), + size, + 0, + ) + }; + let (tight, wide) = match (at(TIGHT), at(WIDE)) { + (Ok(t), Ok(w)) if t > 0 && w > 0 => (t, w), + _ => continue, + }; + assert!( + wide < tight, + "{pool}: a 1% ladder must pay the seller less than a 10 bps one, got {wide} vs {tight}" + ); + let ratio = wide as f64 / tight as f64; + let err = (ratio - predicted).abs() / predicted; + assert!( + err < 0.01, + "{pool}: going from a {TIGHT} tick to a {WIDE} tick changed the fill by a factor of \ + {ratio:.6}, but the 1/2,560,000 unit the template documents requires {predicted:.6} \ + ({:.3}% off). Either the unit is wrong or not every region is being written", + err * 100.0 + ); + checked += 1; + } + assert!( + checked >= 6, + "only {checked} markets exercised the spread template; the claim needs the live markets" + ); +} + +/// SCENARIO: quote wide on one side only. +/// +/// The parity test for the spread template, matching what `bisonfi_scenario_one_sided_liquidity` does +/// for depth. Bid offsets price sells and ask offsets price buys, so widening one side must leave the +/// other untouched. A template whose bid and ask offsets were transposed would still widen a quote and +/// would pass every test that only looks at one direction. +#[tokio::test] +async fn bisonfi_scenario_spread_is_side_specific() { + const WIDE: i32 = 25_600; // 1% + let bids: Vec<(&str, serde_json::Value)> = BISONFI_SPREAD_PROPS + .iter() + .filter(|(p, _, _)| p.contains(".0.")) + .map(|(p, _, _)| (*p, serde_json::json!(-WIDE))) + .collect(); + let asks: Vec<(&str, serde_json::Value)> = BISONFI_SPREAD_PROPS + .iter() + .filter(|(p, _, _)| !p.contains(".0.")) + .map(|(p, _, _)| (*p, serde_json::json!(WIDE))) + .collect(); + + let rig = bisonfi_rig().await; + let mut checked = 0usize; + for (pool, data, tp) in &rig.quoting { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + // No single trade size works everywhere: DSzgmzz1 does not consult the ladder below 5% of its + // reserve, and AfaA4CE8 cannot fill 5% at all. So the size is chosen per market - the first + // that fills both directions AND actually engages the ladder. + let usable = [20u64, 50, 100, 200, 1000].into_iter().find_map(|div| { + let sell = base_reserve / div; + let sell_control = bisonfi_replay(&rig.elf, pool, data, *tp, sell, 0, |_| {}).ok()?; + if sell_control == 0 { + return None; + } + let buy = BisonfiRig::buy_size(sell_control); + let buy_control = bisonfi_replay(&rig.elf, pool, data, *tp, buy, 1, |_| {}).ok()?; + if buy_control == 0 { + return None; + } + // The ladder has to bite at this size, or the assertions below are vacuous. + let probe = rig + .try_scenario(pool, data, *tp, "bisonfi-spread", &bids, sell, 0) + .ok()?; + (probe < sell_control).then_some((sell, sell_control, buy, buy_control)) + }); + let Some((sell, sell_control, buy, buy_control)) = usable else { + continue; + }; + + // Widening the bid must hurt sells and leave buys exactly where they were. + let sell_wide = rig.scenario(pool, data, *tp, "bisonfi-spread", &bids, sell, 0); + let buy_untouched = rig.scenario(pool, data, *tp, "bisonfi-spread", &bids, buy, 1); + assert!( + sell_wide < sell_control, + "{pool}: widening the BID must pay a seller less, got {sell_wide} vs {sell_control}" + ); + assert_eq!( + buy_untouched, buy_control, + "{pool}: widening the BID must not change a BUY. If this fires the bid and ask offsets \ + are transposed in the template" + ); + + // And the mirror image. + let buy_wide = rig.scenario(pool, data, *tp, "bisonfi-spread", &asks, buy, 1); + let sell_untouched = rig.scenario(pool, data, *tp, "bisonfi-spread", &asks, sell, 0); + assert!( + buy_wide < buy_control, + "{pool}: widening the ASK must give a buyer less base, got {buy_wide} vs {buy_control}" + ); + assert_eq!( + sell_untouched, sell_control, + "{pool}: widening the ASK must not change a SELL" + ); + checked += 1; + } + assert!( + checked >= 4, + "only {checked} markets exercised both directions of the spread template" + ); +} + +/// The write half: each property must set exactly its own run of tick fields and nothing else. +/// +/// A strided encoding writes several disjoint four-byte spans, so "nothing outside the field moved" is +/// a different assertion from every other property in this protocol - and getting it wrong would mean +/// silently overwriting a rung's share or level. +#[tokio::test] +async fn bisonfi_spread_template_writes_only_its_tick_fields() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-spread").expect("spread template"); + let raw_layout = template.raw_layout.as_ref().expect("raw layout"); + + for (path, offset, count) in BISONFI_SPREAD_PROPS { + let expected: Vec = (0..count) + .flat_map(|i| { + let at = offset + i * BISONFI_RUNG; + at..at + 4 + }) + .collect(); + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + let forged = raw_layout + .materialize( + data, + &template.properties, + &HashMap::from([(path.to_string(), serde_json::json!(-12_345i32))]), + 0, + ) + .unwrap_or_else(|e| panic!("{path} on {pool}: {e}")); + assert_eq!(forged.len(), 2048, "{path} on {pool}: size changed"); + for i in diff_indices(&forged, data) { + assert!( + expected.contains(&i), + "{path} on {pool}: byte {i} changed, outside the {count} tick fields at \ + {offset} stride 16. A strided write must not touch a rung's share or level" + ); + } + // And every slot in the run actually received the value. + for i in 0..count { + let at = offset + i * BISONFI_RUNG; + let got = i32::from_le_bytes(forged[at..at + 4].try_into().unwrap()); + assert_eq!( + got, -12_345, + "{path} on {pool}: rung {i} at offset {at} did not receive the value" + ); + } + } + } +} + +/// The live Orca Whirlpool SOL/USDC market, used as the AMM side of the arbitrage scenario. +const WHIRLPOOL_SOL_USDC: &str = "HJPjoWUrhoZzkNfRpHuieeFk9WcZWjwy6PBjZ81ngndJ"; + +/// SCENARIO: arbitrage between BisonFi and an AMM on the same pair. +/// +/// Surfpool forks mainnet, so dislocating BisonFi alone creates a real arbitrage against every other +/// venue's live state - no second override needed. This measures that against Orca's actual on-chain +/// price rather than a hardcoded number. +/// +/// The Whirlpool's price comes from its `sqrt_price` (Q64.64 at offset 65), squared. Nothing is +/// executed on the AMM side: pricing an Orca swap needs its tick arrays, which is a much larger piece +/// of harness. What this proves is that the override produces a dislocation that is real, correctly +/// signed, and of the right size against a live competing venue - which is what a router would act on. +/// +/// It is also self-validating: the first assertion is that both venues agree on the price BEFORE any +/// override. If the Whirlpool layout were misread, or the pair mismatched, that would fail rather than +/// silently making the arbitrage numbers meaningless. +#[tokio::test] +async fn bisonfi_scenario_arbitrage_against_an_amm() { + let rig = bisonfi_rig().await; + let (pool, data, tp) = rig + .quoting + .iter() + .find(|(p, _, _)| *p == BISONFI_POOL) + .expect("the WSOL/USDC market must be quoting for this scenario"); + + let whirlpool = fetch(&[WHIRLPOOL_SOL_USDC]).await.remove(0); + // Confirm the two venues really are the same pair and the same way round, so the comparison below + // is between like and like. + let (bisonfi_base, bisonfi_quote) = (&data[184..216], &data[216..248]); + assert_eq!( + &whirlpool[101..133], + bisonfi_base, + "the Whirlpool's token A must be BisonFi's base mint" + ); + assert_eq!( + &whirlpool[181..213], + bisonfi_quote, + "the Whirlpool's token B must be BisonFi's quote mint" + ); + + // Whirlpool price, in quote smallest-units per base smallest-unit. Squaring a Q64.64 needs care: + // done in f64 after the shift, which is ample for a comparison at this tolerance. + let sqrt_price = u128::from_le_bytes(whirlpool[65..81].try_into().unwrap()); + let amm_price = (sqrt_price as f64 / 2f64.powi(64)).powi(2); + assert!( + amm_price > 0.0, + "the Whirlpool must carry a live sqrt_price, got {sqrt_price}" + ); + + // BisonFi's realized price on the same basis: quote received per base sold. + let size = u64::from_le_bytes(data[48..56].try_into().unwrap()) / 100; + let realized = |image: Option>| -> f64 { + let out = match image { + None => bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}), + Some(img) => bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, move |d| *d = img), + } + .unwrap_or_else(|e| panic!("replay failed: {e}")); + assert!( + out > 0, + "BisonFi must fill for the comparison to mean anything" + ); + out as f64 / size as f64 + }; + + // 1. Undislocated, the two venues must agree. A proprietary market maker that disagreed with the + // largest AMM on SOL by more than a fraction of a percent would be arbitraged instantly. + let quiet = realized(None); + let disagreement = (quiet - amm_price).abs() / amm_price; + assert!( + disagreement < 0.02, + "BisonFi and Orca should price SOL within 2% of each other before any override; got \ + {quiet:.9} against {amm_price:.9} ({:.3}% apart). Either a layout offset is wrong or one \ + venue is not live", + disagreement * 100.0 + ); + + // 2. Now dislocate BisonFi upward by 10% through the shipped template, and the arbitrage appears: + // buy SOL on Orca, sell it to BisonFi. + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-fair-value").expect("template"); + let layout = template.raw_layout.as_ref().expect("layout"); + let dislocated = layout + .materialize( + data, + &template.properties, + &HashMap::from([( + "fair_value".to_string(), + serde_json::json!((mid * 11 / 10).to_string()), + )]), + 0, + ) + .expect("price override"); + let rich = realized(Some(dislocated)); + + let edge = (rich - amm_price) / amm_price; + assert!( + rich > quiet, + "the dislocated market must pay more than the quiet one, got {rich:.9} vs {quiet:.9}" + ); + assert!( + edge > 0.05, + "a 10% dislocation should leave at least 5% of edge against the AMM after BisonFi's own \ + spread and slippage; got {:.3}%", + edge * 100.0 + ); + assert!( + edge < 0.11, + "the edge cannot exceed the 10% dislocation that created it; got {:.3}%, which would mean \ + the price override is scaling by more than it was asked to", + edge * 100.0 + ); +} + +/// SCENARIO: the maker goes dark BETWEEN the quote and the fill. +/// +/// This is the one that needed a real gap closing. Every other scenario applies its override once and +/// asks what the program does. This one registers a scenario whose state CHANGES across slots, runs +/// the scheduler slot by slot, and then feeds each slot's account image to the deployed program. +/// +/// Why it matters: on Solana there is a gap of one or two slots between reading a price and the +/// transaction executing. If the maker stops publishing inside that window, a caller who did +/// everything right still gets no fill - and, as `bisonfi_scenario_silence_the_maker` shows, no error +/// either. Reproducing that needs the override to fire on a LATER slot than the one quoted on, which +/// exercises `register_scenario` and `materialize_overrides_for_slot` rather than a single write. +#[tokio::test] +async fn bisonfi_scenario_maker_goes_dark_between_quote_and_fill() { + use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + + const BASE_SLOT: u64 = 1_000_000; + const QUOTE_AT: u64 = 0; // scenario-relative slot the caller quotes on + const FILL_AT: u64 = 2; // and the slot the transaction actually lands on + + let rig = bisonfi_rig().await; + let (pool, data, tp) = rig.quoting.first().expect("a quoting market"); + let pool_key = pool.parse::().expect("pool address"); + let published = u64::from_le_bytes(data[72..80].try_into().unwrap()); + let size = BisonfiRig::sell_size(data); + + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account( + pool_key, + solana_account::Account { + lamports: 1_000_000, + data: data.clone(), + owner: Pubkey::from_str_const(BISONFI_PROGRAM), + executable: false, + rent_epoch: 0, + }, + ) + .expect("seed the pool account"); + + // Two steps on the same field: quoting normally when the caller looks, dark when it lands. + let mut scenario = Scenario::new( + "BisonFi maker goes dark mid-flight".to_string(), + "Quotes normally at the slot the caller prices on, then stops publishing before the \ + transaction executes" + .to_string(), + ); + for (relative, value) in [(QUOTE_AT, published), (FILL_AT, published - 5)] { + scenario.add_override( + OverrideInstance::new( + "bisonfi-freshness".to_string(), + relative, + AccountAddress::Pubkey(pool_key.to_string()), + ) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::json!(value), + )])), + ); + } + svm.register_scenario(scenario, Some(BASE_SLOT)) + .expect("register scenario"); + + // Walk the slots and capture what the account looks like at each one. + let mut images: HashMap> = HashMap::new(); + for slot in BASE_SLOT..=BASE_SLOT + FILL_AT { + svm.materialize_overrides_for_slot(&None, slot) + .await + .expect("materialize"); + let account = svm + .inner + .get_account(&pool_key) + .expect("get_account") + .expect("account present"); + images.insert(slot, account.data); + } + + let field_at = |slot: u64| u64::from_le_bytes(images[&slot][72..80].try_into().unwrap()); + assert_eq!( + field_at(BASE_SLOT), + published, + "at the quoting slot the venue must still be publishing" + ); + assert_eq!( + field_at(BASE_SLOT + 1), + published, + "no override is scheduled for the intermediate slot, so the account must be untouched" + ); + assert_eq!( + field_at(BASE_SLOT + FILL_AT), + published - 5, + "the second step must have fired by the slot the transaction lands on" + ); + + // Now the half that makes this more than a scheduling test: hand each slot's image to the real + // program. The clock is taken from the ORIGINAL account, so the simnet's notion of "now" stays at + // the publication slot while the field moves underneath it - which is what actually happens when + // the maker stops and the chain moves on. + let replay = |image: Vec| { + bisonfi_replay( + &rig.elf, + pool, + data, + *tp, + size, + 0, + move |d: &mut Vec| { + *d = image; + }, + ) + }; + let quoted = replay(images[&BASE_SLOT].clone()).expect("the quoting slot must fill"); + assert!( + quoted > 0, + "the caller's quote has to be real, or the scenario proves nothing" + ); + let filled = replay(images[&(BASE_SLOT + FILL_AT)].clone()); + assert_eq!( + filled, + Ok(0), + "the maker went dark between the quote and the fill, so the swap must return nothing - and \ + it must do so without erroring, which is what makes this a silent failure" + ); +} + +/// SCENARIO: the mid MOVES between the quote and the fill - adverse selection. +/// +/// The other half of the mid-flight pair, and the contrast is the point. When the maker goes dark the +/// swap silently returns zero. When the maker simply reprices against the taker, the caller's own +/// minimum-output bound catches it and the transaction REVERTS. Same timing, same mechanism, two +/// completely different things for a consumer to handle - one detectable, one not. +#[tokio::test] +async fn bisonfi_scenario_mid_moves_between_quote_and_fill() { + use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + + const BASE_SLOT: u64 = 2_000_000; + const FILL_AT: u64 = 2; + + let rig = bisonfi_rig().await; + let (pool, data, tp) = rig.quoting.first().expect("a quoting market"); + let pool_key = pool.parse::().expect("pool address"); + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let size = BisonfiRig::sell_size(data); + let moved = mid * 9 / 10; // the maker marks the asset down 10% while the taker is in flight + + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account( + pool_key, + solana_account::Account { + lamports: 1_000_000, + data: data.clone(), + owner: Pubkey::from_str_const(BISONFI_PROGRAM), + executable: false, + rent_epoch: 0, + }, + ) + .expect("seed the pool account"); + + let mut scenario = Scenario::new( + "BisonFi reprices mid-flight".to_string(), + "Quotes one price at the slot the caller prices on and a worse one before the transaction \ + executes" + .to_string(), + ); + for (relative, value) in [(0u64, mid), (FILL_AT, moved)] { + scenario.add_override( + OverrideInstance::new( + "bisonfi-fair-value".to_string(), + relative, + AccountAddress::Pubkey(pool_key.to_string()), + ) + .with_values(HashMap::from([( + "fair_value".to_string(), + serde_json::json!(value.to_string()), + )])), + ); + } + svm.register_scenario(scenario, Some(BASE_SLOT)) + .expect("register scenario"); + + let mut images: HashMap> = HashMap::new(); + for slot in BASE_SLOT..=BASE_SLOT + FILL_AT { + svm.materialize_overrides_for_slot(&None, slot) + .await + .expect("materialize"); + images.insert( + slot, + svm.inner + .get_account(&pool_key) + .expect("get_account") + .expect("account present") + .data, + ); + } + let mid_at = |slot: u64| u128::from_le_bytes(images[&slot][832..848].try_into().unwrap()); + assert_eq!( + mid_at(BASE_SLOT), + mid, + "the quoting slot must carry the quoted price" + ); + assert_eq!( + mid_at(BASE_SLOT + FILL_AT), + moved, + "the repricing step must have fired by the slot the transaction lands on" + ); + + // What the caller quoted, and therefore the minimum they would sign for. + let quoted = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + let image = images[&BASE_SLOT].clone(); + move |d: &mut Vec| *d = image + }) + .expect("the quoting slot must fill"); + assert!(quoted > 0, "the caller's quote has to be real"); + + // The same transaction, landing after the reprice. Without a minimum it fills at the worse price; + // with the minimum the caller actually quoted, it reverts. + let image = images[&(BASE_SLOT + FILL_AT)].clone(); + let unprotected = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + let image = image.clone(); + move |d: &mut Vec| *d = image + }) + .expect("a repriced market still quotes, just worse"); + assert!( + unprotected < quoted, + "a 10% markdown must pay the seller less: got {unprotected} against a quote of {quoted}" + ); + + let protected = bisonfi_replay_min_out(&rig.elf, pool, data, *tp, size, quoted, 0, { + let image = image.clone(); + move |d: &mut Vec| *d = image + }); + assert!( + protected.is_err(), + "signing for the price that was quoted must REVERT once the maker has repriced, got \ + {protected:?}. This is the case a consumer can actually detect, unlike a dark maker" + ); +} + +/// SCENARIO: a dislocated price behind thin depth, so an arbitrage looks profitable at the quoted +/// mid and is worth materially less once the trade is actually filled. +/// +/// This is the composition of two templates in one scenario, and it is the one that catches a +/// consumer whose price-impact model is wrong rather than one that simply misreads a price. +#[tokio::test] +async fn bisonfi_scenario_dislocated_price_behind_thin_depth() { + let rig = bisonfi_rig().await; + let mut checked = 0usize; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + let dislocated = mid * 11 / 10; // the venue claims 10% above the market + + let control = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: control failed: {e}")); + // Price alone: the full 10% should show up in the fill. + let price_only = rig.scenario( + pool, + data, + *tp, + "bisonfi-fair-value", + &[("fair_value", serde_json::json!(dislocated.to_string()))], + size, + 0, + ); + // Now the same dislocation with the payout side starved. Both templates target the same + // account, so the scenario applies them together. + let registry = TemplateRegistry::new(); + let layout = registry + .get("bisonfi-fair-value") + .and_then(|t| t.raw_layout.clone()) + .expect("layout"); + let priced = layout + .materialize( + data, + ®istry.get("bisonfi-fair-value").unwrap().properties, + &HashMap::from([( + "fair_value".to_string(), + serde_json::json!(dislocated.to_string()), + )]), + 0, + ) + .expect("price override"); + let both = rig.scenario( + pool, + &priced, + *tp, + "bisonfi-depth", + &[("quote_reserve", serde_json::json!(quote_reserve / 4))], + size, + 0, + ); + + assert!( + price_only > control, + "{pool}: a 10% higher mid must pay more, got {price_only} vs {control}" + ); + assert!( + both < price_only, + "{pool}: starving the payout side must claw back part of the dislocation; the arb has \ + to look better on paper ({price_only}) than it fills ({both})" + ); + assert!( + both > control, + "{pool}: the dislocation should still be worth something after slippage, got {both} vs \ + {control}" + ); + checked += 1; + } + assert!( + checked >= 6, + "only {checked} markets exercised the combined scenario" + ); +} + +/// The spread template writes fixed offsets - 540 for level 1 of the bid, 604 for level 1 of the ask +/// and so on - which is only correct if every pool lays its ladder out the same way. This asserts the +/// invariant the template depends on, so a pool that ordered its rungs differently would fail here +/// rather than silently take a price offset meant for the other side of the book. +#[tokio::test] +async fn bisonfi_ladder_layout_is_uniform_across_every_pool() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let i32_at = |d: &[u8], o: usize| i32::from_le_bytes(d[o..o + 4].try_into().unwrap()); + let u32_at = |d: &[u8], o: usize| u32::from_le_bytes(d[o..o + 4].try_into().unwrap()); + + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + for table in [BISONFI_LADDER, BISONFI_LADDER_MIRROR] { + let mut ask_share_total = 0u64; + for rung in 0..8usize { + let o = table + rung * BISONFI_RUNG; + let level = i32_at(data, o + 8); + let tick = i32_at(data, o + 12); + // Rungs 0..3 are the bid side at levels -1..-4, rungs 4..7 the ask side at 1..4. + let expected = if rung < 4 { + -(rung as i32 + 1) + } else { + rung as i32 - 3 + }; + assert_eq!( + level, expected, + "{pool} table {table} rung {rung}: level is {level}, expected {expected}. The \ + spread template writes offsets on the assumption that rungs 0-3 are the bid \ + side and 4-7 the ask side" + ); + // A bid offset must never be above the mid and an ask offset never below it, or the + // venue would be quoting through itself. + if rung < 4 { + assert!( + tick <= 0, + "{pool} table {table} rung {rung}: bid tick {tick} > 0" + ); + } else { + assert!( + tick >= 0, + "{pool} table {table} rung {rung}: ask tick {tick} < 0" + ); + } + ask_share_total += u32_at(data, o) as u64; + } + // Offsets must widen outward, otherwise "level 4 dominates a large trade" is not true and + // the template's guidance would mislead. + for rung in [0usize, 1, 2, 4, 5, 6] { + let inner = i32_at(data, table + rung * BISONFI_RUNG + 12).abs(); + let outer = i32_at(data, table + (rung + 1) * BISONFI_RUNG + 12).abs(); + assert!( + outer >= inner, + "{pool} table {table}: rung {} offset {outer} is closer to the mid than rung \ + {rung}'s {inner}; the ladder is supposed to widen outward", + rung + 1 + ); + } + // Shares are basis points of the book, so the side cannot allocate more than all of it. + // Note the 9999 the program checks at instruction 19120 is an overflow guard on the high + // word of a share*amount product, NOT a bound on this sum: pool 7ZTpmqKW... allocates a + // full 10000, and reading the code's 9999 as a sum limit is what this assertion caught. + assert!( + ask_share_total <= 10_000, + "{pool} table {table}: ask shares sum to {ask_share_total} bps, more than the whole \ + book" + ); + } + } +} + +/// The behavioural half of the spread claim, on every pool that can quote: widening the ladder must +/// make a sell strictly worse, tightening it must make it strictly better, and writing the mirrored +/// table at 288 must change nothing at all. +/// +/// The last assertion is the one that matters most. Table 288 looks exactly like a ladder, is the +/// same size, sits at a lower offset, and is what an earlier version of this work assumed was live. +/// It is inert, so a template pointed at it would appear to write cleanly and silently do nothing. +#[tokio::test] +async fn bisonfi_spread_lever_moves_the_quote_on_every_pool() { + let elf = bisonfi_elf().await; + let all = fetch(&BISONFI_ALL_POOLS).await; + let programs = bisonfi_token_programs(&all).await; + const WIDE: i32 = -25_600; // 1% below mid + const TIGHT: i32 = -13; // about 5 ppm below mid + /// The spread the two tick values differ by. A uniform write puts every slice of the trade at the + /// same offset, so the realized gap should approach this and can never exceed it. + const EXPECTED_GAP: f64 = (TIGHT - WIDE) as f64 / 2_560_000.0; + + // The ladder engages over a window of trade size that differs per market and falls away again on + // very large trades, so the claim is per pool: SOME size pays essentially the whole configured + // spread. Asserting a single fixed size would be asserting a coincidence. + let divs: [u64; 8] = [1000, 200, 100, 50, 20, 10, 4, 2]; + let mut peaks: Vec<(&str, f64, u64)> = Vec::new(); + + for ((pool, data), tp) in BISONFI_ALL_POOLS + .iter() + .zip(all.iter()) + .zip(programs.iter()) + { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + if base_reserve < 1_000_000 { + continue; // dormant market, nothing to price + } + // Drive the SHIPPED template, not raw offsets. This matters and is not stylistic: the + // template writes the bid ticks of all four ladder regions, where the older raw-offset version + // of this test wrote only two of them. A market whose watermark gate happens to be blocking + // the 528 -> 288 refresh prices out of the working copy, so writing only the source region + // moves nothing at all. DSzgmzz1 was in exactly that state and reported 0.0000% of the + // configured spread while the template reaches it. Going through the template also means this + // per-pool proof covers what we actually ship rather than a parallel implementation of it. + let set_bids = |v: i32| bisonfi_apply_template("bisonfi-spread", &bisonfi_spread_bids(v)); + let mut best = (0.0f64, 0u64); + let mut quoted = false; + + for div in divs { + let size = base_reserve / div; + let baseline = match bisonfi_replay(&elf, pool, data, *tp, size, 0, |_| {}) { + Ok(out) if out > 0 => out, + _ => continue, + }; + quoted = true; + let wide = bisonfi_replay(&elf, pool, data, *tp, size, 0, set_bids(WIDE)) + .unwrap_or_else(|e| panic!("{pool} at 1/{div} of reserve: widening failed: {e}")); + let tight = bisonfi_replay(&elf, pool, data, *tp, size, 0, set_bids(TIGHT)) + .unwrap_or_else(|e| panic!("{pool} at 1/{div} of reserve: tightening failed: {e}")); + assert!( + tight >= wide, + "{pool} at 1/{div} of reserve: a 5 ppm spread paid {tight} and a 1% spread paid \ + {wide}; widening the ladder must never pay the seller more" + ); + + let gap = (tight - wide) as f64 / tight as f64; + // The hard ceiling. A uniform write puts every slice at the same offset, so the realized + // gap cannot exceed what the two tick values differ by - if it does, the unit is wrong. + assert!( + gap <= EXPECTED_GAP * 1.02, + "{pool} at 1/{div} of reserve: realized gap {:.4}% exceeds the {:.4}% the tick \ + difference allows, so the 1/2,560,000 unit the template documents is wrong", + gap * 100.0, + EXPECTED_GAP * 100.0 + ); + if gap > best.0 { + best = (gap, div); + } + + // The 288-versus-528 question this test used to hedge about is settled: 288 and 1036 are + // working copies refreshed from 528 and 1196 by a watermark-gated memcpy (traced at + // 10310-10387). The template writes all four regions for that + // reason, so there is no longer an unmeasured case to leave un-asserted here. + let _ = baseline; + } + + if quoted { + assert!( + best.0 >= EXPECTED_GAP * 0.80, + "{pool}: the best of {} trade sizes paid only {:.4}% of spread where {:.4}% was \ + configured. The lever has to reach close to what it is set to on every market that \ + quotes, or the template's unit and guidance would mislead", + divs.len(), + best.0 * 100.0, + EXPECTED_GAP * 100.0 + ); + peaks.push((pool, best.0, best.1)); + } + } + + // Without this the whole test could pass while quoting on nothing at all. Six of the seventeen v3 + // pools publish a current mid and can be replayed; two more are live but quote a Token-2022 base + // asset the harness cannot build accounts for (Custom(60)), and the rest are dormant, between 13 + // and 30 million slots behind, and return no quote whatever is written to them. + assert!( + peaks.len() >= 6, + "only {} pools produced a quote at any size: {peaks:?}. This test proves nothing if the \ + markets are not actually pricing", + peaks.len() + ); +} + +const BISONFI_PROGRAM: &str = "BiSoNHVpsVZW2F7rx2eQ59yQwKxzU5NvBcmKshCSUypi"; + +const BISONFI_PROGRAMDATA: &str = "42snJ7ip4zKKsip3EtaMoBo8wzoRsQJSzgUSFXAVJFfG"; + +const BISONFI_NINTH: &str = "8xeaWCsJYxRoudEZGJWURdfrtFhLYZz9b4iHJnW5tb3d"; + +/// The control the whole exercise needed: the deployed program, entered against a forked pool, +/// prices a swap. It returned zero for a long time because LiteSVM reports LastRestartSlot as 0 and +/// the program refuses to quote below 246_464_040 - it logs "LRS0", Last Restart Slot, and gives up. +/// +/// Asserts the fill lands just below the pool's own published mid, which is the end-to-end check +/// that `fair_value` is the price this venue actually quotes on. +#[tokio::test] +async fn bisonfi_swap_replay_prices_near_the_published_mid() { + const ONE_SOL: u64 = 1_000_000_000; + + let fork = bisonfi_fork(BISONFI_POOL).await; + let mid = u128::from_le_bytes(fork.pool[832..848].try_into().unwrap()) as f64 / 2f64.powi(88); + let out = bisonfi_run(&fork, ONE_SOL, 0, |_| {}) + .expect("the forked pool should price a one SOL sell"); + assert!(out > 0, "a live pool should quote a non-zero amount"); + + // USDC has six decimals, so `out` is the quote in micro-units for one whole SOL. + let realized = out as f64 / 1e6; + let shortfall_ppm = (mid - realized) / mid * 1e6; + assert!( + (0.0..2_000.0).contains(&shortfall_ppm), + "a one SOL sell should fill just below the published mid of {mid}, got {realized} \ + ({shortfall_ppm:.1} ppm away)" + ); +} + +/// Harness control. Proves the replay rig propagates a signer and has the token program loaded, +/// so a MissingRequiredSignature from BisonFi means something about BisonFi. +#[tokio::test] +async fn bisonfi_replay_rig_propagates_signers() { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let token_program = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + let usdc = Pubkey::from_str_const(USDC_MINT); + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000).unwrap(); + let (a, b) = (Pubkey::new_unique(), Pubkey::new_unique()); + let mk = |amount| Account { + lamports: 10_000_000_000, + data: token_account(&usdc, &taker.pubkey(), amount), + owner: token_program, + executable: false, + rent_epoch: 0, + }; + svm.set_account(a, mk(1_000_000)).unwrap(); + svm.set_account(b, mk(0)).unwrap(); + + // SPL Token Transfer: tag 3, u64 amount. Authority must be a signer. + let mut data = vec![3u8]; + data.extend_from_slice(&500_000u64.to_le_bytes()); + let ix = Instruction { + program_id: token_program, + accounts: vec![ + AccountMeta::new(a, false), + AccountMeta::new(b, false), + AccountMeta::new_readonly(taker.pubkey(), true), + ], + data, + }; + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&taker.pubkey()), + &[&taker], + svm.latest_blockhash(), + ); + let res = svm.send_transaction(tx); + assert!( + res.is_ok(), + "the rig cannot even authorise an SPL transfer, so it cannot test BisonFi: {:?}", + res.err().map(|e| (e.err, e.meta.logs)) + ); + assert_eq!(spl_amount(&svm.get_account(&b).unwrap().data), 500_000); +} + +/// The guard must refuse the one pool that is the right size and carries the right magic but is a +/// different layout version. Without the version in the guard this write would land at offset 832 +/// of a v2 account and corrupt whatever lives there. +#[tokio::test] +async fn bisonfi_guard_refuses_the_v2_pool() { + let data = fetch(&[BISONFI_V2_POOL]).await.remove(0); + assert_eq!( + data.len(), + 2048, + "the v2 pool is the same size as a v3 pool" + ); + assert_eq!(&data[..8], b"POOLSTAT", "and carries the same magic"); + assert_eq!( + u64::from_le_bytes(data[8..16].try_into().unwrap()), + 2, + "this test only means anything while that pool is still version 2" + ); + + // Every BisonFi template, taken from the registry rather than a hand-written list, so a template + // added later cannot quietly escape the guard check. + let registry = TemplateRegistry::new(); + let ids: Vec = registry + .all() + .iter() + .filter(|t| t.id.starts_with("bisonfi-")) + .map(|t| t.id.clone()) + .collect(); + assert!( + ids.len() >= 4, + "expected every BisonFi template, found {ids:?}" + ); + for id in ids { + let template = registry.get(&id).unwrap(); + let raw_layout = template.raw_layout.as_ref().unwrap(); + assert!( + raw_layout.guard(&data).is_err(), + "{id} must refuse a v2 pool: size and magic match, but the layout does not" + ); + } +} + +/// And it must still accept every v3 pool, so the tightened guard has not over-fitted. +#[tokio::test] +async fn bisonfi_guard_accepts_every_v3_pool() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let registry = TemplateRegistry::new(); + let templates: Vec<_> = registry + .all() + .into_iter() + .filter(|t| t.id.starts_with("bisonfi-")) + .collect(); + assert!( + templates.len() >= 4, + "expected every BisonFi template, found {}", + templates.len() + ); + + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + assert_eq!( + u64::from_le_bytes(data[8..16].try_into().unwrap()), + 3, + "{pool} is expected to be a version 3 pool" + ); + for template in &templates { + let raw_layout = template.raw_layout.as_ref().unwrap(); + raw_layout + .guard(data) + .unwrap_or_else(|e| panic!("{}: guard rejected v3 pool {pool}: {e}", template.id)); + } + } +} + +/// Replays a swap against arbitrary pool bytes with no RPC of its own, synthesizing the vaults from +/// the reserves they were measured to mirror. +/// +/// This exists so a behavioural claim can be made about *every* pool rather than the one the +/// templates point at. Seventeen pools times several mutations times both directions is several +/// hundred swaps: fine in LiteSVM, and impossible against a live endpoint. The compute limit is +/// raised because the 200k default cannot finish a full rung walk, which is what made the ladder +/// look inert the first time it was tested. +fn bisonfi_replay( + elf: &[u8], + pool_addr: &str, + pool_bytes: &[u8], + token_programs: (Pubkey, Pubkey), + amount_in: u64, + direction: u8, + mutate: impl FnOnce(&mut Vec), +) -> Result { + bisonfi_replay_min_out( + elf, + pool_addr, + pool_bytes, + token_programs, + amount_in, + 0, + direction, + mutate, + ) +} + +/// As [`bisonfi_replay`] but sets the swap's second u64, which the instruction layout suggests is a +/// minimum-output bound. Every other caller passes zero, so this is the only place its behaviour is +/// exercised - and whether it is enforced decides what a silenced venue looks like to a real +/// integration: a transaction that quietly moves nothing, or one that reverts. +#[allow(clippy::too_many_arguments)] +fn bisonfi_replay_min_out( + elf: &[u8], + pool_addr: &str, + pool_bytes: &[u8], + token_programs: (Pubkey, Pubkey), + amount_in: u64, + min_out: u64, + direction: u8, + mutate: impl FnOnce(&mut Vec), +) -> Result { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let mut pool = pool_bytes.to_vec(); + if pool.len() != 2048 { + return Err(format!("pool is {} bytes, expected 2048", pool.len())); + } + let g64 = |b: &[u8], o: usize| u64::from_le_bytes(b[o..o + 8].try_into().unwrap()); + let base_reserve = g64(&pool, 48); + let quote_reserve = g64(&pool, 56); + let base_vault = Pubkey::new_from_array(pool[120..152].try_into().unwrap()); + let quote_vault = Pubkey::new_from_array(pool[152..184].try_into().unwrap()); + let base_mint = Pubkey::new_from_array(pool[184..216].try_into().unwrap()); + let quote_mint = Pubkey::new_from_array(pool[216..248].try_into().unwrap()); + let pool_slot = g64(&pool, 72); + mutate(&mut pool); + + let program_id = Pubkey::from_str_const(BISONFI_PROGRAM); + let pool_key = pool_addr + .parse::() + .map_err(|_| format!("bad pool address {pool_addr}"))?; + // One program per side: slots 6 and 7 of the instruction are the base and quote token programs, + // which is why the account list appears to name the token program twice. + let (base_program, quote_program) = token_programs; + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(program_id, elf) + .map_err(|e| format!("add_program: {e:?}"))?; + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = pool_slot; + clock.unix_timestamp = 1_787_041_969; + svm.set_sysvar(&clock); + svm.set_account( + Pubkey::from_str_const("SysvarLastRestartS1ot1111111111111111111111"), + Account { + lamports: 1_000_000, + data: 246_464_040u64.to_le_bytes().to_vec(), + owner: Pubkey::from_str_const("Sysvar1111111111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|e| format!("set last_restart_slot: {e:?}"))?; + + let owned = |data: Vec, owner: Pubkey| Account { + lamports: 10_000_000_000, + data, + owner, + executable: false, + rent_epoch: 0, + }; + svm.set_account(pool_key, owned(pool, program_id)) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + base_vault, + owned( + token_account(&base_mint, &pool_key, base_reserve), + base_program, + ), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + quote_vault, + owned( + token_account("e_mint, &pool_key, quote_reserve + 79_168), + quote_program, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|e| format!("{e:?}"))?; + let (src_ta, dst_ta) = (Pubkey::new_unique(), Pubkey::new_unique()); + let (base_amt, quote_amt) = if direction == 0 { + (amount_in.saturating_mul(10), 0) + } else { + (0, amount_in.saturating_mul(10)) + }; + svm.set_account( + src_ta, + owned( + token_account(&base_mint, &taker.pubkey(), base_amt), + base_program, + ), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + dst_ta, + owned( + token_account("e_mint, &taker.pubkey(), quote_amt), + quote_program, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + let mut data = Vec::with_capacity(19); + data.push(0x07); + data.extend_from_slice(&amount_in.to_le_bytes()); + data.extend_from_slice(&min_out.to_le_bytes()); + data.push(direction); + data.push(0); + + let mut budget = vec![2u8]; + budget.extend_from_slice(&1_400_000u32.to_le_bytes()); + let ixs = vec![ + Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }, + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(pool_key, false), + AccountMeta::new(base_vault, false), + AccountMeta::new(quote_vault, false), + AccountMeta::new(src_ta, false), + AccountMeta::new(dst_ta, false), + AccountMeta::new_readonly(base_program, false), + AccountMeta::new_readonly(quote_program, false), + AccountMeta::new_readonly(Pubkey::from_str_const(BISONFI_NINTH), true), + ], + data, + }, + ]; + let mut msg = solana_message::Message::new(&ixs, Some(&taker.pubkey())); + msg.recent_blockhash = svm.latest_blockhash(); + let nsig = msg.header.num_required_signatures as usize; + let mut tx = Transaction::new_unsigned(msg); + tx.signatures = vec![solana_signature::Signature::default(); nsig]; + let sig = taker.sign_message(&tx.message.serialize()); + tx.signatures[0] = sig; + + match svm.send_transaction(tx) { + Ok(_) => { + let out = if direction == 0 { + spl_amount(&svm.get_account(&dst_ta).unwrap().data) + } else { + spl_amount(&svm.get_account(&src_ta).unwrap().data) + }; + Ok(out) + } + Err(e) => Err(format!("{:?}", e.err)), + } +} + +/// The program ELF, fetched once per machine and reused. Delete the file to pick up a redeploy. +async fn bisonfi_elf() -> Vec { + let cache = std::env::temp_dir().join("surfpool-bisonfi-program.so"); + match std::fs::read(&cache) { + Ok(bytes) if bytes.len() > 200_000 => bytes, + _ => { + let bytes = fetch(&[BISONFI_PROGRAMDATA]).await.remove(0)[45..].to_vec(); + let _ = std::fs::write(&cache, &bytes); + bytes + } + } +} + +/// Offsets of the live quote ladder. `LADDER` is the table the program actually prices from; +/// `LADDER_INERT` is the mirrored table that writing has no effect on, kept here so the test that +/// proves the difference cannot drift away from the template. +const BISONFI_LADDER: usize = 528; + +const BISONFI_LADDER_MIRROR: usize = 288; + +/// A rung is 16 bytes: share-if-ask, share-if-bid, level, tick offset. +const BISONFI_RUNG: usize = 16; + +/// A forked pool plus the deployed program, ready to run swaps against. +#[derive(Clone)] +struct BisonfiFork { + elf: Vec, + pool_addr: Pubkey, + pool: Vec, + base_vault: (Pubkey, Vec, u64), + quote_vault: (Pubkey, Vec, u64), +} + +/// Cached per process, keyed by pool. Several tests fork the same market, and refetching it for each +/// one is what exhausts the public endpoint. One snapshot per suite run is also more consistent: +/// tests then compare against identical state rather than a market that moved between them. +fn bisonfi_fork_cache() -> &'static std::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +/// Two batched reads: the program and pool, then the vaults the pool names. +async fn bisonfi_fork(pool_addr: &str) -> BisonfiFork { + if let Some(hit) = bisonfi_fork_cache() + .lock() + .ok() + .and_then(|c| c.get(pool_addr).cloned()) + { + return hit; + } + let fork = bisonfi_fork_uncached(pool_addr).await; + if let Ok(mut c) = bisonfi_fork_cache().lock() { + c.insert(pool_addr.to_string(), fork.clone()); + } + fork +} + +async fn bisonfi_fork_uncached(pool_addr: &str) -> BisonfiFork { + // The ELF is ~250 KB and the same for every pool, so it is fetched once per machine and cached. + // Delete the file to pick up a redeploy. + let cache = std::env::temp_dir().join("surfpool-bisonfi-program.so"); + let elf = match std::fs::read(&cache) { + Ok(bytes) if bytes.len() > 200_000 => bytes, + _ => { + let bytes = fetch(&[BISONFI_PROGRAMDATA]).await.remove(0)[45..].to_vec(); + let _ = std::fs::write(&cache, &bytes); + bytes + } + }; + // The vault addresses live in the pool, so learning them takes one read - but the pool's cached + // reserves and the vault balances must come from the SAME slot or they disagree. This market + // turns over tens of thousands of dollars between two requests, which is enough to make the pool + // look like it claims more than it holds. So the first read is only used for the addresses and + // everything is then re-read together. + let probe = fetch(&[pool_addr]).await.remove(0); + assert_eq!(probe.len(), 2048, "{pool_addr} should be a 2048-byte pool"); + let bv = Pubkey::new_from_array(probe[120..152].try_into().unwrap()); + let qv = Pubkey::new_from_array(probe[152..184].try_into().unwrap()); + let snap = fetch_with_lamports(&[pool_addr, &bv.to_string(), &qv.to_string()]).await; + BisonfiFork { + elf, + pool_addr: Pubkey::from_str_const(pool_addr), + pool: snap[0].0.clone(), + base_vault: (bv, snap[1].0.clone(), snap[1].1), + quote_vault: (qv, snap[2].0.clone(), snap[2].1), + } +} + +/// Runs one swap against a mutated copy of the fork. `direction` 0 sells the base token, 1 buys it. +fn bisonfi_run( + fork: &BisonfiFork, + amount_in: u64, + direction: u8, + mutate: impl FnOnce(&mut Vec), +) -> Result { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let mut pool = fork.pool.clone(); + let pool_slot = u64::from_le_bytes(pool[72..80].try_into().unwrap()); + let base_mint = Pubkey::new_from_array(pool[184..216].try_into().unwrap()); + let quote_mint = Pubkey::new_from_array(pool[216..248].try_into().unwrap()); + mutate(&mut pool); + + let program_id = Pubkey::from_str_const(BISONFI_PROGRAM); + let token_program = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(program_id, &fork.elf) + .map_err(|e| format!("add_program: {e:?}"))?; + + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = pool_slot; + clock.unix_timestamp = 1_787_041_969; + svm.set_sysvar(&clock); + // The program refuses to quote unless LastRestartSlot is at least this, logging "LRS0". + svm.set_account( + Pubkey::from_str_const("SysvarLastRestartS1ot1111111111111111111111"), + Account { + lamports: 1_000_000, + data: 246_464_040u64.to_le_bytes().to_vec(), + owner: Pubkey::from_str_const("Sysvar1111111111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|e| format!("{e:?}"))?; + + let owned = |data: Vec, owner: Pubkey| Account { + lamports: 10_000_000_000, + data, + owner, + executable: false, + rent_epoch: 0, + }; + svm.set_account(fork.pool_addr, owned(pool, program_id)) + .map_err(|e| format!("{e:?}"))?; + let vault_acct = |data: Vec, lamports: u64| Account { + lamports, + data, + owner: token_program, + executable: false, + rent_epoch: 0, + }; + svm.set_account( + fork.base_vault.0, + vault_acct(fork.base_vault.1.clone(), fork.base_vault.2), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + fork.quote_vault.0, + vault_acct(fork.quote_vault.1.clone(), fork.quote_vault.2), + ) + .map_err(|e| format!("{e:?}"))?; + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|e| format!("{e:?}"))?; + // Slots 4 and 5 are the user's base and quote accounts, fixed by mint; direction decides flow. + let (user_base, user_quote) = (Pubkey::new_unique(), Pubkey::new_unique()); + let (base_amt, quote_amt) = if direction == 0 { + (amount_in.saturating_mul(2), 0) + } else { + (0, amount_in.saturating_mul(2)) + }; + // A wrapped-SOL account's lamports must cover its balance plus rent, or paying out the base + // token leaves the instruction unbalanced. + const TOKEN_RENT: u64 = 2_039_280; + svm.set_account( + user_base, + vault_acct( + token_account(&base_mint, &taker.pubkey(), base_amt), + base_amt.saturating_add(TOKEN_RENT), + ), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + user_quote, + vault_acct( + token_account("e_mint, &taker.pubkey(), quote_amt), + TOKEN_RENT, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + let mut data = Vec::with_capacity(19); + data.push(0x07); + data.extend_from_slice(&amount_in.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.push(direction); + data.push(0); + + let ix = Instruction { + program_id, + accounts: vec![ + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(fork.pool_addr, false), + AccountMeta::new(fork.base_vault.0, false), + AccountMeta::new(fork.quote_vault.0, false), + AccountMeta::new(user_base, false), + AccountMeta::new(user_quote, false), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(Pubkey::from_str_const(BISONFI_NINTH), true), + ], + data, + }; + // Walking several rungs costs well over the 200k default; the routed swaps observed on mainnet + // run with ~600k available. SetComputeUnitLimit is discriminant 2 followed by a u32. + let mut cu_data = vec![2u8]; + cu_data.extend_from_slice(&1_400_000u32.to_le_bytes()); + let cu_ix = Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: cu_data, + }; + + let mut msg = solana_message::Message::new(&[cu_ix, ix], Some(&taker.pubkey())); + msg.recent_blockhash = svm.latest_blockhash(); + let nsig = msg.header.num_required_signatures as usize; + let mut tx = Transaction::new_unsigned(msg); + tx.signatures = vec![solana_signature::Signature::default(); nsig]; + tx.signatures[0] = taker.sign_message(&tx.message.serialize()); + + match svm.send_transaction(tx) { + Ok(_) => Ok(spl_amount( + &svm.get_account(if direction == 0 { + &user_quote + } else { + &user_base + }) + .unwrap() + .data, + )), + Err(e) => Err(format!("{:?}", e.err)), + } +} + +/// `fair_value` is claimed to be the price the venue quotes on. This pins the exact relationship: +/// scaling it must scale the quote by the same factor, against the deployed program. +#[tokio::test] +async fn bisonfi_fair_value_scales_the_quote_exactly() { + const ONE_SOL: u64 = 1_000_000_000; + let fork = bisonfi_fork(BISONFI_POOL).await; + let mid = u128::from_le_bytes(fork.pool[832..848].try_into().unwrap()); + + let base = bisonfi_run(&fork, ONE_SOL, 0, |_| {}).expect("control should price"); + let doubled = bisonfi_run(&fork, ONE_SOL, 0, |d| { + d[832..848].copy_from_slice(&(mid * 2).to_le_bytes()) + }) + .expect("doubled mid should price"); + let halved = bisonfi_run(&fork, ONE_SOL, 0, |d| { + d[832..848].copy_from_slice(&(mid / 2).to_le_bytes()) + }) + .expect("halved mid should price"); + + // Integer maths, so allow a unit of rounding either way rather than demanding bit equality. + assert!( + doubled.abs_diff(base * 2) <= 2, + "doubling fair_value should double the quote: {base} -> {doubled}" + ); + assert!( + halved.abs_diff(base / 2) <= 2, + "halving fair_value should halve the quote: {base} -> {halved}" + ); +} + +/// The depth template's claim, on several markets with different reserve ratios rather than one. +/// Lowering the reserve the pool pays out of must make the same trade fill worse. +#[tokio::test] +async fn bisonfi_depth_lever_is_monotonic_on_every_quoting_market() { + let rig = bisonfi_rig().await; + let mut checked = 0usize; + + for (pool, data, tp) in &rig.quoting { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + let name = String::from_utf8_lossy( + &data[256..288] + .iter() + .copied() + .take_while(|b| *b != 0) + .collect::>(), + ) + .to_string(); + + let at = |scaled: u64, size: u64| { + rig.try_scenario( + pool, + data, + *tp, + "bisonfi-depth", + &[("quote_reserve", serde_json::json!(scaled))], + size, + 0, + ) + }; + + // Assert on every size where all three legs price, rather than one hand-picked size. The + // ladder engages over a window that differs per market, so a fixed size would be asserting a + // coincidence about today's state - but wherever the market CAN price all three, the ordering + // is a claim the template makes and must hold. + let mut ordered_points = 0usize; + let mut strict_points = 0usize; + for div in [200u64, 100, 50, 20, 10, 5] { + let size = base_reserve / div; + if size == 0 { + continue; + } + let deep = at(quote_reserve.saturating_mul(10), size); + let control = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}); + let thin = at(quote_reserve / 2, size); + let (deep, control, thin) = match (deep, control, thin) { + (Ok(d), Ok(c), Ok(t)) if d > 0 && c > 0 && t > 0 => (d, c, t), + _ => continue, // this market cannot price all three at this size + }; + assert!( + deep >= control && control >= thin, + "{name} at 1/{div} of base reserve: a deeper quote reserve must never pay out less \ + and a thinner one never more, got deep={deep} control={control} thin={thin}" + ); + ordered_points += 1; + if deep > control && control > thin { + strict_points += 1; + } + } + + assert!( + ordered_points > 0, + "{name}: no trade size priced under all three depths, so the depth lever was never \ + actually exercised on this market" + ); + // Monotonic everywhere measurable is necessary but not sufficient - a lever that did nothing + // would satisfy it with equalities. At least one size has to respond strictly. + assert!( + strict_points > 0, + "{name}: the ordering held at {ordered_points} sizes but never strictly, so a 20x range \ + of quote reserve changed nothing. The depth template would not be a lever at all" + ); + checked += 1; + } + + // Previously this test covered three hardcoded markets while the other three templates were proven + // on every pool that quotes. Without this floor it could silently narrow back to one. + assert!( + checked >= 6, + "only {checked} quoting markets exercised the depth lever" + ); +} + +/// Buying pays out of the base reserve, so that is the side that constrains a buy. Confirms the +/// template's direction guidance is the right way round. +#[tokio::test] +async fn bisonfi_depth_lever_is_direction_specific() { + const SELL: u64 = 10_000_000_000_000; // 10k SOL + const BUY: u64 = 500_000_000_000; // 500k USDC + let fork = bisonfi_fork(BISONFI_POOL).await; + let scale = |off: usize, num: u64| { + move |d: &mut Vec| { + let v = u64::from_le_bytes(d[off..off + 8].try_into().unwrap()); + d[off..off + 8].copy_from_slice(&v.saturating_mul(num).to_le_bytes()) + } + }; + + let sell_base = bisonfi_run(&fork, SELL, 0, |_| {}).expect("control sell"); + assert_eq!( + bisonfi_run(&fork, SELL, 0, scale(48, 10)).expect("sell with deeper base"), + sell_base, + "the base reserve must not affect a sell, which pays out quote" + ); + assert!( + bisonfi_run(&fork, SELL, 0, scale(56, 10)).expect("sell with deeper quote") > sell_base, + "the quote reserve must affect a sell" + ); + + let buy_base = bisonfi_run(&fork, BUY, 1, |_| {}).expect("control buy"); + assert!( + bisonfi_run(&fork, BUY, 1, scale(48, 10)).expect("buy with deeper base") > buy_base, + "the base reserve must affect a buy, which pays out base" + ); +} + +/// The template warns that raising a reserve above the vault's real balance breaks settlement. That +/// warning is only worth printing if it is true. +#[tokio::test] +async fn bisonfi_raising_a_reserve_past_the_vault_fails_to_settle() { + let fork = bisonfi_fork(BISONFI_POOL).await; + let held = spl_amount(&fork.quote_vault.1); + let cached = u64::from_le_bytes(fork.pool[56..64].try_into().unwrap()); + // Same-slot snapshot, so the pool's cached quote must not exceed what the vault actually holds. + assert!( + cached <= held, + "same-slot pool and vault disagree: pool claims {cached} quote, vault holds {held}" + ); + + // Claim a thousand times the quote the vault actually has, then try to draw more than it holds. + let sell = 10_000_000_000_000u64; // 10k SOL, worth far more than the vault at 1000x depth + let res = bisonfi_run(&fork, sell, 0, move |d| { + d[56..64].copy_from_slice(&cached.saturating_mul(1000).to_le_bytes()) + }); + match res { + Err(e) => assert!( + !e.is_empty(), + "raising the reserve past the vault should fail, and it did: {e}" + ), + Ok(out) => assert!( + out <= held, + "if it settles at all it can only pay out what the vault holds ({held}), paid {out}" + ), + } +} + +/// The freshness template tells callers to age the quote by N slots. This finds the N at which the +/// venue actually stops quoting, so the guidance can state a real number instead of guessing. +#[tokio::test] +async fn bisonfi_staleness_threshold_is_known() { + const ONE_SOL: u64 = 1_000_000_000; + let fork = bisonfi_fork(BISONFI_POOL).await; + let last = u64::from_le_bytes(fork.pool[72..80].try_into().unwrap()); + let age_by = + |n: u64| move |d: &mut Vec| d[72..80].copy_from_slice(&(last - n).to_le_bytes()); + + assert!(bisonfi_run(&fork, ONE_SOL, 0, age_by(0)).expect("fresh") > 0); + + // Smallest age that stops the quote, by binary search over a generous range. + let (mut lo, mut hi) = (0u64, 4096u64); + assert_eq!( + bisonfi_run(&fork, ONE_SOL, 0, age_by(hi)).unwrap_or(0), + 0, + "aging by {hi} slots should stop the venue quoting" + ); + while lo + 1 < hi { + let mid = (lo + hi) / 2; + if bisonfi_run(&fork, ONE_SOL, 0, age_by(mid)).unwrap_or(0) > 0 { + lo = mid; + } else { + hi = mid; + } + } + println!(" staleness cliff: quotes at -{lo} slots, refuses at -{hi}"); + assert!( + (1..=4096).contains(&hi), + "expected a cliff inside the searched range, found {hi}" + ); + // Pin it so a redeploy that changes the tolerance is noticed. + assert!( + (2..=2000).contains(&hi), + "the staleness tolerance moved to {hi} slots; update the freshness template guidance" + ); +} + +/// SCENARIO: the maker widens its quote ladder between the caller pricing and the caller filling. +/// +/// The spread counterpart to `bisonfi_scenario_mid_moves_between_quote_and_fill`, and the last of the +/// four templates to get a proof that it works as a scheduled, across-slots override rather than a +/// single write. It is also the most realistic way a PMM degrades: a maker that has stopped liking the +/// flow widens before it goes dark, so a taker sees a fill that is legal, non-zero, and worse than the +/// number it priced on. +/// +/// The trade size is searched rather than fixed. The ladder only engages over a window of size that +/// differs per market, so a hardcoded size would be asserting a coincidence about today's live state. +#[tokio::test] +async fn bisonfi_scenario_spread_widens_between_quote_and_fill() { + use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + + const BASE_SLOT: u64 = 2_000_000; + const FILL_AT: u64 = 2; + const TIGHT: i32 = -13; // about 5 ppm below mid + const WIDE: i32 = -25_600; // 1% below mid + /// Widening from TIGHT to WIDE cannot cost the seller more than the tick difference. + const MAX_GAP: f64 = (TIGHT - WIDE) as f64 / 2_560_000.0; + + let rig = bisonfi_rig().await; + + // Find a market and a size where the ladder is genuinely engaged, so that widening it has to show + // up in the fill. Without this the test could pass on a size where the spread is simply inert. + let mut chosen: Option<(&str, &Vec, (Pubkey, Pubkey), u64, u64, u64)> = None; + 'search: for (pool, data, tp) in &rig.quoting { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + for div in [1000u64, 200, 100, 50, 20, 10, 4, 2] { + let size = base_reserve / div; + if size == 0 { + continue; + } + let tight = match bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + bisonfi_apply_template("bisonfi-spread", &bisonfi_spread_bids(TIGHT)) + }) { + Ok(o) if o > 0 => o, + _ => continue, + }; + let wide = match bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + bisonfi_apply_template("bisonfi-spread", &bisonfi_spread_bids(WIDE)) + }) { + Ok(o) if o > 0 => o, + _ => continue, + }; + // Require most of the configured spread to be reachable at this size. + if (tight - wide) as f64 / tight as f64 >= MAX_GAP * 0.5 { + chosen = Some((pool, data, *tp, size, tight, wide)); + break 'search; + } + } + } + let (pool, data, tp, size, _, _) = chosen.expect( + "no quoting market engaged its ladder at any of the eight sizes tried, so a mid-flight \ + widening cannot be demonstrated. Investigate before relaxing this", + ); + let pool_key = pool.parse::().expect("pool address"); + + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account( + pool_key, + solana_account::Account { + lamports: 1_000_000, + data: data.clone(), + owner: Pubkey::from_str_const(BISONFI_PROGRAM), + executable: false, + rent_epoch: 0, + }, + ) + .expect("seed the pool account"); + + let mut scenario = Scenario::new( + "BisonFi widens mid-flight".to_string(), + "Quotes a tight ladder at the slot the caller prices on and a 1% ladder before the \ + transaction executes" + .to_string(), + ); + for (relative, tick) in [(0u64, TIGHT), (FILL_AT, WIDE)] { + scenario.add_override( + OverrideInstance::new( + "bisonfi-spread".to_string(), + relative, + AccountAddress::Pubkey(pool_key.to_string()), + ) + .with_values( + bisonfi_spread_bids(tick) + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect::>(), + ), + ); + } + svm.register_scenario(scenario, Some(BASE_SLOT)) + .expect("register scenario"); + + let mut images: HashMap> = HashMap::new(); + for slot in BASE_SLOT..=BASE_SLOT + FILL_AT { + svm.materialize_overrides_for_slot(&None, slot) + .await + .expect("materialize"); + images.insert( + slot, + svm.inner + .get_account(&pool_key) + .expect("get_account") + .expect("account present") + .data, + ); + } + + // The scheduled writes landed in the right slots, in every region the template covers. + for (path, offset, count) in BISONFI_SPREAD_PROPS { + if !path.contains(".0.") { + continue; // only the bid half is scheduled here + } + for rung in 0..count { + let at = offset + rung * BISONFI_RUNG; + let read = + |slot: u64| i32::from_le_bytes(images[&slot][at..at + 4].try_into().unwrap()); + assert_eq!( + read(BASE_SLOT), + TIGHT, + "{path} rung {rung} at {at}: the quoting slot must carry the tight ladder" + ); + assert_eq!( + read(BASE_SLOT + FILL_AT), + WIDE, + "{path} rung {rung} at {at}: the widening step must have fired by the fill slot" + ); + } + } + + // What the caller quoted, and therefore the minimum they would sign for. + let quoted = bisonfi_replay(&rig.elf, pool, data, tp, size, 0, { + let image = images[&BASE_SLOT].clone(); + move |d: &mut Vec| *d = image + }) + .expect("the quoting slot must fill"); + assert!(quoted > 0, "the caller's quote has to be real"); + + let image = images[&(BASE_SLOT + FILL_AT)].clone(); + let unprotected = bisonfi_replay(&rig.elf, pool, data, tp, size, 0, { + let image = image.clone(); + move |d: &mut Vec| *d = image + }) + .expect("a widened market still quotes, just worse"); + assert!( + unprotected < quoted, + "widening the ladder from 5 ppm to 1% must pay the seller less: got {unprotected} against \ + a quote of {quoted}" + ); + let realized = (quoted - unprotected) as f64 / quoted as f64; + assert!( + realized <= MAX_GAP * 1.02, + "the fill lost {:.4}% but the tick difference only allows {:.4}%, so the 1/2,560,000 unit \ + the template documents is wrong", + realized * 100.0, + MAX_GAP * 100.0 + ); + + // And the case a consumer can actually detect: signing for the quoted price reverts. + let protected = bisonfi_replay_min_out(&rig.elf, pool, data, tp, size, quoted, 0, { + let image = image.clone(); + move |d: &mut Vec| *d = image + }); + assert!( + protected.is_err(), + "signing for the price that was quoted must REVERT once the maker has widened, got \ + {protected:?}" + ); +} + +const WHIRLPOOL_PROGRAM: &str = "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"; + +/// Every account an Orca Whirlpool `swap` needs is derivable and present, so the AMM leg of the +/// cross-venue arbitrage scenario can be executed rather than only priced. +/// +/// `bisonfi_scenario_arbitrage_against_an_amm` currently compares BisonFi's quote against Whirlpool's +/// published state. Making that leg atomic - both swaps in one transaction - needs three tick arrays +/// and an oracle at PDAs that are only created lazily, so whether they exist is a fact about the +/// market and not something to assume. This pins it, and pins the derivation itself: a `TickArray` +/// stores its own `start_tick_index` and a back-pointer to its whirlpool, so if the seed scheme were +/// wrong the addresses would either not resolve or resolve to another pool's arrays. An earlier +/// hand-rolled derivation that skipped the off-curve bump search produced three addresses that all +/// looked plausible and none of which existed, which is exactly the failure this guards against. +#[tokio::test] +async fn whirlpool_swap_account_graph_is_derivable_and_present() { + const TICK_ARRAY_LEN: usize = 9988; + const TICKS_PER_ARRAY: i32 = 88; + + let prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let wp_key = Pubkey::from_str_const(WHIRLPOOL_SOL_USDC); + let wp = fetch(&[WHIRLPOOL_SOL_USDC]).await.remove(0); + + let spacing = u16::from_le_bytes(wp[41..43].try_into().unwrap()); + let tick_current = i32::from_le_bytes(wp[81..85].try_into().unwrap()); + let mint_a = Pubkey::try_from(&wp[101..133]).expect("token_mint_a"); + let vault_a = Pubkey::try_from(&wp[133..165]).expect("token_vault_a"); + let mint_b = Pubkey::try_from(&wp[181..213]).expect("token_mint_b"); + let vault_b = Pubkey::try_from(&wp[213..245]).expect("token_vault_b"); + assert!(spacing > 0, "tick_spacing must be positive, got {spacing}"); + + // The array a tick falls in starts at a multiple of spacing*88, rounded toward negative infinity. + // Integer division truncates toward zero, which is the wrong way for the negative ticks a SOL/USDC + // pool actually sits at, so this rounds explicitly. + let per_array = spacing as i32 * TICKS_PER_ARRAY; + let start = (tick_current as f32 / per_array as f32).floor() as i32 * per_array; + assert!( + start <= tick_current && tick_current < start + per_array, + "the current tick {tick_current} must fall inside its own array [{start}, {})", + start + per_array + ); + + let (oracle, _) = Pubkey::find_program_address(&[b"oracle", wp_key.as_ref()], &prog); + let starts: Vec = [-1i32, 0, 1] + .iter() + .map(|k| start + k * per_array) + .collect(); + let arrays: Vec = starts + .iter() + .map(|s| { + Pubkey::find_program_address( + &[b"tick_array", wp_key.as_ref(), s.to_string().as_bytes()], + &prog, + ) + .0 + }) + .collect(); + + let mut addrs: Vec = arrays.iter().map(|a| a.to_string()).collect(); + addrs.push(vault_a.to_string()); + addrs.push(vault_b.to_string()); + addrs.push(oracle.to_string()); + let refs: Vec<&str> = addrs.iter().map(|s| s.as_str()).collect(); + let got = fetch_optional(&refs).await; + + for ((s, addr), data) in starts.iter().zip(arrays.iter()).zip(got.iter()) { + let data = data.as_ref().unwrap_or_else(|| { + panic!( + "tick array for start {s} ({addr}) does not exist. A swap crossing into it would \ + fail, so the atomic leg needs a pool whose neighbouring arrays are initialized" + ) + }); + assert_eq!(data.len(), TICK_ARRAY_LEN, "{addr}: not a TickArray"); + // start_tick_index sits right after the 8-byte Anchor discriminator. + assert_eq!( + i32::from_le_bytes(data[8..12].try_into().unwrap()), + *s, + "{addr}: the account's own start_tick_index disagrees with the seed it was derived \ + from, so the derivation is wrong" + ); + // ...and the trailing whirlpool back-pointer proves it belongs to THIS pool. + assert_eq!( + Pubkey::try_from(&data[TICK_ARRAY_LEN - 32..]).expect("whirlpool back-pointer"), + wp_key, + "{addr}: belongs to a different whirlpool" + ); + } + + for (label, mint, vault, data) in [ + ("a", mint_a, vault_a, &got[3]), + ("b", mint_b, vault_b, &got[4]), + ] { + let data = data + .as_ref() + .unwrap_or_else(|| panic!("token_vault_{label} {vault} does not exist")); + assert_eq!( + data.len(), + 165, + "token_vault_{label}: not an SPL token account" + ); + assert_eq!( + Pubkey::try_from(&data[0..32]).expect("vault mint"), + mint, + "token_vault_{label} does not hold the mint the whirlpool declares" + ); + } + + // The oracle is only initialized for adaptive-fee pools. Classic `swap` takes it as an + // UncheckedAccount, so an absent one is passable as an empty account - but the address still has to + // be the right PDA, which is why it is derived here rather than faked. + assert!( + got[5].is_none() || got[5].as_ref().map(|d| !d.is_empty()).unwrap_or(false), + "oracle {oracle} resolved to a zero-length account, which is neither absent nor valid" + ); +} + +/// Orca's `swap`, transcribed from the IDL the program itself publishes on chain. +/// +/// Taken from the Anchor IDL account at `2KFqE4RWoPVbvodo8vbggCFeHPS8TDvgpwp79ALMrcyn`, which carries +/// whirlpool v0.9.0, spec 0.1.0, and a self-declared address matching the program. To re-derive it: +/// the address is `create_with_seed(find_program_address([], program).0, "anchor:idl", program)`, and +/// the account holds zlib-compressed JSON behind a 44-byte header (8 discriminator, 32 authority, +/// 4 length). No copy is kept in the repo - it is 105 KB, nothing reads it, and a stale copy would +/// be worse than none if Orca redeploys. +/// +/// Transcribed rather than parsed at runtime because the IDL account stores zlib-compressed JSON and +/// this crate has no direct zlib dependency. The transcription is not load-bearing on trust: a wrong +/// account order or argument encoding cannot produce a swap that succeeds AND moves four balances +/// consistently, which is what the test below asserts. +mod whirlpool_swap { + /// `sha256("global:swap")[..8]`, and byte-identical to the IDL's declared discriminator. + pub const DISCRIMINATOR: [u8; 8] = [248, 198, 158, 145, 225, 117, 135, 200]; + /// Lower bound on sqrt price; passing it as the limit for an a-to-b swap imposes no constraint. + pub const MIN_SQRT_PRICE: u128 = 4295048016; + /// Upper bound, for the b-to-a direction. + pub const MAX_SQRT_PRICE: u128 = 79226673515401279992447579055; + + /// `amount, other_amount_threshold, sqrt_price_limit, amount_specified_is_input, a_to_b` + pub fn data(amount: u64, threshold: u64, limit: u128, is_input: bool, a_to_b: bool) -> Vec { + let mut d = DISCRIMINATOR.to_vec(); + d.extend_from_slice(&amount.to_le_bytes()); + d.extend_from_slice(&threshold.to_le_bytes()); + d.extend_from_slice(&limit.to_le_bytes()); + d.push(is_input as u8); + d.push(a_to_b as u8); + debug_assert_eq!(d.len(), 42); + d + } +} + +/// The Whirlpool program's executable, cached in the temp dir like [`bisonfi_elf`]. +async fn whirlpool_elf() -> Vec { + let cache = std::env::temp_dir().join("surfpool-whirlpool-program.so"); + if let Ok(bytes) = std::fs::read(&cache) { + if bytes.len() > 200_000 { + return bytes; + } + } + let prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let loader = Pubkey::from_str_const("BPFLoaderUpgradeab1e11111111111111111111111"); + let (programdata, _) = Pubkey::find_program_address(&[prog.as_ref()], &loader); + // 45 bytes of UpgradeableLoaderState::ProgramData precede the ELF. + let bytes = fetch(&[&programdata.to_string()]).await.remove(0)[45..].to_vec(); + let _ = std::fs::write(&cache, &bytes); + bytes +} + +/// Everything needed to replay a swap against one Whirlpool's live state. +struct WhirlpoolFork { + elf: Vec, + key: Pubkey, + data: Vec, + mint_a: Pubkey, + mint_b: Pubkey, + vault_a: (Pubkey, Vec), + vault_b: (Pubkey, Vec), + /// Tick arrays keyed by start index, only those that exist on chain. + arrays: Vec<(i32, Pubkey, Vec)>, + start: i32, + per_array: i32, +} + +impl WhirlpoolFork { + /// The three tick arrays a swap in `a_to_b` order must be handed, in sequence from the current + /// one. Uninitialized neighbours are replaced by repeating the last existing array, which is what + /// Orca's own SDK does - the program only requires the sequence be valid for the direction. + fn tick_arrays(&self, a_to_b: bool) -> Vec { + let step = if a_to_b { + -self.per_array + } else { + self.per_array + }; + let mut out = Vec::new(); + for k in 0..3 { + let want = self.start + step * k; + let found = self + .arrays + .iter() + .find(|(s, _, _)| *s == want) + .map(|(_, k, _)| *k); + match found { + Some(k) => out.push(k), + None => out.push(*out.last().expect("the current array must exist")), + } + } + out + } +} + +async fn whirlpool_fork(pool: &str) -> WhirlpoolFork { + let prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let key = Pubkey::from_str_const(pool); + let data = fetch(&[pool]).await.remove(0); + let spacing = u16::from_le_bytes(data[41..43].try_into().unwrap()); + let tick_current = i32::from_le_bytes(data[81..85].try_into().unwrap()); + let mint_a = Pubkey::try_from(&data[101..133]).expect("mint_a"); + let vault_a_key = Pubkey::try_from(&data[133..165]).expect("vault_a"); + let mint_b = Pubkey::try_from(&data[181..213]).expect("mint_b"); + let vault_b_key = Pubkey::try_from(&data[213..245]).expect("vault_b"); + + let per_array = spacing as i32 * 88; + let start = (tick_current as f32 / per_array as f32).floor() as i32 * per_array; + + // Two arrays below and one above, so either direction has a sequence to walk. + let starts: Vec = (-2..=1).map(|k| start + k * per_array).collect(); + let array_keys: Vec = starts + .iter() + .map(|s| { + Pubkey::find_program_address( + &[b"tick_array", key.as_ref(), s.to_string().as_bytes()], + &prog, + ) + .0 + }) + .collect(); + + let mut addrs: Vec = array_keys.iter().map(|k| k.to_string()).collect(); + addrs.push(vault_a_key.to_string()); + addrs.push(vault_b_key.to_string()); + let refs: Vec<&str> = addrs.iter().map(|s| s.as_str()).collect(); + let got = fetch_optional(&refs).await; + + let arrays: Vec<(i32, Pubkey, Vec)> = starts + .iter() + .zip(array_keys.iter()) + .zip(got.iter()) + .filter_map(|((s, k), d)| d.as_ref().map(|d| (*s, *k, d.clone()))) + .collect(); + assert!( + arrays.iter().any(|(s, _, _)| *s == start), + "{pool}: the tick array holding the current tick does not exist, so no swap can be replayed" + ); + + WhirlpoolFork { + elf: whirlpool_elf().await, + key, + data, + mint_a, + mint_b, + vault_a: (vault_a_key, got[4].clone().expect("vault_a exists")), + vault_b: (vault_b_key, got[5].clone().expect("vault_b exists")), + arrays, + start, + per_array, + } +} + +/// Executes a Whirlpool swap in LiteSVM against forked mainnet state. +/// +/// Returns `(amount_in_spent, amount_out_received)` measured from the taker's own token accounts. +fn whirlpool_replay( + fork: &WhirlpoolFork, + amount_in: u64, + a_to_b: bool, + min_out: u64, +) -> Result<(u64, u64), String> { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let spl = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(prog, &fork.elf) + .map_err(|e| format!("add_program: {e:?}"))?; + + // The pool accrues rewards against wall-clock time and refuses to run if the clock is behind its + // own `reward_last_updated_timestamp` (error 6022, InvalidTimestamp). LiteSVM starts near zero, + // which is millions of seconds behind any forked mainnet account, so the clock has to be advanced + // to the pool's own notion of now. + let pool_ts = u64::from_le_bytes(fork.data[261..269].try_into().unwrap()); + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.unix_timestamp = pool_ts as i64; + clock.slot = 300_000_000; + svm.set_sysvar(&clock); + + let owned = |data: Vec, owner: Pubkey| Account { + lamports: 10_000_000_000, + data, + owner, + executable: false, + rent_epoch: 0, + }; + svm.set_account(fork.key, owned(fork.data.clone(), prog)) + .map_err(|e| format!("seed whirlpool: {e:?}"))?; + for (_, key, data) in &fork.arrays { + svm.set_account(*key, owned(data.clone(), prog)) + .map_err(|e| format!("seed tick array: {e:?}"))?; + } + for (key, data) in [&fork.vault_a, &fork.vault_b] { + svm.set_account(*key, owned(data.clone(), spl)) + .map_err(|e| format!("seed vault: {e:?}"))?; + } + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|e| format!("airdrop: {e:?}"))?; + // The taker starts funded on the side they are selling and empty on the side they are buying, so + // the balances below measure the swap and nothing else. + let (ta_a, ta_b) = (Pubkey::new_unique(), Pubkey::new_unique()); + let (amt_a, amt_b) = if a_to_b { + (amount_in.saturating_mul(2), 0) + } else { + (0, amount_in.saturating_mul(2)) + }; + svm.set_account( + ta_a, + owned(token_account(&fork.mint_a, &taker.pubkey(), amt_a), spl), + ) + .map_err(|e| format!("seed taker a: {e:?}"))?; + svm.set_account( + ta_b, + owned(token_account(&fork.mint_b, &taker.pubkey(), amt_b), spl), + ) + .map_err(|e| format!("seed taker b: {e:?}"))?; + + let (oracle, _) = Pubkey::find_program_address(&[b"oracle", fork.key.as_ref()], &prog); + let arrays = fork.tick_arrays(a_to_b); + // Account order is the IDL's, exactly: see `whirlpool_swap`. + let metas = vec![ + AccountMeta::new_readonly(spl, false), + AccountMeta::new_readonly(taker.pubkey(), true), + AccountMeta::new(fork.key, false), + AccountMeta::new(ta_a, false), + AccountMeta::new(fork.vault_a.0, false), + AccountMeta::new(ta_b, false), + AccountMeta::new(fork.vault_b.0, false), + AccountMeta::new(arrays[0], false), + AccountMeta::new(arrays[1], false), + AccountMeta::new(arrays[2], false), + AccountMeta::new_readonly(oracle, false), + AccountMeta::new_readonly(prog, false), + ]; + let limit = if a_to_b { + whirlpool_swap::MIN_SQRT_PRICE + } else { + whirlpool_swap::MAX_SQRT_PRICE + }; + let swap = Instruction { + program_id: prog, + accounts: metas, + data: whirlpool_swap::data(amount_in, min_out, limit, true, a_to_b), + }; + // Crossing tick arrays costs well over the 200k default. + let mut budget = vec![2u8]; + budget.extend_from_slice(&600_000u32.to_le_bytes()); + let cu = Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }; + + let before_a = spl_amount(&svm.get_account(&ta_a).expect("ta_a").data); + let before_b = spl_amount(&svm.get_account(&ta_b).expect("ta_b").data); + let tx = Transaction::new_signed_with_payer( + &[cu, swap], + Some(&taker.pubkey()), + &[&taker], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .map_err(|e| format!("{:?}", e.err))?; + let after_a = spl_amount(&svm.get_account(&ta_a).expect("ta_a").data); + let after_b = spl_amount(&svm.get_account(&ta_b).expect("ta_b").data); + + if a_to_b { + Ok((before_a - after_a, after_b - before_b)) + } else { + Ok((before_b - after_b, after_a - before_a)) + } +} + +/// A real Orca Whirlpool swap executes against forked mainnet state, in both directions. +/// +/// This is the AMM leg the cross-venue arbitrage scenario needs, and it is also what validates the +/// instruction layout transcribed in `whirlpool_swap`: the assertions below pin all four balances that +/// move, so a wrong account order or argument encoding cannot pass by coincidence. +#[tokio::test] +async fn whirlpool_swap_executes_against_forked_state() { + let fork = whirlpool_fork(WHIRLPOOL_SOL_USDC).await; + // 1 SOL. Small enough to stay inside the current tick array on a pool this deep, which keeps the + // test about the instruction rather than about tick-crossing. + const ONE_SOL: u64 = 1_000_000_000; + + let (spent, got) = whirlpool_replay(&fork, ONE_SOL, true, 0).expect("a_to_b swap must execute"); + assert_eq!( + spent, ONE_SOL, + "the swap must consume exactly the input it was given" + ); + assert!(got > 0, "selling 1 SOL must return USDC"); + + // Sanity-check the rate against the pool's own published price rather than a hardcoded number, so + // this does not rot as SOL moves. sqrt_price is Q64.64 over raw units. + let sqrt_price = u128::from_le_bytes(fork.data[65..81].try_into().unwrap()); + let price_raw = (sqrt_price as f64 / 2f64.powi(64)).powi(2); // USDC-raw per SOL-raw + let expected = ONE_SOL as f64 * price_raw; + let ratio = got as f64 / expected; + assert!( + (0.97..=1.0).contains(&ratio), + "1 SOL returned {got} USDC-raw where the pool's own sqrt_price implies about {expected:.0}; \ + ratio {ratio:.4} is outside the fee-and-slippage band, so the swap is not pricing off this \ + pool's state" + ); + + // The other direction, sized from what the first leg produced so it is the same notional. + let (spent_b, got_b) = + whirlpool_replay(&fork, got, false, 0).expect("b_to_a swap must execute"); + assert_eq!( + spent_b, got, + "the reverse swap must consume exactly its input" + ); + assert!( + got_b > 0 && got_b < ONE_SOL, + "round-tripping must return less than the 1 SOL it started with after fees, got {got_b}" + ); + + // And the threshold argument is enforced, which the arbitrage test relies on for its profit floor. + let greedy = whirlpool_replay(&fork, ONE_SOL, true, got + 1); + assert!( + greedy.is_err(), + "asking for more than the swap can deliver must revert, got {greedy:?}" + ); +} + +/// Buys a fixed quantity of the base asset on Orca and sells it on BisonFi in ONE transaction. +/// +/// `dislocation` scales BisonFi's published mid, so a value above 1.0 makes BisonFi the richer bid and +/// the round trip profitable. Returns the taker's net change in the quote asset - negative is a loss. +/// +/// The two legs are coupled by using an exact-OUTPUT swap on Orca: an instruction's amounts are fixed +/// when the transaction is built, so a leg that bought "whatever N USDC gets" could not be followed by +/// a leg that sells exactly that. Asking Orca for exactly N base tokens and paying whatever it costs +/// makes the second leg's size known in advance, which is what lets both legs sit in one transaction. +async fn bisonfi_orca_atomic_arb( + bisonfi_pool: &str, + base_out: u64, + dislocation: f64, +) -> Result { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let orca = whirlpool_fork(WHIRLPOOL_SOL_USDC).await; + let bf_elf = bisonfi_elf().await; + let mut bf = fetch(&[bisonfi_pool]).await.remove(0); + let bf_programs = bisonfi_token_programs(&[bf.clone()]).await.remove(0); + + let g64 = |b: &[u8], o: usize| u64::from_le_bytes(b[o..o + 8].try_into().unwrap()); + let base_reserve = g64(&bf, 48); + let quote_reserve = g64(&bf, 56); + let bf_base_vault = Pubkey::new_from_array(bf[120..152].try_into().unwrap()); + let bf_quote_vault = Pubkey::new_from_array(bf[152..184].try_into().unwrap()); + let base_mint = Pubkey::new_from_array(bf[184..216].try_into().unwrap()); + let quote_mint = Pubkey::new_from_array(bf[216..248].try_into().unwrap()); + let bf_slot = g64(&bf, 72); + + // Both venues have to be quoting the same pair in the same order, or the shared token accounts + // below would be silently routing two unrelated markets. + assert_eq!( + (orca.mint_a, orca.mint_b), + (base_mint, quote_mint), + "the Orca pool and the BisonFi market must quote the same base/quote pair" + ); + + // Dislocate BisonFi's mid through the shipped template. + if dislocation != 1.0 { + let mid = u128::from_le_bytes(bf[832..848].try_into().unwrap()); + let moved = (mid as f64 * dislocation) as u128; + bisonfi_apply_template( + "bisonfi-fair-value", + &[("fair_value", serde_json::json!(moved.to_string()))], + )(&mut bf); + } + + let bf_prog = Pubkey::from_str_const(BISONFI_PROGRAM); + let orca_prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let spl = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + let bf_key = Pubkey::from_str_const(bisonfi_pool); + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(bf_prog, &bf_elf) + .map_err(|e| format!("add bisonfi: {e:?}"))?; + svm.add_program(orca_prog, &orca.elf) + .map_err(|e| format!("add orca: {e:?}"))?; + + // One clock satisfies both venues: BisonFi checks the SLOT against its own last_update_slot and + // Orca checks the TIMESTAMP against its reward accrual, so the two constraints do not collide. + let orca_ts = u64::from_le_bytes(orca.data[261..269].try_into().unwrap()); + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = bf_slot; + clock.unix_timestamp = orca_ts as i64; + svm.set_sysvar(&clock); + svm.set_account( + Pubkey::from_str_const("SysvarLastRestartS1ot1111111111111111111111"), + Account { + lamports: 1_000_000, + data: 246_464_040u64.to_le_bytes().to_vec(), + owner: Pubkey::from_str_const("Sysvar1111111111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|e| format!("set last_restart_slot: {e:?}"))?; + + let owned = |data: Vec, owner: Pubkey| Account { + lamports: 10_000_000_000, + data, + owner, + executable: false, + rent_epoch: 0, + }; + svm.set_account(orca.key, owned(orca.data.clone(), orca_prog)) + .map_err(|e| format!("{e:?}"))?; + for (_, k, d) in &orca.arrays { + svm.set_account(*k, owned(d.clone(), orca_prog)) + .map_err(|e| format!("{e:?}"))?; + } + for (k, d) in [&orca.vault_a, &orca.vault_b] { + svm.set_account(*k, owned(d.clone(), spl)) + .map_err(|e| format!("{e:?}"))?; + } + svm.set_account(bf_key, owned(bf, bf_prog)) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + bf_base_vault, + owned( + token_account(&base_mint, &bf_key, base_reserve), + bf_programs.0, + ), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + bf_quote_vault, + owned( + token_account("e_mint, &bf_key, quote_reserve + 79_168), + bf_programs.1, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + // The arbitrageur: funded in the quote asset, empty in the base. Both legs share these two + // accounts, which is what makes the profit measurable as a single balance change. + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|e| format!("{e:?}"))?; + let (base_ta, quote_ta) = (Pubkey::new_unique(), Pubkey::new_unique()); + let quote_funding = quote_reserve / 4; + svm.set_account( + base_ta, + owned(token_account(&base_mint, &taker.pubkey(), 0), spl), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + quote_ta, + owned( + token_account("e_mint, &taker.pubkey(), quote_funding), + spl, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + let (oracle, _) = Pubkey::find_program_address(&[b"oracle", orca.key.as_ref()], &orca_prog); + let arrays = orca.tick_arrays(false); // buying base means B -> A + let buy_on_orca = Instruction { + program_id: orca_prog, + accounts: vec![ + AccountMeta::new_readonly(spl, false), + AccountMeta::new_readonly(taker.pubkey(), true), + AccountMeta::new(orca.key, false), + AccountMeta::new(base_ta, false), + AccountMeta::new(orca.vault_a.0, false), + AccountMeta::new(quote_ta, false), + AccountMeta::new(orca.vault_b.0, false), + AccountMeta::new(arrays[0], false), + AccountMeta::new(arrays[1], false), + AccountMeta::new(arrays[2], false), + AccountMeta::new_readonly(oracle, false), + AccountMeta::new_readonly(orca_prog, false), + ], + // Exact output: `base_out` of token A, paying up to u64::MAX of token B. + data: whirlpool_swap::data( + base_out, + u64::MAX, + whirlpool_swap::MAX_SQRT_PRICE, + false, + false, + ), + }; + + let mut bf_data = Vec::with_capacity(19); + bf_data.push(0x07); + bf_data.extend_from_slice(&base_out.to_le_bytes()); + bf_data.extend_from_slice(&0u64.to_le_bytes()); // min_out; profit is asserted on balances + bf_data.push(0); // direction 0 = sell base for quote + bf_data.push(0); + let sell_on_bisonfi = Instruction { + program_id: bf_prog, + accounts: vec![ + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(bf_key, false), + AccountMeta::new(bf_base_vault, false), + AccountMeta::new(bf_quote_vault, false), + AccountMeta::new(base_ta, false), + AccountMeta::new(quote_ta, false), + AccountMeta::new_readonly(bf_programs.0, false), + AccountMeta::new_readonly(bf_programs.1, false), + AccountMeta::new_readonly(Pubkey::from_str_const(BISONFI_NINTH), true), + ], + data: bf_data, + }; + + let mut budget = vec![2u8]; + budget.extend_from_slice(&1_800_000u32.to_le_bytes()); + let ixs = vec![ + Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }, + buy_on_orca, + sell_on_bisonfi, + ]; + + let before = spl_amount(&svm.get_account("e_ta).expect("quote_ta").data); + let mut msg = solana_message::Message::new(&ixs, Some(&taker.pubkey())); + msg.recent_blockhash = svm.latest_blockhash(); + let nsig = msg.header.num_required_signatures as usize; + let mut tx = Transaction::new_unsigned(msg); + tx.signatures = vec![solana_signature::Signature::default(); nsig]; + tx.signatures[0] = taker.sign_message(&tx.message.serialize()); + svm.send_transaction(tx) + .map_err(|e| format!("{:?}", e.err))?; + + let after = spl_amount(&svm.get_account("e_ta).expect("quote_ta").data); + let leftover = spl_amount(&svm.get_account(&base_ta).expect("base_ta").data); + assert_eq!( + leftover, 0, + "the arbitrageur must end flat in the base asset, or the profit below is really an \ + unrealized position: {leftover} left over" + ); + Ok(after as i64 - before as i64) +} + +/// SCENARIO: arbitrage between BisonFi and an AMM on the same pair, executed atomically. +/// +/// The upgrade over `bisonfi_scenario_arbitrage_against_an_amm`, which compares the two venues' quotes +/// without trading: here both legs run in a single transaction against forked mainnet state for both +/// programs, and the profit is a real balance change in the arbitrageur's own account. +/// +/// Self-validating in both directions. At the market's true mid the round trip must LOSE money, since +/// the arbitrageur pays fees on both venues - if that leg showed a profit, the harness would be minting +/// value and every number it produced would be suspect. Only once the fair-value template dislocates +/// BisonFi does the same transaction become profitable, and the profit has to grow with the +/// dislocation. +#[tokio::test] +async fn bisonfi_scenario_atomic_arbitrage_against_orca() { + const SOL_USDC: &str = "8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo"; + const ONE_SOL: u64 = 1_000_000_000; + + // No dislocation: buying on Orca and selling on BisonFi at the true mid must not pay. + let fair = bisonfi_orca_atomic_arb(SOL_USDC, ONE_SOL, 1.0) + .await + .expect("the round trip must execute at the true mid"); + assert!( + fair < 0, + "buying on Orca and selling on BisonFi at the true mid returned a profit of {fair}. Two \ + venues both charging a fee cannot pay the taker, so the harness is not measuring a real \ + round trip" + ); + + // Mark BisonFi up so it becomes the richer bid, and the same transaction becomes an arbitrage. + let mut last = fair; + for pct in [2.0f64, 5.0, 10.0] { + let profit = bisonfi_orca_atomic_arb(SOL_USDC, ONE_SOL, 1.0 + pct / 100.0) + .await + .unwrap_or_else(|e| { + panic!("the round trip must execute with BisonFi {pct}% rich: {e}") + }); + assert!( + profit > last, + "marking BisonFi up {pct}% must pay better than the {last} the previous step returned, \ + got {profit}" + ); + last = profit; + } + assert!( + last > 0, + "a 10% dislocation must produce an outright profit, got {last}. The fair-value template's \ + guidance claims this lever creates a cross-venue arbitrage, so it has to actually do so" + ); +} + +/// A stale quote suppresses the price and spread levers entirely, on every market that quotes. +/// +/// This is a PRECEDENCE property: the freshness gate is evaluated before the venue consults its mid +/// or its ladder, so an override that lands byte-perfectly in the account has no effect at all and +/// the transaction still succeeds. It is the most consequential thing to know about combining these +/// templates, and the failure it describes is invisible - no revert, no log, correct bytes. +/// +/// It is also the property most likely to break silently. If a redeploy ever evaluated the quote +/// before the freshness check, every scenario in this suite would keep passing while meaning +/// something different. +/// +/// The fresh leg is what stops this passing vacuously: doubling the published mid on a fresh market +/// has to double the fill, so a run where everything returned zero fails rather than looking green. +#[tokio::test] +async fn bisonfi_staleness_suppresses_the_price_and_spread_levers() { + /// Comfortably past the two-slot cliff. + const STALE_BY: u64 = 5; + + let rig = bisonfi_rig().await; + let mut checked = 0usize; + + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let published = u64::from_le_bytes(data[72..80].try_into().unwrap()); + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let doubled = mid * 2; + let double_mid = || { + bisonfi_apply_template( + "bisonfi-fair-value", + &[("fair_value", serde_json::json!(doubled.to_string()))], + ) + }; + + let baseline = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: control sell must price: {e}")); + assert!(baseline > 0, "{pool}: control sell returned nothing"); + + // Fresh: the price lever works. Without this leg the assertions below would be satisfied by + // a market that simply never quotes. + let fresh_doubled = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, double_mid()) + .unwrap_or_else(|e| panic!("{pool}: fresh market with a doubled mid must price: {e}")); + let ratio = fresh_doubled as f64 / baseline as f64; + assert!( + (1.9..=2.1).contains(&ratio), + "{pool}: doubling the mid on a FRESH market should about double the fill, got \ + {fresh_doubled} against {baseline} (ratio {ratio:.3}). The price lever is not working, \ + so this test cannot say anything about staleness suppressing it" + ); + + // Stale: the same override, byte-identical, now does nothing. + let stale_doubled = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + let apply = double_mid(); + move |d: &mut Vec| { + d[72..80].copy_from_slice(&(published - STALE_BY).to_le_bytes()); + apply(d); + } + }) + .unwrap_or(0); + assert_eq!( + stale_doubled, 0, + "{pool}: a market {STALE_BY} slots stale must ignore a doubled mid, but it paid \ + {stale_doubled}. The freshness gate no longer runs first, and every scenario that sets \ + a price after spending slots would now behave differently" + ); + + // And the same for the spread lever. + let stale_spread = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + let apply = bisonfi_apply_template("bisonfi-spread", &bisonfi_spread_bids(-13)); + move |d: &mut Vec| { + d[72..80].copy_from_slice(&(published - STALE_BY).to_le_bytes()); + apply(d); + } + }) + .unwrap_or(0); + assert_eq!( + stale_spread, 0, + "{pool}: a market {STALE_BY} slots stale must ignore a spread override, but it paid \ + {stale_spread}" + ); + + checked += 1; + } + + assert!( + checked >= 6, + "only {checked} markets exercised the precedence of the freshness gate" + ); +} diff --git a/crates/core/src/tests/kamino/mod.rs b/crates/core/src/tests/kamino/mod.rs index 6a1c6c2d8..cbb4c68de 100644 --- a/crates/core/src/tests/kamino/mod.rs +++ b/crates/core/src/tests/kamino/mod.rs @@ -52,10 +52,27 @@ async fn fetch(addresses: &[&str]) -> Vec> { .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}")) + // The public endpoint throttles and intermittently 503s, which has nothing to do with what these + // tests assert. Retry a few times with backoff so a transient refusal is not read as a failure. + let mut attempt = 0; + let results = loop { + match client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + { + Ok(r) => break r, + Err(e) => { + attempt += 1; + if attempt >= 5 { + panic!( + "failed to fetch {addresses:?} from mainnet after {attempt} attempts: {e}" + ); + } + tokio::time::sleep(std::time::Duration::from_millis(750 * attempt)).await; + } + } + }; + results .into_iter() .zip(addresses) .map(|(result, address)| match result { @@ -108,7 +125,7 @@ async fn real_mainnet_accounts_round_trip_unchanged() { .unwrap_or_else(|| panic!("template {template_id} should exist")); let account_def = template - .idl + .idl() .accounts .iter() .find(|a| a.name == *account_name) @@ -120,7 +137,7 @@ async fn real_mainnet_accounts_round_trip_unchanged() { ); let forged = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .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 \ @@ -168,7 +185,7 @@ async fn override_on_real_account_touches_only_target_bytes() { .get_forged_account_data( &pubkey, reserve_data, - &reserve.idl, + reserve.idl(), &HashMap::from([( "config.liquidation_threshold_pct".to_string(), serde_json::json!(50u8), @@ -202,7 +219,7 @@ async fn override_on_real_account_touches_only_target_bytes() { .get_forged_account_data( &pubkey, scope_data, - &scope.idl, + scope.idl(), &HashMap::from([( format!("prices.{IDX}.price.value"), serde_json::json!(new_value), @@ -333,7 +350,11 @@ const LIVE_ACCOUNTS: &[(&str, &str, &str)] = &[ ("kamino-swap", "Order", SWAP_ORDER), ("kamino-swap", "GlobalConfig", SWAP_GLOBAL_CONFIG), ("kamino-vault", "VaultState", VAULT_STATE), - ("kamino-vault", "ReserveWhitelistEntry", VAULT_WHITELIST_ENTRY), + ( + "kamino-vault", + "ReserveWhitelistEntry", + VAULT_WHITELIST_ENTRY, + ), ("kamino-liquidity", "WhirlpoolStrategy", STRATEGY), ]; @@ -356,9 +377,12 @@ async fn every_template_round_trips_over_a_live_account() { .filter(|t| t.account_type == *account_type) { let identity = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .get_forged_account_data(&pubkey, data, template.idl(), &HashMap::new()) .unwrap_or_else(|e| { - panic!("identity round-trip failed for {} ({address}): {e}", template.id) + 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. @@ -377,7 +401,7 @@ async fn every_template_round_trips_over_a_live_account() { let mut overrides: HashMap = HashMap::new(); for property in &template.properties { let ty = surfpool_types::resolve_idl_type( - &template.idl, + template.idl(), &template.account_type, &property.path, ) @@ -391,7 +415,7 @@ async fn every_template_round_trips_over_a_live_account() { } let forged = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &overrides) + .get_forged_account_data(&pubkey, data, template.idl(), &overrides) .unwrap_or_else(|e| { panic!( "forge failed for {} with {} scalar override(s): {e}", @@ -454,7 +478,7 @@ async fn obligation_array_index_and_pubkey_overrides() { ]); let forged = surfnet_svm - .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .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"); @@ -518,7 +542,7 @@ async fn scope_price_override_writes_expected_bytes() { ]); let forged = surfnet_svm - .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .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"); @@ -568,7 +592,7 @@ async fn farms_reward_override_writes_both_halves() { ), ]); let forged_farm = surfnet_svm - .get_forged_account_data(&pubkey, farm_data, &farm.idl, &farm_overrides) + .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); @@ -585,11 +609,17 @@ async fn farms_reward_override_writes_both_halves() { "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)), + ( + "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) + .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()); @@ -632,7 +662,7 @@ async fn liquidation_setup_writes_durable_inputs() { (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) + .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; @@ -663,7 +693,7 @@ async fn liquidation_setup_writes_durable_inputs() { ), ]); let forged_reserve = surfnet_svm - .get_forged_account_data(&pubkey, reserve_data, &reserve.idl, &reserve_overrides) + .get_forged_account_data(&pubkey, reserve_data, reserve.idl(), &reserve_overrides) .expect("reserve config override should apply"); assert_eq!( @@ -697,7 +727,7 @@ async fn withdraw_ticket_and_queue_cursor() { .get("kamino-withdraw-ticket") .expect("withdraw ticket template"); let ticket_disc = &ticket - .idl + .idl() .accounts .iter() .find(|a| a.name == "WithdrawTicket") @@ -715,7 +745,7 @@ async fn withdraw_ticket_and_queue_cursor() { ("invalid".to_string(), serde_json::json!(0u8)), ]); let forged_ticket = surfnet_svm - .get_forged_account_data(&pubkey, &ticket_data, &ticket.idl, &ticket_overrides) + .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()), @@ -746,7 +776,7 @@ async fn withdraw_ticket_and_queue_cursor() { ), ]); let forged_reserve = surfnet_svm - .get_forged_account_data(&pubkey, &reserve_data, &limits.idl, &queue_overrides) + .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()); diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index 1a4b048be..bfa6c64e7 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,5 +1,7 @@ pub mod helpers; pub mod integration; #[cfg(feature = "integration-tests")] +pub mod bisonfi; +#[cfg(feature = "integration-tests")] pub mod kamino; pub mod plugin; diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index 7df74404a..40cbcb0e2 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -277,6 +277,12 @@ pub struct Property { /// For constant_ref type: the name of the constant definition to use #[serde(default, skip_serializing_if = "Option::is_none")] pub constant: Option, + /// Raw-layout only: byte offset of this field within the account. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Raw-layout only: how this field's bytes are produced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encoding: Option, } impl Property { @@ -288,6 +294,8 @@ impl Property { label: None, description: None, constant: None, + offset: None, + encoding: None, } } @@ -299,6 +307,8 @@ impl Property { label: None, description: None, constant: Some(constant.into()), + offset: None, + encoding: None, } } @@ -383,8 +393,11 @@ pub struct OverrideTemplate { pub description: String, /// Protocol this template is for (e.g., "Pyth", "Switchboard") pub protocol: String, - /// IDL for the account structure - defines all available fields and types - pub idl: Idl, + /// IDL for the account structure - defines all available fields and types. + /// + /// `None` for programs that publish no IDL and are written through `raw_layout` instead. Those + /// templates cannot use the IDL write path at all, so there is nothing to reconstruct here. + pub idl: Option, /// How to determine the account address pub address: AccountAddress, /// Account type name from the IDL (e.g., "PriceAccount") @@ -401,9 +414,27 @@ pub struct OverrideTemplate { /// This helps LLMs understand how to correctly use the template #[serde(default, skip_serializing_if = "Option::is_none")] pub llm_context: Option, + /// Set for programs with no usable IDL. When present the override engine writes bytes at + /// each property's offset instead of decoding and re-encoding through the IDL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_layout: Option, } impl OverrideTemplate { + /// The IDL this template was built from. + /// + /// Panics for templates that have none - those belong to programs that publish no IDL and are + /// written through `raw_layout`. Callers that may legitimately see either must match on the + /// field instead of calling this. + pub fn idl(&self) -> &Idl { + self.idl.as_ref().unwrap_or_else(|| { + panic!( + "template {} has no IDL; it is written through raw_layout", + self.id + ) + }) + } + pub fn new( id: String, name: String, @@ -419,13 +450,14 @@ impl OverrideTemplate { name, description, protocol, - idl, + idl: Some(idl), address, account_type, properties, constants: HashMap::new(), tags: Vec::new(), llm_context: None, + raw_layout: None, } } @@ -642,7 +674,8 @@ pub struct YamlOverrideTemplateFile { pub properties: Vec, #[serde(default)] pub constants: HashMap, - pub idl_file_path: String, + #[serde(default)] + pub idl_file_path: Option, pub address: YamlAccountAddress, #[serde(default)] pub tags: Vec, @@ -659,7 +692,7 @@ impl YamlOverrideTemplateFile { name: self.name, description: self.description, protocol: self.protocol, - idl, + idl: Some(idl), address: self.address.into(), account_type: self.account_type, properties: self.properties.into_iter().map(Into::into).collect(), @@ -670,6 +703,7 @@ impl YamlOverrideTemplateFile { .collect(), tags: self.tags, llm_context: self.llm_context, + raw_layout: None, } } } @@ -840,6 +874,12 @@ pub enum YamlProperty { /// For constant_ref type: the name of the constant definition to use #[serde(default)] constant: Option, + /// Raw-layout only: byte offset of this field within the account + #[serde(default)] + offset: Option, + /// Raw-layout only: how this field's bytes are produced + #[serde(default)] + encoding: Option, }, } @@ -853,6 +893,8 @@ impl From for Property { label, description, constant, + offset, + encoding, } => { let kind = match kind.as_deref() { Some("constant_ref") => PropertyKind::ConstantRef, @@ -864,6 +906,8 @@ impl From for Property { label, description, constant, + offset, + encoding, } } } @@ -914,14 +958,18 @@ pub struct YamlOverrideTemplateCollection { /// Account type name from the IDL (optional, can be overridden per template) #[serde(default)] pub account_type: Option, - /// Path to shared IDL file - pub idl_file_path: String, + /// Path to shared IDL file. Absent for programs that publish no IDL. + #[serde(default)] + pub idl_file_path: Option, /// Common tags for all templates #[serde(default)] pub tags: Vec, /// Protocol-specific constants shared by all templates in this collection #[serde(default)] pub constants: HashMap, + /// Byte layout, for programs with no usable IDL. Shared by every template in the collection. + #[serde(default)] + pub raw_layout: Option, /// The templates pub templates: Vec, } @@ -944,6 +992,229 @@ pub struct YamlOverrideTemplateEntry { pub llm_context: Option, } + +// ======================================== +// Raw byte layouts (programs with no usable IDL) +// ======================================== + +/// How a raw-layout field's bytes are produced. Every variant is integer-exact: values arrive as +/// JSON integers or decimal strings and are written little-endian, never routed through f64. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +pub enum RawEncoding { + U8, + U16, + U32, + U64, + U128, + I32, + I64, + I128, + /// A signed 32-bit value written to `count` slots, `stride` bytes apart. + /// + /// Exists because some layouts repeat one logical setting across a run of fixed-size records, and + /// exposing one property per record means exposing several that must agree - a worse footgun than + /// whatever it was meant to fix. BisonFi's quote ladder. + I32Strided { + count: usize, + stride: usize, + }, + /// A base58 pubkey, written as 32 bytes. + Bytes32, + /// The slot the override materializes at, plus `lead` (may be negative). + Slot { lead: i64 }, +} + +impl RawEncoding { + /// Byte width of this encoding. + pub fn width(&self) -> usize { + match self { + RawEncoding::U8 => 1, + RawEncoding::U16 => 2, + RawEncoding::U32 | RawEncoding::I32 | RawEncoding::I32Strided { .. } => 4, + RawEncoding::U64 | RawEncoding::I64 | RawEncoding::Slot { .. } => 8, + RawEncoding::U128 | RawEncoding::I128 => 16, + RawEncoding::Bytes32 => 32, + } + } + + /// How many times the encoded value is written, and the byte step between writes. + /// + /// Every scalar writes once. Returning this uniformly lets `materialize` place strided and scalar + /// encodings with the same loop instead of special-casing one of them. + pub fn placements(&self) -> (usize, usize) { + match self { + RawEncoding::I32Strided { count, stride } => (*count, *stride), + other => (1, other.width()), + } + } + + /// The little-endian bytes for `value`. `target_slot` is only read by [`RawEncoding::Slot`]. + pub fn encode( + &self, + value: &serde_json::Value, + target_slot: Slot, + ) -> Result, String> { + // Read the digits as text so nothing passes through f64, which cannot hold a u128 + // exactly. A decimal string is the only way to express values above u64::MAX in JSON. + let digits = |what: &str| -> Result { + match value { + serde_json::Value::Number(n) if n.as_u64().is_none() && n.as_i64().is_none() => { + Err(format!( + "{n} exceeds what a JSON number can hold exactly; pass this {what} as a \ + decimal string instead" + )) + } + serde_json::Value::Number(n) => Ok(n.to_string()), + serde_json::Value::String(s) => Ok(s.trim().to_string()), + other => Err(format!("expected a number or decimal string for {what}, found {other}")), + } + }; + macro_rules! int { + ($ty:ty, $what:expr) => {{ + let d = digits($what)?; + d.parse::<$ty>() + .map_err(|e| format!("invalid {}: '{d}': {e}", $what))? + .to_le_bytes() + .to_vec() + }}; + } + Ok(match self { + RawEncoding::U8 => int!(u8, "u8"), + RawEncoding::U16 => int!(u16, "u16"), + RawEncoding::U32 => int!(u32, "u32"), + RawEncoding::U64 => int!(u64, "u64"), + RawEncoding::U128 => int!(u128, "u128"), + RawEncoding::I32 | RawEncoding::I32Strided { .. } => int!(i32, "i32"), + RawEncoding::I64 => int!(i64, "i64"), + RawEncoding::I128 => int!(i128, "i128"), + RawEncoding::Bytes32 => { + let text = value + .as_str() + .ok_or_else(|| "expected a base58 pubkey string".to_string())?; + Pubkey::from_str(text) + .map_err(|e| format!("invalid pubkey '{text}': {e}"))? + .to_bytes() + .to_vec() + } + RawEncoding::Slot { lead } => (target_slot as i64) + .saturating_add(*lead) + .max(0) + .to_le_bytes() + .to_vec(), + }) + } +} + +/// Bytes that must be present for an account to be the one a raw layout describes. Without an +/// IDL there is no discriminator to resolve the type, so this is the only thing standing between +/// a raw write and silently corrupting an unrelated account. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +pub struct RawMagic { + pub offset: usize, + /// Expected bytes, as an ASCII string or a byte list. + pub bytes: Vec, +} + +/// A byte-level description of an account, used instead of an IDL. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +// Deliberately no `ts(export)`: override templates are not part of the TS surface, so the three +// raw-layout types have nothing referencing them there and exporting them produced no file. +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +pub struct RawLayout { + /// Exact account size. A mismatch means this is not the account the layout describes. + /// Serialized camelCase for the JSON API; the alias keeps the YAML snake_case like its peers. + #[serde(alias = "account_size")] + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub account_size: usize, + /// Optional type tag. Omit for programs that have none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub magic: Option, +} + +impl RawLayout { + /// Rejects an account that is not the shape this layout describes. + pub fn guard(&self, data: &[u8]) -> Result<(), String> { + if data.len() != self.account_size { + return Err(format!( + "account is {} bytes, the layout describes {}", + data.len(), + self.account_size + )); + } + if let Some(magic) = &self.magic { + let end = magic + .offset + .checked_add(magic.bytes.len()) + .ok_or_else(|| "magic offset overflow".to_string())?; + if end > data.len() || &data[magic.offset..end] != magic.bytes.as_slice() { + return Err(format!( + "magic bytes at offset {} do not match; this is not the expected account", + magic.offset + )); + } + } + Ok(()) + } + + /// Writes `values` into a copy of `data` using each property's offset and encoding. + pub fn materialize( + &self, + data: &[u8], + properties: &[Property], + values: &HashMap, + target_slot: Slot, + ) -> Result, String> { + self.guard(data)?; + let mut out = data.to_vec(); + for (name, value) in values { + let property = properties + .iter() + .find(|p| &p.path == name) + .ok_or_else(|| format!("'{name}' is not a property of this raw-layout template"))?; + let (Some(offset), Some(encoding)) = (property.offset, property.encoding.as_ref()) + else { + return Err(format!("property '{name}' has no offset or encoding")); + }; + let bytes = encoding.encode(value, target_slot)?; + let (count, stride) = encoding.placements(); + for i in 0..count { + let at = offset + .checked_add(i.checked_mul(stride).ok_or_else(|| { + format!("stride overflow for '{name}'") + })?) + .ok_or_else(|| format!("offset overflow for '{name}'"))?; + let end = at + .checked_add(bytes.len()) + .ok_or_else(|| format!("offset overflow for '{name}'"))?; + if end > out.len() { + // Scalars keep the original wording; only a strided run needs to explain itself. + return Err(if count == 1 { + format!( + "'{name}' at offset {offset} + {} bytes exceeds the {} byte account", + bytes.len(), + out.len() + ) + } else { + format!( + "'{name}' writes {count} x {} bytes from offset {offset} every \ + {stride}, which exceeds the {} byte account", + bytes.len(), + out.len() + ) + }); + } + out[at..end].copy_from_slice(&bytes); + } + } + Ok(out) + } +} + /// Walks a dot-notation path: struct fields by name, array elements by index. /// /// Returns the last named field and the type at the path's end. They differ on a trailing index: @@ -1040,7 +1311,7 @@ fn idl_field_docs(idl: &Idl, account_type: &str, path: &str) -> Option { /// supply one, so field guidance is not written twice. fn describe_properties_from_idl( properties: Vec, - idl: &Idl, + idl: Option<&Idl>, account_type: &str, ) -> Vec { properties @@ -1048,7 +1319,10 @@ fn describe_properties_from_idl( .map(|yaml| { let mut property: Property = yaml.into(); if property.description.is_none() { - property.description = idl_field_docs(idl, account_type, &property.path); + // Only a fallback. A raw_layout collection with no IDL must spell out every + // description in the YAML, since there is no schema to borrow docs from. + property.description = + idl.and_then(|idl| idl_field_docs(idl, account_type, &property.path)); } property }) @@ -1057,7 +1331,7 @@ fn describe_properties_from_idl( impl YamlOverrideTemplateCollection { /// Convert collection to runtime OverrideTemplates with loaded IDL - pub fn to_override_templates(self, idl: Idl) -> Vec { + pub fn to_override_templates(self, idl: Option) -> Vec { // Convert constants once for sharing let constants: HashMap = self .constants @@ -1080,11 +1354,12 @@ impl YamlOverrideTemplateCollection { protocol: self.protocol.clone(), idl: idl.clone(), address: entry.address.into(), - properties: describe_properties_from_idl(entry.properties, &idl, &account_type), + properties: describe_properties_from_idl(entry.properties, idl.as_ref(), &account_type), account_type, constants: constants.clone(), tags: self.tags.clone(), llm_context: entry.llm_context, + raw_layout: self.raw_layout.clone(), } }) .collect() @@ -1122,7 +1397,7 @@ impl YamlOverrideTemplate { name: self.name, description: self.description, protocol: self.protocol, - idl: self.idl, + idl: Some(self.idl), address: self.address.into(), account_type: self.account_type, properties: self.properties.into_iter().map(Into::into).collect(), @@ -1133,6 +1408,7 @@ impl YamlOverrideTemplate { .collect(), tags: self.tags, llm_context: self.llm_context, + raw_layout: None, } } } @@ -1231,6 +1507,123 @@ mod tests { use super::PdaSeed; + /// The encoding layer must never route a value through f64: a 2^88-scaled price is a 29-digit + /// integer and f64 carries about 16 significant digits. + #[test] + fn raw_encoding_writes_large_values_exactly() { + use super::RawEncoding; + + let huge: u128 = 50u128 * (1u128 << 88); + let bytes = RawEncoding::U128 + .encode(&json!(huge.to_string()), 0) + .expect("decimal string"); + assert_eq!(u128::from_le_bytes(bytes.try_into().unwrap()), huge); + + // A bare JSON number that big has already lost digits, so it must be refused rather than + // silently written wrong. + let err = RawEncoding::U128 + .encode(&json!(1.152921504606847e21), 0) + .expect_err("an inexact JSON number must be refused"); + assert!(err.contains("decimal string"), "unexpected error: {err}"); + } + + #[test] + fn raw_encoding_handles_signed_and_slot_fields() { + use super::RawEncoding; + + let bytes = RawEncoding::I64.encode(&json!(-25599i64 << 32), 0).unwrap(); + assert_eq!(i64::from_le_bytes(bytes.try_into().unwrap()) >> 32, -25599); + + let bytes = RawEncoding::Slot { lead: -1 }.encode(&json!(0), 500).unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 499); + + // A lead that would go below zero clamps rather than wrapping. + let bytes = RawEncoding::Slot { lead: -10 }.encode(&json!(0), 3).unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 0); + } + + #[test] + fn raw_layout_rejects_writes_past_the_end_of_the_account() { + use super::{Property, RawEncoding, RawLayout}; + + let layout = RawLayout { + account_size: 16, + magic: None, + }; + let mut property = Property::field("tail".to_string()); + property.offset = Some(12); + property.encoding = Some(RawEncoding::U64); + + let err = layout + .materialize(&[0u8; 16], &[property], &HashMap::from([("tail".to_string(), json!(1))]), 0) + .expect_err("a field crossing the end must be refused"); + assert!(err.contains("exceeds"), "unexpected error: {err}"); + } + + #[test] + fn i32_strided_writes_every_slot_and_nothing_between() { + use super::{Property, RawEncoding, RawLayout}; + let layout = RawLayout { + account_size: 64, + magic: None, + }; + let mut property = Property::field("ticks".to_string()); + property.offset = Some(4); + property.encoding = Some(RawEncoding::I32Strided { + count: 3, + stride: 16, + }); + + let out = layout + .materialize( + &[0u8; 64], + &[property], + &HashMap::from([("ticks".to_string(), json!(-25_600))]), + 0, + ) + .expect("strided write"); + + for i in 0..3usize { + let at = 4 + i * 16; + assert_eq!( + i32::from_le_bytes(out[at..at + 4].try_into().unwrap()), + -25_600, + "slot {i} at offset {at} should carry the value" + ); + } + // Everything outside the three four-byte spans must be untouched. + let written: Vec = (0..3).flat_map(|i| (4 + i * 16)..(8 + i * 16)).collect(); + for (i, b) in out.iter().enumerate() { + if !written.contains(&i) { + assert_eq!(*b, 0, "byte {i} lies between strided slots and must not change"); + } + } + } + + #[test] + fn i32_strided_rejects_a_run_that_leaves_the_account() { + use super::{Property, RawEncoding, RawLayout}; + let layout = RawLayout { + account_size: 32, + magic: None, + }; + let mut property = Property::field("ticks".to_string()); + property.offset = Some(4); + property.encoding = Some(RawEncoding::I32Strided { + count: 3, + stride: 16, + }); + let err = layout + .materialize( + &[0u8; 32], + &[property], + &HashMap::from([("ticks".to_string(), json!(1))]), + 0, + ) + .expect_err("a run crossing the end must be refused"); + assert!(err.contains("exceeds"), "unexpected error: {err}"); + } + #[test] fn u16_be_ref_rejects_out_of_range_values() { let seed = PdaSeed::U16BeRef("index".to_string()); From 686cf071961200ddfdbb903f5f7126da7745955f Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 20 Aug 2026 16:21:59 +0300 Subject: [PATCH 05/32] Introduce 'persist' property to the rpc and update Readme.md with large numbers support --- crates/core/src/scenarios/protocols/kamino/README.md | 2 +- crates/types/src/rpc_endpoints.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/core/src/scenarios/protocols/kamino/README.md b/crates/core/src/scenarios/protocols/kamino/README.md index da0d9e342..7aee1b108 100644 --- a/crates/core/src/scenarios/protocols/kamino/README.md +++ b/crates/core/src/scenarios/protocols/kamino/README.md @@ -198,7 +198,7 @@ kamino-swap-order | 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 | +| `exceeds what a JSON number can hold exactly` | Pass large `u128`/`i128` values as decimal strings, e.g. `"1152921504606846976000"`. Plain JSON numbers are fine below 2^53 | | `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 | diff --git a/crates/types/src/rpc_endpoints.json b/crates/types/src/rpc_endpoints.json index d863d035a..740e1e8a3 100644 --- a/crates/types/src/rpc_endpoints.json +++ b/crates/types/src/rpc_endpoints.json @@ -861,6 +861,7 @@ "label": "Option (An optional label for this override instance)", "enabled": "bool (Indicates whether this override instance is enabled)", "fetchBeforeUse": "bool (Indicates whether to fetch the latest on-chain account data before applying overrides)", + "persist": "bool (Optional, defaults to false. If true, re-applies this override on every following slot instead of only one, which is needed when something else writes the account in between. Use it only for values no transaction writes - an oracle price, a disabled switch, a risk parameter - never for 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)", "account": "AccountAddress (The account this override targets, as {\"pubkey\": \"\"} or {\"pda\": {\"programId\": \"\", \"seeds\": [ ... ]}})" } } From e76b9948638f6173a7d3fb68a21fb0b432f10126 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 24 Aug 2026 15:08:59 +0300 Subject: [PATCH 06/32] Make the bisonfi -> BisonFi --- crates/core/src/scenarios/protocols/bisonfi/overrides.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml b/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml index c05c8560e..29d8fd349 100644 --- a/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml +++ b/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml @@ -1,4 +1,4 @@ -protocol: bisonfi +protocol: BisonFi # The pool-account version this layout describes, and the only one supported. BisonFi ships no # program semver; the guard below rejects the one remaining version-2 account outright. version: v3 From cf7430d9bac517fb771b013f1c25240db0104aa2 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 13 Aug 2026 11:51:57 +0300 Subject: [PATCH 07/32] 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 cb341f2ce..1a2b60375 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 { @@ -2660,6 +2759,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) @@ -2791,6 +2894,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: @@ -2881,12 +3012,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 @@ -6816,4 +6950,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 5ab5f94eb01257050dad0bfcec4460db69cca6ff Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 13 Aug 2026 13:16:10 +0300 Subject: [PATCH 08/32] 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 1a2b60375..4e6aadde6 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -2759,6 +2759,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); } @@ -2887,6 +2888,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); + } } } } @@ -2894,8 +2903,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, @@ -2909,11 +2918,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 {}: {}", @@ -7051,6 +7060,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 2a9f2f69ccc725d1332d0e0f2a8385b8d1c26f39 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Fri, 14 Aug 2026 18:16:36 +0300 Subject: [PATCH 09/32] 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 4e6aadde6..6999558c4 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 @@ -2673,6 +2703,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); @@ -2711,7 +2743,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", @@ -2736,6 +2768,8 @@ impl SurfnetSvm { "Failed to set account {} from remote: {}", account_pubkey, e ); + } else { + settled_this_slot.insert(account_pubkey); } } Ok(GetAccountResult::None(_)) => { @@ -2888,6 +2922,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. @@ -7087,6 +7122,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 01bc99f08..56573e72a 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,4 +1,6 @@ pub mod helpers; pub mod integration; +#[cfg(feature = "integration-tests")] +pub mod kamino; pub mod plugin; pub mod simnet_events; From f71abb842519bc72672615a0eb0e4d29ce9ba3a7 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 20 Aug 2026 16:21:59 +0300 Subject: [PATCH 10/32] Introduce 'persist' property to the rpc and update Readme.md with large numbers support --- crates/core/src/scenarios/protocols/kamino/README.md | 2 +- crates/types/src/rpc_endpoints.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/core/src/scenarios/protocols/kamino/README.md b/crates/core/src/scenarios/protocols/kamino/README.md index da0d9e342..7aee1b108 100644 --- a/crates/core/src/scenarios/protocols/kamino/README.md +++ b/crates/core/src/scenarios/protocols/kamino/README.md @@ -198,7 +198,7 @@ kamino-swap-order | 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 | +| `exceeds what a JSON number can hold exactly` | Pass large `u128`/`i128` values as decimal strings, e.g. `"1152921504606846976000"`. Plain JSON numbers are fine below 2^53 | | `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 | diff --git a/crates/types/src/rpc_endpoints.json b/crates/types/src/rpc_endpoints.json index d863d035a..740e1e8a3 100644 --- a/crates/types/src/rpc_endpoints.json +++ b/crates/types/src/rpc_endpoints.json @@ -861,6 +861,7 @@ "label": "Option (An optional label for this override instance)", "enabled": "bool (Indicates whether this override instance is enabled)", "fetchBeforeUse": "bool (Indicates whether to fetch the latest on-chain account data before applying overrides)", + "persist": "bool (Optional, defaults to false. If true, re-applies this override on every following slot instead of only one, which is needed when something else writes the account in between. Use it only for values no transaction writes - an oracle price, a disabled switch, a risk parameter - never for 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)", "account": "AccountAddress (The account this override targets, as {\"pubkey\": \"\"} or {\"pda\": {\"programId\": \"\", \"seeds\": [ ... ]}})" } } From 46282451dee58bbcd056889b19b91dea36c61f38 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 24 Aug 2026 15:54:16 +0300 Subject: [PATCH 11/32] Add workflow --- .github/openai-review.yml | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/openai-review.yml diff --git a/.github/openai-review.yml b/.github/openai-review.yml new file mode 100644 index 000000000..07fd8a681 --- /dev/null +++ b/.github/openai-review.yml @@ -0,0 +1,73 @@ +name: Perform a code review when a pull request is created. +on: + pull_request: + +jobs: + codex: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + final_message: ${{ steps.run_codex.outputs.final-message }} + steps: + - uses: actions/checkout@v5 + with: + # Explicitly check out the PR's merge commit. + ref: refs/pull/${{ github.event.pull_request.number }}/merge + persist-credentials: false + + - name: Pre-fetch base and head refs for the PR + env: + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Pass GitHub expressions through env and quote shell expansions. + git fetch --no-tags origin \ + "$PR_BASE_REF" \ + "+refs/pull/$PR_NUMBER/head" + + # If you want Codex to build and run code, install any dependencies that + # need to be downloaded before the "Run Codex" step. The recommended + # :workspace permission profile does not grant network access. + + - name: Run Codex + id: run_codex + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + permission-profile: ":workspace" + prompt: | + This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. + + Review ONLY the changes introduced by the PR, so consider: + git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + + Suggest any improvements, potential bugs, or issues. + Be concise and specific in your feedback. + + Pull request title and body: + ---- + ${{ github.event.pull_request.title }} + ${{ github.event.pull_request.body }} + + post_feedback: + runs-on: ubuntu-latest + needs: codex + if: needs.codex.outputs.final_message != '' + permissions: + issues: write + pull-requests: write + steps: + - name: Report Codex feedback + uses: actions/github-script@v7 + env: + CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }} + with: + github-token: ${{ github.token }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: process.env.CODEX_FINAL_MESSAGE, + }); \ No newline at end of file From a02a8cd8a5c59e7fcaea79d836b790fa3860f15a Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 24 Aug 2026 16:00:39 +0300 Subject: [PATCH 12/32] Move workflow to the folder --- .github/{ => workflows}/openai-review.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ => workflows}/openai-review.yml (100%) diff --git a/.github/openai-review.yml b/.github/workflows/openai-review.yml similarity index 100% rename from .github/openai-review.yml rename to .github/workflows/openai-review.yml From c40c4ead796c125dbd519e0d62e2ff2c7d7f01d4 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 24 Aug 2026 17:22:45 +0300 Subject: [PATCH 13/32] Fix duplication in templates --- .gitignore | 2 ++ crates/core/src/surfnet/svm.rs | 55 +++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 360c85527..ba528aa5a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ txtx.yml # ts-rs default export dir (real output lives in crates/sdk-node/surfpool-sdk/kit/generated) crates/types/bindings/ + +.idea diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 6999558c4..2fba73bec 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -2953,7 +2953,11 @@ impl SurfnetSvm { .flatten() .unwrap_or_default(); - if let Some(existing) = next.iter_mut().find(|queued| queued.id == instance.id) { + if let Some(existing) = next.iter_mut().find(|queued| { + queued.id == instance.id + && queued.account == instance.account + && queued.template_id == instance.template_id + }) { *existing = instance.clone(); } else { next.push(instance.clone()); @@ -7170,6 +7174,55 @@ mod tests { assert_eq!(read(ALLOWED_OFFSET), 5_678, "the second override must apply"); } + /// Two persistent overrides that share a caller-supplied id but target different accounts must both survive re-arming. + #[tokio::test] + async fn test_reschedule_keeps_overrides_sharing_an_id_across_accounts() { + const SLOT: u64 = 500; + let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + + let first_account = Pubkey::new_unique(); + let second_account = Pubkey::new_unique(); + + let mut first = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(first_account.to_string()), + ); + // The collision this guards against: a hand-written scenario reusing a plain id. + first.id = "ov-1".to_string(); + first.persist = true; + + let mut second = first.clone(); + second.account = surfpool_types::AccountAddress::Pubkey(second_account.to_string()); + + surfnet_svm.reschedule_override_for_next_slot(&first, SLOT); + surfnet_svm.reschedule_override_for_next_slot(&second, SLOT); + + let queued = surfnet_svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("read scheduled overrides") + .expect("overrides queued for the next slot"); + assert_eq!( + queued.len(), + 2, + "two overrides on different accounts share the id 'ov-1'; keying only on the id drops \ + one of them, so a scenario silently stops being applied" + ); + + surfnet_svm.reschedule_override_for_next_slot(&first, SLOT); + let queued = surfnet_svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("read scheduled overrides") + .expect("overrides queued for the next slot"); + assert_eq!( + queued.len(), + 2, + "re-arming an override must replace its own queued copy, not append a duplicate" + ); + } + #[tokio::test] async fn test_non_persisted_override_is_not_rescheduled() { const SLOT: u64 = 500; From 51de358c116f15678ab57f9e78d4674ac23de30e Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 13 Aug 2026 11:51:57 +0300 Subject: [PATCH 14/32] 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 e7b7a6d24..bdfee5e9a 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; @@ -159,50 +160,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 { @@ -2741,6 +2840,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) @@ -2872,6 +2975,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: @@ -2962,12 +3093,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 @@ -7070,4 +7204,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 76b12f1f880f29c7a40ab5f1e7419d8335c847f4 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 13 Aug 2026 13:16:10 +0300 Subject: [PATCH 15/32] 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 bdfee5e9a..be8cc90c5 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -2840,6 +2840,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); } @@ -2968,6 +2969,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); + } } } } @@ -2975,8 +2984,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, @@ -2990,11 +2999,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 {}: {}", @@ -7305,6 +7314,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 071414f99082dba41130f1ab3ca4e1294167b1e4 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Fri, 14 Aug 2026 18:16:36 +0300 Subject: [PATCH 16/32] 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 6838097cd..fb9b300de 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -126,5 +126,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 be8cc90c5..13c3aa7fc 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -251,6 +251,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, @@ -272,6 +288,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 @@ -2754,6 +2784,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); @@ -2792,7 +2824,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", @@ -2817,6 +2849,8 @@ impl SurfnetSvm { "Failed to set account {} from remote: {}", account_pubkey, e ); + } else { + settled_this_slot.insert(account_pubkey); } } Ok(GetAccountResult::None(_)) => { @@ -2969,6 +3003,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. @@ -7341,6 +7376,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 01bc99f08..56573e72a 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,4 +1,6 @@ pub mod helpers; pub mod integration; +#[cfg(feature = "integration-tests")] +pub mod kamino; pub mod plugin; pub mod simnet_events; From a512e236c7d668a0ec1b6bd502066f63aaa1f43c Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 20 Aug 2026 16:21:59 +0300 Subject: [PATCH 17/32] Introduce 'persist' property to the rpc and update Readme.md with large numbers support --- crates/core/src/scenarios/protocols/kamino/README.md | 2 +- crates/types/src/rpc_endpoints.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/core/src/scenarios/protocols/kamino/README.md b/crates/core/src/scenarios/protocols/kamino/README.md index da0d9e342..7aee1b108 100644 --- a/crates/core/src/scenarios/protocols/kamino/README.md +++ b/crates/core/src/scenarios/protocols/kamino/README.md @@ -198,7 +198,7 @@ kamino-swap-order | 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 | +| `exceeds what a JSON number can hold exactly` | Pass large `u128`/`i128` values as decimal strings, e.g. `"1152921504606846976000"`. Plain JSON numbers are fine below 2^53 | | `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 | diff --git a/crates/types/src/rpc_endpoints.json b/crates/types/src/rpc_endpoints.json index d863d035a..740e1e8a3 100644 --- a/crates/types/src/rpc_endpoints.json +++ b/crates/types/src/rpc_endpoints.json @@ -861,6 +861,7 @@ "label": "Option (An optional label for this override instance)", "enabled": "bool (Indicates whether this override instance is enabled)", "fetchBeforeUse": "bool (Indicates whether to fetch the latest on-chain account data before applying overrides)", + "persist": "bool (Optional, defaults to false. If true, re-applies this override on every following slot instead of only one, which is needed when something else writes the account in between. Use it only for values no transaction writes - an oracle price, a disabled switch, a risk parameter - never for 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)", "account": "AccountAddress (The account this override targets, as {\"pubkey\": \"\"} or {\"pda\": {\"programId\": \"\", \"seeds\": [ ... ]}})" } } From 3e513b7d6de296d35154927e5450ccc0b7882da0 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 24 Aug 2026 15:54:16 +0300 Subject: [PATCH 18/32] Add workflow --- .github/openai-review.yml | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/openai-review.yml diff --git a/.github/openai-review.yml b/.github/openai-review.yml new file mode 100644 index 000000000..07fd8a681 --- /dev/null +++ b/.github/openai-review.yml @@ -0,0 +1,73 @@ +name: Perform a code review when a pull request is created. +on: + pull_request: + +jobs: + codex: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + final_message: ${{ steps.run_codex.outputs.final-message }} + steps: + - uses: actions/checkout@v5 + with: + # Explicitly check out the PR's merge commit. + ref: refs/pull/${{ github.event.pull_request.number }}/merge + persist-credentials: false + + - name: Pre-fetch base and head refs for the PR + env: + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Pass GitHub expressions through env and quote shell expansions. + git fetch --no-tags origin \ + "$PR_BASE_REF" \ + "+refs/pull/$PR_NUMBER/head" + + # If you want Codex to build and run code, install any dependencies that + # need to be downloaded before the "Run Codex" step. The recommended + # :workspace permission profile does not grant network access. + + - name: Run Codex + id: run_codex + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + permission-profile: ":workspace" + prompt: | + This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. + + Review ONLY the changes introduced by the PR, so consider: + git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + + Suggest any improvements, potential bugs, or issues. + Be concise and specific in your feedback. + + Pull request title and body: + ---- + ${{ github.event.pull_request.title }} + ${{ github.event.pull_request.body }} + + post_feedback: + runs-on: ubuntu-latest + needs: codex + if: needs.codex.outputs.final_message != '' + permissions: + issues: write + pull-requests: write + steps: + - name: Report Codex feedback + uses: actions/github-script@v7 + env: + CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }} + with: + github-token: ${{ github.token }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: process.env.CODEX_FINAL_MESSAGE, + }); \ No newline at end of file From fe2c8a4bdcacb338593e76ed85c8c0f844da182c Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 24 Aug 2026 16:00:39 +0300 Subject: [PATCH 19/32] Move workflow to the folder --- .github/{ => workflows}/openai-review.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ => workflows}/openai-review.yml (100%) diff --git a/.github/openai-review.yml b/.github/workflows/openai-review.yml similarity index 100% rename from .github/openai-review.yml rename to .github/workflows/openai-review.yml From 794b8e819c1d96697caa8fed25569cb42fd5e4da Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 24 Aug 2026 17:22:45 +0300 Subject: [PATCH 20/32] Fix duplication in templates --- .gitignore | 2 ++ crates/core/src/surfnet/svm.rs | 55 +++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 360c85527..ba528aa5a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ txtx.yml # ts-rs default export dir (real output lives in crates/sdk-node/surfpool-sdk/kit/generated) crates/types/bindings/ + +.idea diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 13c3aa7fc..18e3b5db7 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -3034,7 +3034,11 @@ impl SurfnetSvm { .flatten() .unwrap_or_default(); - if let Some(existing) = next.iter_mut().find(|queued| queued.id == instance.id) { + if let Some(existing) = next.iter_mut().find(|queued| { + queued.id == instance.id + && queued.account == instance.account + && queued.template_id == instance.template_id + }) { *existing = instance.clone(); } else { next.push(instance.clone()); @@ -7424,6 +7428,55 @@ mod tests { assert_eq!(read(ALLOWED_OFFSET), 5_678, "the second override must apply"); } + /// Two persistent overrides that share a caller-supplied id but target different accounts must both survive re-arming. + #[tokio::test] + async fn test_reschedule_keeps_overrides_sharing_an_id_across_accounts() { + const SLOT: u64 = 500; + let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + + let first_account = Pubkey::new_unique(); + let second_account = Pubkey::new_unique(); + + let mut first = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(first_account.to_string()), + ); + // The collision this guards against: a hand-written scenario reusing a plain id. + first.id = "ov-1".to_string(); + first.persist = true; + + let mut second = first.clone(); + second.account = surfpool_types::AccountAddress::Pubkey(second_account.to_string()); + + surfnet_svm.reschedule_override_for_next_slot(&first, SLOT); + surfnet_svm.reschedule_override_for_next_slot(&second, SLOT); + + let queued = surfnet_svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("read scheduled overrides") + .expect("overrides queued for the next slot"); + assert_eq!( + queued.len(), + 2, + "two overrides on different accounts share the id 'ov-1'; keying only on the id drops \ + one of them, so a scenario silently stops being applied" + ); + + surfnet_svm.reschedule_override_for_next_slot(&first, SLOT); + let queued = surfnet_svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("read scheduled overrides") + .expect("overrides queued for the next slot"); + assert_eq!( + queued.len(), + 2, + "re-arming an override must replace its own queued copy, not append a duplicate" + ); + } + #[tokio::test] async fn test_non_persisted_override_is_not_rescheduled() { const SLOT: u64 = 500; From 1b3323e939f18c8b32cdf50545e0e1933bbaf3ba Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 08:35:32 +0300 Subject: [PATCH 21/32] Fix integration tests --- crates/core/src/tests/kamino/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/core/src/tests/kamino/mod.rs b/crates/core/src/tests/kamino/mod.rs index 6a1c6c2d8..b915f29be 100644 --- a/crates/core/src/tests/kamino/mod.rs +++ b/crates/core/src/tests/kamino/mod.rs @@ -60,8 +60,7 @@ async fn fetch(addresses: &[&str]) -> Vec> { .zip(addresses) .map(|(result, address)| match result { GetAccountResult::FoundAccount(_, account, _) - | GetAccountResult::FoundProgramAccount((_, account), _) - | GetAccountResult::FoundTokenAccount((_, account), _) => account.data, + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account.data, GetAccountResult::None(_) => { panic!("{address} no longer exists on mainnet; the test needs a new address") } From c7dde3cd496beaa8659671caf450d16f3ec50ffa Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 09:23:46 +0300 Subject: [PATCH 22/32] Fix bug --- crates/core/src/surfnet/svm.rs | 130 ++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 11 deletions(-) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 18e3b5db7..03fa1ea85 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -2874,9 +2874,14 @@ impl SurfnetSvm { } } - // Queued before the write so a failed apply is retried next slot, still fetching. + let existing_account = self.inner.get_account(&account_pubkey)?; + if override_instance.persist { - self.reschedule_override_for_next_slot(&override_instance, target_slot); + let mut requeued = override_instance.clone(); + if requeued.fetch_before_use && existing_account.is_some() { + requeued.fetch_before_use = false; + } + self.reschedule_override_for_next_slot(&requeued, target_slot); } // Apply the override values to the account data @@ -2907,7 +2912,7 @@ impl SurfnetSvm { ); // Get the account from the SVM - let Some(account) = self.inner.get_account(&account_pubkey)? else { + let Some(account) = existing_account else { warn!( "Account {} not found in SVM for override {}, skipping modifications", account_pubkey, override_instance.id @@ -3004,14 +3009,6 @@ impl SurfnetSvm { 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. - 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); - } } } } @@ -7380,6 +7377,117 @@ mod tests { ); } + /// An override that writes no account fields still forks the account, so it must stop fetching too. + #[tokio::test] + async fn test_persisted_override_that_writes_no_fields_stops_refetching() { + const SLOT: u64 = 500; + + let (mut svm, account_pubkey, _instance) = scheduled_persist_fixture(true); + + // Values consumed entirely by PDA derivation, so `account_values` filters down to empty. + let seed_only = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pda { + program_id: "KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD".to_string(), + seeds: vec![surfpool_types::PdaSeed::PropertyRef("market".to_string())], + }, + ) + .with_values(HashMap::from([( + "market".to_string(), + serde_json::json!(account_pubkey.to_string()), + )])); + + // Point the derived address at a real forked account so presence is what is being tested. + let derived = seed_only + .account + .resolve(Some(&seed_only.values)) + .expect("derive pda"); + let forked = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("fixture account present"); + svm.inner + .set_account(derived, forked) + .expect("set derived account"); + + let mut no_values = surfpool_types::OverrideInstance::new( + "kamino-obligation-noop".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ); + no_values.persist = true; + no_values.fetch_before_use = true; + + let mut seed_only = seed_only; + seed_only.persist = true; + seed_only.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![seed_only, no_values]) + .expect("schedule overrides"); + + 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(), 2, "both overrides re-armed, one entry each"); + for queued in &next { + assert!(queued.persist, "persist must survive rescheduling"); + assert!( + !queued.fetch_before_use, + "override {} forked its account, so later slots must not re-fetch it", + queued.id + ); + } + } + + /// The flag is only cleared once there is something local to keep. An override whose account + /// never materialized must keep fetching, or it can never recover. + #[tokio::test] + async fn test_persisted_override_keeps_fetching_while_the_account_is_missing() { + const SLOT: u64 = 500; + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + let mut absent = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), + ) + .with_values(HashMap::from([( + "unhealthy_borrow_value_sf".to_string(), + serde_json::json!(1_234u64), + )])); + absent.persist = true; + absent.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![absent]) + .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].fetch_before_use, + "the account is still missing, so the next slot must retry the fetch" + ); + } + /// 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. From 77eca227cabcdb006463242caa92326b9c86c83d Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 09:40:28 +0300 Subject: [PATCH 23/32] fix: stop persisted overrides re-fetching every slot --- crates/core/src/surfnet/svm.rs | 66 +++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 03fa1ea85..ed4173b7c 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -2876,12 +2876,16 @@ impl SurfnetSvm { let existing_account = self.inner.get_account(&account_pubkey)?; + let account_materialized = existing_account + .as_ref() + .is_some_and(|account| account.data().len() >= 8); + if override_instance.persist { let mut requeued = override_instance.clone(); - if requeued.fetch_before_use && existing_account.is_some() { + if requeued.fetch_before_use && account_materialized { requeued.fetch_before_use = false; } - self.reschedule_override_for_next_slot(&requeued, target_slot); + self.reschedule_override_for_next_slot(&requeued, target_slot)?; } // Apply the override values to the account data @@ -3022,13 +3026,11 @@ impl SurfnetSvm { &mut self, instance: &OverrideInstance, target_slot: Slot, - ) { + ) -> SurfpoolResult<()> { let next_slot = target_slot + 1; let mut next = self .scheduled_overrides - .get(&next_slot) - .ok() - .flatten() + .get(&next_slot)? .unwrap_or_default(); if let Some(existing) = next.iter_mut().find(|queued| { @@ -3040,12 +3042,8 @@ impl SurfnetSvm { } else { 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 - ); - } + self.scheduled_overrides.store(next_slot, next)?; + Ok(()) } /// Forges account data by applying overrides to existing account data @@ -7448,6 +7446,50 @@ mod tests { } } + /// A local entry too short to hold a discriminator is not a materialized account - the write + /// rejects it and tells the user to enable `fetchBeforeUse`, so clearing the flag here would + /// strand the override on an unusable stub forever. + #[tokio::test] + async fn test_persisted_override_keeps_fetching_past_a_stub_account() { + const SLOT: u64 = 500; + + let (mut svm, account_pubkey, mut instance) = scheduled_persist_fixture(true); + instance.fetch_before_use = true; + + let klend = Pubkey::from_str_const("KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD"); + svm.inner + .set_account( + account_pubkey, + Account { + lamports: 1_000_000, + data: vec![0u8; 4], + owner: klend, + executable: false, + rent_epoch: 0, + }, + ) + .expect("set stub account"); + + 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].fetch_before_use, + "the stub cannot be written to, so the next slot must retry the fetch" + ); + } + /// The flag is only cleared once there is something local to keep. An override whose account /// never materialized must keep fetching, or it can never recover. #[tokio::test] From b06cd1ec1ad1672d65b4317af99133e6205e88a9 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 10:10:01 +0300 Subject: [PATCH 24/32] fix: retry fetch_before_use after a failed remote fetch --- crates/core/src/surfnet/svm.rs | 91 ++++++++++------------------------ 1 file changed, 25 insertions(+), 66 deletions(-) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index ed4173b7c..16830e156 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -2823,6 +2823,8 @@ impl SurfnetSvm { override_instance.id, account_pubkey, override_instance.label ); + let mut fetch_answered = false; + // Fetch fresh account data from remote if requested if override_instance.fetch_before_use && !settled_this_slot.contains(&account_pubkey) { if let Some((client, _)) = remote_ctx { @@ -2851,13 +2853,17 @@ impl SurfnetSvm { ); } else { settled_this_slot.insert(account_pubkey); + fetch_answered = true; } } Ok(GetAccountResult::None(_)) => { debug!("Account {} not found on remote", account_pubkey); + // A definitive answer, not a failure - retrying cannot change it. + fetch_answered = true; } Ok(_) => { debug!("Account {} fetched (other variant)", account_pubkey); + fetch_answered = true; } Err(e) => { warn!( @@ -2871,18 +2877,18 @@ impl SurfnetSvm { "fetch_before_use enabled but no remote client available for override {}", override_instance.id ); + fetch_answered = true; } + } else if override_instance.fetch_before_use { + // Another override already forked this account this slot. + fetch_answered = true; } let existing_account = self.inner.get_account(&account_pubkey)?; - let account_materialized = existing_account - .as_ref() - .is_some_and(|account| account.data().len() >= 8); - if override_instance.persist { let mut requeued = override_instance.clone(); - if requeued.fetch_before_use && account_materialized { + if requeued.fetch_before_use && fetch_answered { requeued.fetch_before_use = false; } self.reschedule_override_for_next_slot(&requeued, target_slot)?; @@ -7446,35 +7452,26 @@ mod tests { } } - /// A local entry too short to hold a discriminator is not a materialized account - the write - /// rejects it and tells the user to enable `fetchBeforeUse`, so clearing the flag here would - /// strand the override on an unusable stub forever. + /// A transient RPC failure must not be mistaken for a satisfied fetch. The account already + /// being present locally is not enough - the override asked for fresh data and did not get it, + /// so with `persist` the flag has to survive or it pins stale data for the rest of the run. #[tokio::test] - async fn test_persisted_override_keeps_fetching_past_a_stub_account() { + async fn test_persisted_override_retries_after_a_failed_fetch() { const SLOT: u64 = 500; - let (mut svm, account_pubkey, mut instance) = scheduled_persist_fixture(true); - instance.fetch_before_use = true; - - let klend = Pubkey::from_str_const("KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD"); - svm.inner - .set_account( - account_pubkey, - Account { - lamports: 1_000_000, - data: vec![0u8; 4], - owner: klend, - executable: false, - rent_epoch: 0, - }, - ) - .expect("set stub account"); + // Unroutable port: the fetch fails without touching the network. + let unreachable = ( + SurfnetRemoteClient::new("http://127.0.0.1:1"), + CommitmentConfig::confirmed(), + ); + 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) + svm.materialize_overrides_for_slot(&Some(unreachable), SLOT) .await .expect("materialize"); @@ -7485,48 +7482,10 @@ mod tests { .expect("next slot should have queued overrides"); assert_eq!(next.len(), 1, "one entry per override id"); assert!( - next[0].fetch_before_use, - "the stub cannot be written to, so the next slot must retry the fetch" - ); - } - - /// The flag is only cleared once there is something local to keep. An override whose account - /// never materialized must keep fetching, or it can never recover. - #[tokio::test] - async fn test_persisted_override_keeps_fetching_while_the_account_is_missing() { - const SLOT: u64 = 500; - - let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); - - let mut absent = surfpool_types::OverrideInstance::new( - "kamino-obligation-health".to_string(), - 0, - surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), - ) - .with_values(HashMap::from([( - "unhealthy_borrow_value_sf".to_string(), - serde_json::json!(1_234u64), - )])); - absent.persist = true; - absent.fetch_before_use = true; - - svm.scheduled_overrides - .store(SLOT, vec![absent]) - .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"); + next[0].persist, "persist must survive rescheduling"); assert!( next[0].fetch_before_use, - "the account is still missing, so the next slot must retry the fetch" + "the fetch failed, so the next slot must retry it instead of pinning stale data" ); } From 88593fb78fc372313c883418e8c1254fdba64c80 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 10:48:21 +0300 Subject: [PATCH 25/32] fix: retry fetch_before_use when the remote has no account yet --- crates/core/src/surfnet/svm.rs | 328 ++++++++++++++++++++++++++++----- 1 file changed, 283 insertions(+), 45 deletions(-) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 16830e156..0f32ca2f3 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -290,16 +290,16 @@ fn json_to_txtx_value_for_idl_type( } (IdlType::U128, _) => { let digits = json_integer_digits(json, "u128")?; - let value = digits.parse::().map_err(|e| { - SurfpoolError::internal(format!("Invalid u128 '{digits}': {e}")) - })?; + 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}")) - })?; + 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)) @@ -631,6 +631,17 @@ fn synthetic_blockhash_for_slot(slot: Slot, genesis_slot: Slot) -> SyntheticBloc SyntheticBlockhash::new(u64::MAX - (genesis_slot - slot - 1)) } +/// What one `fetch_before_use` attempt settled. Decides whether a persisted override keeps +/// asking on later slots, which it must while another attempt could still change the answer. +enum FetchOutcome { + /// Nothing was asked for, or nothing a further attempt could change. + Retired, + /// The remote has no such account. It may be created later. + NotOnRemote, + /// No answer was obtained. Another attempt may get one. + Unanswered, +} + impl SurfnetSvm { pub fn default() -> (Self, Receiver, Receiver) { Self::new(SurfnetSvmConfig::default()).unwrap() @@ -2786,7 +2797,23 @@ impl SurfnetSvm { let mut settled_this_slot: HashSet = HashSet::new(); - for override_instance in overrides { + // `take` already emptied the slot, so bailing out mid-loop would drop every override that + // has not been reached yet. Put the unprocessed tail back before returning the error. + let restore_unprocessed = |svm: &mut Self, from: usize| { + if let Err(e) = svm + .scheduled_overrides + .store(target_slot, overrides[from..].to_vec()) + { + error!( + "Failed to restore {} unprocessed override(s) for slot {}: {}", + overrides.len() - from, + target_slot, + e + ); + } + }; + + for (index, override_instance) in overrides.iter().enumerate() { if !override_instance.enabled { debug!("Skipping disabled override: {}", override_instance.id); continue; @@ -2823,7 +2850,9 @@ impl SurfnetSvm { override_instance.id, account_pubkey, override_instance.label ); - let mut fetch_answered = false; + // Defaults to Retired: nothing was asked for, the account was already forked by an + // earlier override this slot, or there is no remote to ask. + let mut fetch_outcome = FetchOutcome::Retired; // Fetch fresh account data from remote if requested if override_instance.fetch_before_use && !settled_this_slot.contains(&account_pubkey) { @@ -2833,43 +2862,68 @@ impl SurfnetSvm { account_pubkey ); - match client + let fetched = match client .get_account(&account_pubkey, CommitmentConfig::confirmed()) .await { Ok(GetAccountResult::FoundAccount(_pubkey, remote_account, _)) => { - debug!( - "Fetched account {} from remote: {} lamports, {} bytes", - account_pubkey, - remote_account.lamports(), - remote_account.data().len() - ); - - // Set the fresh account data in the SVM - if let Err(e) = self.inner.set_account(account_pubkey, remote_account) { - warn!( - "Failed to set account {} from remote: {}", - account_pubkey, e - ); - } else { - settled_this_slot.insert(account_pubkey); - fetch_answered = true; - } + Some((remote_account, None)) } + Ok(GetAccountResult::FoundCoupledAccount( + (_pubkey, remote_account), + coupled, + _, + )) => Some(( + remote_account, + match coupled { + CoupledAccount::ProgramData(pubkey, account) + | CoupledAccount::Mint(pubkey, account) => { + account.map(|account| (pubkey, account)) + } + }, + )), Ok(GetAccountResult::None(_)) => { debug!("Account {} not found on remote", account_pubkey); - // A definitive answer, not a failure - retrying cannot change it. - fetch_answered = true; - } - Ok(_) => { - debug!("Account {} fetched (other variant)", account_pubkey); - fetch_answered = true; + fetch_outcome = FetchOutcome::NotOnRemote; + None } Err(e) => { warn!( "Failed to fetch account {} from remote: {}", account_pubkey, e ); + fetch_outcome = FetchOutcome::Unanswered; + None + } + }; + + if let Some((remote_account, coupled)) = fetched { + debug!( + "Fetched account {} from remote: {} lamports, {} bytes", + account_pubkey, + remote_account.lamports(), + remote_account.data().len() + ); + + if let Some((coupled_pubkey, coupled_account)) = coupled { + if let Err(e) = self.inner.set_account(coupled_pubkey, coupled_account) + { + warn!( + "Failed to set coupled account {} from remote: {}", + coupled_pubkey, e + ); + } + } + + // Set the fresh account data in the SVM + if let Err(e) = self.inner.set_account(account_pubkey, remote_account) { + warn!( + "Failed to set account {} from remote: {}", + account_pubkey, e + ); + fetch_outcome = FetchOutcome::Unanswered; + } else { + settled_this_slot.insert(account_pubkey); } } } else { @@ -2877,21 +2931,35 @@ impl SurfnetSvm { "fetch_before_use enabled but no remote client available for override {}", override_instance.id ); - fetch_answered = true; } - } else if override_instance.fetch_before_use { - // Another override already forked this account this slot. - fetch_answered = true; } - let existing_account = self.inner.get_account(&account_pubkey)?; + let existing_account = match self.inner.get_account(&account_pubkey) { + Ok(account) => account, + Err(e) => { + restore_unprocessed(self, index); + return Err(e); + } + }; + + // The request is only retired when another attempt could no longer change anything. + let fetch_retired = match fetch_outcome { + FetchOutcome::Retired => true, + FetchOutcome::Unanswered => false, + // The account may be created later, so keep asking while there is nothing to work + // on. Once something local exists, stop - a later fetch would overwrite it. + FetchOutcome::NotOnRemote => existing_account.is_some(), + }; if override_instance.persist { let mut requeued = override_instance.clone(); - if requeued.fetch_before_use && fetch_answered { + if requeued.fetch_before_use && fetch_retired { requeued.fetch_before_use = false; } - self.reschedule_override_for_next_slot(&requeued, target_slot)?; + if let Err(e) = self.reschedule_override_for_next_slot(&requeued, target_slot) { + restore_unprocessed(self, index); + return Err(e); + } } // Apply the override values to the account data @@ -7452,6 +7520,167 @@ mod tests { } } + /// Minimal JSON-RPC stand-in that answers every request with one canned `result` body, so + /// the remote-fetch branches can be exercised without a network. + async fn canned_rpc(result_json: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind canned rpc"); + let addr = listener.local_addr().expect("local addr"); + + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = vec![0u8; 16 * 1024]; + let _ = stream.read(&mut buf).await; + let body = format!(r#"{{"jsonrpc":"2.0","result":{result_json},"id":1}}"#); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; + }); + } + }); + + format!("http://{addr}") + } + + /// The remote having no such account is only an answer about this slot - accounts get created + /// later. While there is nothing local to work on, a persisted override must keep asking, or + /// it stays inert for the rest of the run. + #[tokio::test] + async fn test_persisted_override_retries_while_the_account_is_not_on_remote() { + const SLOT: u64 = 500; + const NULL_ACCOUNT: &str = r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":null}"#; + + let url = canned_rpc(NULL_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + // An address the SVM has never seen, so there is no local account to fall back on. + let mut absent = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), + ) + .with_values(HashMap::from([( + "unhealthy_borrow_value_sf".to_string(), + serde_json::json!(1_234u64), + )])); + absent.persist = true; + absent.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![absent]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&Some(remote), 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].fetch_before_use, + "the account may appear later, so the next slot must keep asking for it" + ); + } + + /// The mirror case: the remote has nothing but a local account already exists, so the override + /// can work. Asking again would only risk overwriting that local account once the address is + /// populated upstream. + #[tokio::test] + async fn test_persisted_override_stops_asking_when_only_a_local_account_exists() { + const SLOT: u64 = 500; + const NULL_ACCOUNT: &str = r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":null}"#; + + let url = canned_rpc(NULL_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + + 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(&Some(remote), 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].fetch_before_use, + "the local account is usable, so later fetches must not overwrite it" + ); + } + + /// Token and executable accounts return `FoundCoupledAccount`. That arm used to fall through + /// a catch-all that logged and dropped the account, so the fetch reported success while the + /// target was never forked - every later write then failed with "not found in SVM". + #[tokio::test] + async fn test_fetch_before_use_materializes_a_coupled_account() { + const SLOT: u64 = 500; + // A 165-byte SPL token account (state = Initialized), which sends `get_account` down the + // coupled-mint path. The canned server answers the mint lookup with the same body. + const TOKEN_ACCOUNT: &str = concat!( + r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":{"data":[""#, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + r#"","base64"],"executable":false,"lamports":2039280,"#, + r#""owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":0,"space":165}}"# + ); + + let url = canned_rpc(TOKEN_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + let target = Pubkey::new_unique(); + let mut instance = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(target.to_string()), + ); + instance.fetch_before_use = true; + + assert!( + svm.inner + .get_account(&target) + .expect("get_account") + .is_none(), + "the target must start absent so the fetch is what materializes it" + ); + + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&Some(remote), SLOT) + .await + .expect("materialize"); + + let forked = svm + .inner + .get_account(&target) + .expect("get_account") + .expect("the coupled account must be written into the SVM"); + assert_eq!(forked.data.len(), 165, "the token account data was forked"); + assert_eq!(forked.lamports, 2_039_280, "lamports came from the remote"); + } + /// A transient RPC failure must not be mistaken for a satisfied fetch. The account already /// being present locally is not enough - the override asked for fresh data and did not get it, /// so with `persist` the flag has to survive or it pins stale data for the rest of the run. @@ -7481,8 +7710,7 @@ mod tests { .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].persist, "persist must survive rescheduling"); assert!( next[0].fetch_before_use, "the fetch failed, so the next slot must retry it instead of pinning stale data" @@ -7534,7 +7762,11 @@ mod tests { 1_234, "the first override must survive the second override's fetch" ); - assert_eq!(read(ALLOWED_OFFSET), 5_678, "the second override must apply"); + assert_eq!( + read(ALLOWED_OFFSET), + 5_678, + "the second override must apply" + ); } /// Two persistent overrides that share a caller-supplied id but target different accounts must both survive re-arming. @@ -7558,8 +7790,12 @@ mod tests { let mut second = first.clone(); second.account = surfpool_types::AccountAddress::Pubkey(second_account.to_string()); - surfnet_svm.reschedule_override_for_next_slot(&first, SLOT); - surfnet_svm.reschedule_override_for_next_slot(&second, SLOT); + surfnet_svm + .reschedule_override_for_next_slot(&first, SLOT) + .expect("reschedule"); + surfnet_svm + .reschedule_override_for_next_slot(&second, SLOT) + .expect("reschedule"); let queued = surfnet_svm .scheduled_overrides @@ -7573,7 +7809,9 @@ mod tests { one of them, so a scenario silently stops being applied" ); - surfnet_svm.reschedule_override_for_next_slot(&first, SLOT); + surfnet_svm + .reschedule_override_for_next_slot(&first, SLOT) + .expect("reschedule"); let queued = surfnet_svm .scheduled_overrides .get(&(SLOT + 1)) From 6bba69d4d107bf7740b6a883da3317efa84e106c Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 11:03:47 +0300 Subject: [PATCH 26/32] fix: retire fetch_before_use only once the request is met --- crates/core/src/surfnet/svm.rs | 49 +++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 0f32ca2f3..7ac659b88 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -634,8 +634,9 @@ fn synthetic_blockhash_for_slot(slot: Slot, genesis_slot: Slot) -> SyntheticBloc /// What one `fetch_before_use` attempt settled. Decides whether a persisted override keeps /// asking on later slots, which it must while another attempt could still change the answer. enum FetchOutcome { - /// Nothing was asked for, or nothing a further attempt could change. Retired, + /// There is no remote to ask. Only a local account can satisfy the request. + NoRemote, /// The remote has no such account. It may be created later. NotOnRemote, /// No answer was obtained. Another attempt may get one. @@ -2931,6 +2932,7 @@ impl SurfnetSvm { "fetch_before_use enabled but no remote client available for override {}", override_instance.id ); + fetch_outcome = FetchOutcome::NoRemote; } } @@ -2946,9 +2948,7 @@ impl SurfnetSvm { let fetch_retired = match fetch_outcome { FetchOutcome::Retired => true, FetchOutcome::Unanswered => false, - // The account may be created later, so keep asking while there is nothing to work - // on. Once something local exists, stop - a later fetch would overwrite it. - FetchOutcome::NotOnRemote => existing_account.is_some(), + FetchOutcome::NoRemote | FetchOutcome::NotOnRemote => existing_account.is_some(), }; if override_instance.persist { @@ -7681,6 +7681,47 @@ mod tests { assert_eq!(forked.lamports, 2_039_280, "lamports came from the remote"); } + /// With no remote client there is nothing to fetch from, but the request is still unmet while + /// the account is absent. `materialize_overrides_for_slot` is public, so a caller can pass a + /// client on a later slot - retiring the flag here would permanently disable that. + #[tokio::test] + async fn test_persisted_override_keeps_asking_when_absent_and_no_remote() { + const SLOT: u64 = 500; + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + let mut absent = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), + ) + .with_values(HashMap::from([( + "unhealthy_borrow_value_sf".to_string(), + serde_json::json!(1_234u64), + )])); + absent.persist = true; + absent.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![absent]) + .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].fetch_before_use, + "the request is still unmet, so it must not be retired" + ); + } + /// A transient RPC failure must not be mistaken for a satisfied fetch. The account already /// being present locally is not enough - the override asked for fresh data and did not get it, /// so with `persist` the flag has to survive or it pins stale data for the rest of the run. From ce2f758e9737e949acd383a3caf8f1e9f08d850e Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 11:14:35 +0300 Subject: [PATCH 27/32] fix: check slot arithmetic when scheduling overrides --- crates/core/src/surfnet/svm.rs | 49 ++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 7ac659b88..248101b3d 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -3101,7 +3101,12 @@ impl SurfnetSvm { instance: &OverrideInstance, target_slot: Slot, ) -> SurfpoolResult<()> { - let next_slot = target_slot + 1; + let next_slot = target_slot.checked_add(1).ok_or_else(|| { + SurfpoolError::internal(format!( + "Override {} cannot persist past slot {}: there is no next slot", + instance.id, target_slot + )) + })?; let mut next = self .scheduled_overrides .get(&next_slot)? @@ -4408,7 +4413,13 @@ impl SurfnetSvm { // Schedule overrides by adding base slot to their scenario-relative slots for override_instance in scenario.overrides { let scenario_relative_slot = override_instance.scenario_relative_slot; - let absolute_slot = base_slot + scenario_relative_slot; + // Both operands are caller-supplied, so the sum has to be checked. + let absolute_slot = base_slot.checked_add(scenario_relative_slot).ok_or_else(|| { + SurfpoolError::internal(format!( + "Override {} cannot be scheduled: base slot {} plus relative slot {} overflows", + override_instance.id, base_slot, scenario_relative_slot + )) + })?; debug!( "Scheduling override at absolute slot {} (base {} + relative {})", @@ -4417,9 +4428,7 @@ impl SurfnetSvm { let mut slot_overrides = self .scheduled_overrides - .get(&absolute_slot) - .ok() - .flatten() + .get(&absolute_slot)? .unwrap_or_default(); slot_overrides.push(override_instance); self.scheduled_overrides @@ -7722,6 +7731,36 @@ mod tests { ); } + #[tokio::test] + async fn test_slot_overflow_is_an_error_not_a_wrap() { + let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); + + assert!( + svm.reschedule_override_for_next_slot(&instance, u64::MAX) + .is_err(), + "there is no slot after u64::MAX" + ); + + let mut far = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 10, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ); + far.scenario_relative_slot = 10; + let scenario = surfpool_types::Scenario { + id: "overflow".to_string(), + name: "overflow".to_string(), + description: String::new(), + tags: vec![], + overrides: vec![far], + }; + + assert!( + svm.register_scenario(scenario, Some(u64::MAX - 1)).is_err(), + "base slot plus relative slot overflows and must be rejected" + ); + } + /// A transient RPC failure must not be mistaken for a satisfied fetch. The account already /// being present locally is not enough - the override asked for fresh data and did not get it, /// so with `persist` the flag has to survive or it pins stale data for the rest of the run. From 61a390f85967ff955b332deeba2674f5feeddd91 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 11:45:26 +0300 Subject: [PATCH 28/32] feat: make the slot encoding lead operator-supplied --- .../protocols/bisonfi/overrides.yaml | 28 +++++-- crates/core/src/tests/bisonfi/mod.rs | 21 +++-- crates/types/src/scenarios.rs | 79 ++++++++++++++----- 3 files changed, 89 insertions(+), 39 deletions(-) diff --git a/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml b/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml index 29d8fd349..0ead16eb1 100644 --- a/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml +++ b/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml @@ -239,9 +239,13 @@ templates: properties: - path: last_update_slot offset: 72 - encoding: u64 - label: Last update slot - description: "Slot of the most recent mid publication. Example: 439868588" + encoding: + slot: + lead: 0 + label: Slots behind the chain + description: >- + How far behind the executing slot the quote was published, as a signed offset. 0 is live, + -1 still fills, -2 or lower is silent. llm_context: | This field is the venue's liveness signal. @@ -252,13 +256,21 @@ templates: makes this template the precondition for the other three: if your scenario spends more than a slot before executing, refresh this field first. + THE VALUE IS AN OFFSET, NOT A SLOT NUMBER. It is resolved against the slot the override + materializes at, so 0 means "published this slot". An absolute slot number would be wrong here: + persist replays the same value every slot, so a fixed number ages by one slot per slot and the + quote goes stale anyway. + HOW TO USE THIS TEMPLATE: - 1. Set last_update_slot to the current slot. The venue resumes quoting the price it already - held - a fresh timestamp is enough, no new price is needed - 2. Set persist: true, or the next slot's state overwrites your value + 1. Set last_update_slot to 0. The venue resumes quoting the price it already held - a fresh + timestamp is enough, no new price is needed + 2. Set persist: true, so every slot re-stamps itself and the quote stays live indefinitely A scenario that executes within a slot of forking does not need this. One that spends longer on setup does. - EXAMPLE - "keep the maker quoting at chain slot 439868588": - last_update_slot: 439868588 + EXAMPLE - "keep the maker quoting for the whole run": + last_update_slot: 0, persist: true + + EXAMPLE - "the maker went dark five slots ago": + last_update_slot: -5 diff --git a/crates/core/src/tests/bisonfi/mod.rs b/crates/core/src/tests/bisonfi/mod.rs index 3ac1d753f..90401806a 100644 --- a/crates/core/src/tests/bisonfi/mod.rs +++ b/crates/core/src/tests/bisonfi/mod.rs @@ -644,9 +644,10 @@ impl BisonfiRig { .collect(); let props = template.properties.clone(); let layout = raw_layout.clone(); + let target_slot = u64::from_le_bytes(data[72..80].try_into().expect("8 bytes")); bisonfi_replay(&self.elf, pool, data, tp, amount_in, direction, move |d| { let forged = layout - .materialize(d.as_slice(), &props, &map, 0) + .materialize(d.as_slice(), &props, &map, target_slot) .unwrap_or_else(|e| panic!("materialize failed: {e}")); *d = forged; }) @@ -928,7 +929,7 @@ async fn bisonfi_scenario_silence_the_maker() { data, *tp, "bisonfi-freshness", - &[("last_update_slot", serde_json::json!(published - 1))], + &[("last_update_slot", serde_json::json!(-1))], size, 0, ); @@ -945,10 +946,7 @@ async fn bisonfi_scenario_silence_the_maker() { data, *tp, "bisonfi-freshness", - &[( - "last_update_slot", - serde_json::json!(published.saturating_sub(back)), - )], + &[("last_update_slot", serde_json::json!(-(back as i64)))], size, 0, ); @@ -1436,7 +1434,6 @@ async fn bisonfi_scenario_maker_goes_dark_between_quote_and_fill() { let rig = bisonfi_rig().await; let (pool, data, tp) = rig.quoting.first().expect("a quoting market"); let pool_key = pool.parse::().expect("pool address"); - let published = u64::from_le_bytes(data[72..80].try_into().unwrap()); let size = BisonfiRig::sell_size(data); let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); @@ -1460,7 +1457,7 @@ async fn bisonfi_scenario_maker_goes_dark_between_quote_and_fill() { transaction executes" .to_string(), ); - for (relative, value) in [(QUOTE_AT, published), (FILL_AT, published - 5)] { + for (relative, value) in [(QUOTE_AT, 0i64), (FILL_AT, -5i64)] { scenario.add_override( OverrideInstance::new( "bisonfi-freshness".to_string(), @@ -1491,19 +1488,21 @@ async fn bisonfi_scenario_maker_goes_dark_between_quote_and_fill() { } let field_at = |slot: u64| u64::from_le_bytes(images[&slot][72..80].try_into().unwrap()); + // The lead is resolved against the slot each step materializes at, so the first step stamps + // its own slot and the second lands five behind its own. assert_eq!( field_at(BASE_SLOT), - published, + BASE_SLOT, "at the quoting slot the venue must still be publishing" ); assert_eq!( field_at(BASE_SLOT + 1), - published, + BASE_SLOT, "no override is scheduled for the intermediate slot, so the account must be untouched" ); assert_eq!( field_at(BASE_SLOT + FILL_AT), - published - 5, + BASE_SLOT + FILL_AT - 5, "the second step must have fired by the slot the transaction lands on" ); diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index 40cbcb0e2..afe34986b 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1023,7 +1023,9 @@ pub enum RawEncoding { /// A base58 pubkey, written as 32 bytes. Bytes32, /// The slot the override materializes at, plus `lead` (may be negative). - Slot { lead: i64 }, + Slot { + lead: i64, + }, } impl RawEncoding { @@ -1051,11 +1053,7 @@ impl RawEncoding { } /// The little-endian bytes for `value`. `target_slot` is only read by [`RawEncoding::Slot`]. - pub fn encode( - &self, - value: &serde_json::Value, - target_slot: Slot, - ) -> Result, String> { + pub fn encode(&self, value: &serde_json::Value, target_slot: Slot) -> Result, String> { // Read the digits as text so nothing passes through f64, which cannot hold a u128 // exactly. A decimal string is the only way to express values above u64::MAX in JSON. let digits = |what: &str| -> Result { @@ -1068,7 +1066,9 @@ impl RawEncoding { } serde_json::Value::Number(n) => Ok(n.to_string()), serde_json::Value::String(s) => Ok(s.trim().to_string()), - other => Err(format!("expected a number or decimal string for {what}, found {other}")), + other => Err(format!( + "expected a number or decimal string for {what}, found {other}" + )), } }; macro_rules! int { @@ -1098,11 +1098,21 @@ impl RawEncoding { .to_bytes() .to_vec() } - RawEncoding::Slot { lead } => (target_slot as i64) - .saturating_add(*lead) - .max(0) - .to_le_bytes() - .to_vec(), + RawEncoding::Slot { lead } => { + let lead = match value { + serde_json::Value::Null => *lead, + _ => { + let d = digits("slot lead")?; + d.parse::() + .map_err(|e| format!("invalid slot lead: '{d}': {e}"))? + } + }; + (target_slot as i64) + .saturating_add(lead) + .max(0) + .to_le_bytes() + .to_vec() + } }) } } @@ -1184,9 +1194,10 @@ impl RawLayout { let (count, stride) = encoding.placements(); for i in 0..count { let at = offset - .checked_add(i.checked_mul(stride).ok_or_else(|| { - format!("stride overflow for '{name}'") - })?) + .checked_add( + i.checked_mul(stride) + .ok_or_else(|| format!("stride overflow for '{name}'"))?, + ) .ok_or_else(|| format!("offset overflow for '{name}'"))?; let end = at .checked_add(bytes.len()) @@ -1354,7 +1365,11 @@ impl YamlOverrideTemplateCollection { protocol: self.protocol.clone(), idl: idl.clone(), address: entry.address.into(), - properties: describe_properties_from_idl(entry.properties, idl.as_ref(), &account_type), + properties: describe_properties_from_idl( + entry.properties, + idl.as_ref(), + &account_type, + ), account_type, constants: constants.clone(), tags: self.tags.clone(), @@ -1534,11 +1549,27 @@ mod tests { let bytes = RawEncoding::I64.encode(&json!(-25599i64 << 32), 0).unwrap(); assert_eq!(i64::from_le_bytes(bytes.try_into().unwrap()) >> 32, -25599); - let bytes = RawEncoding::Slot { lead: -1 }.encode(&json!(0), 500).unwrap(); + // The supplied value is the lead, so one property covers live and stale. + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(0), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 500); + + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(-5), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 495); + + // The manifest lead is the default, used when no value is given. + let bytes = RawEncoding::Slot { lead: -1 } + .encode(&json!(null), 500) + .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 499); // A lead that would go below zero clamps rather than wrapping. - let bytes = RawEncoding::Slot { lead: -10 }.encode(&json!(0), 3).unwrap(); + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(-10), 3) + .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 0); } @@ -1555,7 +1586,12 @@ mod tests { property.encoding = Some(RawEncoding::U64); let err = layout - .materialize(&[0u8; 16], &[property], &HashMap::from([("tail".to_string(), json!(1))]), 0) + .materialize( + &[0u8; 16], + &[property], + &HashMap::from([("tail".to_string(), json!(1))]), + 0, + ) .expect_err("a field crossing the end must be refused"); assert!(err.contains("exceeds"), "unexpected error: {err}"); } @@ -1595,7 +1631,10 @@ mod tests { let written: Vec = (0..3).flat_map(|i| (4 + i * 16)..(8 + i * 16)).collect(); for (i, b) in out.iter().enumerate() { if !written.contains(&i) { - assert_eq!(*b, 0, "byte {i} lies between strided slots and must not change"); + assert_eq!( + *b, 0, + "byte {i} lies between strided slots and must not change" + ); } } } From 26c7b5c65e84f3794fd5a9d812678e852212280e Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 12:37:49 +0300 Subject: [PATCH 29/32] Merge with Kamino branch --- .github/workflows/openai-review.yml | 73 ++ .github/workflows/release_sdk_node_npm.yml | 2 +- .github/workflows/sdk_node.yml | 2 +- Cargo.lock | 1 + Cargo.toml | 1 + crates/core/Cargo.toml | 1 + crates/core/src/rpc/accounts_data.rs | 46 +- crates/core/src/rpc/full.rs | 15 +- crates/core/src/rpc/jito.rs | 3 +- crates/core/src/rpc/minimal.rs | 5 +- crates/core/src/rpc/surfnet_cheatcodes.rs | 16 +- crates/core/src/rpc/ws.rs | 224 ++-- crates/core/src/runloops/mod.rs | 9 +- crates/core/src/surfnet/locker.rs | 697 ++++++++--- crates/core/src/surfnet/mod.rs | 133 ++- crates/core/src/surfnet/remote.rs | 39 +- crates/core/src/surfnet/surfnet_lite_svm.rs | 26 +- crates/core/src/surfnet/svm.rs | 1036 ++++++++++++++--- crates/core/src/tests/bisonfi/mod.rs | 12 +- crates/core/src/tests/integration.rs | 382 +++++- crates/core/src/tests/kamino/mod.rs | 3 +- crates/sdk-node/README.md | 21 + crates/sdk-node/package-lock.json | 757 ++++++------ crates/sdk-node/package.json | 12 +- crates/sdk-node/scripts/kit-smoke.js | 28 + crates/sdk-node/scripts/kit-unit.js | 221 +++- .../kit/__typetests__/typetests.ts | 37 +- crates/sdk-node/surfpool-sdk/kit/index.ts | 2 + crates/sdk-node/surfpool-sdk/kit/surfpool.ts | 192 ++- 29 files changed, 3017 insertions(+), 979 deletions(-) create mode 100644 .github/workflows/openai-review.yml diff --git a/.github/workflows/openai-review.yml b/.github/workflows/openai-review.yml new file mode 100644 index 000000000..07fd8a681 --- /dev/null +++ b/.github/workflows/openai-review.yml @@ -0,0 +1,73 @@ +name: Perform a code review when a pull request is created. +on: + pull_request: + +jobs: + codex: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + final_message: ${{ steps.run_codex.outputs.final-message }} + steps: + - uses: actions/checkout@v5 + with: + # Explicitly check out the PR's merge commit. + ref: refs/pull/${{ github.event.pull_request.number }}/merge + persist-credentials: false + + - name: Pre-fetch base and head refs for the PR + env: + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Pass GitHub expressions through env and quote shell expansions. + git fetch --no-tags origin \ + "$PR_BASE_REF" \ + "+refs/pull/$PR_NUMBER/head" + + # If you want Codex to build and run code, install any dependencies that + # need to be downloaded before the "Run Codex" step. The recommended + # :workspace permission profile does not grant network access. + + - name: Run Codex + id: run_codex + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + permission-profile: ":workspace" + prompt: | + This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. + + Review ONLY the changes introduced by the PR, so consider: + git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + + Suggest any improvements, potential bugs, or issues. + Be concise and specific in your feedback. + + Pull request title and body: + ---- + ${{ github.event.pull_request.title }} + ${{ github.event.pull_request.body }} + + post_feedback: + runs-on: ubuntu-latest + needs: codex + if: needs.codex.outputs.final_message != '' + permissions: + issues: write + pull-requests: write + steps: + - name: Report Codex feedback + uses: actions/github-script@v7 + env: + CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }} + with: + github-token: ${{ github.token }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: process.env.CODEX_FINAL_MESSAGE, + }); \ No newline at end of file diff --git a/.github/workflows/release_sdk_node_npm.yml b/.github/workflows/release_sdk_node_npm.yml index 527d64c86..c54e6f9aa 100644 --- a/.github/workflows/release_sdk_node_npm.yml +++ b/.github/workflows/release_sdk_node_npm.yml @@ -159,7 +159,7 @@ jobs: node -e "require('@solana/surfpool'); console.log('require @solana/surfpool OK');" # The kit peers are optional and not auto-installed; installing them # here validates the documented opt-in flow for the ./kit entry. - npm install --no-audit --no-fund "@solana/kit@^7.0.0" "@solana/kit-plugin-rpc@^0.15.0" "@solana/kit-plugin-signer@^0.13.0" + npm install --no-audit --no-fund "@solana/kit@^8.0.0" "@solana/kit-plugin-rpc@^0.18.0" "@solana/kit-plugin-signer@^0.18.0" node -e " const kit = require('@solana/surfpool/kit'); if (typeof kit.surfpool !== 'function') { throw new Error('missing surfpool export'); } diff --git a/.github/workflows/sdk_node.yml b/.github/workflows/sdk_node.yml index 19e430e3d..7612f4735 100644 --- a/.github/workflows/sdk_node.yml +++ b/.github/workflows/sdk_node.yml @@ -192,7 +192,7 @@ jobs: npm install "./$(basename "${{ steps.pack.outputs.root_pack }}")" # The kit peers are optional and not auto-installed; installing them # here validates the documented opt-in flow. - npm install "@solana/kit@^7.0.0" "@solana/kit-plugin-rpc@^0.15.0" "@solana/kit-plugin-signer@^0.13.0" + npm install "@solana/kit@^8.0.0" "@solana/kit-plugin-rpc@^0.18.0" "@solana/kit-plugin-signer@^0.18.0" node - <<'EOF' const assert = require("node:assert/strict"); const { createClient } = require("@solana/kit"); diff --git a/Cargo.lock b/Cargo.lock index 649aa3715..a93f44cf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12015,6 +12015,7 @@ dependencies = [ "solana-program-option 3.1.0", "solana-program-pack 3.1.0", "solana-pubkey 3.0.0", + "solana-pubsub-client", "solana-rpc-client", "solana-rpc-client-api", "solana-runtime", diff --git a/Cargo.toml b/Cargo.toml index 127ee3c4f..b62e882b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,6 +130,7 @@ solana-nonce = { version = "3.0", default-features = false } solana-packet = { version = "4.0", default-features = false } solana-program-option = { version = "3.0", default-features = false } solana-program-pack = { version = "3.1", default-features = false } +solana-pubsub-client = { version = "4.0", default-features = false } solana-pubkey = { version = "3.0", default-features = false } solana-rpc-client = { version = "4.0", default-features = false } solana-rpc-client-api = { version = "4.0", default-features = false } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index caa72f83f..fb9b300de 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -114,6 +114,7 @@ p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } test-case = { workspace = true } env_logger = "0.11" solana-ed25519-program = { workspace = true } +solana-pubsub-client = { workspace = true } solana-secp256k1-program = { version = "3.0", default-features = false, features = ["bincode"] } solana-secp256r1-program = "3.0" tempfile = { workspace = true } diff --git a/crates/core/src/rpc/accounts_data.rs b/crates/core/src/rpc/accounts_data.rs index 5475ea95e..eff4892c9 100644 --- a/crates/core/src/rpc/accounts_data.rs +++ b/crates/core/src/rpc/accounts_data.rs @@ -383,8 +383,6 @@ impl AccountsData for SurfpoolAccountsDataRpc { if let Some(m) = crate::telemetry::metrics() { m.record_rpc_request("getAccountInfo", rpc_start.elapsed().as_millis() as u64); } - svm_locker.write_account_update(account_update.clone()); - let ui_account = if let Some(((pubkey, account), token_data)) = account_update.map_account_with_token_data() { @@ -453,8 +451,6 @@ impl AccountsData for SurfpoolAccountsDataRpc { ); } - svm_locker.write_multiple_account_updates(&account_updates); - // Convert account updates to UI accounts, order is already preserved by get_multiple_accounts let mut ui_accounts = vec![]; for account_update in account_updates.into_iter() { @@ -544,8 +540,6 @@ impl AccountsData for SurfpoolAccountsDataRpc { .await? .inner; - svm_locker.write_account_update(token_account_result.clone()); - let token_account = token_account_result.map_account()?; let (mint_pubkey, _amount) = if is_supported_token_program(&token_account.owner) { @@ -571,8 +565,6 @@ impl AccountsData for SurfpoolAccountsDataRpc { .get_account(&remote_ctx, &mint_pubkey, None) .await?; - svm_locker.write_account_update(mint_account_result.clone()); - let mint_account = mint_account_result.map_account()?; let token_decimals = if is_supported_token_program(&mint_account.owner) { @@ -635,8 +627,6 @@ impl AccountsData for SurfpoolAccountsDataRpc { .get_account(&remote_ctx, &mint_pubkey, None) .await?; - svm_locker.write_account_update(mint_account_result.clone()); - let mint_account = mint_account_result.map_account()?; if !is_supported_token_program(&mint_account.owner) { @@ -715,7 +705,9 @@ mod tests { use super::*; use crate::{ - surfnet::{GetAccountResult, remote::SurfnetRemoteClient}, + surfnet::{ + AccountSource, GetAccountResult, remote::SurfnetRemoteClient, svm::AccountUpdatePolicy, + }, tests::helpers::TestSetup, types::SyntheticBlockhash, }; @@ -754,7 +746,11 @@ mod tests { setup .context .svm_locker - .write_account_update(GetAccountResult::FoundAccount(mint_pk, mint_account, true)); + .apply_account_update( + GetAccountResult::FoundAccount(mint_pk, mint_account, AccountSource::Generated), + AccountUpdatePolicy::Authoritative, + ) + .unwrap(); let token_account_pk = Pubkey::new_unique(); @@ -786,11 +782,15 @@ mod tests { setup .context .svm_locker - .write_account_update(GetAccountResult::FoundAccount( - token_account_pk, - token_account, - true, - )); + .apply_account_update( + GetAccountResult::FoundAccount( + token_account_pk, + token_account, + AccountSource::Generated, + ), + AccountUpdatePolicy::Authoritative, + ) + .unwrap(); let res = setup .rpc @@ -1567,11 +1567,19 @@ mod tests { setup .context .svm_locker - .write_account_update(GetAccountResult::FoundAccount(pk1, account1, true)); + .apply_account_update( + GetAccountResult::FoundAccount(pk1, account1, AccountSource::Generated), + AccountUpdatePolicy::Authoritative, + ) + .unwrap(); setup .context .svm_locker - .write_account_update(GetAccountResult::FoundAccount(pk3, account3, true)); + .apply_account_update( + GetAccountResult::FoundAccount(pk3, account3, AccountSource::Generated), + AccountUpdatePolicy::Authoritative, + ) + .unwrap(); // Request accounts in order: [pk1, pk2, pk3] // pk1 and pk3 are local, pk2 is missing (will try remote fetch and fail) diff --git a/crates/core/src/rpc/full.rs b/crates/core/src/rpc/full.rs index cfa4df266..fa1ec8ac0 100644 --- a/crates/core/src/rpc/full.rs +++ b/crates/core/src/rpc/full.rs @@ -48,7 +48,7 @@ use crate::{ error::{SurfpoolError, SurfpoolResult}, rpc::utils::{adjust_default_transaction_config, get_default_transaction_config}, surfnet::{ - FINALIZATION_SLOT_THRESHOLD, GetAccountResult, GetTransactionResult, + CoupledAccount, FINALIZATION_SLOT_THRESHOLD, GetAccountResult, GetTransactionResult, locker::SvmAccessContext, svm::MAX_RECENT_BLOCKHASHES_STANDARD, }, types::{SurfnetTransactionStatus, surfpool_tx_metadata_to_litesvm_tx_metadata}, @@ -1901,9 +1901,10 @@ impl Full for SurfpoolFullRpc { } } // According to SIMD 0186, program data is tracked as well as program accounts - GetAccountResult::FoundProgramAccount( + GetAccountResult::FoundCoupledAccount( (pubkey, account), - (pd_pubkey, pd_account), + CoupledAccount::ProgramData(pd_pubkey, pd_account), + _, ) => { if seen_accounts.insert(*pubkey) { loaded_accounts_data_size += account.data.len() as u64; @@ -1914,9 +1915,10 @@ impl Full for SurfpoolFullRpc { } } } - GetAccountResult::FoundTokenAccount( + GetAccountResult::FoundCoupledAccount( (pubkey, account), - (td_pubkey, td_account), + CoupledAccount::Mint(td_pubkey, td_account), + _, ) => { if seen_accounts.insert(*pubkey) { loaded_accounts_data_size += account.data.len() as u64; @@ -1937,8 +1939,6 @@ impl Full for SurfpoolFullRpc { track_accounts_data_size(res); } - svm_locker.write_multiple_account_updates(&account_updates); - // Convert TransactionLoadedAddresses to LoadedAddresses before it gets consumed let loaded_addresses_data = loaded_addresses.as_ref().map(|la| la.loaded_addresses()); @@ -1950,7 +1950,6 @@ impl Full for SurfpoolFullRpc { for res in alt_updates.iter() { track_accounts_data_size(res); } - svm_locker.write_multiple_account_updates(&alt_updates); } let replacement_blockhash = if config.replace_recent_blockhash { diff --git a/crates/core/src/rpc/jito.rs b/crates/core/src/rpc/jito.rs index 26c19d25e..015dae52e 100644 --- a/crates/core/src/rpc/jito.rs +++ b/crates/core/src/rpc/jito.rs @@ -1047,8 +1047,7 @@ async fn snapshot_accounts( } } crate::surfnet::GetAccountResult::FoundAccount(_, account, _) - | crate::surfnet::GetAccountResult::FoundProgramAccount((_, account), _) - | crate::surfnet::GetAccountResult::FoundTokenAccount((_, account), _) => { + | crate::surfnet::GetAccountResult::FoundCoupledAccount((_, account), _, _) => { account.clone() } }; diff --git a/crates/core/src/rpc/minimal.rs b/crates/core/src/rpc/minimal.rs index ae31ece9a..ee13a14e0 100644 --- a/crates/core/src/rpc/minimal.rs +++ b/crates/core/src/rpc/minimal.rs @@ -626,13 +626,10 @@ impl Minimal for SurfpoolMinimalRpc { let balance = match &account_update { GetAccountResult::FoundAccount(_, account, _) - | GetAccountResult::FoundProgramAccount((_, account), _) - | GetAccountResult::FoundTokenAccount((_, account), _) => account.lamports, + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account.lamports, GetAccountResult::None(_) => 0, }; - svm_locker.write_account_update(account_update); - #[cfg(feature = "prometheus")] if let Some(m) = crate::telemetry::metrics() { m.record_rpc_request("getBalance", rpc_start.elapsed().as_millis() as u64); diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index 154057c51..15e05e345 100644 --- a/crates/core/src/rpc/surfnet_cheatcodes.rs +++ b/crates/core/src/rpc/surfnet_cheatcodes.rs @@ -30,7 +30,9 @@ use crate::{ State, utils::{decode_and_deserialize, verify_pubkey, verify_pubkeys}, }, - surfnet::{GetAccountResult, locker::SvmAccessContext}, + surfnet::{ + AccountSource, GetAccountResult, locker::SvmAccessContext, svm::AccountUpdatePolicy, + }, types::{ TimeTravelConfig, TokenAccount, build_confidential_token_account_data, mint_has_transfer_fee_config, @@ -1422,7 +1424,7 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc { Box::pin(async move { let (account_to_set, latest_absolute_slot) = if let Some(account) = account_update_opt { ( - GetAccountResult::FoundAccount(pubkey, account, true), + GetAccountResult::FoundAccount(pubkey, account, AccountSource::Generated), svm_locker.get_latest_absolute_slot(), ) } else { @@ -1445,7 +1447,7 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc { rent_epoch: 0, data: vec![], }, - true, // indicate that the account should be updated in the SVM, since it's new + AccountSource::Generated, ) }))).await?; @@ -1453,7 +1455,7 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc { (account_result_to_update, slot) }; - svm_locker.write_account_update(account_to_set); + svm_locker.apply_account_update(account_to_set, AccountUpdatePolicy::Authoritative)?; Ok(RpcResponse { context: RpcResponseContext::new(latest_absolute_slot), @@ -1642,8 +1644,6 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc { let mint_has_transfer_fee = confidential.is_some() && !get_mint_result.is_none() && mint_has_transfer_fee_config(get_mint_result.expected_data()); - svm_locker.write_account_update(get_mint_result); - let minimum_rent = svm_locker.with_svm_reader(|svm_reader| { svm_reader.inner.minimum_balance_for_rent_exemption( TokenAccount::get_packed_len_for_token_program_id(&token_program_id), @@ -1678,7 +1678,7 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc { rent_epoch: 0, data, }, - true, // indicate that the account should be updated in the SVM, since it's new + AccountSource::Generated, ) })), ) @@ -1722,7 +1722,7 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc { account.data = final_account_bytes.clone(); Ok(()) })?; - svm_locker.write_account_update(token_account); + svm_locker.apply_account_update(token_account, AccountUpdatePolicy::Authoritative)?; Ok(RpcResponse { context: RpcResponseContext::new(slot), diff --git a/crates/core/src/rpc/ws.rs b/crates/core/src/rpc/ws.rs index 7f9498ea2..2187ecd0a 100644 --- a/crates/core/src/rpc/ws.rs +++ b/crates/core/src/rpc/ws.rs @@ -20,16 +20,18 @@ use solana_client::{ RpcResponseContext, RpcSignatureResult, }, }; -use solana_commitment_config::{CommitmentConfig, CommitmentLevel}; +use solana_commitment_config::CommitmentConfig; use solana_pubkey::Pubkey; use solana_rpc_client_api::response::{Response as RpcResponse, SlotInfo, SlotUpdate}; use solana_signature::Signature; -use solana_transaction_status::{TransactionConfirmationStatus, UiTransactionEncoding}; +use solana_transaction_status::UiTransactionEncoding; use super::{State, SurfnetRpcContext, SurfpoolWebsocketMeta}; use crate::{ rpc::utils::MAX_SUPPORTED_TRANSACTION_VERSION, - surfnet::{GetTransactionResult, SignatureSubscriptionType}, + surfnet::{ + GetTransactionResult, LocalSignatureStatusOrSubscription, SignatureSubscriptionType, + }, }; /// Configuration for account subscription requests. @@ -1100,6 +1102,50 @@ pub struct SurfpoolWsRpc { pub tokio_handle: tokio::runtime::Handle, } +impl SurfpoolWsRpc { + /// Send the one notification a signature subscription is allowed to produce and remove its + /// sink from the active subscription map. + fn notify_signature_subscriber( + active: &Arc>>>>, + sub_id: &SubscriptionId, + subscription_type: &SignatureSubscriptionType, + slot: u64, + err: Option, + is_received_event: bool, + ) -> bool { + let Ok(mut guard) = active.write() else { + log::error!("Failed to acquire write lock on signature_subscription_map"); + return false; + }; + + let Some(sink) = guard.remove(sub_id) else { + return false; + }; + + let result = match (subscription_type, is_received_event) { + (SignatureSubscriptionType::Received, true) => sink.notify(Ok(RpcResponse { + context: RpcResponseContext::new(slot), + value: RpcSignatureResult::ReceivedSignature( + ReceivedSignatureResult::ReceivedSignature, + ), + })), + _ => sink.notify(Ok(RpcResponse { + context: RpcResponseContext::new(slot), + value: RpcSignatureResult::ProcessedSignature(ProcessedSignatureResult { + err: err.map(Into::into), + }), + })), + }; + + if let Err(error) = result { + log::error!("Failed to notify client about signature update: {error}"); + return false; + } + + true + } +} + impl Rpc for SurfpoolWsRpc { type Metadata = Option; @@ -1195,77 +1241,83 @@ impl Rpc for SurfpoolWsRpc { return; } }; - // get the signature from the SVM to see if it's already been processed - let tx_result = match svm_locker - .get_transaction( - &remote_ctx.map(|(r, _)| r), - &signature, - rpc_transaction_config, - ) - .await - { - Ok(res) => res, - Err(e) => { - if let Ok(mut guard) = active.write() { - if let Some(sink) = guard.remove(&sub_id) { - let _ = sink.notify(Err(e.into())); - } - } - return; - } - }; - - // if we already had the transaction, check if its confirmation status matches the desired status set by the subscription - // if so, notify the user and complete the subscription - // otherwise, subscribe to the transaction updates - if let GetTransactionResult::FoundTransaction(_, _, tx) = tx_result { - match (&subscription_type, tx.confirmation_status) { - (&SignatureSubscriptionType::Received, _) - | ( - &SignatureSubscriptionType::Commitment(CommitmentLevel::Processed), - Some(TransactionConfirmationStatus::Processed), - ) - | ( - &SignatureSubscriptionType::Commitment(CommitmentLevel::Processed), - Some(TransactionConfirmationStatus::Confirmed), - ) - | ( - &SignatureSubscriptionType::Commitment(CommitmentLevel::Processed), - Some(TransactionConfirmationStatus::Finalized), - ) - | ( - &SignatureSubscriptionType::Commitment(CommitmentLevel::Confirmed), - Some(TransactionConfirmationStatus::Confirmed), - ) - | ( - &SignatureSubscriptionType::Commitment(CommitmentLevel::Confirmed), - Some(TransactionConfirmationStatus::Finalized), - ) - | ( - &SignatureSubscriptionType::Commitment(CommitmentLevel::Finalized), - Some(TransactionConfirmationStatus::Finalized), - ) => { + // Check local history first. A remote miss can take arbitrarily long, so it must not + // be part of the window between the final local check and receiver registration. + let local_tx_result = + match svm_locker.get_transaction_local(&signature, &rpc_transaction_config) { + Ok(res) => res, + Err(e) => { if let Ok(mut guard) = active.write() { if let Some(sink) = guard.remove(&sub_id) { - let _ = sink.notify(Ok(RpcResponse { - context: RpcResponseContext::new(tx.slot), - value: RpcSignatureResult::ProcessedSignature( - ProcessedSignatureResult { - err: tx.err.map(|e| e.into()), - }, - ), - })); + let _ = sink.notify(Err(e.into())); } } return; } - _ => {} + }; + + // Preserve the historical remote lookup for signatures that were not executed + // locally. Its result is intentionally not inserted into the SVM. + let tx_result = if local_tx_result.is_none() { + match remote_ctx.as_ref() { + Some((remote_client, _)) => { + remote_client + .get_transaction( + signature, + rpc_transaction_config.clone(), + svm_locker.get_latest_absolute_slot(), + ) + .await + } + None => local_tx_result, + } + } else { + local_tx_result + }; + + if let GetTransactionResult::FoundTransaction(_, _, tx) = tx_result { + if tx + .confirmation_status + .is_some_and(|status| subscription_type.is_satisfied_by(status)) + { + Self::notify_signature_subscriber( + &active, + &sub_id, + &subscription_type, + tx.slot, + tx.err, + false, + ); + return; } } - // update our surfnet SVM to subscribe to the signature updates - let rx = - svm_locker.subscribe_for_signature_updates(&signature, subscription_type.clone()); + // Check local status and install the receiver while holding one SVM write lock. A + // locally committed transaction therefore cannot be missed after a remote miss. + let rx = match svm_locker + .get_local_signature_status_or_subscribe(&signature, subscription_type.clone()) + { + Ok(LocalSignatureStatusOrSubscription::Status(status)) => { + Self::notify_signature_subscriber( + &active, + &sub_id, + &subscription_type, + status.slot, + status.err, + false, + ); + return; + } + Ok(LocalSignatureStatusOrSubscription::Subscription(rx)) => rx, + Err(error) => { + if let Ok(mut guard) = active.write() { + if let Some(sink) = guard.remove(&sub_id) { + let _ = sink.notify(Err(error.into())); + } + } + return; + } + }; loop { let (slot, some_err) = match rx.try_recv() { @@ -1289,39 +1341,15 @@ impl Rpc for SurfpoolWsRpc { } }; - let Ok(mut guard) = active.write() else { - log::error!("Failed to acquire read lock on signature_subscription_map"); - break; - }; - - let Some(sink) = guard.remove(&sub_id) else { - log::error!("Failed to get sink for subscription ID"); - break; - }; - - let res = match subscription_type { - SignatureSubscriptionType::Received => sink.notify(Ok(RpcResponse { - context: RpcResponseContext::new(slot), - value: RpcSignatureResult::ReceivedSignature( - ReceivedSignatureResult::ReceivedSignature, - ), - })), - SignatureSubscriptionType::Commitment(_) => sink.notify(Ok(RpcResponse { - context: RpcResponseContext::new(slot), - value: RpcSignatureResult::ProcessedSignature(ProcessedSignatureResult { - err: some_err.map(|e| e.into()), - }), - })), - }; - - if guard.is_empty() { - break; - } - - if let Err(e) = res { - log::error!("Failed to notify client about account update: {e}"); - break; - } + Self::notify_signature_subscriber( + &active, + &sub_id, + &subscription_type, + slot, + some_err, + true, + ); + break; } }); } diff --git a/crates/core/src/runloops/mod.rs b/crates/core/src/runloops/mod.rs index f4366adcc..496afabaf 100644 --- a/crates/core/src/runloops/mod.rs +++ b/crates/core/src/runloops/mod.rs @@ -45,7 +45,7 @@ use crate::{ surfnet_cheatcodes::SurfnetCheatcodes, ws::Rpc, }, surfnet::{ - GetAccountResult, GeyserEvent, PluginCommand, locker::SurfnetSvmLocker, + AccountSource, GetAccountResult, GeyserEvent, PluginCommand, locker::SurfnetSvmLocker, remote::SurfnetRemoteClient, }, }; @@ -577,11 +577,6 @@ pub async fn start_block_production_runloop( .await { Ok(account_updates) => { - // The locker holds one write guard while applying the complete - // batch, so Ready cannot expose a partially installed clone set. - svm_locker - .write_multiple_account_updates(&account_updates.inner); - // A cloned account the datasource does not have is not a // failure: hydration did complete, and some workflows clone // addresses that do not exist yet. Warn, though, because the @@ -1171,7 +1166,7 @@ mod absent_after_hydration_tests { let results = vec![ GetAccountResult::None(missing), GetAccountResult::None(offline), - GetAccountResult::FoundAccount(found, Account::default(), true), + GetAccountResult::FoundAccount(found, Account::default(), AccountSource::Generated), ]; assert_eq!( diff --git a/crates/core/src/surfnet/locker.rs b/crates/core/src/surfnet/locker.rs index 1606b40bb..78d111039 100644 --- a/crates/core/src/surfnet/locker.rs +++ b/crates/core/src/surfnet/locker.rs @@ -64,8 +64,9 @@ use txtx_addon_kit::indexmap::IndexSet; use uuid::Uuid; use super::{ - AccountFactory, GetAccountResult, GetTransactionResult, GeyserEvent, SignatureSubscriptionType, - SurfnetSvm, remote::SurfnetRemoteClient, + AccountFactory, AccountSource, CoupledAccount, GetAccountResult, GetTransactionResult, + GeyserEvent, LocalSignatureStatusOrSubscription, SignatureSubscriptionType, SurfnetSvm, + remote::SurfnetRemoteClient, svm::AccountUpdatePolicy, }; use crate::{ error::{AirdropError, SurfpoolError, SurfpoolResult}, @@ -283,8 +284,7 @@ impl SurfnetSvmLocker { ) -> GetAccountResult { match result { GetAccountResult::FoundAccount(_, account, _) - | GetAccountResult::FoundProgramAccount((_, account), _) - | GetAccountResult::FoundTokenAccount((_, account), _) + | GetAccountResult::FoundCoupledAccount((_, account), _, _) if offline_owners.contains(&account.owner) => { GetAccountResult::None(*requested_pubkey) @@ -293,6 +293,154 @@ impl SurfnetSvmLocker { } } + /// Re-checks local state after an asynchronous fetch, returning the local + /// account when another writer won the race. + fn resolve_account_after_fetch( + &self, + pubkey: Pubkey, + fetched_account: Option, + ) -> SurfpoolContextualizedResult { + self.with_svm_writer(move |svm_writer| { + let account = Self::resolve_fetched_account(svm_writer, pubkey, fetched_account)?; + Ok(SvmAccessContext::new( + svm_writer.get_latest_absolute_slot(), + svm_writer.latest_epoch_info(), + svm_writer.latest_blockhash(), + account, + )) + }) + } + + /// Re-checks and resolves a batch under one writer lock so callers never + /// expose a partially hydrated account set. + fn resolve_accounts_after_fetch( + &self, + pubkeys: &[Pubkey], + fetched_accounts: HashMap, + ) -> SurfpoolContextualizedResult> { + self.with_svm_writer(move |svm_writer| { + let mut accounts = Vec::with_capacity(pubkeys.len()); + for pubkey in pubkeys { + accounts.push(Self::resolve_fetched_account( + svm_writer, + *pubkey, + fetched_accounts.get(pubkey).cloned(), + )?); + } + + Ok(SvmAccessContext::new( + svm_writer.get_latest_absolute_slot(), + svm_writer.latest_epoch_info(), + svm_writer.latest_blockhash(), + accounts, + )) + }) + } + + fn resolve_fetched_account( + svm_writer: &mut SurfnetSvm, + pubkey: Pubkey, + fetched_account: Option, + ) -> SurfpoolResult { + let local_account = svm_writer.inner.get_account_result(&pubkey)?; + if !local_account.is_none() { + if local_account + .source() + .and_then(AccountUpdatePolicy::for_source) + .is_some() + { + svm_writer.apply_account_update( + local_account.clone(), + AccountUpdatePolicy::HydrateIfAbsent, + )?; + } + return Ok(local_account); + } + + if svm_writer + .offline_accounts + .contains_key(&pubkey.to_string())? + { + return Ok(GetAccountResult::None(pubkey)); + } + + let Some(fetched_account) = fetched_account else { + return Ok(GetAccountResult::None(pubkey)); + }; + + let offline_owners = Self::offline_account_owners(svm_writer); + let fetched_account = + Self::filter_downloaded_account_result(&pubkey, fetched_account, &offline_owners); + + if !fetched_account.is_none() { + svm_writer.apply_account_update( + fetched_account.clone(), + AccountUpdatePolicy::HydrateIfAbsent, + )?; + } + + Self::refresh_coupled_account(svm_writer, fetched_account) + } + + /// Rebuilds the returned coupled result from the state that is now live in + /// LiteSVM. Hydration may intentionally keep a newer local dependency, + /// so returning the original fetched composite would expose stale data to + /// callers even though the SVM itself is correct. + fn refresh_coupled_account( + svm_writer: &SurfnetSvm, + account_result: GetAccountResult, + ) -> SurfpoolResult { + let GetAccountResult::FoundCoupledAccount((pubkey, account), coupled, source) = + account_result + else { + return Ok(account_result); + }; + + let coupled = match coupled { + CoupledAccount::ProgramData(coupled_pubkey, fallback_account) => { + let local = svm_writer.inner.get_account_result(&coupled_pubkey)?; + let account = match local { + GetAccountResult::None(_) => fallback_account, + local => Some(local.map_account()?), + }; + CoupledAccount::ProgramData(coupled_pubkey, account) + } + CoupledAccount::Mint(coupled_pubkey, fallback_account) => { + let local = svm_writer.inner.get_account_result(&coupled_pubkey)?; + let account = match local { + GetAccountResult::None(_) => fallback_account, + local => Some(local.map_account()?), + }; + CoupledAccount::Mint(coupled_pubkey, account) + } + }; + + Ok(GetAccountResult::FoundCoupledAccount( + (pubkey, account), + coupled, + source, + )) + } + + fn offline_account_owners(svm_writer: &SurfnetSvm) -> Vec { + svm_writer + .offline_accounts + .into_iter() + .unwrap_or_else(|e| { + warn!("Failed to iterate offline_accounts: {}", e); + Box::new(std::iter::empty()) + }) + .filter(|(_, config)| config.include_owned_accounts) + .filter_map(|(key, _)| match key.parse() { + Ok(pubkey) => Some(pubkey), + Err(e) => { + warn!("Invalid pubkey in offline_accounts: {}: {}", key, e); + None + } + }) + .collect() + } + /// Retrieves a local account from the SVM cache, returning a contextualized result. pub fn get_account_local(&self, pubkey: &Pubkey) -> SvmAccessContext { self.with_contextualized_svm_reader(|svm_reader| { @@ -315,18 +463,21 @@ impl SurfnetSvmLocker { let is_offline = self.is_account_offline(pubkey); if !is_offline { - let offline_owners = self.get_offline_account_owners(); let remote_account = client.get_account(pubkey, commitment_config).await?; - Ok( - result.with_new_value(Self::filter_downloaded_account_result( - pubkey, - remote_account, - &offline_owners, - )), - ) + self.resolve_account_after_fetch(*pubkey, Some(remote_account)) } else { Ok(result) } + } else if result + .inner + .source() + .and_then(AccountUpdatePolicy::for_source) + .is_some() + { + // An account read from the configured database must be restored to + // LiteSVM. Re-check under the write lock so a concurrent local + // write wins over the stale database value. + self.resolve_account_after_fetch(*pubkey, None) } else { Ok(result) } @@ -343,7 +494,17 @@ impl SurfnetSvmLocker { self.get_account_local_then_remote(remote_client, pubkey, *commitment_config) .await? } else { - self.get_account_local(pubkey) + let result = self.get_account_local(pubkey); + if result + .inner + .source() + .and_then(AccountUpdatePolicy::for_source) + .is_some() + { + self.resolve_account_after_fetch(*pubkey, None)? + } else { + result + } }; match (&result.inner, factory) { @@ -402,7 +563,16 @@ impl SurfnetSvmLocker { } if missing_accounts.is_empty() { - // All accounts found locally, already in correct order + if local_results.iter().any(|result| { + result + .source() + .and_then(AccountUpdatePolicy::for_source) + .is_some() + }) { + return self.resolve_accounts_after_fetch(pubkeys, HashMap::new()); + } + + // All accounts found in LiteSVM, already in correct order. return Ok(SvmAccessContext::new( slot, latest_epoch_info, @@ -419,49 +589,16 @@ impl SurfnetSvmLocker { .get_multiple_accounts(&missing_accounts, commitment_config) .await?; - // Build map of pubkey -> remote result for O(1) lookup - let offline_owners = self.get_offline_account_owners(); + // Build map of pubkey -> remote result for O(1) lookup. Offline-owner + // filtering occurs under the final writer lock, after the remote await, + // so a concurrent offline marker cannot be bypassed. let remote_map: HashMap = missing_accounts .iter() .copied() .zip(remote_results.into_iter()) - .map(|(requested_pubkey, result)| { - ( - requested_pubkey, - Self::filter_downloaded_account_result( - &requested_pubkey, - result, - &offline_owners, - ), - ) - }) - .collect(); - - // Replace None entries with remote results while preserving order - // We iterate through original pubkeys array to ensure order is explicit - let combined_results: Vec = pubkeys - .iter() - .zip(local_results.into_iter()) - .map(|(pubkey, local_result)| { - match local_result { - GetAccountResult::None(_) => remote_map - .get(pubkey) - .cloned() - .unwrap_or(GetAccountResult::None(*pubkey)), - found => { - debug!("Keeping local account: {}", pubkey); - found - } // Keep found accounts (no clone, just move) - } - }) .collect(); - Ok(SvmAccessContext::new( - slot, - latest_epoch_info, - latest_blockhash, - combined_results, - )) + self.resolve_accounts_after_fetch(pubkeys, remote_map) } /// Retrieves multiple accounts, using local or remote context and applying factory defaults if provided. @@ -479,7 +616,17 @@ impl SurfnetSvmLocker { ) .await? } else { - self.get_multiple_accounts_local(pubkeys) + let results = self.get_multiple_accounts_local(pubkeys); + if results.inner.iter().any(|result| { + result + .source() + .and_then(AccountUpdatePolicy::for_source) + .is_some() + }) { + self.resolve_accounts_after_fetch(pubkeys, HashMap::new())? + } else { + results + } }; let mut combined = Vec::with_capacity(results.inner.len()); @@ -522,7 +669,10 @@ impl SurfnetSvmLocker { let mut loaded_count = 0; // Separate accounts into those with data and those needing remote fetch - let mut accounts_to_load: Vec<(Pubkey, Account)> = Vec::new(); + // The boolean records whether an entry was fetched because a snapshot + // value was `null`. Explicit snapshot values intentionally overwrite + // local state; fetched values must not overwrite a newer local write. + let mut accounts_to_load: Vec<(Pubkey, Account, bool)> = Vec::new(); let mut pubkeys_to_fetch: Vec = Vec::new(); for (pubkey_str, account_snapshot_opt) in snapshot.iter() { @@ -578,7 +728,7 @@ impl SurfnetSvmLocker { rent_epoch: account_snapshot.rent_epoch, }; - accounts_to_load.push((pubkey, account)); + accounts_to_load.push((pubkey, account, false)); } None => { // Queue for remote fetch if client is available @@ -607,24 +757,26 @@ impl SurfnetSvmLocker { for (pubkey, result) in pubkeys_to_fetch.iter().zip(remote_results) { match result { GetAccountResult::FoundAccount(_, account, _) => { - accounts_to_load.push((*pubkey, account)); + accounts_to_load.push((*pubkey, account, true)); } - GetAccountResult::FoundProgramAccount( + GetAccountResult::FoundCoupledAccount( (program_pubkey, program_account), - (data_pubkey, data_account_opt), + CoupledAccount::ProgramData(data_pubkey, data_account_opt), + _, ) => { - accounts_to_load.push((program_pubkey, program_account)); + accounts_to_load.push((program_pubkey, program_account, true)); if let Some(data_account) = data_account_opt { - accounts_to_load.push((data_pubkey, data_account)); + accounts_to_load.push((data_pubkey, data_account, true)); } } - GetAccountResult::FoundTokenAccount( + GetAccountResult::FoundCoupledAccount( (token_pubkey, token_account), - (mint_pubkey, mint_account_opt), + CoupledAccount::Mint(mint_pubkey, mint_account_opt), + _, ) => { - accounts_to_load.push((token_pubkey, token_account)); + accounts_to_load.push((token_pubkey, token_account, true)); if let Some(mint_account) = mint_account_opt { - accounts_to_load.push((mint_pubkey, mint_account)); + accounts_to_load.push((mint_pubkey, mint_account, true)); } } GetAccountResult::None(_) => { @@ -643,19 +795,45 @@ impl SurfnetSvmLocker { } } - accounts_to_load.sort_by_key(|(_, account)| snapshot_load_priority(account)); + accounts_to_load.sort_by_key(|(_, account, _)| snapshot_load_priority(account)); // Load all accounts into the SVM self.with_svm_writer(|svm| { let slot = svm.get_latest_absolute_slot(); - for (pubkey, account) in accounts_to_load { - if let Err(e) = svm.set_account(&pubkey, account.clone()) { - svm.simnet_events_tx + for (pubkey, account, fetched_from_remote) in accounts_to_load { + let load_result = if fetched_from_remote { + svm.apply_account_update( + GetAccountResult::FoundAccount( + pubkey, + account.clone(), + AccountSource::Remote, + ), + AccountUpdatePolicy::HydrateIfAbsent, + ) + } else { + svm.set_account(&pubkey, account.clone()) + }; + if let Err(e) = load_result { + let _ = svm + .simnet_events_tx .warn(format!("Failed to set account '{}': {}", pubkey, e)); continue; } + // Deliberately inspect LiteSVM only. Conditional remote hydration may + // lose to newer live state, and `set_account` can persist before + // LiteSVM rejects an account (for example, an incomplete program). + // A DB hit alone must not produce a Geyser startup update for an + // account that is not actually available in the live SVM. + let Some(account) = svm.inner.get_account_no_db(&pubkey) else { + let _ = svm.simnet_events_tx.warn(format!( + "Account '{}' was not present after snapshot load", + pubkey + )); + continue; + }; + // Send startup account update to geyser let write_version = svm.increment_write_version(); let _ = svm.geyser_events_tx.send(GeyserEvent::StartupAccountUpdate( @@ -768,12 +946,8 @@ impl SurfnetSvmLocker { combined.append(&mut remote_non_circulating_pubkeys); combined.append(&mut remote_circulating_pubkeys); - let get_account_results = self - .get_multiple_accounts_with_remote_fallback(client, &combined, commitment_config) - .await? - .inner; - - self.write_multiple_account_updates(&get_account_results); + self.get_multiple_accounts_with_remote_fallback(client, &combined, commitment_config) + .await?; } // now that our local cache is aware of all large remote accounts, we can get the largest accounts locally @@ -1700,17 +1874,15 @@ impl SurfnetSvmLocker { .inner; // We also need the pubkeys of the ALTs to be pulled from the remote, so we'll do a fetch for them - let alt_account_updates = self - .get_multiple_accounts( - remote_ctx, - &tx_loaded_addresses - .as_ref() - .map(|l| l.alt_addresses()) - .unwrap_or_default(), - None, - ) - .await? - .inner; + self.get_multiple_accounts( + remote_ctx, + &tx_loaded_addresses + .as_ref() + .map(|l| l.alt_addresses()) + .unwrap_or_default(), + None, + ) + .await?; let readonly_account_states = transaction_accounts .iter() @@ -1728,15 +1900,6 @@ impl SurfnetSvmLocker { }) .collect::>(); - self.with_svm_writer(|svm_writer| { - for update in &account_updates { - svm_writer.write_account_update(update.clone()); - } - for update in alt_account_updates { - svm_writer.write_account_update(update); - } - }); - let pre_execution_capture = { let mut capture = ExecutionCapture::new(); for account_update in account_updates.into_iter() { @@ -1745,8 +1908,7 @@ impl SurfnetSvmLocker { capture.insert(pubkey, None); } GetAccountResult::FoundAccount(pubkey, account, _) - | GetAccountResult::FoundProgramAccount((pubkey, account), _) - | GetAccountResult::FoundTokenAccount((pubkey, account), _) => { + | GetAccountResult::FoundCoupledAccount((pubkey, account), _, _) => { capture.insert(pubkey, Some(account)); } } @@ -2421,35 +2583,19 @@ impl SurfnetSvmLocker { } } -/// Functions for writing account updates to the underlying SurfnetSvm instance +/// Functions for materializing account lookup results into the underlying SVM. impl SurfnetSvmLocker { - /// Writes a single account update into the SVM state if present. - pub fn write_account_update(&self, account_update: GetAccountResult) { - if !account_update.requires_update() { - return; - } - + /// Applies an account lookup result using the explicit source-precedence policy. + pub(crate) fn apply_account_update( + &self, + account_update: GetAccountResult, + policy: AccountUpdatePolicy, + ) -> SurfpoolResult<()> { self.with_svm_writer(move |svm_writer| { - svm_writer.write_account_update(account_update.clone()) + svm_writer.apply_account_update(account_update, policy) }) } - /// Writes multiple account updates into the SVM state when any are present. - pub fn write_multiple_account_updates(&self, account_updates: &[GetAccountResult]) { - if account_updates - .iter() - .all(|update| !update.requires_update()) - { - return; - } - - self.with_svm_writer(move |svm_writer| { - for update in account_updates { - svm_writer.write_account_update(update.clone()); - } - }); - } - /// Resets an account in the SVM state for refresh/streaming. /// /// This function coordinates the reset of accounts by removing them from the local cache, @@ -2606,24 +2752,7 @@ impl SurfnetSvmLocker { /// Gets all owners whose accounts are marked offline. pub fn get_offline_account_owners(&self) -> Vec { - self.with_svm_reader(|svm_reader| { - svm_reader - .offline_accounts - .into_iter() - .unwrap_or_else(|e| { - warn!("Failed to iterate offline_accounts: {}", e); - Box::new(std::iter::empty()) - }) - .filter(|(_, config)| config.include_owned_accounts) - .filter_map(|(k, _)| match k.parse() { - Ok(pk) => Some(pk), - Err(e) => { - warn!("Invalid pubkey in offline_accounts: {}: {}", k, e); - None - } - }) - .collect() - }) + self.with_svm_reader(Self::offline_account_owners) } /// Registers a scenario for execution @@ -3434,15 +3563,18 @@ impl SurfnetSvmLocker { new_authority, )?; - get_account_result = GetAccountResult::FoundProgramAccount( + get_account_result = GetAccountResult::FoundCoupledAccount( (*pubkey, program_account.clone()), - (programdata_address, Some(programdata_account.clone())), + CoupledAccount::ProgramData( + programdata_address, + Some(programdata_account.clone()), + ), + AccountSource::Generated, ); original_authority } - GetAccountResult::FoundProgramAccount(_, _) - | GetAccountResult::FoundTokenAccount(_, _) => { + GetAccountResult::FoundCoupledAccount(_, _, _) => { return Err(SurfpoolError::invalid_program_account( pubkey, "Not a program account", @@ -3450,16 +3582,18 @@ impl SurfnetSvmLocker { } } } - GetAccountResult::FoundProgramAccount(_, (_, None)) => { + GetAccountResult::FoundCoupledAccount(_, CoupledAccount::ProgramData(_, None), _) => { return Err(SurfpoolError::invalid_program_account( program_id, "Program data account does not exist", )); } - GetAccountResult::FoundProgramAccount(_, (_, Some(programdata_account))) => { - update_programdata_account(&program_id, programdata_account, new_authority)? - } - GetAccountResult::FoundTokenAccount(_, _) => { + GetAccountResult::FoundCoupledAccount( + _, + CoupledAccount::ProgramData(_, Some(programdata_account)), + _, + ) => update_programdata_account(&program_id, programdata_account, new_authority)?, + GetAccountResult::FoundCoupledAccount(_, CoupledAccount::Mint(_, _), _) => { return Err(SurfpoolError::invalid_program_account( program_id, "Not a program account", @@ -3494,7 +3628,7 @@ impl SurfnetSvmLocker { } }; - self.write_account_update(get_account_result); + self.apply_account_update(get_account_result, AccountUpdatePolicy::Authoritative)?; Ok(SvmAccessContext::new( slot, @@ -3821,6 +3955,18 @@ impl SurfnetSvmLocker { }) } + /// Atomically checks whether a local transaction already satisfies a signature + /// subscription, otherwise registers its receiver under the SVM write lock. + pub fn get_local_signature_status_or_subscribe( + &self, + signature: &Signature, + subscription_type: SignatureSubscriptionType, + ) -> SurfpoolResult { + self.with_svm_writer(|svm_writer| { + svm_writer.get_local_signature_status_or_subscribe(signature, subscription_type) + }) + } + /// Subscribes for account updates and returns a receiver of account updates. pub fn subscribe_for_account_updates( &self, @@ -4108,7 +4254,7 @@ impl SurfnetSvmLocker { executable: true, rent_epoch: 0, }, - true, + AccountSource::Generated, ) })), ) @@ -4117,7 +4263,7 @@ impl SurfnetSvmLocker { // Check if account was created before consuming it let was_program_created = matches!( program_account_result, - GetAccountResult::FoundAccount(_, _, true) + GetAccountResult::FoundAccount(_, _, AccountSource::Generated) ); // Ensure we have a valid program account @@ -4141,11 +4287,14 @@ impl SurfnetSvmLocker { // Persist the program account if it was newly created if was_program_created { - self.write_account_update(GetAccountResult::FoundAccount( - program_id, - program_account.clone(), - true, - )); + self.apply_account_update( + GetAccountResult::FoundAccount( + program_id, + program_account.clone(), + AccountSource::Generated, + ), + AccountUpdatePolicy::Authoritative, + )?; } Ok(program_account) } @@ -4204,7 +4353,7 @@ impl SurfnetSvmLocker { executable: false, rent_epoch: 0, }, - true, + AccountSource::Generated, ) })), ) @@ -4428,13 +4577,22 @@ mod tests { use solana_account::Account; use solana_account_decoder::UiAccountEncoding; use solana_epoch_schedule::EpochSchedule; + use solana_keypair::Keypair; + use solana_message::{Message, VersionedMessage}; + use solana_sdk_ids::system_program; + use solana_signer::Signer; + use solana_system_interface::instruction as system_instruction; + use solana_transaction::versioned::VersionedTransaction; use solana_transaction_status::TransactionStatusMeta; use super::*; use crate::{ rpc::full::RpcTransactionsForAddressFilters, scenarios::registry::PYTH_V2_IDL_CONTENT, - surfnet::{BlockHeader, SurfnetSvm, svm::apply_override_to_decoded_account}, + surfnet::{ + BlockHeader, SurfnetSvm, + svm::{SurfnetSvmConfig, apply_override_to_decoded_account}, + }, }; /// A real `PriceUpdateV2` account. Its `VerificationLevel` is the one-byte `Full` variant and @@ -4454,6 +4612,233 @@ mod tests { ] } + #[cfg(feature = "sqlite")] + #[tokio::test(flavor = "multi_thread")] + async fn delayed_remote_account_cannot_overwrite_locally_created_account() { + let (svm, _simnet_events_rx, _geyser_events_rx) = + SurfnetSvm::new_with_db(Some(":memory:"), SurfnetSvmConfig::default()).unwrap(); + let locker = SurfnetSvmLocker::new(svm); + let payer = Keypair::new(); + let created = Keypair::new(); + let created_pubkey = created.pubkey(); + + let delayed_remote_result = GetAccountResult::FoundAccount( + created_pubkey, + Account { + lamports: 99, + data: vec![9, 9, 9], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }, + AccountSource::Remote, + ); + + // This represents a request that observed the account as absent and + // is now blocked on its upstream response. + assert!( + locker + .with_svm_reader(|svm| { svm.inner.get_account_no_db(&created_pubkey).is_none() }) + ); + + let _ = locker.airdrop(&payer.pubkey(), 1_000_000_000).unwrap(); + let rent_exempt_lamports = + locker.with_svm_reader(|svm| svm.inner.minimum_balance_for_rent_exemption(0)); + let create_instruction = system_instruction::create_account( + &payer.pubkey(), + &created_pubkey, + rent_exempt_lamports, + 0, + &system_program::id(), + ); + let message = Message::new_with_blockhash( + &[create_instruction], + Some(&payer.pubkey()), + &locker.latest_absolute_blockhash(), + ); + let transaction = + VersionedTransaction::try_new(VersionedMessage::Legacy(message), &[&payer, &created]) + .unwrap(); + let (status_tx, _status_rx) = crossbeam_channel::unbounded(); + locker + .process_transaction(&None, transaction, status_tx, true, true) + .await + .unwrap(); + + // Release the delayed upstream result after the local transaction has + // created the account. The resolver must return and retain local state. + let resolved = locker + .resolve_account_after_fetch(created_pubkey, Some(delayed_remote_result)) + .unwrap() + .inner + .map_account() + .unwrap(); + assert_eq!(resolved.lamports, rent_exempt_lamports); + assert!(resolved.data.is_empty()); + assert_eq!(resolved.owner, system_program::id()); + + locker.with_svm_reader(|svm| { + let in_memory = svm + .inner + .get_account_no_db(&created_pubkey) + .expect("locally created account should remain in LiteSVM"); + let in_db: Account = svm + .inner + .db + .as_ref() + .expect("configured account database should exist") + .get(&created_pubkey.to_string()) + .unwrap() + .expect("locally created account should remain in the database") + .into(); + + assert_eq!(in_memory.lamports, rent_exempt_lamports); + assert_eq!(in_db.lamports, rent_exempt_lamports); + assert!(in_memory.data.is_empty()); + assert!(in_db.data.is_empty()); + }); + } + + #[test] + fn fetched_dependency_does_not_overwrite_newer_local_account() { + let (svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + let primary = Pubkey::new_unique(); + let dependency = Pubkey::new_unique(); + let local_dependency = Account { + lamports: 7, + data: vec![7], + owner: spl_token_interface::id(), + executable: false, + rent_epoch: 0, + }; + let remote_dependency = Account { + lamports: 3, + data: vec![3], + owner: spl_token_interface::id(), + executable: false, + rent_epoch: 0, + }; + let remote_primary = Account { + lamports: 5, + data: vec![5], + owner: spl_token_interface::id(), + executable: false, + rent_epoch: 0, + }; + + locker.with_svm_writer(|svm| { + svm.set_account(&dependency, local_dependency.clone()) + .unwrap(); + }); + + let resolved = locker + .resolve_account_after_fetch( + primary, + Some(GetAccountResult::FoundCoupledAccount( + (primary, remote_primary), + CoupledAccount::Mint(dependency, Some(remote_dependency)), + AccountSource::Remote, + )), + ) + .unwrap(); + + match resolved.inner { + GetAccountResult::FoundCoupledAccount( + (_, _), + CoupledAccount::Mint(_, Some(returned_dependency)), + _, + ) => assert_eq!(returned_dependency, local_dependency), + other => panic!("expected a coupled token result, got {other:?}"), + } + + locker.with_svm_reader(|svm| { + assert_eq!( + svm.inner.get_account_no_db(&dependency), + Some(local_dependency) + ); + assert_eq!( + svm.inner + .get_account_no_db(&primary) + .expect("primary account should be hydrated") + .lamports, + 5 + ); + }); + } + + #[test] + fn batch_fetch_keeps_newer_local_account_and_hydrates_missing_account() { + let (svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + let locally_written = Pubkey::new_unique(); + let missing = Pubkey::new_unique(); + let local_account = Account { + lamports: 11, + data: vec![1], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + + locker.with_svm_writer(|svm| { + svm.set_account(&locally_written, local_account.clone()) + .unwrap(); + }); + + let fetched_accounts = HashMap::from([ + ( + locally_written, + GetAccountResult::FoundAccount( + locally_written, + Account { + lamports: 1, + data: vec![9], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }, + AccountSource::Remote, + ), + ), + ( + missing, + GetAccountResult::FoundAccount( + missing, + Account { + lamports: 2, + data: vec![2], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }, + AccountSource::Remote, + ), + ), + ]); + + let resolved = locker + .resolve_accounts_after_fetch(&[locally_written, missing], fetched_accounts) + .unwrap() + .inner; + assert_eq!(resolved[0].clone().map_account().unwrap(), local_account); + assert_eq!(resolved[1].clone().map_account().unwrap().lamports, 2); + + locker.with_svm_reader(|svm| { + assert_eq!( + svm.inner.get_account_no_db(&locally_written), + Some(local_account) + ); + assert_eq!( + svm.inner + .get_account_no_db(&missing) + .expect("missing account should be hydrated") + .lamports, + 2 + ); + }); + } + #[test] fn test_get_forged_account_data_with_pyth_fixture() { use borsh::{BorshDeserialize, BorshSerialize}; diff --git a/crates/core/src/surfnet/mod.rs b/crates/core/src/surfnet/mod.rs index 55fc53204..0e1e5bb82 100644 --- a/crates/core/src/surfnet/mod.rs +++ b/crates/core/src/surfnet/mod.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, fmt::Display, sync::Arc}; -use crossbeam_channel::Sender; +use crossbeam_channel::{Receiver, Sender}; use jsonrpc_core::Result as RpcError; use locker::SurfnetSvmLocker; use solana_account::Account; @@ -18,7 +18,9 @@ use solana_rpc_client_api::response::SlotUpdate; use solana_signature::Signature; use solana_transaction::versioned::VersionedTransaction; use solana_transaction_error::TransactionError; -use solana_transaction_status::{EncodedConfirmedTransactionWithStatusMeta, TransactionStatus}; +use solana_transaction_status::{ + EncodedConfirmedTransactionWithStatusMeta, TransactionConfirmationStatus, TransactionStatus, +}; use svm::SurfnetSvm; use crate::{ @@ -159,6 +161,22 @@ pub type SignatureSubscriptionData = ( Sender<(Slot, Option)>, ); +/// The status returned by an atomic signature lookup. +/// +/// This deliberately contains only the fields needed to produce a +/// `signatureNotification`; serializing the transaction is both unnecessary and would make the +/// registration path needlessly expensive. +pub struct LocalSignatureStatus { + pub slot: Slot, + pub err: Option, +} + +/// The outcome of atomically checking a local signature and registering for updates. +pub enum LocalSignatureStatusOrSubscription { + Status(LocalSignatureStatus), + Subscription(Receiver<(Slot, Option)>), +} + pub type AccountSubscriptionData = HashMap, Sender)>>; @@ -220,30 +238,51 @@ impl Display for SignatureSubscriptionType { } } -type DoUpdateSvm = bool; +/// Identifies where an account result was read from. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountSource { + /// The account is already present in the live LiteSVM state. + Svm, + /// The account was read from the configured database and is not yet in LiteSVM. + Database, + /// The account was fetched from the remote RPC. + Remote, + /// The account was created locally by a default factory or mutation path. + Generated, +} + +/// The kind of secondary account returned with a coupled account result. +#[derive(Clone, Debug)] +pub enum CoupledAccount { + /// Upgradeable programs may be returned with their program-data account. + ProgramData(Pubkey, Option), + /// Token accounts may be returned with their mint account. + Mint(Pubkey, Option), +} #[derive(Clone, Debug)] -/// Represents the result of a get_account operation. +/// Represents the result of a `get_account` operation. +/// +/// The result records provenance, while the caller chooses how that result may +/// affect the SVM through [svm::AccountUpdatePolicy]. In particular, +/// provenance does not imply authoritative replacement. See the policy +/// documentation for the complete result-by-policy decision table. pub enum GetAccountResult { /// Represents that the account was not found. None(Pubkey), - /// Represents that the account was found. - /// The `DoUpdateSvm` flag indicates whether the SVM should be updated after this account is found. - /// This is useful for cases where the account was fetched from a remote source and needs to be - /// updated in the SVM to reflect the latest state. However, when the account is found locally, - /// it likely does not need to be updated in the SVM. - FoundAccount(Pubkey, Account, DoUpdateSvm), - FoundProgramAccount((Pubkey, Account), (Pubkey, Option)), - FoundTokenAccount((Pubkey, Account), (Pubkey, Option)), + /// Represents an account found in one of the account stores. + FoundAccount(Pubkey, Account, AccountSource), + /// Represents an account coupled to a program-data or mint account. + FoundCoupledAccount((Pubkey, Account), CoupledAccount, AccountSource), } impl GetAccountResult { pub fn expected_data(&self) -> &Vec { match &self { Self::None(_) => unreachable!(), - Self::FoundAccount(_, account, _) - | Self::FoundProgramAccount((_, account), _) - | Self::FoundTokenAccount((_, account), _) => &account.data, + Self::FoundAccount(_, account, _) | Self::FoundCoupledAccount((_, account), _, _) => { + &account.data + } } } @@ -253,15 +292,15 @@ impl GetAccountResult { { match self { Self::None(_) => unreachable!(), - Self::FoundAccount(_, account, do_update_account) => { - update(account)?; - *do_update_account = true; - } - Self::FoundProgramAccount((_, account), _) => { + Self::FoundAccount(_, account, source) => { update(account)?; + // Applying an override turns a read result into an explicit + // local mutation, regardless of where the original account came from. + *source = AccountSource::Generated; } - Self::FoundTokenAccount((_, account), _) => { + Self::FoundCoupledAccount((_, account), _, source) => { update(account)?; + *source = AccountSource::Generated; } } Ok(()) @@ -270,9 +309,9 @@ impl GetAccountResult { pub fn map_account(self) -> SurfpoolResult { match self { Self::None(pubkey) => Err(SurfpoolError::account_not_found(pubkey)), - Self::FoundAccount(_, account, _) - | Self::FoundProgramAccount((_, account), _) - | Self::FoundTokenAccount((_, account), _) => Ok(account), + Self::FoundAccount(_, account, _) | Self::FoundCoupledAccount((_, account), _, _) => { + Ok(account) + } } } @@ -283,10 +322,12 @@ impl GetAccountResult { match self { Self::None(_) => None, Self::FoundAccount(pubkey, account, _) => Some(((pubkey, account), None)), - Self::FoundProgramAccount((pubkey, account), _) => Some(((pubkey, account), None)), - Self::FoundTokenAccount((pubkey, account), token_data) => { - Some(((pubkey, account), Some(token_data))) - } + Self::FoundCoupledAccount((pubkey, account), coupled, _) => match coupled { + CoupledAccount::ProgramData(_, _) => Some(((pubkey, account), None)), + CoupledAccount::Mint(coupled_pubkey, coupled_account) => { + Some(((pubkey, account), Some((coupled_pubkey, coupled_account)))) + } + }, } } @@ -294,12 +335,12 @@ impl GetAccountResult { matches!(self, Self::None(_)) } - pub const fn requires_update(&self) -> bool { + pub const fn source(&self) -> Option { match self { - Self::None(_) => false, - Self::FoundAccount(_, _, do_update) => *do_update, - Self::FoundProgramAccount(_, _) => true, - Self::FoundTokenAccount(_, _) => true, + Self::None(_) => None, + Self::FoundAccount(_, _, source) | Self::FoundCoupledAccount(_, _, source) => { + Some(*source) + } } } } @@ -326,6 +367,32 @@ impl SignatureSubscriptionType { pub const fn finalized() -> Self { SignatureSubscriptionType::Commitment(CommitmentLevel::Finalized) } + + /// Whether a transaction at `confirmation_status` has reached this subscription's target. + pub const fn is_satisfied_by( + &self, + confirmation_status: TransactionConfirmationStatus, + ) -> bool { + matches!( + (self, confirmation_status), + (Self::Received, _) + | ( + Self::Commitment(CommitmentLevel::Processed), + TransactionConfirmationStatus::Processed + | TransactionConfirmationStatus::Confirmed + | TransactionConfirmationStatus::Finalized + ) + | ( + Self::Commitment(CommitmentLevel::Confirmed), + TransactionConfirmationStatus::Confirmed + | TransactionConfirmationStatus::Finalized + ) + | ( + Self::Commitment(CommitmentLevel::Finalized), + TransactionConfirmationStatus::Finalized + ) + ) + } } #[allow(clippy::large_enum_variant)] diff --git a/crates/core/src/surfnet/remote.rs b/crates/core/src/surfnet/remote.rs index 3b50bd9c3..fe879a8ce 100644 --- a/crates/core/src/surfnet/remote.rs +++ b/crates/core/src/surfnet/remote.rs @@ -38,7 +38,9 @@ use super::GetTransactionResult; use crate::{ error::{SurfpoolError, SurfpoolResult}, rpc::utils::is_method_not_supported_error, - surfnet::{GetAccountResult, locker::is_supported_token_program}, + surfnet::{ + AccountSource, CoupledAccount, GetAccountResult, locker::is_supported_token_program, + }, types::{RemoteRpcResult, TokenAccount}, }; @@ -216,9 +218,10 @@ impl SurfnetRemoteClient { .await .map_err(|e| SurfpoolError::get_account(*pubkey, e))?; - result = Some(GetAccountResult::FoundTokenAccount( + result = Some(GetAccountResult::FoundCoupledAccount( (*pubkey, account.clone()), - (token_account.mint(), mint.value), + CoupledAccount::Mint(token_account.mint(), mint.value), + AccountSource::Remote, )); }; } else if account.executable { @@ -230,16 +233,17 @@ impl SurfnetRemoteClient { .await .map_err(|e| SurfpoolError::get_account(*pubkey, e))?; - result = Some(GetAccountResult::FoundProgramAccount( + result = Some(GetAccountResult::FoundCoupledAccount( (*pubkey, account.clone()), - (program_data_address, program_data.value), + CoupledAccount::ProgramData(program_data_address, program_data.value), + AccountSource::Remote, )); } result.unwrap_or(GetAccountResult::FoundAccount( - *pubkey, account, - // Mark this account as needing to be updated in the SVM, since we fetched it - true, + *pubkey, + account, + AccountSource::Remote, )) } None => GetAccountResult::None(*pubkey), @@ -291,8 +295,7 @@ impl SurfnetRemoteClient { GetAccountResult::FoundAccount( *pubkey, remote_account, - // Mark this account as needing to be updated in the SVM, since we fetched it - true, + AccountSource::Remote, ), ); } @@ -305,8 +308,7 @@ impl SurfnetRemoteClient { GetAccountResult::FoundAccount( *pubkey, remote_account, - // Mark this account as needing to be updated in the SVM, since we fetched it - true, + AccountSource::Remote, ), ); } @@ -357,17 +359,22 @@ impl SurfnetRemoteClient { // mint accounts to be inserted results_map.insert( account_buffer[index].0, - GetAccountResult::FoundTokenAccount( + GetAccountResult::FoundCoupledAccount( (account_buffer[index].0, account_buffer[index].1.clone()), - (account_buffer[index].2, remote_account.clone()), + CoupledAccount::Mint(account_buffer[index].2, remote_account.clone()), + AccountSource::Remote, ), ); } else { results_map.insert( account_buffer[index].0, - GetAccountResult::FoundProgramAccount( + GetAccountResult::FoundCoupledAccount( (account_buffer[index].0, account_buffer[index].1.clone()), - (account_buffer[index].2, remote_account.clone()), + CoupledAccount::ProgramData( + account_buffer[index].2, + remote_account.clone(), + ), + AccountSource::Remote, ), ); } diff --git a/crates/core/src/surfnet/surfnet_lite_svm.rs b/crates/core/src/surfnet/surfnet_lite_svm.rs index 11b66c111..2086e902c 100644 --- a/crates/core/src/surfnet/surfnet_lite_svm.rs +++ b/crates/core/src/surfnet/surfnet_lite_svm.rs @@ -19,7 +19,9 @@ use solana_transaction::versioned::VersionedTransaction; use crate::{ error::{SurfpoolError, SurfpoolResult}, storage::{OverlayStorage, Storage, StorageBackend}, - surfnet::{GetAccountResult, locker::is_supported_token_program}, + surfnet::{ + AccountSource, CoupledAccount, GetAccountResult, locker::is_supported_token_program, + }, }; pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000; @@ -191,9 +193,9 @@ impl SurfnetLiteSvm { pub fn get_account_result(&self, pubkey: &Pubkey) -> SurfpoolResult { if let Some(account) = self.svm.get_account(pubkey) { return Ok(GetAccountResult::FoundAccount( - *pubkey, account, - // mark as not an account that should be updated in the SVM, since this is a local read and it already exists - false, + *pubkey, + account, + AccountSource::Svm, )); } else if let Some(db) = &self.db { let mut result = None; @@ -202,9 +204,10 @@ impl SurfnetLiteSvm { if let Ok(token_account) = crate::types::TokenAccount::unpack(&account.data) { let mint = db.get(&token_account.mint().to_string())?.map(Into::into); - result = Some(GetAccountResult::FoundTokenAccount( + result = Some(GetAccountResult::FoundCoupledAccount( (*pubkey, account.clone()), - (token_account.mint(), mint), + CoupledAccount::Mint(token_account.mint(), mint), + AccountSource::Database, )); }; } else if account.executable { @@ -212,16 +215,17 @@ impl SurfnetLiteSvm { let program_data = db.get(&program_data_address.to_string())?.map(Into::into); - result = Some(GetAccountResult::FoundProgramAccount( + result = Some(GetAccountResult::FoundCoupledAccount( (*pubkey, account.clone()), - (program_data_address, program_data), + CoupledAccount::ProgramData(program_data_address, program_data), + AccountSource::Database, )); } return Ok(result.unwrap_or(GetAccountResult::FoundAccount( - *pubkey, account, - // Mark this account as needing to be updated in the SVM, since we pulled it from the db - true, + *pubkey, + account, + AccountSource::Database, ))); } } diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 7fa06023d..b22b9747c 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -49,7 +49,10 @@ use solana_slot_hashes::MAX_ENTRIES as MAX_SLOT_HASHES_ENTRIES; use solana_system_interface::instruction as system_instruction; use solana_transaction::versioned::VersionedTransaction; use solana_transaction_error::TransactionError; -use solana_transaction_status::{TransactionDetails, TransactionStatusMeta, UiConfirmedBlock}; +use solana_transaction_status::{ + TransactionConfirmationStatus as RpcTransactionConfirmationStatus, TransactionDetails, + TransactionStatusMeta, UiConfirmedBlock, +}; use spl_token_2022_interface::extension::{ BaseStateWithExtensions, StateWithExtensions, interest_bearing_mint::InterestBearingConfig, scaled_ui_amount::ScaledUiAmountConfig, @@ -77,8 +80,9 @@ use txtx_addon_network_svm_types::idl::{ use uuid::Uuid; use super::{ - AccountSubscriptionData, BlockHeader, BlockIdentifier, FINALIZATION_SLOT_THRESHOLD, - GetAccountResult, GeyserBlockMetadata, GeyserEntryInfo, GeyserEvent, GeyserSlotStatus, + AccountSource, AccountSubscriptionData, BlockHeader, BlockIdentifier, CoupledAccount, + FINALIZATION_SLOT_THRESHOLD, GetAccountResult, GeyserBlockMetadata, GeyserEntryInfo, + GeyserEvent, GeyserSlotStatus, LocalSignatureStatus, LocalSignatureStatusOrSubscription, ProgramSubscriptionData, SignatureSubscriptionData, SignatureSubscriptionType, SlotsUpdatesSubscriptionData, remote::SurfnetRemoteClient, }; @@ -118,6 +122,39 @@ lazy_static::lazy_static! { }; } +/// Determines how an account result may change the SVM. +/// +/// The result's [`AccountSource`] describes where the data came from; this +/// policy describes what the current operation is allowed to do with it. +/// +/// | Result | Source | `Authoritative` | `HydrateIfAbsent` | +/// | --- | --- | --- | --- | +/// | `None` | Any | No-op | No-op | +/// | `FoundAccount` | `Svm` | No-op; it is already live | No-op | +/// | `FoundAccount` | `Database` or `Remote` | Replace when explicitly applied | Insert only when absent; preserve live state | +/// | `FoundAccount` | `Generated` | Replace when explicitly applied | No-op; generated state is already an explicit mutation | +/// | `FoundCoupledAccount::ProgramData` | `Database` or `Remote` | Apply program-data before program | Hydrate each missing component, preserving live state | +/// | `FoundCoupledAccount::Mint` | `Database` or `Remote` | Apply mint before token account when present | Hydrate each missing component, preserving live state | +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AccountUpdatePolicy { + /// Replace local state with the supplied account update. + Authoritative, + /// Keep any live LiteSVM state and rehydrate database-only state instead + /// of replacing it with a fetched result. + HydrateIfAbsent, +} + +impl AccountUpdatePolicy { + /// Converts account provenance into the non-authoritative policy used when + /// a result needs to be materialized in LiteSVM. + pub(crate) const fn for_source(source: AccountSource) -> Option { + match source { + AccountSource::Database | AccountSource::Remote => Some(Self::HydrateIfAbsent), + AccountSource::Svm | AccountSource::Generated => None, + } + } +} + /// Helper function to apply an override to a decoded account value using dot notation pub fn apply_override_to_decoded_account( decoded_value: &mut Value, @@ -601,6 +638,18 @@ fn synthetic_blockhash_for_slot(slot: Slot, genesis_slot: Slot) -> SyntheticBloc SyntheticBlockhash::new(u64::MAX - (genesis_slot - slot - 1)) } +/// What one `fetch_before_use` attempt settled. Decides whether a persisted override keeps +/// asking on later slots, which it must while another attempt could still change the answer. +enum FetchOutcome { + Retired, + /// There is no remote to ask. Only a local account can satisfy the request. + NoRemote, + /// The remote has no such account. It may be created later. + NotOnRemote, + /// No answer was obtained. Another attempt may get one. + Unanswered, +} + impl SurfnetSvm { pub fn default() -> (Self, Receiver, Receiver) { Self::new(SurfnetSvmConfig::default()).unwrap() @@ -2375,117 +2424,161 @@ impl SurfnetSvm { Ok(()) } - /// Writes account updates to the SVM state based on the provided account update result. - /// - /// # Arguments - /// * `account_update` - The account update result to process. - pub fn write_account_update(&mut self, account_update: GetAccountResult) { - let init_programdata_account = |program_account: &Account| { - if !program_account.executable { - return None; - } - if !program_account - .owner - .eq(&solana_sdk_ids::bpf_loader_upgradeable::id()) - { - return None; - } - let Ok(UpgradeableLoaderState::Program { - programdata_address, - }) = bincode::deserialize::(&program_account.data) - else { - return None; - }; - - let programdata_state = UpgradeableLoaderState::ProgramData { - upgrade_authority_address: Some(system_program::id()), - slot: self.get_latest_absolute_slot(), - }; - let mut data = bincode::serialize(&programdata_state).unwrap(); + /// Materializes an account lookup result into the SVM according to its + /// source policy. This is the sole insertion path for `GetAccountResult`; + /// callers must state whether the result is authoritative or cache-only. + pub(crate) fn apply_account_update( + &mut self, + account_update: GetAccountResult, + policy: AccountUpdatePolicy, + ) -> SurfpoolResult<()> { + let account_update = self.account_update_for_policy(account_update, policy)?; - data.extend_from_slice(crate::surfnet::noop_program::NOOP_PROGRAM_ELF); - let lamports = self.inner.minimum_balance_for_rent_exemption(data.len()); - Some(( - programdata_address, - Account { - lamports, - data, - owner: solana_sdk_ids::bpf_loader_upgradeable::id(), - executable: false, - rent_epoch: 0, - }, - )) - }; match account_update { - GetAccountResult::FoundAccount(pubkey, account, do_update_account) => { - if do_update_account { - if let Some((programdata_address, programdata_account)) = - init_programdata_account(&account) - { - match self.get_account(&programdata_address) { - Ok(None) => { - if let Err(e) = - self.set_account(&programdata_address, programdata_account) - { - let _ = self.simnet_events_tx.error(e.to_string()); - } - } - Ok(Some(_)) => {} - Err(e) => { - let _ = self.simnet_events_tx.error(e.to_string()); - } - } - } - if let Err(e) = self.set_account(&pubkey, account.clone()) { - let _ = self.simnet_events_tx.error(e.to_string()); - } + GetAccountResult::None(_) => {} + GetAccountResult::FoundAccount(pubkey, account, source) => { + if source != AccountSource::Svm { + self.apply_synthetic_programdata(&account)?; + self.apply_account_component(pubkey, account, policy)?; } } - GetAccountResult::FoundProgramAccount((pubkey, account), (_, None)) => { - if let Some((programdata_address, programdata_account)) = - init_programdata_account(&account) - { - match self.get_account(&programdata_address) { - Ok(None) => { - if let Err(e) = - self.set_account(&programdata_address, programdata_account) - { - let _ = self.simnet_events_tx.error(e.to_string()); - } - } - Ok(Some(_)) => {} - Err(e) => { - let _ = self.simnet_events_tx.error(e.to_string()); - } - } - } - if let Err(e) = self.set_account(&pubkey, account.clone()) { - let _ = self.simnet_events_tx.error(e.to_string()); - } + GetAccountResult::FoundCoupledAccount( + (pubkey, account), + CoupledAccount::ProgramData(_, None), + _, + ) => { + self.apply_synthetic_programdata(&account)?; + self.apply_account_component(pubkey, account, policy)?; } - GetAccountResult::FoundTokenAccount((pubkey, account), (_, None)) => { - if let Err(e) = self.set_account(&pubkey, account.clone()) { - let _ = self.simnet_events_tx.error(e.to_string()); - } + GetAccountResult::FoundCoupledAccount( + (pubkey, account), + CoupledAccount::ProgramData(coupled_pubkey, Some(coupled_account)), + _, + ) => { + self.apply_account_component(coupled_pubkey, coupled_account, policy)?; + self.apply_account_component(pubkey, account, policy)?; } - GetAccountResult::FoundProgramAccount( + GetAccountResult::FoundCoupledAccount( (pubkey, account), - (coupled_pubkey, Some(coupled_account)), - ) - | GetAccountResult::FoundTokenAccount( + CoupledAccount::Mint(coupled_pubkey, Some(coupled_account)), + _, + ) => { + self.apply_account_component(coupled_pubkey, coupled_account, policy)?; + self.apply_account_component(pubkey, account, policy)?; + } + GetAccountResult::FoundCoupledAccount( (pubkey, account), - (coupled_pubkey, Some(coupled_account)), + CoupledAccount::Mint(_, None), + _, ) => { - // The data account _must_ be set first, as the program account depends on it. - if let Err(e) = self.set_account(&coupled_pubkey, coupled_account.clone()) { - let _ = self.simnet_events_tx.error(e.to_string()); - } - if let Err(e) = self.set_account(&pubkey, account.clone()) { - let _ = self.simnet_events_tx.error(e.to_string()); + self.apply_account_component(pubkey, account, policy)?; + } + } + + Ok(()) + } + + fn account_update_for_policy( + &self, + account_update: GetAccountResult, + policy: AccountUpdatePolicy, + ) -> SurfpoolResult { + if policy != AccountUpdatePolicy::HydrateIfAbsent { + return Ok(account_update); + } + + let pubkey = match &account_update { + GetAccountResult::None(pubkey) | GetAccountResult::FoundAccount(pubkey, ..) => *pubkey, + GetAccountResult::FoundCoupledAccount((pubkey, _), _, _) => *pubkey, + }; + let local = self.inner.get_account_result(&pubkey)?; + + // A database result includes its own associated programdata or mint + // account. Prefer that complete local representation to a stale + // fetched result. Conversely, a live primary makes the entire fetched + // result stale: do not install its coupled mint or programdata before + // skipping the primary, or the live account could observe mismatched + // dependency state. + if local + .source() + .and_then(AccountUpdatePolicy::for_source) + .is_some() + { + Ok(local) + } else if local.is_none() { + Ok(account_update) + } else { + Ok(GetAccountResult::None(pubkey)) + } + } + + fn apply_account_component( + &mut self, + pubkey: Pubkey, + account: Account, + policy: AccountUpdatePolicy, + ) -> SurfpoolResult<()> { + let account = if policy == AccountUpdatePolicy::HydrateIfAbsent { + match self.inner.get_account_result(&pubkey)? { + GetAccountResult::None(_) => account, + local + if local + .source() + .and_then(AccountUpdatePolicy::for_source) + .is_some() => + { + local.map_account()? } + _ => return Ok(()), } - GetAccountResult::None(_) => {} + } else { + account + }; + + // Preserve the established behavior for fetched data: an account that + // LiteSVM rejects (such as an incomplete program upload) is still + // returned to the caller, with the insertion failure emitted as an + // event for observability. + if let Err(error) = self.set_account(&pubkey, account) { + let _ = self.simnet_events_tx.error(error.to_string()); + } + Ok(()) + } + + fn apply_synthetic_programdata(&mut self, program_account: &Account) -> SurfpoolResult<()> { + if !program_account.executable + || program_account.owner != solana_sdk_ids::bpf_loader_upgradeable::id() + { + return Ok(()); } + let Ok(UpgradeableLoaderState::Program { + programdata_address, + }) = bincode::deserialize::(&program_account.data) + else { + return Ok(()); + }; + + let programdata_state = UpgradeableLoaderState::ProgramData { + upgrade_authority_address: Some(system_program::id()), + slot: self.get_latest_absolute_slot(), + }; + let mut data = bincode::serialize(&programdata_state).unwrap(); + data.extend_from_slice(crate::surfnet::noop_program::NOOP_PROGRAM_ELF); + let lamports = self.inner.minimum_balance_for_rent_exemption(data.len()); + + // A synthesized fallback is never authoritative: retain any real + // programdata already held in memory or in the configured database. + self.apply_account_component( + programdata_address, + Account { + lamports, + data, + owner: solana_sdk_ids::bpf_loader_upgradeable::id(), + executable: false, + rent_epoch: 0, + }, + AccountUpdatePolicy::HydrateIfAbsent, + ) } pub fn confirm_current_block(&mut self) -> SurfpoolResult<()> { @@ -2716,7 +2809,23 @@ impl SurfnetSvm { let mut settled_this_slot: HashSet = HashSet::new(); - for override_instance in overrides { + // `take` already emptied the slot, so bailing out mid-loop would drop every override that + // has not been reached yet. Put the unprocessed tail back before returning the error. + let restore_unprocessed = |svm: &mut Self, from: usize| { + if let Err(e) = svm + .scheduled_overrides + .store(target_slot, overrides[from..].to_vec()) + { + error!( + "Failed to restore {} unprocessed override(s) for slot {}: {}", + overrides.len() - from, + target_slot, + e + ); + } + }; + + for (index, override_instance) in overrides.iter().enumerate() { if !override_instance.enabled { debug!("Skipping disabled override: {}", override_instance.id); continue; @@ -2753,6 +2862,10 @@ impl SurfnetSvm { override_instance.id, account_pubkey, override_instance.label ); + // Defaults to Retired: nothing was asked for, the account was already forked by an + // earlier override this slot, or there is no remote to ask. + let mut fetch_outcome = FetchOutcome::Retired; + // Fetch fresh account data from remote if requested if override_instance.fetch_before_use && !settled_this_slot.contains(&account_pubkey) { if let Some((client, _)) = remote_ctx { @@ -2761,39 +2874,68 @@ impl SurfnetSvm { account_pubkey ); - match client + let fetched = match client .get_account(&account_pubkey, CommitmentConfig::confirmed()) .await { Ok(GetAccountResult::FoundAccount(_pubkey, remote_account, _)) => { - debug!( - "Fetched account {} from remote: {} lamports, {} bytes", - account_pubkey, - remote_account.lamports(), - remote_account.data().len() - ); - - // Set the fresh account data in the SVM - if let Err(e) = self.inner.set_account(account_pubkey, remote_account) { - warn!( - "Failed to set account {} from remote: {}", - account_pubkey, e - ); - } else { - settled_this_slot.insert(account_pubkey); - } + Some((remote_account, None)) } + Ok(GetAccountResult::FoundCoupledAccount( + (_pubkey, remote_account), + coupled, + _, + )) => Some(( + remote_account, + match coupled { + CoupledAccount::ProgramData(pubkey, account) + | CoupledAccount::Mint(pubkey, account) => { + account.map(|account| (pubkey, account)) + } + }, + )), Ok(GetAccountResult::None(_)) => { debug!("Account {} not found on remote", account_pubkey); - } - Ok(_) => { - debug!("Account {} fetched (other variant)", account_pubkey); + fetch_outcome = FetchOutcome::NotOnRemote; + None } Err(e) => { warn!( "Failed to fetch account {} from remote: {}", account_pubkey, e ); + fetch_outcome = FetchOutcome::Unanswered; + None + } + }; + + if let Some((remote_account, coupled)) = fetched { + debug!( + "Fetched account {} from remote: {} lamports, {} bytes", + account_pubkey, + remote_account.lamports(), + remote_account.data().len() + ); + + if let Some((coupled_pubkey, coupled_account)) = coupled { + if let Err(e) = self.inner.set_account(coupled_pubkey, coupled_account) + { + warn!( + "Failed to set coupled account {} from remote: {}", + coupled_pubkey, e + ); + } + } + + // Set the fresh account data in the SVM + if let Err(e) = self.inner.set_account(account_pubkey, remote_account) { + warn!( + "Failed to set account {} from remote: {}", + account_pubkey, e + ); + fetch_outcome = FetchOutcome::Unanswered; + } else { + settled_this_slot.insert(account_pubkey); } } } else { @@ -2801,12 +2943,34 @@ impl SurfnetSvm { "fetch_before_use enabled but no remote client available for override {}", override_instance.id ); + fetch_outcome = FetchOutcome::NoRemote; } } - // Queued before the write so a failed apply is retried next slot, still fetching. + let existing_account = match self.inner.get_account(&account_pubkey) { + Ok(account) => account, + Err(e) => { + restore_unprocessed(self, index); + return Err(e); + } + }; + + // The request is only retired when another attempt could no longer change anything. + let fetch_retired = match fetch_outcome { + FetchOutcome::Retired => true, + FetchOutcome::Unanswered => false, + FetchOutcome::NoRemote | FetchOutcome::NotOnRemote => existing_account.is_some(), + }; + if override_instance.persist { - self.reschedule_override_for_next_slot(&override_instance, target_slot); + let mut requeued = override_instance.clone(); + if requeued.fetch_before_use && fetch_retired { + requeued.fetch_before_use = false; + } + if let Err(e) = self.reschedule_override_for_next_slot(&requeued, target_slot) { + restore_unprocessed(self, index); + return Err(e); + } } // Apply the override values to the account data @@ -2837,7 +3001,7 @@ impl SurfnetSvm { ); // Get the account from the SVM - let Some(account) = self.inner.get_account(&account_pubkey)? else { + let Some(account) = existing_account else { warn!( "Account {} not found in SVM for override {}, skipping modifications", account_pubkey, override_instance.id @@ -2879,11 +3043,6 @@ impl SurfnetSvm { account_pubkey ); settled_this_slot.insert(account_pubkey); - 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); - } } } Err(e) => warn!( @@ -2983,14 +3142,6 @@ impl SurfnetSvm { 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. - 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); - } } } } @@ -3004,13 +3155,16 @@ impl SurfnetSvm { &mut self, instance: &OverrideInstance, target_slot: Slot, - ) { - let next_slot = target_slot + 1; + ) -> SurfpoolResult<()> { + let next_slot = target_slot.checked_add(1).ok_or_else(|| { + SurfpoolError::internal(format!( + "Override {} cannot persist past slot {}: there is no next slot", + instance.id, target_slot + )) + })?; let mut next = self .scheduled_overrides - .get(&next_slot) - .ok() - .flatten() + .get(&next_slot)? .unwrap_or_default(); if let Some(existing) = next.iter_mut().find(|queued| { @@ -3022,12 +3176,8 @@ impl SurfnetSvm { } else { 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 - ); - } + self.scheduled_overrides.store(next_slot, next)?; + Ok(()) } /// Forges account data by applying overrides to existing account data @@ -3181,6 +3331,46 @@ impl SurfnetSvm { rx } + /// Atomically returns a local signature status that already satisfies a subscription, or + /// registers the subscription before releasing the SVM write lock. + /// + /// This closes the check-then-subscribe race for WebSocket clients: a transaction cannot be + /// committed between the local status check and receiver registration. The compact status is + /// derived directly from the stored transaction metadata, avoiding transaction encoding. + pub fn get_local_signature_status_or_subscribe( + &mut self, + signature: &Signature, + subscription_type: SignatureSubscriptionType, + ) -> SurfpoolResult { + let current_slot = self.get_latest_absolute_slot(); + if let Some(SurfnetTransactionStatus::Processed(transaction)) = + self.transactions.get(&signature.to_string())? + { + let (transaction, _) = transaction.as_ref(); + let confirmation_status = + if current_slot >= transaction.slot + FINALIZATION_SLOT_THRESHOLD { + RpcTransactionConfirmationStatus::Finalized + } else if current_slot > transaction.slot { + RpcTransactionConfirmationStatus::Confirmed + } else { + RpcTransactionConfirmationStatus::Processed + }; + + if subscription_type.is_satisfied_by(confirmation_status) { + return Ok(LocalSignatureStatusOrSubscription::Status( + LocalSignatureStatus { + slot: transaction.slot, + err: transaction.meta.status.clone().err(), + }, + )); + } + } + + Ok(LocalSignatureStatusOrSubscription::Subscription( + self.subscribe_for_signature_updates(signature, subscription_type), + )) + } + pub fn subscribe_for_account_updates( &mut self, account_pubkey: &Pubkey, @@ -4278,7 +4468,13 @@ impl SurfnetSvm { // Schedule overrides by adding base slot to their scenario-relative slots for override_instance in scenario.overrides { let scenario_relative_slot = override_instance.scenario_relative_slot; - let absolute_slot = base_slot + scenario_relative_slot; + // Both operands are caller-supplied, so the sum has to be checked. + let absolute_slot = base_slot.checked_add(scenario_relative_slot).ok_or_else(|| { + SurfpoolError::internal(format!( + "Override {} cannot be scheduled: base slot {} plus relative slot {} overflows", + override_instance.id, base_slot, scenario_relative_slot + )) + })?; debug!( "Scheduling override at absolute slot {} (base {} + relative {})", @@ -4287,9 +4483,7 @@ impl SurfnetSvm { let mut slot_overrides = self .scheduled_overrides - .get(&absolute_slot) - .ok() - .flatten() + .get(&absolute_slot)? .unwrap_or_default(); slot_overrides.push(override_instance); self.scheduled_overrides @@ -4716,6 +4910,63 @@ mod tests { ) } + #[test] + fn hydrate_if_absent_skips_coupled_dependencies_when_primary_is_live() { + let (mut svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let local_primary = Account { + lamports: 1, + data: vec![1], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + let fetched_primary = Account { + lamports: 2, + data: vec![2], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + + let token_primary = Pubkey::new_unique(); + let token_mint = Pubkey::new_unique(); + svm.set_account(&token_primary, local_primary.clone()) + .unwrap(); + svm.apply_account_update( + GetAccountResult::FoundCoupledAccount( + (token_primary, fetched_primary.clone()), + CoupledAccount::Mint(token_mint, Some(fetched_primary.clone())), + AccountSource::Remote, + ), + AccountUpdatePolicy::HydrateIfAbsent, + ) + .unwrap(); + assert_eq!( + svm.get_account(&token_primary).unwrap(), + Some(local_primary.clone()) + ); + assert!(svm.inner.get_account_no_db(&token_mint).is_none()); + + let program_primary = Pubkey::new_unique(); + let programdata = Pubkey::new_unique(); + svm.set_account(&program_primary, local_primary.clone()) + .unwrap(); + svm.apply_account_update( + GetAccountResult::FoundCoupledAccount( + (program_primary, fetched_primary.clone()), + CoupledAccount::ProgramData(programdata, Some(fetched_primary)), + AccountSource::Remote, + ), + AccountUpdatePolicy::HydrateIfAbsent, + ) + .unwrap(); + assert_eq!( + svm.get_account(&program_primary).unwrap(), + Some(local_primary) + ); + assert!(svm.inner.get_account_no_db(&programdata).is_none()); + } + #[test_case(TestType::sqlite(); "with on-disk sqlite db")] #[test_case(TestType::in_memory(); "with in-memory sqlite db")] #[test_case(TestType::no_db(); "with no db")] @@ -4732,27 +4983,32 @@ mod tests { rent_epoch: 0, }; - // GetAccountResult::None should be a noop when writing account updates + // GetAccountResult::None should be a noop when materializing account updates. { let index_before = svm.get_all_accounts().unwrap(); let empty_update = GetAccountResult::None(pubkey); - svm.write_account_update(empty_update); + svm.apply_account_update(empty_update, AccountUpdatePolicy::Authoritative) + .unwrap(); assert_eq!(svm.get_all_accounts().unwrap(), index_before); } - // GetAccountResult::FoundAccount with `DoUpdateSvm` flag to false should be a noop + // An account already present in LiteSVM is not materialized again. { let index_before = svm.get_all_accounts().unwrap(); - let found_update = GetAccountResult::FoundAccount(pubkey, account.clone(), false); - svm.write_account_update(found_update); + let found_update = + GetAccountResult::FoundAccount(pubkey, account.clone(), AccountSource::Svm); + svm.apply_account_update(found_update, AccountUpdatePolicy::Authoritative) + .unwrap(); assert_eq!(svm.get_all_accounts().unwrap(), index_before); } - // GetAccountResult::FoundAccount with `DoUpdateSvm` flag to true should update the account + // A generated account is explicitly materialized by the caller. { let index_before = svm.get_all_accounts().unwrap(); - let found_update = GetAccountResult::FoundAccount(pubkey, account.clone(), true); - svm.write_account_update(found_update); + let found_update = + GetAccountResult::FoundAccount(pubkey, account.clone(), AccountSource::Generated); + svm.apply_account_update(found_update, AccountUpdatePolicy::Authoritative) + .unwrap(); assert_eq!( svm.get_all_accounts().unwrap().len(), index_before.len() + 1 @@ -4764,7 +5020,59 @@ mod tests { } } - // GetAccountResult::FoundProgramAccount with no program account inserts a default programdata account + // Hydration preserves live LiteSVM state, while an authoritative + // update explicitly replaces it. + { + let policy_pubkey = Pubkey::new_unique(); + let local_account = Account { + lamports: 1, + data: vec![1], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + let fetched_account = Account { + lamports: 2, + data: vec![2], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + svm.set_account(&policy_pubkey, local_account.clone()) + .unwrap(); + + svm.apply_account_update( + GetAccountResult::FoundAccount( + policy_pubkey, + fetched_account.clone(), + AccountSource::Remote, + ), + AccountUpdatePolicy::HydrateIfAbsent, + ) + .unwrap(); + assert_eq!( + svm.get_account(&policy_pubkey).unwrap(), + Some(local_account) + ); + + svm.apply_account_update( + GetAccountResult::FoundAccount( + policy_pubkey, + fetched_account.clone(), + AccountSource::Remote, + ), + AccountUpdatePolicy::Authoritative, + ) + .unwrap(); + assert_eq!( + svm.get_account(&policy_pubkey).unwrap(), + Some(fetched_account) + ); + + while events_rx.try_recv().is_ok() {} + } + + // A coupled program result with no program-data account inserts a default programdata account. { let (program_address, program_account, program_data_address, _) = create_program_accounts(); @@ -4789,11 +5097,16 @@ mod tests { }; let index_before = svm.get_all_accounts().unwrap(); - let found_program_account_update = GetAccountResult::FoundProgramAccount( + let found_program_account_update = GetAccountResult::FoundCoupledAccount( (program_address, program_account.clone()), - (program_data_address, None), + CoupledAccount::ProgramData(program_data_address, None), + AccountSource::Remote, ); - svm.write_account_update(found_program_account_update); + svm.apply_account_update( + found_program_account_update, + AccountUpdatePolicy::Authoritative, + ) + .unwrap(); if !expect_account_update_event( &events_rx, @@ -4808,7 +5121,7 @@ mod tests { if !expect_account_update_event(&events_rx, &svm, &program_address, &program_account) { panic!( - "Expected account update event not received after GetAccountResult::FoundProgramAccount update for program pubkey" + "Expected account update event not received after coupled program update for program pubkey" ); } assert_eq!( @@ -4817,17 +5130,25 @@ mod tests { ); } - // GetAccountResult::FoundProgramAccount with program account + program data account inserts two accounts + // A coupled program result with program data inserts both accounts. { let (program_address, program_account, program_data_address, program_data_account) = create_program_accounts(); let index_before = svm.get_all_accounts().unwrap(); - let found_program_account_update = GetAccountResult::FoundProgramAccount( + let found_program_account_update = GetAccountResult::FoundCoupledAccount( (program_address, program_account.clone()), - (program_data_address, Some(program_data_account.clone())), + CoupledAccount::ProgramData( + program_data_address, + Some(program_data_account.clone()), + ), + AccountSource::Remote, ); - svm.write_account_update(found_program_account_update); + svm.apply_account_update( + found_program_account_update, + AccountUpdatePolicy::Authoritative, + ) + .unwrap(); assert_eq!( svm.get_all_accounts().unwrap().len(), index_before.len() + 2 @@ -4839,18 +5160,18 @@ mod tests { &program_data_account, ) { panic!( - "Expected account update event not received after GetAccountResult::FoundProgramAccount update for program data pubkey" + "Expected account update event not received after coupled program update for program data pubkey" ); } if !expect_account_update_event(&events_rx, &svm, &program_address, &program_account) { panic!( - "Expected account update event not received after GetAccountResult::FoundProgramAccount update for program pubkey" + "Expected account update event not received after coupled program update for program pubkey" ); } } - // If we insert the program data account ahead of time, then have a GetAccountResult::FoundProgramAccount with just the program data account, + // If we insert the program data account ahead of time, then apply a coupled program result, // we should get one insert { let (program_address, program_account, program_data_address, program_data_account) = @@ -4860,9 +5181,10 @@ mod tests { let found_update = GetAccountResult::FoundAccount( program_data_address, program_data_account.clone(), - true, + AccountSource::Remote, ); - svm.write_account_update(found_update); + svm.apply_account_update(found_update, AccountUpdatePolicy::Authoritative) + .unwrap(); assert_eq!( svm.get_all_accounts().unwrap().len(), index_before.len() + 1 @@ -4879,11 +5201,16 @@ mod tests { } let index_before = svm.get_all_accounts().unwrap(); - let program_account_found_update = GetAccountResult::FoundProgramAccount( + let program_account_found_update = GetAccountResult::FoundCoupledAccount( (program_address, program_account.clone()), - (program_data_address, None), + CoupledAccount::ProgramData(program_data_address, None), + AccountSource::Remote, ); - svm.write_account_update(program_account_found_update); + svm.apply_account_update( + program_account_found_update, + AccountUpdatePolicy::Authoritative, + ) + .unwrap(); assert_eq!( svm.get_all_accounts().unwrap().len(), index_before.len() + 1 @@ -7194,6 +7521,345 @@ mod tests { ); } + /// An override that writes no account fields still forks the account, so it must stop fetching too. + #[tokio::test] + async fn test_persisted_override_that_writes_no_fields_stops_refetching() { + const SLOT: u64 = 500; + + let (mut svm, account_pubkey, _instance) = scheduled_persist_fixture(true); + + // Values consumed entirely by PDA derivation, so `account_values` filters down to empty. + let seed_only = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pda { + program_id: "KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD".to_string(), + seeds: vec![surfpool_types::PdaSeed::PropertyRef("market".to_string())], + }, + ) + .with_values(HashMap::from([( + "market".to_string(), + serde_json::json!(account_pubkey.to_string()), + )])); + + // Point the derived address at a real forked account so presence is what is being tested. + let derived = seed_only + .account + .resolve(Some(&seed_only.values)) + .expect("derive pda"); + let forked = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("fixture account present"); + svm.inner + .set_account(derived, forked) + .expect("set derived account"); + + let mut no_values = surfpool_types::OverrideInstance::new( + "kamino-obligation-noop".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ); + no_values.persist = true; + no_values.fetch_before_use = true; + + let mut seed_only = seed_only; + seed_only.persist = true; + seed_only.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![seed_only, no_values]) + .expect("schedule overrides"); + + 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(), 2, "both overrides re-armed, one entry each"); + for queued in &next { + assert!(queued.persist, "persist must survive rescheduling"); + assert!( + !queued.fetch_before_use, + "override {} forked its account, so later slots must not re-fetch it", + queued.id + ); + } + } + + /// Minimal JSON-RPC stand-in that answers every request with one canned `result` body, so + /// the remote-fetch branches can be exercised without a network. + async fn canned_rpc(result_json: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind canned rpc"); + let addr = listener.local_addr().expect("local addr"); + + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = vec![0u8; 16 * 1024]; + let _ = stream.read(&mut buf).await; + let body = format!(r#"{{"jsonrpc":"2.0","result":{result_json},"id":1}}"#); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; + }); + } + }); + + format!("http://{addr}") + } + + /// The remote having no such account is only an answer about this slot - accounts get created + /// later. While there is nothing local to work on, a persisted override must keep asking, or + /// it stays inert for the rest of the run. + #[tokio::test] + async fn test_persisted_override_retries_while_the_account_is_not_on_remote() { + const SLOT: u64 = 500; + const NULL_ACCOUNT: &str = r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":null}"#; + + let url = canned_rpc(NULL_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + // An address the SVM has never seen, so there is no local account to fall back on. + let mut absent = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), + ) + .with_values(HashMap::from([( + "unhealthy_borrow_value_sf".to_string(), + serde_json::json!(1_234u64), + )])); + absent.persist = true; + absent.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![absent]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&Some(remote), 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].fetch_before_use, + "the account may appear later, so the next slot must keep asking for it" + ); + } + + /// The mirror case: the remote has nothing but a local account already exists, so the override + /// can work. Asking again would only risk overwriting that local account once the address is + /// populated upstream. + #[tokio::test] + async fn test_persisted_override_stops_asking_when_only_a_local_account_exists() { + const SLOT: u64 = 500; + const NULL_ACCOUNT: &str = r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":null}"#; + + let url = canned_rpc(NULL_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + + 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(&Some(remote), 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].fetch_before_use, + "the local account is usable, so later fetches must not overwrite it" + ); + } + + /// Token and executable accounts return `FoundCoupledAccount`. That arm used to fall through + /// a catch-all that logged and dropped the account, so the fetch reported success while the + /// target was never forked - every later write then failed with "not found in SVM". + #[tokio::test] + async fn test_fetch_before_use_materializes_a_coupled_account() { + const SLOT: u64 = 500; + // A 165-byte SPL token account (state = Initialized), which sends `get_account` down the + // coupled-mint path. The canned server answers the mint lookup with the same body. + const TOKEN_ACCOUNT: &str = concat!( + r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":{"data":[""#, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + r#"","base64"],"executable":false,"lamports":2039280,"#, + r#""owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":0,"space":165}}"# + ); + + let url = canned_rpc(TOKEN_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + let target = Pubkey::new_unique(); + let mut instance = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(target.to_string()), + ); + instance.fetch_before_use = true; + + assert!( + svm.inner + .get_account(&target) + .expect("get_account") + .is_none(), + "the target must start absent so the fetch is what materializes it" + ); + + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&Some(remote), SLOT) + .await + .expect("materialize"); + + let forked = svm + .inner + .get_account(&target) + .expect("get_account") + .expect("the coupled account must be written into the SVM"); + assert_eq!(forked.data.len(), 165, "the token account data was forked"); + assert_eq!(forked.lamports, 2_039_280, "lamports came from the remote"); + } + + /// With no remote client there is nothing to fetch from, but the request is still unmet while + /// the account is absent. `materialize_overrides_for_slot` is public, so a caller can pass a + /// client on a later slot - retiring the flag here would permanently disable that. + #[tokio::test] + async fn test_persisted_override_keeps_asking_when_absent_and_no_remote() { + const SLOT: u64 = 500; + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + let mut absent = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), + ) + .with_values(HashMap::from([( + "unhealthy_borrow_value_sf".to_string(), + serde_json::json!(1_234u64), + )])); + absent.persist = true; + absent.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![absent]) + .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].fetch_before_use, + "the request is still unmet, so it must not be retired" + ); + } + + #[tokio::test] + async fn test_slot_overflow_is_an_error_not_a_wrap() { + let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); + + assert!( + svm.reschedule_override_for_next_slot(&instance, u64::MAX) + .is_err(), + "there is no slot after u64::MAX" + ); + + let mut far = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 10, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ); + far.scenario_relative_slot = 10; + let scenario = surfpool_types::Scenario { + id: "overflow".to_string(), + name: "overflow".to_string(), + description: String::new(), + tags: vec![], + overrides: vec![far], + }; + + assert!( + svm.register_scenario(scenario, Some(u64::MAX - 1)).is_err(), + "base slot plus relative slot overflows and must be rejected" + ); + } + + /// A transient RPC failure must not be mistaken for a satisfied fetch. The account already + /// being present locally is not enough - the override asked for fresh data and did not get it, + /// so with `persist` the flag has to survive or it pins stale data for the rest of the run. + #[tokio::test] + async fn test_persisted_override_retries_after_a_failed_fetch() { + const SLOT: u64 = 500; + + // Unroutable port: the fetch fails without touching the network. + let unreachable = ( + SurfnetRemoteClient::new("http://127.0.0.1:1"), + CommitmentConfig::confirmed(), + ); + + 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(&Some(unreachable), 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 fetch failed, so the next slot must retry it instead of pinning stale data" + ); + } + /// 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. @@ -7263,8 +7929,12 @@ mod tests { let mut second = first.clone(); second.account = surfpool_types::AccountAddress::Pubkey(second_account.to_string()); - surfnet_svm.reschedule_override_for_next_slot(&first, SLOT); - surfnet_svm.reschedule_override_for_next_slot(&second, SLOT); + surfnet_svm + .reschedule_override_for_next_slot(&first, SLOT) + .expect("reschedule"); + surfnet_svm + .reschedule_override_for_next_slot(&second, SLOT) + .expect("reschedule"); let queued = surfnet_svm .scheduled_overrides @@ -7278,7 +7948,9 @@ mod tests { one of them, so a scenario silently stops being applied" ); - surfnet_svm.reschedule_override_for_next_slot(&first, SLOT); + surfnet_svm + .reschedule_override_for_next_slot(&first, SLOT) + .expect("reschedule"); let queued = surfnet_svm .scheduled_overrides .get(&(SLOT + 1)) diff --git a/crates/core/src/tests/bisonfi/mod.rs b/crates/core/src/tests/bisonfi/mod.rs index 90401806a..4d9c20b48 100644 --- a/crates/core/src/tests/bisonfi/mod.rs +++ b/crates/core/src/tests/bisonfi/mod.rs @@ -72,8 +72,7 @@ async fn fetch(addresses: &[&str]) -> Vec> { .zip(addresses) .map(|(result, address)| match result { GetAccountResult::FoundAccount(_, account, _) - | GetAccountResult::FoundProgramAccount((_, account), _) - | GetAccountResult::FoundTokenAccount((_, account), _) => account.data, + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account.data, GetAccountResult::None(_) => { panic!("{address} no longer exists on mainnet; the test needs a new address") } @@ -102,8 +101,7 @@ async fn fetch_optional(addresses: &[&str]) -> Vec>> { .into_iter() .map(|r| match r { GetAccountResult::FoundAccount(_, account, _) - | GetAccountResult::FoundProgramAccount((_, account), _) - | GetAccountResult::FoundTokenAccount((_, account), _) => Some(account.data), + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => Some(account.data), GetAccountResult::None(_) => None, }) .collect() @@ -139,8 +137,7 @@ async fn fetch_owners(addresses: &[Pubkey]) -> Vec { .zip(addresses) .map(|(result, address)| match result { GetAccountResult::FoundAccount(_, account, _) - | GetAccountResult::FoundProgramAccount((_, account), _) - | GetAccountResult::FoundTokenAccount((_, account), _) => account.owner, + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account.owner, GetAccountResult::None(_) => panic!("{address} no longer exists on mainnet"), }) .collect() @@ -203,8 +200,7 @@ async fn fetch_with_lamports(addresses: &[&str]) -> Vec<(Vec, u64)> { .zip(addresses) .map(|(result, address)| match result { GetAccountResult::FoundAccount(_, account, _) - | GetAccountResult::FoundProgramAccount((_, account), _) - | GetAccountResult::FoundTokenAccount((_, account), _) => { + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => { (account.data, account.lamports) } GetAccountResult::None(_) => panic!("{address} no longer exists on mainnet"), diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index 03247694c..bcb71ee45 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -1,4 +1,13 @@ -use std::{str::FromStr, sync::Arc, time::Duration}; +use std::{ + io::{Read, Write}, + net::TcpListener, + str::FromStr, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use base64::Engine; use crossbeam_channel::{ @@ -7,7 +16,10 @@ use crossbeam_channel::{ use ed25519_dalek::Signer as DalekSigner; use jsonrpc_core::{ Error, Result as JsonRpcResult, - futures::future::{self, join_all}, + futures::{ + StreamExt, + future::{self, join_all}, + }, }; use jsonrpc_core_client::transports::http; use p256::ecdsa::{ @@ -38,12 +50,17 @@ use solana_message::{ v0::{self, MessageAddressTableLookup}, }; use solana_pubkey::Pubkey; -use solana_rpc_client_api::response::{Response as RpcResponse, SlotUpdate}; +use solana_pubsub_client::nonblocking::pubsub_client::PubsubClient; +use solana_rpc_client_api::{ + config::RpcSignatureSubscribeConfig, + response::{Response as RpcResponse, SlotUpdate}, +}; use solana_secp256k1_program::{ eth_address_from_pubkey, new_secp256k1_instruction_with_signature, sign_message as sign_secp256k1_message, }; use solana_secp256r1_program::new_secp256r1_instruction_with_signature; +use solana_signature::Signature; use solana_signer::Signer; use solana_system_interface::{ instruction as system_instruction, instruction::transfer, program as system_program, @@ -82,8 +99,8 @@ use crate::{ runloops::start_local_surfnet_runloop, storage::tests::TestType, surfnet::{ - FINALIZATION_SLOT_THRESHOLD, GeyserEvent, PluginCommand, SignatureSubscriptionType, - locker::SurfnetSvmLocker, svm::SurfnetSvm, + FINALIZATION_SLOT_THRESHOLD, GeyserEvent, LocalSignatureStatusOrSubscription, + PluginCommand, SignatureSubscriptionType, locker::SurfnetSvmLocker, svm::SurfnetSvm, }, tests::helpers::get_free_port, types::{TimeTravelConfig, TransactionLoadedAddresses}, @@ -7180,6 +7197,361 @@ async fn test_ws_signature_subscribe_before_transaction_exists(test_type: TestTy ); } +#[test] +fn test_atomic_signature_subscription_returns_live_receiver_for_absent_signature() { + let (svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + let signature = Signature::new_unique(); + + let receiver = match locker + .get_local_signature_status_or_subscribe(&signature, SignatureSubscriptionType::processed()) + .expect("an absent signature should register successfully") + { + LocalSignatureStatusOrSubscription::Subscription(receiver) => receiver, + LocalSignatureStatusOrSubscription::Status(_) => { + panic!("an absent signature must return a subscription receiver") + } + }; + + locker.with_svm_writer(|svm| { + svm.notify_signature_subscribers( + SignatureSubscriptionType::processed(), + &signature, + svm.get_latest_absolute_slot(), + None, + ); + }); + + assert!( + receiver.recv_timeout(Duration::from_secs(1)).is_ok(), + "the returned receiver should remain live after atomic registration" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_atomic_signature_subscription_returns_committed_local_status() { + use crossbeam_channel::unbounded; + + let (svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + let payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + locker + .airdrop(&payer.pubkey(), LAMPORTS_PER_SOL) + .unwrap() + .unwrap(); + let recent_blockhash = locker.with_svm_reader(|svm| svm.latest_blockhash()); + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::transfer( + &payer.pubkey(), + &recipient, + 1_000_000, + )], + Some(&payer.pubkey()), + &[&payer], + recent_blockhash, + ); + let signature = transaction.signatures[0]; + + let (status_tx, _status_rx) = unbounded(); + locker + .process_transaction( + &None, + VersionedTransaction::from(transaction), + status_tx, + false, + false, + ) + .await + .expect("transaction should be committed locally"); + + match locker + .get_local_signature_status_or_subscribe(&signature, SignatureSubscriptionType::processed()) + .expect("the atomic lookup should succeed") + { + LocalSignatureStatusOrSubscription::Status(status) => { + assert!( + status.err.is_none(), + "the committed transaction should succeed" + ); + assert_eq!( + status.slot, + locker.with_svm_reader(|svm| svm.get_latest_absolute_slot()), + "the immediate status should use the transaction metadata slot" + ); + } + LocalSignatureStatusOrSubscription::Subscription(_) => { + panic!("a committed processed transaction must not register a new receiver") + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_atomic_higher_commitment_subscription_waits_for_promotion() { + use crossbeam_channel::unbounded; + + let (svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + let payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + locker + .airdrop(&payer.pubkey(), LAMPORTS_PER_SOL) + .unwrap() + .unwrap(); + let recent_blockhash = locker.with_svm_reader(|svm| svm.latest_blockhash()); + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::transfer( + &payer.pubkey(), + &recipient, + 1_000_000, + )], + Some(&payer.pubkey()), + &[&payer], + recent_blockhash, + ); + let signature = transaction.signatures[0]; + + let (status_tx, _status_rx) = unbounded(); + locker + .process_transaction( + &None, + VersionedTransaction::from(transaction), + status_tx, + false, + false, + ) + .await + .expect("transaction should be committed locally"); + + let receiver = match locker + .get_local_signature_status_or_subscribe(&signature, SignatureSubscriptionType::confirmed()) + .expect("the atomic lookup should succeed") + { + LocalSignatureStatusOrSubscription::Subscription(receiver) => receiver, + LocalSignatureStatusOrSubscription::Status(_) => { + panic!("a processed transaction must wait for confirmed commitment") + } + }; + assert!( + receiver.try_recv().is_err(), + "the confirmed receiver must remain pending before promotion" + ); + + locker + .confirm_current_block(&None) + .await + .expect("the current block should confirm"); + assert!( + receiver.recv_timeout(Duration::from_secs(1)).is_ok(), + "the pending receiver should be notified at confirmed commitment" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_ws_signature_subscribe_does_not_miss_local_commit_during_remote_lookup() { + let listener = TcpListener::bind("127.0.0.1:0").expect("the delayed datasource should bind"); + listener + .set_nonblocking(true) + .expect("the delayed datasource should be non-blocking"); + let remote_url = format!("http://{}", listener.local_addr().unwrap()); + let (transaction_lookup_tx, transaction_lookup_rx) = crossbeam_channel::bounded(1); + let (release_lookup_tx, release_lookup_rx) = crossbeam_channel::bounded(1); + let stop_mock = Arc::new(AtomicBool::new(false)); + let mock_stop = Arc::clone(&stop_mock); + + let mock_server = std::thread::spawn(move || { + while !mock_stop.load(Ordering::Relaxed) { + let Ok((mut stream, _)) = listener.accept() else { + std::thread::sleep(Duration::from_millis(5)); + continue; + }; + + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let bytes_read = stream.read(&mut buffer).expect("mock should read request"); + request.extend_from_slice(&buffer[..bytes_read]); + let Some(headers_end) = request.windows(4).position(|window| window == b"\r\n\r\n") + else { + continue; + }; + let headers = std::str::from_utf8(&request[..headers_end]) + .expect("request headers should be UTF-8"); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("Content-Length") + .then_some(value.trim()) + }) + .expect("request should include Content-Length") + .parse::() + .expect("Content-Length should be numeric"); + if request.len() < headers_end + 4 + content_length { + continue; + } + + let body = &request[headers_end + 4..headers_end + 4 + content_length]; + let request_json: serde_json::Value = + serde_json::from_slice(body).expect("request body should be JSON-RPC"); + let method = request_json["method"] + .as_str() + .expect("JSON-RPC request should name its method"); + if method == "getTransaction" { + transaction_lookup_tx + .send(()) + .expect("test should still wait for the remote lookup"); + release_lookup_rx + .recv_timeout(Duration::from_secs(5)) + .expect("test should release the remote lookup"); + } + + let result = match method { + "getEpochInfo" => serde_json::json!({ + "absoluteSlot": 0, + "blockHeight": 0, + "epoch": 0, + "slotIndex": 0, + "slotsInEpoch": 432000, + "transactionCount": null, + }), + "getEpochSchedule" => serde_json::json!({ + "slotsPerEpoch": 432000, + "leaderScheduleSlotOffset": 432000, + "warmup": false, + "firstNormalEpoch": 0, + "firstNormalSlot": 0, + }), + "getTransaction" => serde_json::Value::Null, + unexpected => panic!("unexpected datasource method: {unexpected}"), + }; + let response = serde_json::json!({ + "jsonrpc": "2.0", + "result": result, + "id": request_json["id"].clone(), + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.len(), + response, + ) + .expect("mock should respond to request"); + break; + } + } + }); + + let bind_host = "127.0.0.1"; + let bind_port = get_free_port().unwrap(); + let ws_port = get_free_port().unwrap(); + let payer = Keypair::new(); + let config = SurfpoolConfig { + simnets: vec![SimnetConfig { + block_production_mode: BlockProductionMode::Manual, + remote_rpc_url: Some(remote_url), + airdrop_addresses: vec![payer.pubkey()], + airdrop_token_amount: LAMPORTS_PER_SOL, + ..SimnetConfig::default() + }], + rpc: RpcConfig { + bind_host: bind_host.to_string(), + bind_port, + ws_port, + ..Default::default() + }, + ..SurfpoolConfig::default() + }; + let (svm, simnet_events_rx, geyser_events_rx) = TestType::no_db().initialize_svm(); + let locker = SurfnetSvmLocker::new(svm); + let (commands_tx, commands_rx) = unbounded(); + let runloop = spawn_runloop( + locker.clone(), + config, + (commands_tx, commands_rx), + geyser_events_rx, + ) + .expect("the surfnet should start"); + wait_for_ready_and_connected(&simnet_events_rx).expect("the surfnet should connect"); + + let recipient = Pubkey::new_unique(); + let recent_blockhash = locker.with_svm_reader(|svm| svm.latest_blockhash()); + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::transfer( + &payer.pubkey(), + &recipient, + 1_000_000, + )], + Some(&payer.pubkey()), + &[&payer], + recent_blockhash, + ); + let signature = transaction.signatures[0]; + let ws_client = PubsubClient::new(format!("ws://{bind_host}:{ws_port}")) + .await + .expect("the WebSocket RPC server should accept connections"); + let (mut notifications, unsubscribe) = ws_client + .signature_subscribe( + &signature, + Some(RpcSignatureSubscribeConfig { + commitment: Some(CommitmentConfig::processed()), + enable_received_notification: None, + }), + ) + .await + .expect("the signature subscription should be established"); + + transaction_lookup_rx + .recv_timeout(Duration::from_secs(2)) + .expect("signatureSubscribe should be waiting on the delayed getTransaction lookup"); + let (status_tx, _status_rx) = unbounded(); + locker + .process_transaction( + &None, + VersionedTransaction::from(transaction), + status_tx, + false, + false, + ) + .await + .expect("the local transaction should commit while remote lookup is blocked"); + release_lookup_tx + .send(()) + .expect("the delayed lookup should still be waiting"); + + let notification = tokio::time::timeout(Duration::from_secs(2), notifications.next()) + .await + .expect("a processed signature notification should arrive") + .expect("the signature subscription should remain open"); + assert!( + matches!( + notification.value, + solana_rpc_client_api::response::RpcSignatureResult::ProcessedSignature(ref result) + if result.err.is_none() + ), + "expected one successful processed signature notification, got {notification:?}" + ); + assert!( + tokio::time::timeout(Duration::from_millis(200), notifications.next()) + .await + .is_err(), + "a signature subscription must send exactly one notification" + ); + + unsubscribe().await; + drop(notifications); + ws_client + .shutdown() + .await + .expect("the WebSocket client should close"); + runloop.stop().expect("the surfnet should stop"); + stop_mock.store(true, Ordering::Relaxed); + mock_server + .join() + .expect("the delayed datasource should stop"); +} + #[test_case(TestType::sqlite(); "with on-disk sqlite db")] #[test_case(TestType::in_memory(); "with in-memory sqlite db")] #[test_case(TestType::no_db(); "with no db")] diff --git a/crates/core/src/tests/kamino/mod.rs b/crates/core/src/tests/kamino/mod.rs index cbb4c68de..17cbdee6c 100644 --- a/crates/core/src/tests/kamino/mod.rs +++ b/crates/core/src/tests/kamino/mod.rs @@ -77,8 +77,7 @@ async fn fetch(addresses: &[&str]) -> Vec> { .zip(addresses) .map(|(result, address)| match result { GetAccountResult::FoundAccount(_, account, _) - | GetAccountResult::FoundProgramAccount((_, account), _) - | GetAccountResult::FoundTokenAccount((_, account), _) => account.data, + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account.data, GetAccountResult::None(_) => { panic!("{address} no longer exists on mainnet; the test needs a new address") } diff --git a/crates/sdk-node/README.md b/crates/sdk-node/README.md index 5982dfcab..f6f00ff2d 100644 --- a/crates/sdk-node/README.md +++ b/crates/sdk-node/README.md @@ -82,6 +82,27 @@ const client = await createClient() .use(surfpool({ rpcUrl: "http://127.0.0.1:8899" })); ``` +That payer is usually unfunded on the running Surfnet. `airdropAddresses` +credits each listed address or signer while the client is composed, so no +separate cheatcode call is needed before sending a transaction: + +```ts +const client = await createClient() + .use(payer(myPayer)) + .use( + surfpool({ + airdropAddresses: [myPayer, someRecipient], + airdropAmount: 5_000_000_000n, // lamports, defaults to 10 SOL + rpcUrl: "http://127.0.0.1:8899", + }), + ); +``` + +Funding is additive, like a real airdrop: `airdropAmount` is added to whatever +the address already holds, and only the lamport balance is written, so existing +account data and owner survive. A failure to fund throws, naming the address. +The option works in embedded mode too, alongside the pre-funded payer. + For one-off use without a client, `createSurfnetCheatcodesRpc(url)` returns a standalone `Rpc`, and `surfnetCheatcodes()` installs `client.cheatcodes` on any existing client. diff --git a/crates/sdk-node/package-lock.json b/crates/sdk-node/package-lock.json index b57f56a49..f808a60ca 100644 --- a/crates/sdk-node/package-lock.json +++ b/crates/sdk-node/package-lock.json @@ -10,9 +10,9 @@ "license": "Apache-2.0", "devDependencies": { "@napi-rs/cli": "^2.18.4", - "@solana/kit": "^7.0.0", - "@solana/kit-plugin-rpc": "^0.15.0", - "@solana/kit-plugin-signer": "^0.13.0", + "@solana/kit": "^8.0.0", + "@solana/kit-plugin-rpc": "^0.18.0", + "@solana/kit-plugin-signer": "^0.18.0", "typescript": "^5.7.0" }, "engines": { @@ -24,9 +24,9 @@ "@solana/surfpool-linux-x64-gnu": "1.5.0" }, "peerDependencies": { - "@solana/kit": "^7.0.0", - "@solana/kit-plugin-rpc": "^0.15.0", - "@solana/kit-plugin-signer": "^0.13.0" + "@solana/kit": "^8.0.0", + "@solana/kit-plugin-rpc": "^0.18.0", + "@solana/kit-plugin-signer": "^0.18.0" }, "peerDependenciesMeta": { "@solana/kit": { @@ -58,18 +58,18 @@ } }, "node_modules/@solana/accounts": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-7.0.0.tgz", - "integrity": "sha512-RfbinkhuWxcObxZIdjeWEn/mzLqRp/h2hAk/ZQCUxPdDBW8h4XxEybK9GBItGOAcrUz5HuusXTD+cXXnlIxWcg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-8.0.0.tgz", + "integrity": "sha512-+uqFCoI/P9oo0FGR3t3TJJxi0aoAdaEvFXzZCfNoLH5txZLTiw6/d/9QCSE/FXpbNZfk0VPGWcGN+QAXp/yrRA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/rpc-spec": "7.0.0", - "@solana/rpc-types": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/rpc-spec": "8.0.0", + "@solana/rpc-types": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -84,17 +84,17 @@ } }, "node_modules/@solana/addresses": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-7.0.0.tgz", - "integrity": "sha512-E7sJtV5d3bXrmw3I30rcKY+xoqM++6KIVJCi+q8ZaSMyP04UMfEENPHIJ+TkyS1RUgjzPT91ka/oWrtTh6EfkQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-8.0.0.tgz", + "integrity": "sha512-hPtZOGxeMVEVoXleEsYcOj1mhxtvUTeEivE3iqCT7HJGGnYZFaV0/MduoqsEMiaBM+2ggjpVh9V7QoXPJIhbhw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/assertions": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/nominal-types": "7.0.0" + "@solana/assertions": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/nominal-types": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -109,13 +109,13 @@ } }, "node_modules/@solana/assertions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-7.0.0.tgz", - "integrity": "sha512-CShOQLPezI0tbrih+L88fzt8FMHDyJoWkmulk4wfRp3HhpQL2yNlH/SWLH033qysnvkmE7TxBFUtcnbnf7jz1g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-8.0.0.tgz", + "integrity": "sha512-qvS9Jicl3ZKc4QkRhz6ZT32E/hmPMR3CkGaQccG8JhMVbOSJBK6+18IO2T03K1kNGnP335FDDL6H/Yzw0IhQ7g==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0" + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -130,18 +130,18 @@ } }, "node_modules/@solana/codecs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-7.0.0.tgz", - "integrity": "sha512-xT1IbwKkPZ544u/eqhb9SZ0fNYJidWgIUzKNQMbvLCwduayAkQp+czlGdvLQ6CVlqtCewtH3gW8biGU7YuBdEw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-8.0.0.tgz", + "integrity": "sha512-Pk33P602YQSYHJka2IH4LhTg9vcVskYQb/XfOCqXyXDsgJS2x3au7kpXB7Q6M5yiw8VBw8ZNu/a0iGBX44v7tw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "7.0.0", - "@solana/codecs-data-structures": "7.0.0", - "@solana/codecs-numbers": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/fixed-points": "7.0.0", - "@solana/options": "7.0.0" + "@solana/codecs-core": "8.0.0", + "@solana/codecs-data-structures": "8.0.0", + "@solana/codecs-numbers": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/fixed-points": "8.0.0", + "@solana/options": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -156,13 +156,13 @@ } }, "node_modules/@solana/codecs-core": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-7.0.0.tgz", - "integrity": "sha512-6HtEisZEtFb6okARUgYqmKdDbn2aHRrSCDgB1/GEwr0s6fK5XNYpafaSjorbs2MEyZV3tCUFTj2j6fk/4nNcLg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-8.0.0.tgz", + "integrity": "sha512-WU/W1IEssIae1rKfJHAwuxvbnhwFH9+WCjD+l4oK2TiKgRDIdO3ndF58ABl5hFqgISCthHxeiFqBtd8C8EhzWw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0" + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -177,15 +177,15 @@ } }, "node_modules/@solana/codecs-data-structures": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-7.0.0.tgz", - "integrity": "sha512-P0Ys1mB4lYlz3MMTCaJSysE3OYrq8WvsveU1ta8U/yG1qChXFGCOxPVh06swjaRxwPrEakb9VUESS5vNtp1rRA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-8.0.0.tgz", + "integrity": "sha512-iCwbuANIuUbr7f69wx8E7tzT5tHcrJpKq8Ni06Y7H8aKhzJUlTeNQnpCbQr/e2bhvM6VI65yaXLUNEXfM7OS6Q==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "7.0.0", - "@solana/codecs-numbers": "7.0.0", - "@solana/errors": "7.0.0" + "@solana/codecs-core": "8.0.0", + "@solana/codecs-numbers": "8.0.0", + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -200,14 +200,14 @@ } }, "node_modules/@solana/codecs-numbers": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-7.0.0.tgz", - "integrity": "sha512-XL0jnmnXr3ceoX4tusT+XkBVR2iGKEJecTIXbIV7ILi9xObg3fNXafNbTmbIOvKc0ByTyjo8EWZ9jQKSRWgsgA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-8.0.0.tgz", + "integrity": "sha512-Islv8gZPQhVA1DvCk3KspTTw9r0z/qL6EWwN5/vvA9LYWe11RmNarJ+C0qxg1vPh2lQh8W6t/GcpZS/TJbHI0A==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "7.0.0", - "@solana/errors": "7.0.0" + "@solana/codecs-core": "8.0.0", + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -222,15 +222,15 @@ } }, "node_modules/@solana/codecs-strings": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-7.0.0.tgz", - "integrity": "sha512-zXE1PE9HkVk6phZ6aqHTXvLZ0qIl5bJNIvG9eMB7LuFO1XBVQywJUtjKS8fE3/xmRCWSMilFSrEXGA+SpOyLrQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-8.0.0.tgz", + "integrity": "sha512-4l8XCWIFt9DYX/zH22Jygmu+DRjKpFjFE1CNqnin24+LZ6WQu6Zk3cU+nftV3OrLns2Yi+o4cSlv/eVUsxA3qA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "7.0.0", - "@solana/codecs-numbers": "7.0.0", - "@solana/errors": "7.0.0" + "@solana/codecs-core": "8.0.0", + "@solana/codecs-numbers": "8.0.0", + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -249,9 +249,9 @@ } }, "node_modules/@solana/errors": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-7.0.0.tgz", - "integrity": "sha512-94r+LLSzZ0XVp+LOogwxWGXeo138uvwqtqRW9Tjl1DIXrFgh8euXnJSXZyydu1UXJs7ItOSm0QreJviSGw3TGQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-8.0.0.tgz", + "integrity": "sha512-S7vuD1EWVkp2OX9J7fQW7j7nGP3e+iuscDktheMDxpM/5d9GwfjjNiFDTOQB/SwEKuFd9PnZ5Wq5MmMBJxVSrw==", "dev": true, "license": "MIT", "dependencies": { @@ -274,9 +274,9 @@ } }, "node_modules/@solana/fast-stable-stringify": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-7.0.0.tgz", - "integrity": "sha512-i/b5ZJMqMJXa6etjypANa2/ErPZfNG9/EVIYl7HpWooyCRgZ8hZJmJ2Cgrp0r4EMXCLeWn4aEG2HOBTzOJ4F7Q==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-8.0.0.tgz", + "integrity": "sha512-2cg1e6ytDOtID8AnoqdIC0vzmEo5J2N96RtON6+nuyYjaOKH+i8T5nLmUAmVpcozcdntp899MfGGrUcxHJ/7Sw==", "dev": true, "license": "MIT", "engines": { @@ -292,14 +292,14 @@ } }, "node_modules/@solana/fixed-points": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/fixed-points/-/fixed-points-7.0.0.tgz", - "integrity": "sha512-Y3gcyHTponi5kXpWVEJIhuZ7yT84N+8He4dbjXWqwMBJnzKM+4tqFCbzy6y+6+Jxt44RT1lDmbpxmZopFRXU8Q==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/fixed-points/-/fixed-points-8.0.0.tgz", + "integrity": "sha512-KA7ZA3xUIGNqDlagrTpzUtXsVSZn+vZC4odBDT3iXoRQysqV9t5g191HFH1OiTM68+7H8Rrte1Z0pTZSrQceTg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "7.0.0", - "@solana/errors": "7.0.0" + "@solana/codecs-core": "8.0.0", + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -314,9 +314,9 @@ } }, "node_modules/@solana/functional": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-7.0.0.tgz", - "integrity": "sha512-ix9fzYhc2hCLiYf+hGI00mzzayANKDExEBxbwrtMj/BdQkwgUIvIlOssqHeSqDChL3UZhq9lR24Nz4JwYX8Jbw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-8.0.0.tgz", + "integrity": "sha512-w5GZeeLJQyIBc21PRWPzOs7M+7aKJdbklA5cqzSx7u5cNjJQ9VDs7/JkPTtgTzrhNIqa7jqZRNf97ecx7dQVvg==", "dev": true, "license": "MIT", "engines": { @@ -332,18 +332,18 @@ } }, "node_modules/@solana/instruction-plans": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-7.0.0.tgz", - "integrity": "sha512-uzXHztc8hLoT5TNWFsBX2DIETyp/Lr2l36O330s3YCgpRmfI4IRbBrjjq7TxDYFm/QY8+DD4CRG8p7wZSqG8dQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-8.0.0.tgz", + "integrity": "sha512-sD9W72Pn59UT1swV5wwR7uFpBdAUAc+o2Am3fEFZbm24HWQVhcV5rOmCZqzu/dlU10puFFra28AEVq20o5crAg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0", - "@solana/instructions": "7.0.0", - "@solana/keys": "7.0.0", - "@solana/promises": "7.0.0", - "@solana/transaction-messages": "7.0.0", - "@solana/transactions": "7.0.0" + "@solana/errors": "8.0.0", + "@solana/instructions": "8.0.0", + "@solana/keys": "8.0.0", + "@solana/promises": "8.0.0", + "@solana/transaction-messages": "8.0.0", + "@solana/transactions": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -358,14 +358,14 @@ } }, "node_modules/@solana/instructions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-7.0.0.tgz", - "integrity": "sha512-ZN0gKAtCOKDuIaStcvLZDf5H20fkk7jr4dZE0Rk7z0kslf6mrRam9Y23N6AzeHp/b5ZeVKyfaTf4M35mOktLNg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-8.0.0.tgz", + "integrity": "sha512-xcToa7n5IBnjaKfOBwHwj2UU+wmE7Rvh8jLFHRrSeAXzqZkABDviMrrKAPdcgD+/lhnofyuz1cYLH6cuc5hdnQ==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "7.0.0", - "@solana/errors": "7.0.0" + "@solana/codecs-core": "8.0.0", + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -380,18 +380,18 @@ } }, "node_modules/@solana/keys": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-7.0.0.tgz", - "integrity": "sha512-JsdYR/YN3AGHZN2aZoeE5cymHYkNoBLnqgXoRncW/VyD7fMcR350aHU1hCMYd0b5BtITLsGmvvtvrgVkzK83Eg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-8.0.0.tgz", + "integrity": "sha512-nUg748MurpMmfVoWYwshpxrWsJZIcJ31mYf9xm1Z7NV0fySWyZYhgQbrEtONpMW6TPt2kpgBYDVKWs/wAyPokw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/assertions": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/nominal-types": "7.0.0", - "@solana/promises": "7.0.0" + "@solana/assertions": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/nominal-types": "8.0.0", + "@solana/promises": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -406,39 +406,39 @@ } }, "node_modules/@solana/kit": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-7.0.0.tgz", - "integrity": "sha512-ZCeai4LRJQooUmJXvpgMEGFTrCdJnV1ODbDJ8oqFZ+Y4t/9x1baQsFFpruqsdRyeGv2Rr+X6jV7cldVD+hyzRA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-8.0.0.tgz", + "integrity": "sha512-HAruLcW5OPVJu/T/NeMHFTPC8c6De1TjXsOGF8ZablZV14ytlyx2CwyIkOJeU2WTRq1WNM933RI/XuOBUCPrtA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@solana/accounts": "7.0.0", - "@solana/addresses": "7.0.0", - "@solana/codecs": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/functional": "7.0.0", - "@solana/instruction-plans": "7.0.0", - "@solana/instructions": "7.0.0", - "@solana/keys": "7.0.0", - "@solana/offchain-messages": "7.0.0", - "@solana/plugin-core": "7.0.0", - "@solana/plugin-interfaces": "7.0.0", - "@solana/program-client-core": "7.0.0", - "@solana/programs": "7.0.0", - "@solana/rpc": "7.0.0", - "@solana/rpc-api": "7.0.0", - "@solana/rpc-parsed-types": "7.0.0", - "@solana/rpc-spec-types": "7.0.0", - "@solana/rpc-subscriptions": "7.0.0", - "@solana/rpc-types": "7.0.0", - "@solana/signers": "7.0.0", - "@solana/subscribable": "7.0.0", - "@solana/sysvars": "7.0.0", - "@solana/transaction-confirmation": "7.0.0", - "@solana/transaction-introspection": "7.0.0", - "@solana/transaction-messages": "7.0.0", - "@solana/transactions": "7.0.0" + "@solana/accounts": "8.0.0", + "@solana/addresses": "8.0.0", + "@solana/codecs": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/functional": "8.0.0", + "@solana/instruction-plans": "8.0.0", + "@solana/instructions": "8.0.0", + "@solana/keys": "8.0.0", + "@solana/offchain-messages": "8.0.0", + "@solana/plugin-core": "8.0.0", + "@solana/plugin-interfaces": "8.0.0", + "@solana/program-client-core": "8.0.0", + "@solana/programs": "8.0.0", + "@solana/promises": "8.0.0", + "@solana/rpc": "8.0.0", + "@solana/rpc-api": "8.0.0", + "@solana/rpc-parsed-types": "8.0.0", + "@solana/rpc-spec-types": "8.0.0", + "@solana/rpc-subscriptions": "8.0.0", + "@solana/rpc-types": "8.0.0", + "@solana/signers": "8.0.0", + "@solana/subscribable": "8.0.0", + "@solana/sysvars": "8.0.0", + "@solana/transaction-confirmation": "8.0.0", + "@solana/transaction-introspection": "8.0.0", + "@solana/transaction-messages": "8.0.0", + "@solana/transactions": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -453,42 +453,42 @@ } }, "node_modules/@solana/kit-plugin-instruction-plan": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@solana/kit-plugin-instruction-plan/-/kit-plugin-instruction-plan-0.13.0.tgz", - "integrity": "sha512-9RA94e0LLtVLN+bvGclpu8l0lAXGOGS72DxPIQ1VyGZs7vK/WTwPOqKnWhjJs2Cv/SDJ2xsCAhYkXr0UltVrOg==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@solana/kit-plugin-instruction-plan/-/kit-plugin-instruction-plan-0.18.0.tgz", + "integrity": "sha512-GVhSf/QFtowshm4zhwqOrFI5oMpeOgRDCv5WMmYER0xZqE8+aSDxHsbEyI3dS/s4YilA296EHpQpzTAdlpj/bw==", "dev": true, "license": "MIT", "peerDependencies": { - "@solana/kit": "^7.0.0" + "@solana/kit": "^8.0.0" } }, "node_modules/@solana/kit-plugin-rpc": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@solana/kit-plugin-rpc/-/kit-plugin-rpc-0.15.0.tgz", - "integrity": "sha512-Abymr7WLP9yQ6ixCCv+tKzjoWpAkxJ90JWzL+pfkTAFGTpVk9N0myhMcO2ohl6yztJRMOxsw1CXWjcVecxxUlg==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@solana/kit-plugin-rpc/-/kit-plugin-rpc-0.18.0.tgz", + "integrity": "sha512-R6q1MfdxTvcqouPV4PtDB8cXV/Ye9Mal7WZVEMFPZ/sdXbe35K92/7P3YjpQ6I1eqEv8lnBbu7/KCWExvofKVA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/kit-plugin-instruction-plan": "0.13.0" + "@solana/kit-plugin-instruction-plan": "0.18.0" }, "peerDependencies": { - "@solana/kit": "^7.0.0" + "@solana/kit": "^8.0.0" } }, "node_modules/@solana/kit-plugin-signer": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@solana/kit-plugin-signer/-/kit-plugin-signer-0.13.0.tgz", - "integrity": "sha512-Vtlyt2Td8WfeQvC30yOU7a+CFo4+loMCSQKUFwvSQvbpKG39S6UyQc1euhcvtwCbJRZR3m5nAZvHayQ1rxeUkA==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@solana/kit-plugin-signer/-/kit-plugin-signer-0.18.0.tgz", + "integrity": "sha512-/CkyDE0HArqH503UUdsiiknBYpM0gAbe/QhlQ37hCRwP+WcnQkqQNdqhx999vMZaKii68ffSD8+xsvOZcSvC+w==", "dev": true, "license": "MIT", "peerDependencies": { - "@solana/kit": "^7.0.0" + "@solana/kit": "^8.0.0" } }, "node_modules/@solana/nominal-types": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-7.0.0.tgz", - "integrity": "sha512-ff21hmKKMckDkGWah9tRXsEyFCtSnkugH+EMLGJOn7tiXdtFljOXW5Q12IXyeil87EE8aWb1MS6p8v5+hi71Vg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-8.0.0.tgz", + "integrity": "sha512-aCvkLVfJLy7q0xSbzJXLMZkLEDtxbQZtJKJJAsdi7u40KwkK4ZLITLYc7wbIVKCpxLJcs1XspwJKLZGlcFGwSQ==", "dev": true, "license": "MIT", "engines": { @@ -504,20 +504,20 @@ } }, "node_modules/@solana/offchain-messages": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-7.0.0.tgz", - "integrity": "sha512-fGrzxmqVStweGHRlXVAPKACdDboFCwXq5m8C+aD9Nupax6Q9rnvjICzYRLypwqB9X90XIZazh0ys+0KGLMpIJQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-8.0.0.tgz", + "integrity": "sha512-wynBOsq3bwwqDG+KIUK9/5hNlo/tpRjmUf+VcFWUdD0LgSRr9NtF8QL2/Modj/m+Xj1iFkjfP+p8f94iGUF56g==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-data-structures": "7.0.0", - "@solana/codecs-numbers": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/keys": "7.0.0", - "@solana/nominal-types": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-data-structures": "8.0.0", + "@solana/codecs-numbers": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/keys": "8.0.0", + "@solana/nominal-types": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -532,17 +532,17 @@ } }, "node_modules/@solana/options": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/options/-/options-7.0.0.tgz", - "integrity": "sha512-6DhvMqRcL3mG0R5JejYIW5PDTDr7HcLX2R9iCs6OPN1HdsyXmE2rX2EldnuA0rYz1buOodeWYsu4N1lpDcEpaQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-8.0.0.tgz", + "integrity": "sha512-OOrtPfPOuJY2+jNUUUgm6d9pQewhs+fTaR6R6nRAGJOl2IVgmTOMQ6+mpj2nfH36bLXRNuXmJR7SCG64mMi8Dg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "7.0.0", - "@solana/codecs-data-structures": "7.0.0", - "@solana/codecs-numbers": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0" + "@solana/codecs-core": "8.0.0", + "@solana/codecs-data-structures": "8.0.0", + "@solana/codecs-numbers": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -557,9 +557,9 @@ } }, "node_modules/@solana/plugin-core": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-7.0.0.tgz", - "integrity": "sha512-EwTUfOGoQQ3aXooRlQFVbk+sJW7NqJl1K+bmSLiJUjaBTSqtbr3GtMu7aoybJqzZQKjOGSG4Hn0BzK24SvWy3A==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-8.0.0.tgz", + "integrity": "sha512-8ciqi0mT7TocVt/ZjW0rzch8jgmaYbZeW6J2EFM2Oxe1vG2QloK+voikRxkYtsHMxGtSLfiTZDnklX24xBSa9Q==", "dev": true, "license": "MIT", "engines": { @@ -575,19 +575,21 @@ } }, "node_modules/@solana/plugin-interfaces": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/plugin-interfaces/-/plugin-interfaces-7.0.0.tgz", - "integrity": "sha512-fz/HknZLGnVIjhjXrMzW3Qm1x80oeEMSOk4RzMwjcyAEyfzhHtGNW74NsU5W+uFpYz6UBbV5mB1jpuAdJdnj9A==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/plugin-interfaces/-/plugin-interfaces-8.0.0.tgz", + "integrity": "sha512-xi8co+87aAT51XBt0GJDntww172Rke+Bb+hjxPHMoNHd4C1WdMf9VQHwpGhh/km3DYYGx75JBxwpNwPZIbzNxQ==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/instruction-plans": "7.0.0", - "@solana/keys": "7.0.0", - "@solana/rpc-spec": "7.0.0", - "@solana/rpc-subscriptions-spec": "7.0.0", - "@solana/rpc-types": "7.0.0", - "@solana/signers": "7.0.0" + "@solana/accounts": "8.0.0", + "@solana/addresses": "8.0.0", + "@solana/instruction-plans": "8.0.0", + "@solana/keys": "8.0.0", + "@solana/rpc-spec": "8.0.0", + "@solana/rpc-subscriptions-spec": "8.0.0", + "@solana/rpc-types": "8.0.0", + "@solana/signers": "8.0.0", + "@solana/transactions": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -602,21 +604,21 @@ } }, "node_modules/@solana/program-client-core": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/program-client-core/-/program-client-core-7.0.0.tgz", - "integrity": "sha512-+N8HImlR3MTbbvhOShsLelQXGZKbi6KhPhyy+4ZkDLbnRs4QikTamIChXZmoCyxB51S8/9cNRAKyQhbeF5Y8Qg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/program-client-core/-/program-client-core-8.0.0.tgz", + "integrity": "sha512-LS2cgz6imea+PLCQ9J3wKbacR127YpO8aKo/gl1x60GoBasdT1km5Dw63gsv7HEkmEF0iQZGjA9TBwYZNAOXFw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/accounts": "7.0.0", - "@solana/addresses": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/instruction-plans": "7.0.0", - "@solana/instructions": "7.0.0", - "@solana/plugin-interfaces": "7.0.0", - "@solana/rpc-api": "7.0.0", - "@solana/signers": "7.0.0" + "@solana/accounts": "8.0.0", + "@solana/addresses": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/instruction-plans": "8.0.0", + "@solana/instructions": "8.0.0", + "@solana/plugin-interfaces": "8.0.0", + "@solana/rpc-api": "8.0.0", + "@solana/signers": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -631,14 +633,14 @@ } }, "node_modules/@solana/programs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-7.0.0.tgz", - "integrity": "sha512-a1HNgzr9YiiZ8vK4VaKHEXjnZmKX7W5ab2ghuseIalEw/+PJLFcTRTOw8n3efRu677b/bG2Icmb3MBBEXmvbPg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-8.0.0.tgz", + "integrity": "sha512-7n40XqVlTntQWYGwSiFyY4pQSOmCdLmI13oXWbHeSh/e1f4hJvPrVIpKe/h/liT630mSdjgxy1Je+2c7cV24Sw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/errors": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -653,9 +655,9 @@ } }, "node_modules/@solana/promises": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-7.0.0.tgz", - "integrity": "sha512-rjoHnaR4zeEIHqIzfgotxRrLKqY4Goj0G5duZOnjHm8ZC+7eDkH5/mXj1bDJ4ROM70dVM+y1Xkrjg7IL6k6StA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-8.0.0.tgz", + "integrity": "sha512-KYjpZeKOkW4eAserk48349BlWjPorhjP2dkGjYMG0ZRWg67tpby708Yizu1g3S4CAwOAH40EFFYTW6NXaZqcpQ==", "dev": true, "license": "MIT", "engines": { @@ -671,21 +673,21 @@ } }, "node_modules/@solana/rpc": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-7.0.0.tgz", - "integrity": "sha512-hCf4XEhspsNb8TnQ+E961+rBuJcTyrmwNr4LDfVHEeO/VTqaN+yVwAI1wpxcnzwc+T4OoXcyujNiQWhVZPOhiA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-8.0.0.tgz", + "integrity": "sha512-wzo+xjw0oTVOf8jX1dfowegVv8kjpSE7AMoAcBWRiTOOzrLViYiVEPSHvJAow27TTgLoI8ujkfCl+emXFFrA9A==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0", - "@solana/fast-stable-stringify": "7.0.0", - "@solana/functional": "7.0.0", - "@solana/rpc-api": "7.0.0", - "@solana/rpc-spec": "7.0.0", - "@solana/rpc-spec-types": "7.0.0", - "@solana/rpc-transformers": "7.0.0", - "@solana/rpc-transport-http": "7.0.0", - "@solana/rpc-types": "7.0.0" + "@solana/errors": "8.0.0", + "@solana/fast-stable-stringify": "8.0.0", + "@solana/functional": "8.0.0", + "@solana/rpc-api": "8.0.0", + "@solana/rpc-spec": "8.0.0", + "@solana/rpc-spec-types": "8.0.0", + "@solana/rpc-transformers": "8.0.0", + "@solana/rpc-transport-http": "8.0.0", + "@solana/rpc-types": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -700,23 +702,23 @@ } }, "node_modules/@solana/rpc-api": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-7.0.0.tgz", - "integrity": "sha512-MTtBO883st83CjWpo8B4g8EKzXaeoBX5N7+sv4vcvsn2q1NpY4SG3XejdX1FrKeTe9Xjbv0/FzFtykstbKe1UA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-8.0.0.tgz", + "integrity": "sha512-NT2fBS9wivcMuNAwFiiiVpQ1EIR29mTzq0j7fnq9T5D4EBrl/OmA7xZXN+Z6TzUPpNLAaLADzPceXHrqqeaRSw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/keys": "7.0.0", - "@solana/rpc-parsed-types": "7.0.0", - "@solana/rpc-spec": "7.0.0", - "@solana/rpc-transformers": "7.0.0", - "@solana/rpc-types": "7.0.0", - "@solana/transaction-messages": "7.0.0", - "@solana/transactions": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/keys": "8.0.0", + "@solana/rpc-parsed-types": "8.0.0", + "@solana/rpc-spec": "8.0.0", + "@solana/rpc-transformers": "8.0.0", + "@solana/rpc-types": "8.0.0", + "@solana/transaction-messages": "8.0.0", + "@solana/transactions": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -731,9 +733,9 @@ } }, "node_modules/@solana/rpc-parsed-types": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-7.0.0.tgz", - "integrity": "sha512-80VjPbB/TZ/Hy5qdRF7onPfMPHk5cwBVbtNUGbilrmFuqRzSvzSOY1ynNXXODPZBytNmPq7u7oJfSCsQ5MA9Ig==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-8.0.0.tgz", + "integrity": "sha512-UEzab+gqgWZ84e1SwMoofInmu5Q0a8+DB7YYC+YnzfJJjbEL/qyFPdNpIvKX3DFyHJ+Hg5rjE9iVUVOP6Pqk2w==", "dev": true, "license": "MIT", "engines": { @@ -749,15 +751,15 @@ } }, "node_modules/@solana/rpc-spec": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-7.0.0.tgz", - "integrity": "sha512-GRGlXpLgap9yVh3qmCl4huuKAoLMp/p82/9Q9ONj6lmFH+RqJE4Q+16V+HxHI5ZakmwZYoVZU4GEKjumse9SCg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-8.0.0.tgz", + "integrity": "sha512-8Mji0374hnloSXSMgLQSCj+E/c2A8qMCa9IOm1xbdHtDu91q/1fB7v88j9jT/lHmSBbShVJLyGCI9OKzxT1Tiw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0", - "@solana/rpc-spec-types": "7.0.0", - "@solana/subscribable": "7.0.0" + "@solana/errors": "8.0.0", + "@solana/rpc-spec-types": "8.0.0", + "@solana/subscribable": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -772,13 +774,13 @@ } }, "node_modules/@solana/rpc-spec-types": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-7.0.0.tgz", - "integrity": "sha512-V4Hp2//fW8eYq1zcGgHPu7FXKHyIWERBQd4+NRaKn2m8rPiVAm3pVRwPzSvVE0+8Nyf5qzdcfcMf7NQdhl6j7Q==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-8.0.0.tgz", + "integrity": "sha512-o9/NGXsY2fBOMsJxt/exjDyV+zPsDDUl0szspDdLuh2w/YUb3U9D2ZvsCmxjtWotgbmIqFn6uKKf1/0pL/Wtdw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0" + "@solana/errors": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -793,23 +795,23 @@ } }, "node_modules/@solana/rpc-subscriptions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-7.0.0.tgz", - "integrity": "sha512-jLNjUCGBbCIfABqqHopNJIeAkhd4GzhbodgCH4x2X+S0ycBaERX393+k+fG8JPJpGujkmeQFBCeIKO3lK+PoYg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-8.0.0.tgz", + "integrity": "sha512-Ar2UgTHqx6W1xFJ8JmkZiY2xvcSzrW7FrXF2cg7AiBw89PIeMcpQOUHokaZtI8bN2CHp3f5VT1d4CcKFe0UIJg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0", - "@solana/fast-stable-stringify": "7.0.0", - "@solana/functional": "7.0.0", - "@solana/promises": "7.0.0", - "@solana/rpc-spec-types": "7.0.0", - "@solana/rpc-subscriptions-api": "7.0.0", - "@solana/rpc-subscriptions-channel-websocket": "7.0.0", - "@solana/rpc-subscriptions-spec": "7.0.0", - "@solana/rpc-transformers": "7.0.0", - "@solana/rpc-types": "7.0.0", - "@solana/subscribable": "7.0.0" + "@solana/errors": "8.0.0", + "@solana/fast-stable-stringify": "8.0.0", + "@solana/functional": "8.0.0", + "@solana/promises": "8.0.0", + "@solana/rpc-spec-types": "8.0.0", + "@solana/rpc-subscriptions-api": "8.0.0", + "@solana/rpc-subscriptions-channel-websocket": "8.0.0", + "@solana/rpc-subscriptions-spec": "8.0.0", + "@solana/rpc-transformers": "8.0.0", + "@solana/rpc-types": "8.0.0", + "@solana/subscribable": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -824,19 +826,19 @@ } }, "node_modules/@solana/rpc-subscriptions-api": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-7.0.0.tgz", - "integrity": "sha512-o2PZDeNC/kg3VO3ry4R0JySJ1xMHLchZwmzVwamxGidlLe9uvzm6CHlCqv/JCq4/zHLo7qbLqnmOckZ6vlDqaw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-8.0.0.tgz", + "integrity": "sha512-ILmWLSRMs0cmes78FDln79OCVpRn5WwVg8aIgkH+phR6qsybg9ZA6JmVKPsNjRJzWhnvKw3cnY3YscRNk/uW2A==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/keys": "7.0.0", - "@solana/rpc-subscriptions-spec": "7.0.0", - "@solana/rpc-transformers": "7.0.0", - "@solana/rpc-types": "7.0.0", - "@solana/transaction-messages": "7.0.0", - "@solana/transactions": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/keys": "8.0.0", + "@solana/rpc-subscriptions-spec": "8.0.0", + "@solana/rpc-transformers": "8.0.0", + "@solana/rpc-types": "8.0.0", + "@solana/transaction-messages": "8.0.0", + "@solana/transactions": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -851,16 +853,16 @@ } }, "node_modules/@solana/rpc-subscriptions-channel-websocket": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-7.0.0.tgz", - "integrity": "sha512-79ecXBCT2pG+vXNBZim80vUz+B04L1mEduXobBIXM55VvxsHYT+4H4V+/ZHoFJUK6Lhbt+6zhpconx8Wa3H+JA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-8.0.0.tgz", + "integrity": "sha512-5gOIiaA+UsDGTuVIszwUzwlGI0AKOxsStXg5abk9BdWsx8B0iEqRZ0FTXeZxHOs6t4k25yhLFkAeam7i18qKeg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0", - "@solana/functional": "7.0.0", - "@solana/rpc-subscriptions-spec": "7.0.0", - "@solana/subscribable": "7.0.0", + "@solana/errors": "8.0.0", + "@solana/functional": "8.0.0", + "@solana/rpc-subscriptions-spec": "8.0.0", + "@solana/subscribable": "8.0.0", "ws": "^8.21.0" }, "engines": { @@ -876,16 +878,16 @@ } }, "node_modules/@solana/rpc-subscriptions-spec": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-7.0.0.tgz", - "integrity": "sha512-Oips5ciWqPGO5Cx7hcEQ/czbcrUjPf+4cTam0UMrK3BnvHPcEAWkPYlIgabQiqa60/pjOOz7B936oMXA5XwL+g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-8.0.0.tgz", + "integrity": "sha512-RdKaVmuC1ATsCiiFzb8Fd5uJIouS32TvF+eVCRx/fmqHmbW7J7testCqi/gjITJGjr8JE/7qPJIoUU1dccWzLg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0", - "@solana/promises": "7.0.0", - "@solana/rpc-spec-types": "7.0.0", - "@solana/subscribable": "7.0.0" + "@solana/errors": "8.0.0", + "@solana/promises": "8.0.0", + "@solana/rpc-spec-types": "8.0.0", + "@solana/subscribable": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -900,17 +902,17 @@ } }, "node_modules/@solana/rpc-transformers": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-7.0.0.tgz", - "integrity": "sha512-NHkTKC2J4oaMMzyOtIEDOLCGtzg1Y8vlPoBmUeA82o+DNjBSiZLR2Ariy7n7chmwKchCZ8aGirBxjLCSRc6b/g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-8.0.0.tgz", + "integrity": "sha512-OA4wEcLte+WJirlYfZcsFcX6Sn5/deWaJ+G1x1VbfDYXwGEzo39JBk7A23VZ9T9MOmDfpgfds7lCjCix9lFZJQ==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0", - "@solana/functional": "7.0.0", - "@solana/nominal-types": "7.0.0", - "@solana/rpc-spec-types": "7.0.0", - "@solana/rpc-types": "7.0.0" + "@solana/errors": "8.0.0", + "@solana/functional": "8.0.0", + "@solana/nominal-types": "8.0.0", + "@solana/rpc-spec-types": "8.0.0", + "@solana/rpc-types": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -925,16 +927,16 @@ } }, "node_modules/@solana/rpc-transport-http": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-7.0.0.tgz", - "integrity": "sha512-W0G15BN0xljXzRRo/ZwepJNiOjKhRImF5SxhjZauh2yVBoUjfd6NSCmYcdWu0tj9lypKKrB1ReS4oPmb4yCHbA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-8.0.0.tgz", + "integrity": "sha512-fGk4ym3zApquQnKI2vyO+wdEwfKEKsRQfUunLVgjAMLYSd2dxV4rQ2rFErxzJpJE0gMWE3vBH9JTPm9NXHgiuA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0", - "@solana/rpc-spec": "7.0.0", - "@solana/rpc-spec-types": "7.0.0", - "undici-types": "^8.5.0" + "@solana/errors": "8.0.0", + "@solana/rpc-spec": "8.0.0", + "@solana/rpc-spec-types": "8.0.0", + "undici-types": "^8.10.0" }, "engines": { "node": ">=20.18.0" @@ -949,19 +951,19 @@ } }, "node_modules/@solana/rpc-types": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-7.0.0.tgz", - "integrity": "sha512-Lf2csFSWHwN/8EL03uWfS7n1J19vWLK4DBGkQ5jebRhoJ3dD+xPcJtS+epI2281t5aghJvoV7D+RIM0NextZJg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-8.0.0.tgz", + "integrity": "sha512-OvuMUHeeuK7cySRGs8pMd/qXvYyUiAAeTI6NRgQCR0moRbegQ9DqtQycnS1y9wkpywv8bEjpoJoi8QZfMpx/WQ==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-numbers": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/fixed-points": "7.0.0", - "@solana/nominal-types": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-numbers": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/fixed-points": "8.0.0", + "@solana/nominal-types": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -976,21 +978,21 @@ } }, "node_modules/@solana/signers": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-7.0.0.tgz", - "integrity": "sha512-4E3xYQ0b9OZCTLghqfeHh66lfipy3jV1REdFJLk9WqPBaXVa/wSgGGIqZVa744ATSd9AeEfL/I5n/LrMNQOhoA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-8.0.0.tgz", + "integrity": "sha512-yKqD54Hh8a+BBgjdcyRDYukKRG9d2Zj9aLAOuXapIcj1fWG1bWGIf3LEdVAjdoNJPTa2NEb787ouM6QOA+DAMA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/instructions": "7.0.0", - "@solana/keys": "7.0.0", - "@solana/nominal-types": "7.0.0", - "@solana/offchain-messages": "7.0.0", - "@solana/transaction-messages": "7.0.0", - "@solana/transactions": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/instructions": "8.0.0", + "@solana/keys": "8.0.0", + "@solana/nominal-types": "8.0.0", + "@solana/offchain-messages": "8.0.0", + "@solana/transaction-messages": "8.0.0", + "@solana/transactions": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -1005,14 +1007,14 @@ } }, "node_modules/@solana/subscribable": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-7.0.0.tgz", - "integrity": "sha512-dJQA5AxDv/7YxuNe7GXIkaOUNhXczFaL3/SNOvXe7k77bC4dx4HPkySfcVDfEWBOePe3+8iyVbT8DZ3aOcp8Ng==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-8.0.0.tgz", + "integrity": "sha512-/vu8j2WeAklP5h7xBgbrM4zG9/W+lju0HN50f0qMRqcmo1VhQejHFb42MVaHFb3iRUvsuvC2M627DGUFb8CMZw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "7.0.0", - "@solana/promises": "7.0.0" + "@solana/errors": "8.0.0", + "@solana/promises": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -1075,18 +1077,18 @@ } }, "node_modules/@solana/sysvars": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-7.0.0.tgz", - "integrity": "sha512-GKND6hCcBcrak3/VAtx6aEoAi9wQjJQzU2Fcu6JYmnTjGe5MHf21FqbjGl0aQ0C2HlJQQJmA07wgsuOiYDTDog==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-8.0.0.tgz", + "integrity": "sha512-z8bZDr/RfvEeQr5t2UVtdG/qzS5yjBnTpcuVvpQcoWqkfzMISb5+dMTc1qiYtnwGz2176QZROgZ7+OQogfDbnw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/accounts": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-data-structures": "7.0.0", - "@solana/codecs-numbers": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/rpc-types": "7.0.0" + "@solana/accounts": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-data-structures": "8.0.0", + "@solana/codecs-numbers": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/rpc-types": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -1101,22 +1103,22 @@ } }, "node_modules/@solana/transaction-confirmation": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-7.0.0.tgz", - "integrity": "sha512-SaU2CY9ZDJGK47DtQ7xwKFmbgzDGqIN/Ug89ZSUzDPD9QdMRXhtxgH8KZgb0gSgpyel85sSt0QH9wkWzhp69ow==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-8.0.0.tgz", + "integrity": "sha512-vz72wtWChtoqzCXmYpQwD0ZRtA1qM10FGg+hwtmUeoxvoq014MAJ5JEJ4PN5X3D5ST/qdmv3TmmFyfdyJ8kfCQ==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/keys": "7.0.0", - "@solana/promises": "7.0.0", - "@solana/rpc": "7.0.0", - "@solana/rpc-subscriptions": "7.0.0", - "@solana/rpc-types": "7.0.0", - "@solana/transaction-messages": "7.0.0", - "@solana/transactions": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/keys": "8.0.0", + "@solana/promises": "8.0.0", + "@solana/rpc": "8.0.0", + "@solana/rpc-subscriptions": "8.0.0", + "@solana/rpc-types": "8.0.0", + "@solana/transaction-messages": "8.0.0", + "@solana/transactions": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -1131,20 +1133,20 @@ } }, "node_modules/@solana/transaction-introspection": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/transaction-introspection/-/transaction-introspection-7.0.0.tgz", - "integrity": "sha512-aO+5ewxGaziXYcXJZ6F3doq/KI1L3WU2z5eCjs4DBO0kRQBHp4bH6ZKygVX2JIxOZrd1rB9SxP/CCCX2wRm8Xw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-introspection/-/transaction-introspection-8.0.0.tgz", + "integrity": "sha512-GkmR+SXN70Amh1f+MvzEGUiqr3/YL/FyEqeT6oCAMTR2AidXfyZIKNkoTB4+1jABeXFFMaYiCfPhKD6+BznnXA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/instructions": "7.0.0", - "@solana/rpc-api": "7.0.0", - "@solana/transaction-messages": "7.0.0", - "@solana/transactions": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/instructions": "8.0.0", + "@solana/rpc-types": "8.0.0", + "@solana/transaction-messages": "8.0.0", + "@solana/transactions": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -1159,21 +1161,21 @@ } }, "node_modules/@solana/transaction-messages": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-7.0.0.tgz", - "integrity": "sha512-qCBYR3QQvykcI36vnqwI5090hGXS3mmCe72b/f/n9kBsBaA2oowdBXZupB1wwKe8+6x8o8VkhKQaK9oTKKbWTg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-8.0.0.tgz", + "integrity": "sha512-/3QXUWIPLDicPLOt2aVczgTRJrWih59mHndLOwCD3i7/phGFnH66cjoyYy7+ootJJJwDlsgMoOmgdGJJHnqyFA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-data-structures": "7.0.0", - "@solana/codecs-numbers": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/functional": "7.0.0", - "@solana/instructions": "7.0.0", - "@solana/nominal-types": "7.0.0", - "@solana/rpc-types": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-data-structures": "8.0.0", + "@solana/codecs-numbers": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/functional": "8.0.0", + "@solana/instructions": "8.0.0", + "@solana/nominal-types": "8.0.0", + "@solana/rpc-types": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -1188,24 +1190,24 @@ } }, "node_modules/@solana/transactions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-7.0.0.tgz", - "integrity": "sha512-y5nayd2Ozld/4Bxefz50e/E6qxyZteuZCZ7suh7z96KY6QUJlZDR+eCG1v/lA7Azt3GSOevJuYFX/ept/mqZkw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-8.0.0.tgz", + "integrity": "sha512-8HQmyVN9qv0v1ylTqOR38T8834oQ+sPLSRpPueTd5yfwiv60CtUwndHg1M+y5n7Q4DP1jJZ0M5YReJ5BpnD2Zg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "7.0.0", - "@solana/codecs-core": "7.0.0", - "@solana/codecs-data-structures": "7.0.0", - "@solana/codecs-numbers": "7.0.0", - "@solana/codecs-strings": "7.0.0", - "@solana/errors": "7.0.0", - "@solana/functional": "7.0.0", - "@solana/instructions": "7.0.0", - "@solana/keys": "7.0.0", - "@solana/nominal-types": "7.0.0", - "@solana/rpc-types": "7.0.0", - "@solana/transaction-messages": "7.0.0" + "@solana/addresses": "8.0.0", + "@solana/codecs-core": "8.0.0", + "@solana/codecs-data-structures": "8.0.0", + "@solana/codecs-numbers": "8.0.0", + "@solana/codecs-strings": "8.0.0", + "@solana/errors": "8.0.0", + "@solana/functional": "8.0.0", + "@solana/instructions": "8.0.0", + "@solana/keys": "8.0.0", + "@solana/nominal-types": "8.0.0", + "@solana/rpc-types": "8.0.0", + "@solana/transaction-messages": "8.0.0" }, "engines": { "node": ">=20.18.0" @@ -1248,7 +1250,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1258,16 +1259,16 @@ } }, "node_modules/undici-types": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.0.tgz", + "integrity": "sha512-ibvdovq3nCFs8Msrd95BW+zUOq+aOVbT+wpHUoPWhztbHEoPc6oof51iFDB6Es8lTKvNvVW9jNSAB8dwrKTMGg==", "dev": true, "license": "MIT" }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { diff --git a/crates/sdk-node/package.json b/crates/sdk-node/package.json index f158101e8..400d2ddb1 100644 --- a/crates/sdk-node/package.json +++ b/crates/sdk-node/package.json @@ -71,15 +71,15 @@ }, "devDependencies": { "@napi-rs/cli": "^2.18.4", - "@solana/kit": "^7.0.0", - "@solana/kit-plugin-rpc": "^0.15.0", - "@solana/kit-plugin-signer": "^0.13.0", + "@solana/kit": "^8.0.0", + "@solana/kit-plugin-rpc": "^0.18.0", + "@solana/kit-plugin-signer": "^0.18.0", "typescript": "^5.7.0" }, "peerDependencies": { - "@solana/kit": "^7.0.0", - "@solana/kit-plugin-rpc": "^0.15.0", - "@solana/kit-plugin-signer": "^0.13.0" + "@solana/kit": "^8.0.0", + "@solana/kit-plugin-rpc": "^0.18.0", + "@solana/kit-plugin-signer": "^0.18.0" }, "peerDependenciesMeta": { "@solana/kit": { diff --git a/crates/sdk-node/scripts/kit-smoke.js b/crates/sdk-node/scripts/kit-smoke.js index b562312ba..7d1a13246 100644 --- a/crates/sdk-node/scripts/kit-smoke.js +++ b/crates/sdk-node/scripts/kit-smoke.js @@ -61,6 +61,34 @@ test("embedded surfpool() boots a Surfnet and wires the full kit client", async assert.equal(funded.value, 1_000_000_000n); }); +test("embedded surfpool() airdrops configured addresses at startup", async (t) => { + const recipient = Surfnet.newKeypair().publicKey; + const signerLike = { address: Surfnet.newKeypair().publicKey }; + // Funded by the Surfnet itself before the plugin runs, so the plugin's own + // airdrop lands on an address that already holds lamports. + const preFunded = Surfnet.newKeypair().publicKey; + const client = await createClient().use( + surfpool({ + airdropAddresses: [recipient, signerLike, preFunded], + airdropAmount: 3_000_000_000n, + surfnet: { + airdropAddresses: [preFunded], + airdropSol: 1_000_000_000, + offline: true, + }, + }), + ); + t.after(() => client.surfnet.stop()); + + const funded = await client.rpc.getBalance(recipient).send(); + assert.equal(funded.value, 3_000_000_000n); + const fundedSigner = await client.rpc.getBalance(signerLike.address).send(); + assert.equal(fundedSigner.value, 3_000_000_000n); + // Additive: the startup balance survives and the airdrop is added to it. + const toppedUp = await client.rpc.getBalance(preFunded).send(); + assert.equal(toppedUp.value, 4_000_000_000n); +}); + test("disposing the embedded client stops the Surfnet", async () => { const client = await createClient().use(surfpool({ surfnet: { offline: true } })); await client.rpc.getSlot().send(); diff --git a/crates/sdk-node/scripts/kit-unit.js b/crates/sdk-node/scripts/kit-unit.js index 43ccdfd48..6f868e8ec 100644 --- a/crates/sdk-node/scripts/kit-unit.js +++ b/crates/sdk-node/scripts/kit-unit.js @@ -29,6 +29,10 @@ function mockFetch(handler) { }; } +function fakePayer() { + return { address: "SurfpoolTestPayer11111111111111111111111111" }; +} + test("cheatcodes RPC prefixes method names and unwraps { context, value } envelopes", async () => { const seenMethods = []; const restore = mockFetch((request) => { @@ -179,8 +183,8 @@ test("cheatcodes RPC sends configured extra headers", async () => { }); test("attach mode installs the full client surface without loading the native module", () => { - const fakePayer = { address: "SurfpoolTestPayer11111111111111111111111111" }; - const client = createClient({ payer: fakePayer }).use( + const payer = fakePayer(); + const client = createClient({ payer }).use( surfpool({ rpcUrl: "http://127.0.0.1:8899" }), ); @@ -196,26 +200,26 @@ test("attach mode installs the full client surface without loading the native mo assert.equal(typeof client.sendTransaction, "function"); assert.equal(typeof client.sendTransactions, "function"); assert.equal(client.surfnet, undefined); - assert.equal(client.payer, fakePayer); + assert.equal(client.payer, payer); }); test("attach mode defaults the WebSocket URL to surfpool's default WS port", () => { - const fakePayer = { address: "SurfpoolTestPayer11111111111111111111111111" }; + const payer = fakePayer(); // Surfpool's WebSocket port (default 8900) is independent of its HTTP // port, so a custom --port keeps subscriptions on 8900. - const customPort = createClient({ payer: fakePayer }).use( + const customPort = createClient({ payer }).use( surfpool({ rpcUrl: "http://127.0.0.1:12345" }), ); assert.equal(customPort.wsUrl, "ws://127.0.0.1:8900"); // Port-less URLs (e.g. behind a proxy) only swap the protocol. - const proxied = createClient({ payer: fakePayer }).use( + const proxied = createClient({ payer }).use( surfpool({ rpcUrl: "https://surfpool.example.com" }), ); assert.equal(proxied.wsUrl, "wss://surfpool.example.com"); - const explicit = createClient({ payer: fakePayer }).use( + const explicit = createClient({ payer }).use( surfpool({ rpcUrl: "http://127.0.0.1:12345", rpcSubscriptionsUrl: "ws://127.0.0.1:54321", @@ -224,6 +228,209 @@ test("attach mode defaults the WebSocket URL to surfpool's default WS port", () assert.equal(explicit.wsUrl, "ws://127.0.0.1:54321"); }); +test("attach mode without airdropAddresses stays synchronous and funds nothing", () => { + const calls = []; + const restore = mockFetch((request) => { + calls.push(request.method); + return { result: { context: { slot: 1 }, value: null } }; + }); + try { + const client = createClient({ payer: fakePayer() }).use( + surfpool({ rpcUrl: ENDPOINT }), + ); + assert.equal(typeof client.then, "undefined"); + assert.deepEqual(calls, []); + } finally { + restore(); + } +}); + +test("attach mode airdrops configured addresses, accepting signers and bare addresses", async () => { + const funded = new Map(); + const restore = mockFetch((request) => { + if (request.method === "getBalance") { + return { result: { context: { slot: 1 }, value: 0 } }; + } + if (request.method === "surfnet_setAccount") { + funded.set(request.params[0], request.params[1].lamports); + return { result: { context: { slot: 1 }, value: null } }; + } + throw new Error(`unexpected method ${request.method}`); + }); + try { + const payer = fakePayer(); + const other = "SurfpoolTestOther111111111111111111111111111"; + const client = await createClient({ payer }).use( + surfpool({ airdropAddresses: [payer, other], rpcUrl: ENDPOINT }), + ); + + assert.equal(client.rpcUrl, ENDPOINT); + assert.equal(typeof client.cheatcodes.setAccount, "function"); + // 10 SOL by default, matching Surfnet's own startup airdrop. + assert.equal(funded.get(payer.address), 10_000_000_000); + assert.equal(funded.get(other), 10_000_000_000); + } finally { + restore(); + } +}); + +test("attach mode honors airdropAmount and adds it to an existing balance", async () => { + const balances = { + SurfpoolTestOther111111111111111111111111111: 5_000_000_000, + SurfpoolTestPayer11111111111111111111111111: 0, + }; + const funded = new Map(); + const restore = mockFetch((request) => { + if (request.method === "getBalance") { + return { result: { context: { slot: 1 }, value: balances[request.params[0]] } }; + } + funded.set(request.params[0], request.params[1].lamports); + return { result: { context: { slot: 1 }, value: null } }; + }); + try { + const payer = fakePayer(); + await createClient({ payer }).use( + surfpool({ + airdropAddresses: [payer, "SurfpoolTestOther111111111111111111111111111"], + airdropAmount: 2_000_000_000n, + rpcUrl: ENDPOINT, + }), + ); + assert.equal(funded.get(payer.address), 2_000_000_000); + assert.equal( + funded.get("SurfpoolTestOther111111111111111111111111111"), + 7_000_000_000, + ); + } finally { + restore(); + } +}); + +test("attach mode funds an address named twice exactly once", async () => { + const funded = []; + const restore = mockFetch((request) => { + if (request.method === "getBalance") { + return { result: { context: { slot: 1 }, value: 5_000_000_000 } }; + } + funded.push([request.params[0], request.params[1].lamports]); + return { result: { context: { slot: 1 }, value: null } }; + }); + try { + const payer = fakePayer(); + await createClient({ payer }).use( + surfpool({ + // The signer and its own address are the same target spelled two ways. + airdropAddresses: [payer, payer.address], + airdropAmount: 2_000_000_000n, + rpcUrl: ENDPOINT, + }), + ); + assert.deepEqual(funded, [[payer.address, 7_000_000_000]]); + } finally { + restore(); + } +}); + +test("attach mode funds nothing when airdropAmount is zero", async () => { + const restore = mockFetch((request) => { + throw new Error(`no request should be made for a zero amount, got ${request.method}`); + }); + try { + const payer = fakePayer(); + const client = await createClient({ payer }).use( + surfpool({ airdropAddresses: [payer], airdropAmount: 0, rpcUrl: ENDPOINT }), + ); + assert.equal(client.rpcUrl, ENDPOINT); + } finally { + restore(); + } +}); + +test("attach mode rejects airdropAmount values that cannot represent a lamport amount", async () => { + const restore = mockFetch(() => { + throw new Error("no request should be made for an invalid amount"); + }); + try { + for (const airdropAmount of [Number.MAX_SAFE_INTEGER + 2, 1.5]) { + const payer = fakePayer(); + await assert.rejects( + createClient({ payer }).use( + surfpool({ airdropAddresses: [payer], airdropAmount, rpcUrl: ENDPOINT }), + ), + /airdropAmount must be a safe integer or a bigint/, + ); + } + // A negative amount would debit the address rather than fund it. + for (const airdropAmount of [-1, -1n]) { + const payer = fakePayer(); + await assert.rejects( + createClient({ payer }).use( + surfpool({ airdropAddresses: [payer], airdropAmount, rpcUrl: ENDPOINT }), + ), + /airdropAmount must not be negative/, + ); + } + // A lamport balance is a u64 on the wire, so anything beyond that range is + // rejected here rather than by the RPC deserializer. + for (const airdropAmount of [2n ** 64n, 2n ** 70n]) { + const payer = fakePayer(); + await assert.rejects( + createClient({ payer }).use( + surfpool({ airdropAddresses: [payer], airdropAmount, rpcUrl: ENDPOINT }), + ), + /airdropAmount must not exceed 18446744073709551615 lamports/, + ); + } + } finally { + restore(); + } +}); + +test("attach mode rejects an airdrop whose sum with the existing balance exceeds u64", async () => { + const restore = mockFetch((request) => { + if (request.method === "getBalance") { + return { result: { context: { slot: 1 }, value: 5_000_000_000 } }; + } + throw new Error("no account should be written for an unrepresentable balance"); + }); + try { + const payer = fakePayer(); + await assert.rejects( + createClient({ payer }).use( + surfpool({ + airdropAddresses: [payer], + airdropAmount: 2n ** 64n - 1n, + rpcUrl: ENDPOINT, + }), + ), + (error) => + /Failed to airdrop/.test(error.message) && + /exceeds the maximum lamport balance 18446744073709551615/.test(error.cause.message), + ); + } finally { + restore(); + } +}); + +test("attach mode airdrop failures reject with the offending address", async () => { + const restore = mockFetch((request) => + request.method === "getBalance" + ? { result: { context: { slot: 1 }, value: 0 } } + : { error: { code: -32601, message: "cheatcode disabled" } }, + ); + try { + const payer = fakePayer(); + await assert.rejects( + createClient({ payer }).use( + surfpool({ airdropAddresses: [payer], rpcUrl: ENDPOINT }), + ), + /Failed to airdrop 10000000000 lamports to SurfpoolTestPayer11111111111111111111111111/, + ); + } finally { + restore(); + } +}); + test("ESM and CJS builds expose the same named exports", async () => { const esm = await import("@solana/surfpool/kit"); const cjsKeys = Object.keys(kit).filter((k) => k !== "__esModule"); diff --git a/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts b/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts index a8d1ba95b..d3e11662b 100644 --- a/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts +++ b/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts @@ -3,7 +3,7 @@ * the emitting builds; checked by `npm run typecheck:kit`. Each * `@ts-expect-error` documents a misuse the types must keep rejecting. */ -import { createClient, type KeyPairSigner } from '@solana/kit'; +import { type Address, createClient, type KeyPairSigner } from '@solana/kit'; import { surfpool } from '../surfpool.js'; @@ -34,6 +34,41 @@ void (async () => { // @ts-expect-error attach mode has no native Surfnet handle. void attached.surfnet; }); +// Attach mode without funding stays synchronous. +void (() => { + const attached = createClient({ payer: payerSigner }).use(surfpool({ rpcUrl: 'http://127.0.0.1:8899' })); + void attached.rpc.getSlot(); +}); + +// Attach mode with `airdropAddresses` becomes asynchronous. +void (async () => { + const attached = await createClient({ payer: payerSigner }).use( + surfpool({ + airdropAddresses: [payerSigner, '11111111111111111111111111111111' as Address], + airdropAmount: 1_000_000_000n, + rpcUrl: 'http://127.0.0.1:8899', + }), + ); + void attached.rpc.getSlot(); +}); +// @ts-expect-error airdrop targets must be addresses or carry one. +void surfpool({ airdropAddresses: [42], rpcUrl: 'http://127.0.0.1:8899' }); + +declare const shouldFund: boolean; +// A possibly-present `airdropAddresses` is rejected rather than typed as the +// synchronous plugin it would not be at runtime. +// @ts-expect-error the funding decision must be made at the type level. +void surfpool({ + airdropAddresses: shouldFund ? [payerSigner] : undefined, + rpcUrl: 'http://127.0.0.1:8899', +}); +const conditionalConfig = { + rpcUrl: 'http://127.0.0.1:8899', + ...(shouldFund ? { airdropAddresses: [payerSigner] } : {}), +}; +// @ts-expect-error same, spread into the config rather than written inline. +void surfpool(conditionalConfig); + // @ts-expect-error attach mode requires the client to already have a payer. void createClient().use(surfpool({ rpcUrl: 'http://127.0.0.1:8899' })); // @ts-expect-error embedded startup options cannot be combined with attach mode. diff --git a/crates/sdk-node/surfpool-sdk/kit/index.ts b/crates/sdk-node/surfpool-sdk/kit/index.ts index 5733a6e90..41c88bb91 100644 --- a/crates/sdk-node/surfpool-sdk/kit/index.ts +++ b/crates/sdk-node/surfpool-sdk/kit/index.ts @@ -1,7 +1,9 @@ export { createSurfnetCheatcodesRpc, DEFAULT_SURFNET_ENDPOINT, surfnetCheatcodes } from './cheatcodes.js'; export { surfpool } from './surfpool.js'; export type { + AirdropTarget, SurfpoolAttachConfig, + SurfpoolAttachConfigWithAirdrop, SurfpoolConfig, SurfpoolEmbeddedConfig, SurfpoolRpcOptions, diff --git a/crates/sdk-node/surfpool-sdk/kit/surfpool.ts b/crates/sdk-node/surfpool-sdk/kit/surfpool.ts index 44b4de5c3..6523668b7 100644 --- a/crates/sdk-node/surfpool-sdk/kit/surfpool.ts +++ b/crates/sdk-node/surfpool-sdk/kit/surfpool.ts @@ -1,9 +1,23 @@ -import { type ClientWithPayer, createKeyPairSignerFromBytes, extendClient, pipe, withCleanup } from '@solana/kit'; +import { + type Address, + type ClientWithPayer, + createKeyPairSignerFromBytes, + extendClient, + pipe, + withCleanup, +} from '@solana/kit'; import { solanaLocalRpc, type SolanaRpcConfig } from '@solana/kit-plugin-rpc'; import type { SurfnetConfig } from '@solana/surfpool'; import { createSurfnetCheatcodesRpc } from './cheatcodes.js'; +/** Lamports each `airdropAddresses` entry is credited with when no amount is given. */ +const DEFAULT_AIRDROP_LAMPORTS = 10_000_000_000n; +const MAX_LAMPORTS = 2n ** 64n - 1n; + +/** An address to fund, or anything carrying one (a signer, a PDA, an account). */ +export type AirdropTarget = Address | { readonly address: Address }; + /** * Transaction planner/executor and RPC options forwarded to the standard * local-cluster Solana RPC plugin. URLs are excluded because they are @@ -11,35 +25,128 @@ import { createSurfnetCheatcodesRpc } from './cheatcodes.js'; */ export type SurfpoolRpcOptions = Omit, 'rpcSubscriptionsUrl' | 'rpcUrl'>; -/** Configuration for {@link surfpool} in embedded mode (boots an in-process Surfnet). */ -export type SurfpoolEmbeddedConfig = SurfpoolRpcOptions & { - rpcSubscriptionsUrl?: never; - rpcUrl?: never; - /** Startup options forwarded verbatim to `Surfnet.startWithConfig()`. */ - surfnet?: SurfnetConfig; +/** Startup funding applied to both modes. */ +type SurfpoolAirdropOptions = { + /** + * Addresses (or signers) credited with {@link SurfpoolAirdropOptions.airdropAmount} + * lamports while the client is being composed. The amount is added to + * whatever the address already holds, the way a real airdrop behaves. + * Entries naming the same address are funded once. + */ + airdropAddresses?: readonly AirdropTarget[]; + /** + * Lamports to fund each entry of `airdropAddresses` with. Defaults to 10 SOL. + * A `number` must be a safe integer; pass a `bigint` for amounts above 2^53. + * Zero funds nothing. + */ + airdropAmount?: bigint | number; }; +/** Configuration for {@link surfpool} in embedded mode (boots an in-process Surfnet). */ +export type SurfpoolEmbeddedConfig = SurfpoolAirdropOptions & + SurfpoolRpcOptions & { + rpcSubscriptionsUrl?: never; + rpcUrl?: never; + /** Startup options forwarded verbatim to `Surfnet.startWithConfig()`. */ + surfnet?: SurfnetConfig; + }; + /** Configuration for {@link surfpool} in attach mode (connects to a running Surfpool). */ -export type SurfpoolAttachConfig = SurfpoolRpcOptions & { - /** - * The WebSocket URL of the running Surfpool instance. When omitted and - * the `rpcUrl` has an explicit port, defaults to Surfpool's default - * WebSocket port (8900, `--ws-port`) on the same host — Surfpool's - * WebSocket port is independent of its HTTP port. For a `rpcUrl` without - * a port (e.g. behind a proxy), only the protocol is swapped to - * `ws`/`wss`. Set this explicitly when your setup differs. - */ - rpcSubscriptionsUrl?: string; - /** The HTTP RPC URL of a running Surfpool instance to attach to. */ - rpcUrl: string; - surfnet?: never; +export type SurfpoolAttachConfig = SurfpoolAirdropOptions & + SurfpoolRpcOptions & { + /** + * The WebSocket URL of the running Surfpool instance. When omitted and + * the `rpcUrl` has an explicit port, defaults to Surfpool's default + * WebSocket port (8900, `--ws-port`) on the same host — Surfpool's + * WebSocket port is independent of its HTTP port. For a `rpcUrl` without + * a port (e.g. behind a proxy), only the protocol is swapped to + * `ws`/`wss`. Set this explicitly when your setup differs. + */ + rpcSubscriptionsUrl?: string; + /** The HTTP RPC URL of a running Surfpool instance to attach to. */ + rpcUrl: string; + surfnet?: never; + }; + +/** Attach-mode configuration that funds addresses, making the plugin asynchronous. */ +export type SurfpoolAttachConfigWithAirdrop = SurfpoolAttachConfig & { + airdropAddresses: readonly AirdropTarget[]; }; export type SurfpoolConfig = SurfpoolAttachConfig | SurfpoolEmbeddedConfig; +/** + * A `number` above `Number.MAX_SAFE_INTEGER` has already lost precision by the + * time it is read, and a fractional one is not a lamport amount at all. A + * negative amount would debit the address instead of funding it, which is not + * what an airdrop means. An amount beyond `u64::MAX` cannot be represented as a + * lamport balance at all. All four are rejected instead of silently funding + * something other than what was asked for. + */ +function toLamports(amount: bigint | number = DEFAULT_AIRDROP_LAMPORTS): bigint { + if (typeof amount === 'number' && !Number.isSafeInteger(amount)) { + throw new Error(`airdropAmount must be a safe integer or a bigint; received ${amount}`); + } + const lamports = BigInt(amount); + if (lamports < 0n) { + throw new Error(`airdropAmount must not be negative; received ${amount}`); + } + if (lamports > MAX_LAMPORTS) { + throw new Error(`airdropAmount must not exceed ${MAX_LAMPORTS} lamports; received ${amount}`); + } + return lamports; +} + +/** + * Credits each target with `amount` lamports through the `setAccount` + * cheatcode. The cheatcode writes an absolute balance, so the current balance + * is read first and the amount added to it, matching what a real airdrop does + * to an address that already holds lamports. Only the lamport balance is + * written, so an existing account keeps its data and owner. A sum past + * `u64::MAX` is not a representable balance and is rejected before it reaches + * the cheatcode. + */ +async function fundAirdropAddresses( + client: { + cheatcodes: ReturnType; + rpc: { getBalance: (address: Address) => { send: () => Promise<{ value: bigint }> } }; + }, + targets: readonly AirdropTarget[], + amount: bigint, +): Promise { + if (amount === 0n) { + return; + } + // Collapsing aliases to a set of addresses funds each exactly once. + const addresses = new Set(targets.map(target => (typeof target === 'string' ? target : target.address))); + await Promise.all( + [...addresses].map(async address => { + try { + const { value: balance } = await client.rpc.getBalance(address).send(); + const lamports = balance + amount; + if (lamports > MAX_LAMPORTS) { + throw new Error( + `balance ${balance} plus airdropAmount ${amount} exceeds the maximum lamport balance ${MAX_LAMPORTS}`, + ); + } + await client.cheatcodes.setAccount(address, { lamports }).send(); + } catch (error) { + throw new Error(`Failed to airdrop ${amount} lamports to ${address}`, { cause: error }); + } + }), + ); +} + function surfpoolEmbedded(config: SurfpoolEmbeddedConfig = {}) { return async (client: T) => { - const { rpcSubscriptionsUrl: _unusedWs, rpcUrl: _unusedRpc, surfnet: surfnetConfig, ...rpcOptions } = config; + const { + airdropAddresses, + airdropAmount, + rpcSubscriptionsUrl: _unusedWs, + rpcUrl: _unusedRpc, + surfnet: surfnetConfig, + ...rpcOptions + } = config; // Lazy imports keep the optional peers optional: the native module is // only needed in embedded mode, and the signer package is only needed // for the payer this mode installs. @@ -66,6 +173,10 @@ function surfpoolEmbedded(config: SurfpoolEmbeddedConfig = {}) { }), ); + if (airdropAddresses?.length) { + await fundAirdropAddresses(configuredClient, airdropAddresses, toLamports(airdropAmount)); + } + // Disposing the client stops the in-process Surfnet so its servers // and ports are freed; recreating the client boots a fresh one. if (typeof DisposableStack !== 'undefined') { @@ -103,7 +214,14 @@ function surfpoolEmbedded(config: SurfpoolEmbeddedConfig = {}) { function surfpoolAttach(config: SurfpoolAttachConfig) { return (client: T) => { - const { rpcSubscriptionsUrl, rpcUrl, surfnet: _unusedSurfnet, ...rpcOptions } = config; + const { + airdropAddresses: _unusedAirdropAddresses, + airdropAmount: _unusedAirdropAmount, + rpcSubscriptionsUrl, + rpcUrl, + surfnet: _unusedSurfnet, + ...rpcOptions + } = config; const wsUrl = rpcSubscriptionsUrl ?? deriveSubscriptionsUrl(rpcUrl); return pipe( @@ -121,6 +239,15 @@ function surfpoolAttach(config: SurfpoolAttachConfig) { }; } +function surfpoolAttachFunded(config: SurfpoolAttachConfigWithAirdrop) { + const attach = surfpoolAttach(config); + return async (client: T) => { + const configuredClient = attach(client); + await fundAirdropAddresses(configuredClient, config.airdropAddresses, toLamports(config.airdropAmount)); + return configuredClient; + }; +} + /** * Kit plugin for Surfpool. A drop-in replacement for `solanaLocalRpc()` or * `litesvm()` backed by a Surfpool Surfnet. @@ -138,7 +265,10 @@ function surfpoolAttach(config: SurfpoolAttachConfig) { * **Attach mode** (when `rpcUrl` is set): connects to an already-running * Surfpool instance (e.g. `surfpool start`) instead of booting one. No native * module is loaded, no `payer` is installed (the client must already have - * one), and there is no `client.surfnet` handle. + * one), and there is no `client.surfnet` handle. Because that payer is usually + * unfunded on the running Surfnet, `airdropAddresses` credits it (and anything + * else listed) with `airdropAmount` lamports as the client is composed; the + * plugin then returns a promise, so `.use()` must be awaited. * * @example Embedded * ```ts @@ -154,19 +284,29 @@ function surfpoolAttach(config: SurfpoolAttachConfig) { * ```ts * const client = await createClient() * .use(payer(myPayer)) - * .use(surfpool({ rpcUrl: 'http://127.0.0.1:8899' })); + * .use(surfpool({ airdropAddresses: [myPayer], rpcUrl: 'http://127.0.0.1:8899' })); * ``` */ export function surfpool(config?: SurfpoolEmbeddedConfig): ReturnType; -export function surfpool(config: SurfpoolAttachConfig): ReturnType; +export function surfpool(config: SurfpoolAttachConfigWithAirdrop): ReturnType; +export function surfpool( + config: SurfpoolAttachConfig & { airdropAddresses?: never }, +): ReturnType; export function surfpool(config: SurfpoolConfig = {}) { - return isAttachConfig(config) ? surfpoolAttach(config) : surfpoolEmbedded(config); + if (!isAttachConfig(config)) { + return surfpoolEmbedded(config); + } + return hasAirdropAddresses(config) ? surfpoolAttachFunded(config) : surfpoolAttach(config); } function isAttachConfig(config: SurfpoolConfig): config is SurfpoolAttachConfig { return typeof config.rpcUrl === 'string'; } +function hasAirdropAddresses(config: SurfpoolAttachConfig): config is SurfpoolAttachConfigWithAirdrop { + return config.airdropAddresses !== undefined; +} + function deriveSubscriptionsUrl(rpcUrl: string): string { // Surfpool serves WebSocket subscriptions on its own port (default 8900, // `--ws-port`), independent of the HTTP port. A protocol-swapped copy of From bf04d2a02196c23d5908f763a24f8eafe201994f Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 12:47:41 +0300 Subject: [PATCH 30/32] Remove workflow --- .github/workflows/openai-review.yml | 73 ----------------------------- 1 file changed, 73 deletions(-) delete mode 100644 .github/workflows/openai-review.yml diff --git a/.github/workflows/openai-review.yml b/.github/workflows/openai-review.yml deleted file mode 100644 index 07fd8a681..000000000 --- a/.github/workflows/openai-review.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Perform a code review when a pull request is created. -on: - pull_request: - -jobs: - codex: - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - final_message: ${{ steps.run_codex.outputs.final-message }} - steps: - - uses: actions/checkout@v5 - with: - # Explicitly check out the PR's merge commit. - ref: refs/pull/${{ github.event.pull_request.number }}/merge - persist-credentials: false - - - name: Pre-fetch base and head refs for the PR - env: - PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - # Pass GitHub expressions through env and quote shell expansions. - git fetch --no-tags origin \ - "$PR_BASE_REF" \ - "+refs/pull/$PR_NUMBER/head" - - # If you want Codex to build and run code, install any dependencies that - # need to be downloaded before the "Run Codex" step. The recommended - # :workspace permission profile does not grant network access. - - - name: Run Codex - id: run_codex - uses: openai/codex-action@v1 - with: - openai-api-key: ${{ secrets.OPENAI_API_KEY }} - permission-profile: ":workspace" - prompt: | - This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. - - Review ONLY the changes introduced by the PR, so consider: - git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} - - Suggest any improvements, potential bugs, or issues. - Be concise and specific in your feedback. - - Pull request title and body: - ---- - ${{ github.event.pull_request.title }} - ${{ github.event.pull_request.body }} - - post_feedback: - runs-on: ubuntu-latest - needs: codex - if: needs.codex.outputs.final_message != '' - permissions: - issues: write - pull-requests: write - steps: - - name: Report Codex feedback - uses: actions/github-script@v7 - env: - CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }} - with: - github-token: ${{ github.token }} - script: | - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: process.env.CODEX_FINAL_MESSAGE, - }); \ No newline at end of file From b58e57bd32516bca47918372c7e8e462baebdbcb Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 14:35:34 +0300 Subject: [PATCH 31/32] Include the workflow --- .github/{ => workflows}/openai-review.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ => workflows}/openai-review.yml (100%) diff --git a/.github/openai-review.yml b/.github/workflows/openai-review.yml similarity index 100% rename from .github/openai-review.yml rename to .github/workflows/openai-review.yml From a633dbedfec48966dcec6f94267e8812ff48cbbb Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 2 Sep 2026 16:23:41 +0300 Subject: [PATCH 32/32] fix(types): prevent slot encoding truncation above i64 max --- crates/types/src/scenarios.rs | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index cab1fc00e..8c3a52809 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1116,11 +1116,14 @@ impl RawEncoding { .map_err(|e| format!("invalid slot lead: '{d}': {e}"))? } }; - (target_slot as i64) - .saturating_add(lead) - .max(0) - .to_le_bytes() - .to_vec() + let slot = if lead >= 0 { + target_slot.checked_add(lead as u64).ok_or_else(|| { + format!("slot {target_slot} plus lead {lead} exceeds u64::MAX") + })? + } else { + target_slot.checked_sub(lead.unsigned_abs()).unwrap_or(0) + }; + slot.to_le_bytes().to_vec() } }) } @@ -1580,6 +1583,28 @@ mod tests { .encode(&json!(-10), 3) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 0); + + // Slot is a u64. Values above i64::MAX must not wrap through a signed cast and become zero. + let large_slot = i64::MAX as u64 + 1; + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(0), large_slot) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), large_slot); + + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(-1), u64::MAX) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX - 1); + + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(0), u64::MAX) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX); + + let err = RawEncoding::Slot { lead: 0 } + .encode(&json!(1), u64::MAX) + .expect_err("a positive lead must not wrap past u64::MAX"); + assert!(err.contains("exceeds u64::MAX"), "unexpected error: {err}"); } #[test]