From 982f031db15daab740c9a528fc7c0363f86e056c Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 13 Aug 2026 11:51:57 +0300 Subject: [PATCH 01/35] 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/35] 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/35] 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/35] =?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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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] From d1454dd3e138ef55a97eae920baedae868fc9ff9 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 9 Sep 2026 16:08:01 +0300 Subject: [PATCH 33/35] feat(scenarios): add GoonFi state preparation GoonFi is a two-account dark AMM with no IDL: a 32-byte external price oracle plus a 2048-byte market carrying the reference band that guards it. - Four raw-layout templates over the two accounts (price, reference band, freshness, stale quote), plus a live market catalog read from program accounts. - Two builders. Price moves the oracle bid and ask together with the market's reference band and a persistent freshness stamp, because the deployed program rejects a decoupled move with 0x24. Liquidity resolves both token vaults from the market's own pointers and scales their balances. - Three MCP tools on the generic scenario path: list_goonfi_markets, create_goonfi_price_scenario and create_goonfi_liquidity_scenario. No protocol HTTP endpoints. - The Slot raw encoding now takes a width, so the 4-byte freshness field no longer clobbers the decay multiplier stored beside it. - Live gated tests against the deployed program. The shared live-test helper duplicates the Tessera branch's on purpose; they fold together when the branches converge. --- Cargo.lock | 3 + crates/core/Cargo.toml | 2 + .../src/scenarios/protocols/goonfi/README.md | 139 +++ .../src/scenarios/protocols/goonfi/mod.rs | 1 + .../protocols/goonfi/v1/liquidity.rs | 417 +++++++ .../protocols/goonfi/v1/market_overrides.yaml | 55 + .../scenarios/protocols/goonfi/v1/markets.rs | 284 +++++ .../src/scenarios/protocols/goonfi/v1/mod.rs | 15 + .../protocols/goonfi/v1/oracle_overrides.yaml | 130 ++ .../scenarios/protocols/goonfi/v1/price.rs | 447 +++++++ crates/core/src/scenarios/protocols/mod.rs | 1 + crates/core/src/scenarios/registry.rs | 25 +- crates/core/src/surfnet/svm.rs | 11 +- crates/core/src/tests/goonfi/mod.rs | 1110 +++++++++++++++++ crates/core/src/tests/live.rs | 68 + crates/core/src/tests/mod.rs | 4 + crates/mcp/Cargo.toml | 2 + crates/mcp/src/surfpool/mod.rs | 256 +++- crates/types/src/scenarios.rs | 134 +- 19 files changed, 3084 insertions(+), 20 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/goonfi/README.md create mode 100644 crates/core/src/scenarios/protocols/goonfi/mod.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/markets.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/mod.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/price.rs create mode 100644 crates/core/src/tests/goonfi/mod.rs create mode 100644 crates/core/src/tests/live.rs diff --git a/Cargo.lock b/Cargo.lock index 141bce739..eda85fc42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12154,6 +12154,7 @@ dependencies = [ "solana-packet", "solana-program-option 3.1.0", "solana-program-pack 3.1.0", + "solana-program-runtime", "solana-pubkey 3.0.0", "solana-pubsub-client", "solana-rpc-client", @@ -12218,6 +12219,8 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "solana-account 4.3.1", + "solana-commitment-config", "solana-keypair", "solana-pubkey 3.0.0", "solana-signer", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 53f510da0..36631ffb7 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -115,6 +115,8 @@ axum = { version = "0.8", default-features = false, features = ["tokio", "http1" [dev-dependencies] ed25519-dalek = "1.0.1" +# Only the GoonFi live suite uses it, to host the CPI wrapper that drives the deployed program. +solana-program-runtime = "4.1.2" libsecp256k1 = "0.7.2" p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } test-case = { workspace = true } diff --git a/crates/core/src/scenarios/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md new file mode 100644 index 000000000..74a7a134b --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -0,0 +1,139 @@ +# GoonFi + +GoonFi V2 uses raw account layouts rather than an IDL. Each market points to a 32-byte +oracle owned by a companion publisher program. The oracle stores bid/ask prices; the +market stores the reference prices that guard them. Surfpool prepares these accounts +before a user runs a strategy. Product scenarios do not construct or submit swaps. + +## Pinned deployment + +The live tests in `crates/core/src/tests/goonfi/mod.rs` check these ProgramData sizes, +deployment slots and ELF hashes before replaying the program: + +| | Trading program | Oracle publisher | +|---|---|---| +| Program | `goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE` | `dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu` | +| ProgramData | `124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s` | `7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z` | +| ProgramData bytes | 252,429 | 557 | +| Deployment slot | 438563879 | 404369628 | +| ELF SHA-256 | `73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e` | `0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695` | + +A deployment change requires revalidation. These are test pins, not an upgrade-monitoring +service or a claim that every future deployment has the same layout. + +## Layouts and templates + +A market is 2048 bytes with magic `30 bc 2f 35 34 58 32 9a` at offset 0. Its base/quote +mints are at offsets 80/112, vaults at 144/176, and oracle pointer at 208. The oracle is +32 bytes with no discriminator. Both YAML layouts declare their expected program owner; +the shared materializer checks ownership before writing, then validates size, optional +magic bytes and write bounds. A failed owner check skips the override with a warning. + +| Template | Account | Fields | +|---|---|---| +| `goonfi-price` | Oracle | Bid and ask, u64 at offsets 0 and 8 | +| `goonfi-stale-quote` | Oracle | Freshness slot, u32 at offset 16; default lead -2000 | +| `goonfi-freshness` | Oracle | Freshness slot, u32 at offset 16; default lead 0 | +| `goonfi-reference-band` | Market | Reference prices, u64 at offsets 1712 and 1720 | + +Prices use the human pair price multiplied by `10^6`, independent of mint decimals. +For example, 99.74 quote tokens per base token becomes the integer string `"99740000"`. +Use strings for u64 price values to preserve precision in JSON and Studio. + +Slot templates write exactly four bytes. The u32 multiplier at offset 20 and the +millisecond timestamp at offset 24 remain untouched. A slot value of `null` selects the +template's default lead; an integer specifies a lead relative to the materialization +slot. The resulting slot must fit u32. + +## Catalog and price scenario + +The backend exposes three GoonFi MCP tools: + +- `list_goonfi_markets` discovers program accounts and validates market, oracle and mint + relationships. It returns market/oracle addresses, labels, mint addresses and decimals. + The YAML files contain no market catalog, and discovery does not require a fixed count. +- `create_goonfi_price_scenario` accepts a market address and a positive human price with + up to six decimal places. It resolves the oracle from the market account, validates + both accounts, and composes three overrides: equal oracle bid/ask, equal market reference + prices, and persistent freshness. An omitted market selects the default SOL/USDC market. +- `create_goonfi_liquidity_scenario` accepts a market address and per-vault remaining basis + points. It resolves both token vaults from the market's own pointers (offsets 144 and + 176), reads each current balance, validates the vault and oracle owners, and scales each + vault through `spl-token-account-balance`: 0 drains a vault so a swap rejects with `0x1`, + 10000 leaves it unchanged. A persistent freshness override keeps the rejection about + liquidity rather than a stale quote. Both default to 0; an omitted market selects the + default SOL/USDC market. + +These tools accept optional `surfnet_port`, defaulting to 8899, and read through the local +Surfnet RPC. Missing accounts fall back to that Surfnet's datasource. The price tool +stages through the shared Studio scenario API; Play registers the scenario. + +Studio's PMM fair-value dialog selects a protocol, a live market and a human price. It +calls these tools through Studio MCP without forwarding `rpcUrl` or `surfnet_port`, +matching the Tessera dialog convention. Consequently, these Studio GoonFi calls use the +backend's default RPC port. Studio retains only each catalog entry's market address and +label; the backend resolves the oracle when creating a price scenario. + +The price builder does not set `fetchBeforeUse`: the accounts read at creation retain +local edits, and only the specified fields are changed. Freshness uses `persist: true` +to stamp each subsequent materialization slot. These settings do not establish +transactional atomicity across all overrides in a scenario. + +## Composing other prepared states + +The four templates remain available through the generic scenario editor and AI flow. +There are no dedicated GoonFi spread or delayed-event builders. + +For a stale quote, target the oracle returned by `list_goonfi_markets` with +`goonfi-stale-quote`. Do not run a persistent freshness override over the same interval: +it would erase the stale state. Recovery can use `goonfi-freshness` at a later relative +slot. The Studio AI chip requests a stale-quote scenario through this generic flow. + +For depletion, `create_goonfi_liquidity_scenario` resolves the vaults from the market and +scales each balance for you; the AI chip calls it directly. Composing the same by hand +means reading the selected vault address from market offset 144 or 176, checking its token +program, and using `spl-token-account-balance` with an absolute amount, applied once. The +override does not recalculate percentages at execution time. + +## Behavioral verification + +The live suite fetches deployed account data and runs the pinned trading ELF in LiteSVM, +using a builtin wrapper for the Jupiter-shaped CPI. It checks: + +- Unchanged encoding produces the same fill; coupled price/reference changes alter output. +- Raising only the bid or lowering only the ask rejects with `0x24` (reference-band guard). +- Quotes decay with slot age and eventually reject with `0x15`. Changing the multiplier + changes decay in the tested fixture; stamping the slot restores freshness. Changing + the wall-clock timestamp alone does not change the tested fill. +- An impossible minimum output rejects with `0xf`. +- A successful sell still fills with exactly enough quote inventory. One atomic unit less + or an empty quote vault rejects with `0x1`, with the trade input held constant. +- The price builder's three overrides register and materialize through the production + path on two markets, preserving unrelated bytes and refreshing the u32 slot afterwards. +- Live discovery returns valid market/oracle relationships without a fixed catalog count. + +Behavior fixtures fund local vaults to at least 10,000 whole tokens and retain wrapped SOL +backing. This isolates price, ageing and inventory changes from fluctuating live liquidity; +it does not prove that the same trade currently has sufficient mainnet liquidity. Layout +and discovery checks use unfunded fetched accounts. Owner-predicate unit tests live in +`crates/types/src/scenarios.rs`. This suite does not provide a `pmm-sim` differential run +or a Studio browser test. + +Run all GoonFi unit and live checks serially: + +```bash +SURFPOOL_TEST_RPC_URL= cargo test -p surfpool-core --features integration-tests \ + goonfi -- --test-threads=1 --nocapture +``` + +The RPC variable is optional and defaults to the public mainnet endpoint. A private endpoint +can avoid public RPC rate limits. Re-run after a program upgrade or account-layout change. + +## Known boundaries + +The staleness window's on-chain source and exact decay formula remain unidentified. +Observed windows vary by market and time; historical slot ages are not fixed protocol +limits. The global account and other market fields are forked without assigned override +semantics. No enable/disable field is exposed. Direct top-level swaps are not covered by +the CPI replay, and the exact tolerance of the reference-band guard is not established +by these tests. diff --git a/crates/core/src/scenarios/protocols/goonfi/mod.rs b/crates/core/src/scenarios/protocols/goonfi/mod.rs new file mode 100644 index 000000000..a3a6d96c3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/mod.rs @@ -0,0 +1 @@ +pub mod v1; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs new file mode 100644 index 000000000..adf3f58bd --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -0,0 +1,417 @@ +//! GoonFi liquidity state preparation. +//! +//! A market draws liquidity from two SPL token vaults whose addresses live in the market account +//! at fixed offsets. Unlike price or depth, the balances are not in the protocol account itself but +//! in those separate token accounts, so this scales each vault through the generic +//! `spl-token-account-balance` template. Draining a vault to zero makes the deployed program reject +//! a swap with custom error 0x1; a fresh re-stamp keeps that rejection about liquidity and not a +//! stale quote. + +use std::collections::HashMap; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, Scenario}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, +}; + +use super::{ + GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, +}; + +/// Read, never written, so no template declares them. +const BASE_MINT_OFFSET: usize = 80; +const QUOTE_MINT_OFFSET: usize = 112; +const BASE_VAULT_OFFSET: usize = 144; +const QUOTE_VAULT_OFFSET: usize = 176; +/// The SPL token account amount field. +const AMOUNT_OFFSET: usize = 64; + +const LIQUIDITY_TEMPLATE: &str = "spl-token-account-balance"; +const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; + +/// Both overrides apply on Play, before any slot advance. +const PREPARATION_SLOT: u64 = 0; + +/// 10000 basis points leaves a vault untouched; 0 drains it. +const FULL_BPS: u16 = 10_000; + +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiLiquidityPreparation { + pub scenario: Scenario, + pub market: Pubkey, + pub base_vault: Pubkey, + pub quote_vault: Pubkey, + pub base_amount: u64, + pub quote_amount: u64, +} + +/// The two SPL token vaults a market draws liquidity from, read from the market's own pointers. +/// +/// Validates the market first: the shared raw-layout guard has no owner predicate, so the owner +/// check in `validate_goonfi_market_layout` is what keeps these offsets pointed at a real market. +pub fn vault_addresses(market_account: &Account) -> SurfpoolResult<[Pubkey; 2]> { + validate_goonfi_market_layout(market_account)?; + let base = read_pubkey(&market_account.data, BASE_VAULT_OFFSET)?; + let quote = read_pubkey(&market_account.data, QUOTE_VAULT_OFFSET)?; + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(invalid("market carries invalid vault pointers")); + } + Ok([base, quote]) +} + +/// Scales each vault balance to the requested basis points and keeps the quote fresh. +/// +/// `market_account` is the source of truth for the vault and oracle addresses; the three passed +/// accounts are the base vault, quote vault and oracle the caller fetched by those addresses, in +/// that order. A side left at 10000 bps is untouched and gets no override. +pub fn build_goonfi_liquidity_scenario( + market: Pubkey, + market_account: &Account, + base_vault_account: &Account, + quote_vault_account: &Account, + oracle_account: &Account, + base_remaining_bps: u16, + quote_remaining_bps: u16, +) -> SurfpoolResult { + if [base_remaining_bps, quote_remaining_bps] + .iter() + .any(|bps| *bps > FULL_BPS) + { + return Err(invalid( + "remaining liquidity must be 0..=10000 basis points; 0 drains a vault, 10000 leaves it unchanged", + )); + } + if base_remaining_bps == FULL_BPS && quote_remaining_bps == FULL_BPS { + return Err(invalid( + "both vaults left unchanged; set a lower basis point value to drain at least one side", + )); + } + + let [base_vault, quote_vault] = vault_addresses(market_account)?; + let oracle = GoonfiMarket::oracle_address(market_account)?; + validate_goonfi_oracle_layout(oracle_account)?; + + let base_amount = vault_amount(base_vault_account)?; + let quote_amount = vault_amount(quote_vault_account)?; + + let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; + let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; + let label = market_label(&base_mint, "e_mint); + + let registry = TemplateRegistry::new(); + let liquidity = template(®istry, LIQUIDITY_TEMPLATE)?; + + let mut scenario = Scenario::new( + format!("GoonFi {label} liquidity drain"), + format!( + "Prepare GoonFi {label} market ({market}) vaults to {} of base and {} of quote liquidity; no swap is sent.", + remaining_label(base_remaining_bps), + remaining_label(quote_remaining_bps) + ), + ); + scenario.tags = vec![ + "goonfi".to_string(), + "pmm".to_string(), + "liquidity-drain".to_string(), + ]; + + for (side, vault, current, bps) in [ + ("base", base_vault, base_amount, base_remaining_bps), + ("quote", quote_vault, quote_amount, quote_remaining_bps), + ] { + if bps == FULL_BPS { + continue; + } + let scaled = (u128::from(current) * u128::from(bps) / u128::from(FULL_BPS)) as u64; + scenario.add_override( + OverrideInstance::new( + liquidity.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(vault.to_string()), + ) + .with_values(HashMap::from([( + "amount".to_string(), + serde_json::json!(scaled.to_string()), + )])) + .with_label(format!("Drain GoonFi {side} vault")), + ); + } + + // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null keeps the + // template's own lead of zero. Persisted so the quote stays inside the staleness window and the + // swap the drained state is proven against is rejected for liquidity (0x1), not a stale quote. + scenario.add_override( + OverrideInstance::new( + FRESHNESS_TEMPLATE.to_string(), + PREPARATION_SLOT, + AccountAddress::Pubkey(oracle.to_string()), + ) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep GoonFi quote fresh".to_string()) + .with_persist(true), + ); + + Ok(GoonfiLiquidityPreparation { + scenario, + market, + base_vault, + quote_vault, + base_amount, + quote_amount, + }) +} + +/// The SPL token vaults are 32 undiscriminated-looking bytes at the front; the owner check is the +/// real discriminator that keeps a balance write out of a foreign account. +fn vault_amount(account: &Account) -> SurfpoolResult { + if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { + return Err(invalid("vault is not owned by a supported token program")); + } + let bytes: [u8; 8] = account + .data + .get(AMOUNT_OFFSET..AMOUNT_OFFSET + 8) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid("vault is too small to be an SPL token account"))?; + Ok(u64::from_le_bytes(bytes)) +} + +fn remaining_label(bps: u16) -> String { + format!("{}.{:02}%", bps / 100, bps % 100) +} + +fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { + let bytes: [u8; 32] = data + .get(offset..offset + 32) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid("market vault bytes are truncated"))?; + Ok(Pubkey::new_from_array(bytes)) +} + +fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) +} + +fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenarios::protocols::goonfi::v1::{GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID}; + + const FIXTURE_ORACLE: Pubkey = + Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); + const WSOL: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + const USDC: Pubkey = Pubkey::from_str_const("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + + fn market_account(base_vault: &Pubkey, quote_vault: &Pubkey) -> Account { + let mut data = vec![0u8; 2048]; + // Magic tag every live market shares. + data[0..8].copy_from_slice(&[48, 188, 47, 53, 52, 88, 50, 154]); + data[BASE_MINT_OFFSET..BASE_MINT_OFFSET + 32].copy_from_slice(WSOL.as_ref()); + data[QUOTE_MINT_OFFSET..QUOTE_MINT_OFFSET + 32].copy_from_slice(USDC.as_ref()); + data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].copy_from_slice(base_vault.as_ref()); + data[QUOTE_VAULT_OFFSET..QUOTE_VAULT_OFFSET + 32].copy_from_slice(quote_vault.as_ref()); + data[208..240].copy_from_slice(FIXTURE_ORACLE.as_ref()); + Account { + data, + owner: GOONFI_PROGRAM_ID, + ..Account::default() + } + } + + fn vault(amount: u64) -> Account { + let mut data = vec![0u8; 165]; + data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); + Account { + data, + owner: spl_token_interface::ID, + ..Account::default() + } + } + + fn oracle() -> Account { + Account { + data: vec![0u8; 32], + owner: GOONFI_ORACLE_PROGRAM_ID, + ..Account::default() + } + } + + #[test] + fn drains_both_vaults_and_keeps_the_quote_fresh() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let market = Pubkey::new_unique(); + let preparation = build_goonfi_liquidity_scenario( + market, + &market_account(&base_vault, "e_vault), + &vault(2_441_078_070_812), + &vault(216_136_231_615), + &oracle(), + 0, + 0, + ) + .unwrap(); + + assert_eq!(preparation.base_vault, base_vault); + assert_eq!(preparation.quote_vault, quote_vault); + // A friendly pair label, not the raw market pubkey. + assert_eq!( + preparation.scenario.name, + "GoonFi SOL/USDC liquidity drain" + ); + let [base, quote, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected base drain, quote drain and freshness overrides"); + }; + assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + assert_eq!(quote.account, AccountAddress::Pubkey(quote_vault.to_string())); + assert_eq!(base.values.get("amount"), Some(&serde_json::json!("0"))); + assert_eq!(quote.values.get("amount"), Some(&serde_json::json!("0"))); + assert!(!base.fetch_before_use); + assert!(!base.persist); + assert_eq!( + freshness.account, + AccountAddress::Pubkey(FIXTURE_ORACLE.to_string()) + ); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } + + #[test] + fn scales_partially_and_skips_an_unchanged_side() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let preparation = build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market_account(&base_vault, "e_vault), + &vault(1_000), + &vault(999), + &oracle(), + 2_500, + FULL_BPS, + ) + .unwrap(); + + let [base, freshness] = &preparation.scenario.overrides[..] else { + panic!("the unchanged quote side must not get an override"); + }; + assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + // 1000 * 2500 / 10000, exact integer arithmetic. + assert_eq!(base.values.get("amount"), Some(&serde_json::json!("250"))); + assert_eq!(freshness.values.len(), 1); + } + + #[test] + fn rejects_bad_basis_points_and_accounts() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let good_market = market_account(&base_vault, "e_vault); + + // Out of range and a no-op leave nothing to prepare. + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(1), + &vault(1), + &oracle(), + 10_001, + 0 + ) + .is_err() + ); + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(1), + &vault(1), + &oracle(), + FULL_BPS, + FULL_BPS + ) + .is_err() + ); + + // A foreign account of the same size passes the raw guard, so the owner check must reject. + let foreign_market = Account { + owner: Pubkey::new_unique(), + ..market_account(&base_vault, "e_vault) + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &foreign_market, + &vault(1), + &vault(1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A vault not owned by a token program is not a real vault. + let foreign_vault = Account { + owner: Pubkey::new_unique(), + ..vault(1) + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &foreign_vault, + &vault(1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A foreign oracle carries no magic, so its owner is the only discriminator. + let foreign_oracle = Account { + owner: Pubkey::new_unique(), + ..oracle() + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(1), + &vault(1), + &foreign_oracle, + 0, + 0 + ) + .is_err() + ); + } + + #[test] + fn resolves_vault_addresses_from_the_market() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let [base, quote] = vault_addresses(&market_account(&base_vault, "e_vault)).unwrap(); + assert_eq!(base, base_vault); + assert_eq!(quote, quote_vault); + + let mut zero_pointer = market_account(&base_vault, "e_vault); + zero_pointer.data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].fill(0); + assert!(vault_addresses(&zero_pointer).is_err()); + } +} diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml new file mode 100644 index 000000000..6a218ccfa --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml @@ -0,0 +1,55 @@ +protocol: GoonFi +version: deployed-438563879 +account_type: MarketState + +# The write target here is the market account itself: 2048 bytes owned by the GoonFi program, +# tagged by the 8 magic bytes every live market shares. Mints, vaults and the oracle pointer live +# in cleartext at fixed offsets; the only fields a product flow writes are the two reference +# prices the deployed program uses as an anti-manipulation band around the oracle. +raw_layout: + account_size: 2048 + owner: goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE + magic: + offset: 0 + bytes: [48, 188, 47, 53, 52, 88, 50, 154] + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: goonfi-reference-band + name: Override GoonFi Reference Band + description: Move the market's reference prices that band-guard the oracle + idl_account_name: MarketState + address: + type: pubkey + value: GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC + properties: + - path: reference_price_a_x1e6 + offset: 1712 + encoding: u64 + label: Reference price A + description: "First reference anchor, human pair price times 10^6. Same scale as the oracle's bid and ask." + - path: reference_price_b_x1e6 + offset: 1720 + encoding: u64 + label: Reference price B + description: "Second reference anchor, human pair price times 10^6. The pair's order is not fixed; scale both by the same factor." + llm_context: | + SET BOTH FIELDS AS ONE INVARIANT, scaled by the same factor as the oracle price move they + accompany. The deployed program rejects a swap with custom error 0x24 when the oracle + price it is about to use falls outside the band these two anchors define, in the + direction unfavorable to the venue: a raised bid blocks sells, a lowered ask blocks buys. + + This template exists as the second half of goonfi-price: apply both to shift a market's + price beyond a fraction of a percent. Use the market address returned by + list_goonfi_markets; its oracle field identifies the paired price account. The + GoonFi price builder composes the pair (plus freshness) automatically; composing by hand + and skipping either account breaks the invariant with error 0x24. + + Set fetchBeforeUse: true so the live market is forked before your reference prices apply; on + a fresh fork the account is not local yet, and an override on a missing account is skipped. + Use false only for a later override that builds on state an earlier override prepared in the + same scenario. diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs new file mode 100644 index 000000000..e4f4a6ba3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -0,0 +1,284 @@ +use std::collections::HashMap; + +use solana_account::Account; +use solana_account_decoder::UiAccountEncoding; +use solana_client::{ + rpc_config::RpcAccountInfoConfig, + rpc_filter::{Memcmp, RpcFilterType}, +}; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; +use surfpool_types::VERIFIED_TOKENS_BY_SYMBOL; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, + surfnet::remote::SurfnetRemoteClient, + types::MintAccount, +}; + +use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket}; + +#[derive(Debug, PartialEq)] +pub struct GoonfiDiscoveredMarket { + pub address: Pubkey, + pub oracle: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub base_decimals: u8, + pub quote_decimals: u8, +} + +impl GoonfiDiscoveredMarket { + pub fn label(&self) -> String { + market_label(&self.base_mint, &self.quote_mint) + } +} + +/// A human pair label from the two mints, e.g. "SOL/USDC". Falls back to a mint's full address +/// when it is not in the verified token list, so an unknown pair is still uniquely named. +pub fn market_label(base_mint: &Pubkey, quote_mint: &Pubkey) -> String { + let symbol = |mint: &Pubkey| { + let address = mint.to_string(); + VERIFIED_TOKENS_BY_SYMBOL + .values() + .filter(|token| token.address == address) + .map(|token| token.symbol.as_str()) + .min() + .map(str::to_string) + .unwrap_or(address) + }; + format!("{}/{}", symbol(base_mint), symbol(quote_mint)) +} + +fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 3]> { + let oracle = GoonfiMarket::oracle_address(account)?; + let base = Pubkey::new_from_array(account.data[80..112].try_into().unwrap()); + let quote = Pubkey::new_from_array(account.data[112..144].try_into().unwrap()); + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(SurfpoolError::internal( + "GoonFi market has invalid mint identities", + )); + } + Ok([base, quote, oracle]) +} + +fn mint_decimals(account: &Account) -> SurfpoolResult { + if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { + return Err(SurfpoolError::internal( + "GoonFi mint is not owned by a supported token program", + )); + } + Ok(MintAccount::unpack(&account.data)?.decimals()) +} + +fn resolve_market( + address: Pubkey, + account: &Account, + references: &HashMap, +) -> SurfpoolResult { + let [base, quote, oracle] = market_references(account)?; + let required = |address: &Pubkey| { + references.get(address).ok_or_else(|| { + SurfpoolError::internal(format!("GoonFi referenced account {address} was not found")) + }) + }; + GoonfiMarket::validate(address, account, required(&oracle)?)?; + Ok(GoonfiDiscoveredMarket { + address, + oracle, + base_mint: base, + quote_mint: quote, + base_decimals: mint_decimals(required(&base)?)?, + quote_decimals: mint_decimals(required("e)?)?, + }) +} + +pub async fn discover_goonfi_markets( + client: &SurfnetRemoteClient, +) -> SurfpoolResult> { + let registry = TemplateRegistry::new(); + let layout = registry + .get("goonfi-reference-band") + .and_then(|template| template.raw_layout.as_ref()) + .ok_or_else(|| SurfpoolError::internal("GoonFi market layout is unavailable"))?; + let mut filters = vec![RpcFilterType::DataSize(layout.account_size as u64)]; + if let Some(magic) = &layout.magic { + filters.push(RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + magic.offset, + magic.bytes.clone(), + ))); + } + let accounts = client + .get_program_accounts( + &GOONFI_PROGRAM_ID, + RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + commitment: Some(CommitmentConfig::confirmed()), + ..Default::default() + }, + Some(filters), + ) + .await? + .into_result()?; + let accounts = accounts + .into_iter() + .map(|(address, encoded)| { + let account: Account = encoded.to_account().ok_or_else(|| { + SurfpoolError::internal(format!("Could not decode GoonFi market {address}")) + })?; + market_references(&account)?; + Ok((address, account)) + }) + .collect::>>()?; + let mut addresses = Vec::new(); + for (_, account) in &accounts { + addresses.extend(market_references(account)?); + } + addresses.sort_unstable(); + addresses.dedup(); + let mut references = HashMap::new(); + for batch in addresses.chunks(100) { + let fetched = client + .get_multiple_accounts(batch, CommitmentConfig::confirmed()) + .await?; + for (address, account) in batch.iter().zip(fetched) { + references.insert(*address, account.map_account()?); + } + } + let mut markets = accounts + .iter() + .map(|(address, account)| resolve_market(*address, account, &references)) + .collect::>>()?; + markets.sort_by_cached_key(|market| { + ( + market.address != GOONFI_DEFAULT_MARKET, + market.label(), + market.address, + ) + }); + Ok(markets) +} + +#[cfg(test)] +mod tests { + use solana_program_pack::Pack; + + use super::*; + use crate::scenarios::protocols::goonfi::v1::GOONFI_ORACLE_PROGRAM_ID; + + fn fixture() -> (Pubkey, Account, HashMap) { + let address = Pubkey::new_unique(); + let base = Pubkey::new_unique(); + let quote = Pubkey::new_unique(); + let oracle = Pubkey::new_unique(); + let registry = TemplateRegistry::new(); + let layout = registry + .get("goonfi-reference-band") + .unwrap() + .raw_layout + .as_ref() + .unwrap(); + let mut market = Account { + owner: GOONFI_PROGRAM_ID, + data: vec![0; layout.account_size], + ..Account::default() + }; + let magic = layout.magic.as_ref().unwrap(); + market.data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + market.data[80..112].copy_from_slice(base.as_ref()); + market.data[112..144].copy_from_slice(quote.as_ref()); + market.data[208..240].copy_from_slice(oracle.as_ref()); + let mint = |decimals| { + let mut account = Account { + owner: spl_token_interface::ID, + data: vec![0; spl_token_interface::state::Mint::LEN], + ..Account::default() + }; + spl_token_interface::state::Mint { + decimals, + is_initialized: true, + ..Default::default() + } + .pack_into_slice(&mut account.data); + account + }; + ( + address, + market, + HashMap::from([ + (base, mint(9)), + (quote, mint(6)), + ( + oracle, + Account { + owner: GOONFI_ORACLE_PROGRAM_ID, + data: vec![0; 32], + ..Account::default() + }, + ), + ]), + ) + } + + #[test] + fn goonfi_discovery_accepts_uncataloged_markets_and_preserves_mint_identity() { + let (address, account, references) = fixture(); + let result = resolve_market(address, &account, &references).unwrap(); + assert_eq!(result.address, address); + assert_eq!((result.base_decimals, result.quote_decimals), (9, 6)); + assert_eq!( + result.label(), + format!("{}/{}", result.base_mint, result.quote_mint) + ); + assert_eq!(result.oracle, market_references(&account).unwrap()[2]); + } + + #[test] + fn goonfi_discovery_rejects_invalid_market_layouts_and_mint_identities() { + let (_, account, _) = fixture(); + for invalid in 0..5 { + let mut account = account.clone(); + match invalid { + 0 => account.owner = Pubkey::new_unique(), + 1 => { + account.data.pop(); + } + 2 => account.data[0] ^= 1, + 3 => account.data[80..112].fill(0), + _ => { + let base = account.data[80..112].to_vec(); + account.data[112..144].copy_from_slice(&base); + } + } + assert!( + market_references(&account).is_err(), + "invalid case {invalid}" + ); + } + } + + #[test] + fn goonfi_discovery_rejects_missing_or_invalid_referenced_accounts() { + let (address, account, references) = fixture(); + let [base, _, oracle] = market_references(&account).unwrap(); + for invalid in 0..5 { + let mut references = references.clone(); + match invalid { + 0 => { + references.remove(&oracle); + } + 1 => references.get_mut(&oracle).unwrap().owner = Pubkey::new_unique(), + 2 => { + references.get_mut(&oracle).unwrap().data.pop(); + } + 3 => references.get_mut(&base).unwrap().owner = Pubkey::new_unique(), + _ => references.get_mut(&base).unwrap().data.fill(0), + } + assert!( + resolve_market(address, &account, &references).is_err(), + "invalid case {invalid}" + ); + } + } +} diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs new file mode 100644 index 000000000..c788b126d --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -0,0 +1,15 @@ +mod liquidity; +mod markets; +mod price; + +pub use liquidity::{ + GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses, +}; + +pub use price::{ + GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, + GoonfiPricePreparation, build_goonfi_price_scenario, validate_goonfi_market_layout, + validate_goonfi_oracle_layout, +}; + +pub use markets::{GoonfiDiscoveredMarket, discover_goonfi_markets, market_label}; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml new file mode 100644 index 000000000..70ca05004 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml @@ -0,0 +1,130 @@ +protocol: GoonFi +version: deployed-438563879 +account_type: PriceOracle + +# The write target of every template here is the market's price-oracle account: 32 bytes owned by +# the companion publisher program. It carries no discriminator, so the byte guard can only pin +# the size; the owner predicate below is what keeps a raw write out of a foreign 32-byte account, +# and the builder additionally resolves the oracle through the market account's own pointer. +raw_layout: + account_size: 32 + owner: dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: goonfi-price + name: Override GoonFi Price + description: Move a GoonFi market's oracle bid and ask atomically in both directions + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: bid_price_x1e6 + offset: 0 + encoding: u64 + label: Bid price + description: "The human pair price the venue buys base at, multiplied by 10^6. Independent of mint decimals." + - path: ask_price_x1e6 + offset: 8 + encoding: u64 + label: Ask price + description: "The human pair price the venue sells base at, multiplied by 10^6. Live oracles publish it at or above the bid; keep that shape." + llm_context: | + SET BOTH FIELDS AS ONE INVARIANT, with ask >= bid. Values are the human pair price times + 10^6 regardless of mint decimals: SOL at 99.74 USDC is bid_price_x1e6 "99740000". Use + decimal integer strings, not JSON numbers. + + THE PRICE IS BAND-GUARDED ACROSS TWO ACCOUNTS. The deployed program rejects a swap with + custom error 0x24 when the oracle price sits outside the reference band stored in the + market account - a decoupled move of even 5% is proven to reject, and the live oracle + tracks its band within a fraction of a percent. Always apply goonfi-reference-band to the + market address returned by list_goonfi_markets, scaled by the same factor. The GoonFi price + builder composes both overrides plus freshness automatically; composing the raw templates + by hand and skipping one of them breaks the invariant. + + Set fetchBeforeUse: true so the live oracle is forked before your bid and ask apply; on a + fresh fork the account is not local yet, and an override on a missing account is skipped. + Use false only for a later override that builds on state an earlier override prepared in the + same scenario. + + GoonFi rejects a quote whose oracle is past its staleness window with custom error 0x15. + Pair long-running scenarios with goonfi-freshness. + + - id: goonfi-stale-quote + name: Make GoonFi Quote Stale + description: Age a GoonFi oracle past its rejection window + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: last_update_slot + offset: 16 + encoding: + slot: + lead: -2000 + width: 4 + label: Slot lead + description: >- + How far behind the materialization slot to place the oracle's update slot, as a + negative integer. Pass null to use -2000, past every observed window including the + stablecoin tier's. + llm_context: | + The value you pass IS the lead: Surfpool writes the materialization slot plus it, clamped + at zero. Pass null to take the -2000 default. The slot field is 4 bytes; the dynamic + multiplier stored beside it stays untouched. + + The rejection window is per-market and publisher-adjustable: ages 16 and 21 were observed + on volatile pairs and windows of a few hundred slots on stablecoin pairs, all on one day. + These observations are not fixed limits. Inside the window the deployed program decays + the quote with age - + faster the higher the oracle's multiplier at offset 20 - before rejecting outright with + custom error 0x15, so a small negative lead prepares a degraded-but-fillable quote and + the -2000 default prepares a rejected one on every observed market. + + Do not persist this override: the quote should stay stale. For a standalone stale quote on a + fresh fork, set fetchBeforeUse: true so the live oracle is forked before the ageing applies; + an override on an account not yet local is skipped. In a lifecycle where an earlier + goonfi-freshness override already forked and edited the oracle locally, use false so this + override does not refetch remote bytes over that local edit: refresh once at slot zero, age + at the requested relative slot, then optionally refresh with persist at a later recovery + slot. The initial refresh must not persist or it will erase the stale event. Before a + delayed event, the initial quote naturally ages. Keep override labels short ("SOL/USDC stale + quote"). + + - id: goonfi-freshness + name: Refresh GoonFi Quote + description: Publish the materialization slot into the oracle's freshness field + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: last_update_slot + offset: 16 + encoding: + slot: + lead: 0 + width: 4 + label: Current materialization slot + description: Slot lead, as an integer. Pass null to take the lead of zero and write the materialization slot itself. + llm_context: | + GoonFi rejects a swap whose oracle has aged past its per-market window with custom error + 0x15 - a loud failure, unlike BisonFi's silent zero - and decays the quote with age before + that, at a rate proportional to the oracle's multiplier at offset 20. Re-stamping this + field alone restores the full quote; the market account's own slot fields do not gate + freshness. + + Pass null for last_update_slot to take this template's lead of zero, which writes the + exact materialization slot. A number would be read as the lead instead. Use persist: true + when the prepared state must remain executable beyond the window; each application then + writes its own slot. + + Set fetchBeforeUse: true when this is the first override to touch the oracle on a fresh fork, + so the live account is forked before the stamp; an override on an account not yet local is + skipped. Use false when an earlier override in the same scenario already forked it. diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs new file mode 100644 index 000000000..61cda2863 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -0,0 +1,447 @@ +//! GoonFi price state preparation. +//! +//! GoonFi publishes no IDL. Every write goes through the raw layouts in `oracle_overrides.yaml` +//! and `market_overrides.yaml`; this module exists for what those templates cannot express: the +//! price lives in a per-market oracle account that must be resolved from the market's own pointer +//! and validated by owner, and a price move is one invariant across two accounts - oracle bid and +//! ask, the market's reference band, and a freshness re-stamp. + +use std::{collections::HashMap, sync::LazyLock}; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, RawLayout, Scenario}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, +}; + +pub const GOONFI_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"); +/// The companion publisher program that owns every market's price oracle. +pub const GOONFI_ORACLE_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu"); +pub const GOONFI_DEFAULT_MARKET: Pubkey = + Pubkey::from_str_const("GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC"); + +/// Read, never written, so no template declares it. +const ORACLE_POINTER_OFFSET: usize = 208; + +/// The layouts a GoonFi market and its oracle must have, taken from the manifests the raw +/// templates are written against so there is one definition of them. Built once; both manifests +/// are compiled in. +static ORACLE_LAYOUT: LazyLock = LazyLock::new(|| layout_of(PRICE_TEMPLATE)); +static MARKET_LAYOUT: LazyLock = LazyLock::new(|| layout_of(REFERENCE_TEMPLATE)); + +fn layout_of(template_id: &str) -> RawLayout { + template(&TemplateRegistry::new(), template_id) + .and_then(|template| { + template + .raw_layout + .clone() + .ok_or_else(|| SurfpoolError::internal("the GoonFi manifests carry no raw layout")) + }) + .expect("the GoonFi manifests are compiled in and always parse") +} + +const PRICE_TEMPLATE: &str = "goonfi-price"; +const REFERENCE_TEMPLATE: &str = "goonfi-reference-band"; +const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; + +/// Prices are the human pair price times 10^6, independent of mint decimals. +const PRICE_SCALE_DECIMALS: u32 = 6; + +/// All three overrides apply on Play, before any slot advance. +const PREPARATION_SLOT: u64 = 0; + +/// The parts of a GoonFi market a price move needs: the market account itself and the oracle it +/// points at. +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiMarket { + pub address: Pubkey, + pub oracle: Pubkey, +} + +impl GoonfiMarket { + /// The oracle the market prices from, read from the market's own pointer. Never trust a + /// caller-supplied oracle address: the oracle is 32 undiscriminated bytes, so the pointer + /// plus the owner check below are what keep a write out of a foreign account. + pub fn oracle_address(market_account: &Account) -> SurfpoolResult { + validate_goonfi_market_layout(market_account)?; + let oracle = read_pubkey(&market_account.data, ORACLE_POINTER_OFFSET)?; + if oracle == Pubkey::default() { + return Err(invalid("market carries no oracle pointer")); + } + Ok(oracle) + } + + pub fn validate( + address: Pubkey, + market_account: &Account, + oracle_account: &Account, + ) -> SurfpoolResult { + let oracle = Self::oracle_address(market_account)?; + validate_goonfi_oracle_layout(oracle_account)?; + Ok(Self { address, oracle }) + } +} + +/// Rejects an account that is not a GoonFi market. +/// +/// The shared raw-layout guard has no owner predicate, so a foreign account of the same size +/// carrying the same magic would pass it. Every builder-made scenario comes through here, which +/// adds the ownership check the schema cannot express. +pub fn validate_goonfi_market_layout(account: &Account) -> SurfpoolResult<()> { + if account.owner != GOONFI_PROGRAM_ID { + return Err(invalid("market is not owned by GoonFi")); + } + MARKET_LAYOUT.guard(&account.data).map_err(invalid) +} + +/// Rejects an account that is not a GoonFi price oracle. +/// +/// The oracle is 32 bytes with no magic at all, so its guard pins only the size; the owner check +/// here is the real discriminator. +pub fn validate_goonfi_oracle_layout(account: &Account) -> SurfpoolResult<()> { + if account.owner != GOONFI_ORACLE_PROGRAM_ID { + return Err(invalid("oracle is not owned by the GoonFi publisher")); + } + ORACLE_LAYOUT.guard(&account.data).map_err(invalid) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiPricePreparation { + pub scenario: Scenario, + pub market: Pubkey, + pub oracle: Pubkey, + pub price_x1e6: u64, +} + +pub fn build_goonfi_price_scenario( + market: &GoonfiMarket, + price: &str, +) -> SurfpoolResult { + let price_x1e6 = human_price_to_x1e6(price)?; + let scaled = price_x1e6.to_string(); + + let registry = TemplateRegistry::new(); + let price_template = template(®istry, PRICE_TEMPLATE)?; + let reference = template(®istry, REFERENCE_TEMPLATE)?; + let freshness = template(®istry, FRESHNESS_TEMPLATE)?; + let market_name = market.address.to_string(); + let oracle_target = AccountAddress::Pubkey(market.oracle.to_string()); + + // No fetch_before_use anywhere: the oracle and reference values are absolute targets for the + // account graph creation read, and a Play-time refetch would reinstall remote bytes over any + // local edit. + let price_override = OverrideInstance::new( + price_template.id.clone(), + PREPARATION_SLOT, + oracle_target.clone(), + ) + .with_values(HashMap::from([ + ( + "bid_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ( + "ask_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ])) + .with_label(format!("GoonFi {market_name} price")); + + // The deployed program rejects an oracle price outside the market's reference band with + // custom error 0x24, so the band moves to the same target as one invariant. + let reference_override = OverrideInstance::new( + reference.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(market.address.to_string()), + ) + .with_values(HashMap::from([ + ( + "reference_price_a_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ( + "reference_price_b_x1e6".to_string(), + serde_json::json!(scaled), + ), + ])) + .with_label(format!("GoonFi {market_name} reference band")); + + // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null takes + // the template's own lead of zero. Persisted, so the prepared price stays inside the oracle's + // staleness window however long the scenario is left running. + let freshness_override = + OverrideInstance::new(freshness.id.clone(), PREPARATION_SLOT, oracle_target) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep GoonFi quote fresh".to_string()) + .with_persist(true); + + let normalized_price = price.trim(); + let mut scenario = Scenario::new( + format!("GoonFi {market_name} at {normalized_price}"), + format!( + "Prepare GoonFi market {} to quote one base token at {normalized_price} quote tokens; no swap is sent.", + market.address + ), + ); + scenario.tags = vec![ + "goonfi".to_string(), + "pmm".to_string(), + "price-dislocation".to_string(), + ]; + scenario.add_override(price_override); + scenario.add_override(reference_override); + scenario.add_override(freshness_override); + + Ok(GoonfiPricePreparation { + scenario, + market: market.address, + oracle: market.oracle, + price_x1e6, + }) +} + +fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { + let bytes: [u8; 32] = data[offset..offset + 32] + .try_into() + .map_err(|_| invalid("market oracle bytes are truncated"))?; + Ok(Pubkey::new_from_array(bytes)) +} + +pub(super) fn human_price_to_x1e6(price: &str) -> SurfpoolResult { + let value = price.trim(); + let mut parts = value.split('.'); + let whole = parts.next().unwrap_or_default(); + let fractional = parts.next().unwrap_or_default(); + if parts.next().is_some() + || whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fractional.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(invalid("price must be a positive decimal string")); + } + + // Reject rather than truncate: a seventh decimal place cannot be represented, and silently + // dropping it would prepare a different price than the caller asked for. + if fractional.len() > PRICE_SCALE_DECIMALS as usize { + return Err(invalid(format!( + "price carries more than {PRICE_SCALE_DECIMALS} decimal places, past GoonFi's 10^-6 resolution" + ))); + } + let digits = format!("{whole}{fractional}") + .parse::() + .map_err(|_| invalid("price is too large"))?; + let exponent = PRICE_SCALE_DECIMALS - fractional.len() as u32; + let scaled = 10u128 + .checked_pow(exponent) + .and_then(|power| digits.checked_mul(power)) + .ok_or_else(|| invalid("price is too large"))?; + if scaled == 0 { + return Err(invalid("price must be greater than zero")); + } + u64::try_from(scaled).map_err(|_| { + let max_price = u64::MAX / 10u64.pow(PRICE_SCALE_DECIMALS); + invalid(format!( + "price is too large for GoonFi's u64 field; a market accepts at most about {max_price} quote per base" + )) + }) +} + +fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) +} + +fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn market_account(oracle: &Pubkey) -> Account { + let mut data = vec![0; MARKET_LAYOUT.account_size]; + let magic = MARKET_LAYOUT.magic.as_ref().expect("manifest layout tag"); + data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + data[ORACLE_POINTER_OFFSET..ORACLE_POINTER_OFFSET + 32].copy_from_slice(oracle.as_ref()); + Account { + data, + owner: GOONFI_PROGRAM_ID, + ..Account::default() + } + } + + fn oracle_account() -> Account { + Account { + data: vec![0; ORACLE_LAYOUT.account_size], + owner: GOONFI_ORACLE_PROGRAM_ID, + ..Account::default() + } + } + + const FIXTURE_ORACLE: Pubkey = + Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); + + fn market() -> GoonfiMarket { + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&FIXTURE_ORACLE), + &oracle_account(), + ) + .expect("valid GoonFi market") + } + + #[test] + fn builds_price_scenario_across_both_accounts() { + let market = market(); + let preparation = build_goonfi_price_scenario(&market, "99.74").unwrap(); + assert_eq!(preparation.price_x1e6, 99_740_000); + + let [price, reference, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected price, reference-band and freshness overrides"); + }; + assert_eq!( + price.account, + AccountAddress::Pubkey(market.oracle.to_string()) + ); + assert_eq!( + reference.account, + AccountAddress::Pubkey(market.address.to_string()) + ); + assert_eq!( + freshness.account, + AccountAddress::Pubkey(market.oracle.to_string()) + ); + assert_eq!( + price.values.get("bid_price_x1e6"), + Some(&serde_json::json!("99740000")) + ); + assert_eq!( + reference.values.get("reference_price_b_x1e6"), + Some(&serde_json::json!("99740000")) + ); + } + + /// The values are absolute targets for the creation read, so nothing refetches at Play; the + /// freshness value must stay null because the slot encoder reads a supplied number as the + /// lead rather than ignoring it. + #[test] + fn price_stays_on_the_creation_read_and_freshness_keeps_the_template_lead() { + let preparation = build_goonfi_price_scenario(&market(), "1").unwrap(); + let [price, reference, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected exactly three overrides"); + }; + assert!(!price.fetch_before_use); + assert!(!price.persist); + assert!(!reference.fetch_before_use); + assert!(!reference.persist); + assert!(!freshness.fetch_before_use); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } + + #[test] + fn scales_prices_by_ten_to_the_sixth_regardless_of_decimals() { + for (price, expected) in [ + ("77526.523154", 77_526_523_154u64), + ("0.00841", 8_410), + ("1558.9384", 1_558_938_400), + ] { + let preparation = build_goonfi_price_scenario(&market(), price).unwrap(); + assert_eq!(preparation.price_x1e6, expected, "price {price}"); + } + } + + #[test] + fn rejects_invalid_price_and_account_inputs() { + let market = market(); + for price in [ + "0", + "-1", + "1.2.3", + "not-a-price", + "", + "0.0000001", + "1.0000009", + ] { + assert!( + build_goonfi_price_scenario(&market, price).is_err(), + "price {price} must be refused" + ); + } + + // A pathological fraction must come back as an error, never a panic or a wrapped value. + let poison = format!("0.{}1", "0".repeat(133)); + assert!(build_goonfi_price_scenario(&market, &poison).is_err()); + let long_whole = "9".repeat(60); + assert!(build_goonfi_price_scenario(&market, &long_whole).is_err()); + + let uncataloged = GoonfiMarket { + address: Pubkey::new_unique(), + oracle: Pubkey::new_unique(), + }; + let preparation = build_goonfi_price_scenario(&uncataloged, "1").unwrap(); + assert!( + preparation + .scenario + .name + .contains(&uncataloged.address.to_string()) + ); + assert_eq!(preparation.oracle, uncataloged.oracle); + + let oracle = Pubkey::new_unique(); + let wrong_owner = Account { + owner: Pubkey::new_unique(), + ..market_account(&oracle) + }; + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &wrong_owner, &oracle_account()).is_err() + ); + // The raw guard cannot see the owner, which is the whole reason this check sits on top. + assert!(MARKET_LAYOUT.guard(&wrong_owner.data).is_ok()); + + let mut bad_magic = market_account(&oracle); + bad_magic.data[0] ^= 0xff; + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &bad_magic, &oracle_account()).is_err() + ); + + let no_pointer = market_account(&Pubkey::default()); + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &no_pointer, &oracle_account()).is_err() + ); + + // The oracle carries no magic at all, so the owner check is its only discriminator. + let foreign_oracle = Account { + owner: Pubkey::new_unique(), + ..oracle_account() + }; + assert!( + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&oracle), + &foreign_oracle + ) + .is_err() + ); + assert!(ORACLE_LAYOUT.guard(&foreign_oracle.data).is_ok()); + + let truncated_oracle = Account { + data: vec![0; 16], + ..oracle_account() + }; + assert!(validate_goonfi_oracle_layout(&truncated_oracle).is_err()); + } +} diff --git a/crates/core/src/scenarios/protocols/mod.rs b/crates/core/src/scenarios/protocols/mod.rs index 99f0b0967..dbcb89738 100644 --- a/crates/core/src/scenarios/protocols/mod.rs +++ b/crates/core/src/scenarios/protocols/mod.rs @@ -1 +1,2 @@ +pub mod goonfi; pub mod pump; diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 4f95a059d..d9cff132d 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -25,6 +25,12 @@ pub const KAMINO_V1_OVERRIDES_CONTENT: &str = include_str!("./protocols/kamino/v pub const BISONFI_OVERRIDES_CONTENT: &str = include_str!("./protocols/bisonfi/overrides.yaml"); +pub const GOONFI_V1_ORACLE_OVERRIDES_CONTENT: &str = + include_str!("./protocols/goonfi/v1/oracle_overrides.yaml"); + +pub const GOONFI_V1_MARKET_OVERRIDES_CONTENT: &str = + include_str!("./protocols/goonfi/v1/market_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"); @@ -79,6 +85,7 @@ impl TemplateRegistry { default.load_meteora_overrides(); default.load_kamino_overrides(); default.load_bisonfi_overrides(); + default.load_goonfi_overrides(); default.load_drift_overrides(); default.load_whirlpool_overrides(); default.load_spl_token_overrides(); @@ -123,6 +130,12 @@ impl TemplateRegistry { self.load_protocol_overrides_without_idl(BISONFI_OVERRIDES_CONTENT, "bisonfi"); } + /// GoonFi writes two account shapes - the price oracle and the market that band-guards it. + pub fn load_goonfi_overrides(&mut self) { + self.load_protocol_overrides_without_idl(GOONFI_V1_ORACLE_OVERRIDES_CONTENT, "goonfi"); + self.load_protocol_overrides_without_idl(GOONFI_V1_MARKET_OVERRIDES_CONTENT, "goonfi"); + } + pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); @@ -505,13 +518,19 @@ mod tests { // Pyth (1) + 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 = 36) - // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + BisonFi (4) = 66 + // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + BisonFi (4) + // + GoonFi (oracle 3 + market 1) = 70 assert_eq!( registry.count(), - 66, - "Registry should load 66 templates total" + 70, + "Registry should load 70 templates total" ); + assert!(registry.contains("goonfi-price")); + assert!(registry.contains("goonfi-stale-quote")); + assert!(registry.contains("goonfi-freshness")); + assert!(registry.contains("goonfi-reference-band")); + assert!(registry.contains("pyth-price-feed-v2")); assert!(registry.contains("jupiter-token-ledger-override")); diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 831432580..da32e51cb 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -3085,6 +3085,13 @@ impl SurfnetSvm { .cloned(); if let Some(template) = raw_template { let raw_layout = template.raw_layout.expect("filtered above"); + if let Err(e) = raw_layout.guard_owner(account.owner()) { + warn!( + "Raw-layout override {} refused on {}: {}", + override_instance.id, account_pubkey, e + ); + continue; + } let properties = template.properties; match raw_layout.materialize( account.data(), @@ -4529,10 +4536,6 @@ impl SurfnetSvm { Ok(fixtures) } - /// Registers a scenario for execution by scheduling its overrides - /// - /// The `slot` parameter is the base slot from which relative override slot heights are calculated. - /// If not provided, uses the current slot. pub fn register_scenario( &mut self, scenario: surfpool_types::Scenario, diff --git a/crates/core/src/tests/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs new file mode 100644 index 000000000..9bbaaedc0 --- /dev/null +++ b/crates/core/src/tests/goonfi/mod.rs @@ -0,0 +1,1110 @@ +//! Behavioral proofs for GoonFi's oracle and market layouts against the current deployed program. +//! +//! GoonFi V2 prices swaps from a per-market oracle account owned by a companion publisher +//! program, not from the market account itself. The market account carries the pair's identities +//! (mints, vaults, oracle pointer) in cleartext plus the reference band that guards the oracle +//! price; the oracle carries bid/ask, a u32 freshness slot, and a dynamic staleness multiplier. +//! +//! Run serially against mainnet: +//! `cargo test -p surfpool-core --features integration-tests tests::goonfi -- --test-threads=1` + +use std::collections::HashMap; + +use sha2::{Digest, Sha256}; +use solana_account::Account; +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_pack::Pack; +use solana_program_runtime::{ + declare_process_instruction, solana_sbpf::program::BuiltinFunctionDefinition, +}; +use solana_pubkey::Pubkey; + +use crate::{ + scenarios::{ + TemplateRegistry, + protocols::goonfi::v1::{ + GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, + }, + }, + surfnet::svm::SurfnetSvm, + tests::live, +}; + +const GOONFI_PROGRAM: &str = "goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"; +const GOONFI_PROGRAMDATA: &str = "124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s"; +const ORACLE_PROGRAMDATA: &str = "7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z"; +const GOONFI_ORACLE_PROGRAM: &str = "dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu"; +const GOONFI_GLOBAL: &str = "BNrK9LpEn65QA4TyBLVSMdngW3XHj3xLfFPwGdCBv8wV"; +const JUPITER_PROGRAM: &str = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"; +const TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +const CURRENT_DEPLOY_SLOT: u64 = 438_563_879; +const CURRENT_ELF_SHA256: &str = "73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e"; +const ORACLE_DEPLOY_SLOT: u64 = 404_369_628; +const ORACLE_ELF_SHA256: &str = "0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695"; + +/// Deployed-program error codes, proven by the replay runs below. +const ERROR_STALE_ORACLE: &str = "Custom(21)"; +const ERROR_PRICE_OUT_OF_BAND: &str = "Custom(36)"; +const ERROR_MIN_AMOUNT_OUT: &str = "Custom(15)"; +const ERROR_INSUFFICIENT_LIQUIDITY: &str = "Custom(1)"; + +/// Oracle layout: both prices are the human pair price times 10^6, independent of mint decimals. +/// The freshness slot is 4 bytes; the u32 beside it is the decay-rate multiplier around 10^6 - +/// it scales how fast a quote degrades with age and does not move the rejection boundary. +const ORACLE_BID_OFFSET: usize = 0; +const ORACLE_ASK_OFFSET: usize = 8; +const ORACLE_SLOT_OFFSET: usize = 16; +const ORACLE_MULTIPLIER_OFFSET: usize = 20; +const ORACLE_TS_MS_OFFSET: usize = 24; + +/// Market-account fields the flows touch or read. The two reference prices band-guard the oracle; +/// the mint and oracle pointers identify the pair. +const MARKET_BASE_MINT_OFFSET: usize = 80; +const MARKET_QUOTE_MINT_OFFSET: usize = 112; +const MARKET_ORACLE_OFFSET: usize = 208; +const MARKET_REF_A_OFFSET: usize = 1712; +const MARKET_REF_B_OFFSET: usize = 1720; + +#[derive(Clone, Copy)] +struct MarketSpec { + market: &'static str, + base_vault: &'static str, + quote_vault: &'static str, + base_mint: &'static str, + quote_mint: &'static str, + oracle: &'static str, + amount_in: u64, +} + +/// The pair the captured reference swap traded, so the replay mirrors a known-good transaction. +const PRIMARY_MARKET: MarketSpec = MarketSpec { + market: "HBDaV4ndLuVe6qK1vGCXReon4B1DJKa9UrbqP8cVqywx", + base_vault: "4KDPiofhBxLMuTuvaYtMAqY6e5DnzbHLB6i7eeU239f6", + quote_vault: "DAogoedaaCcn2SzTc3yi7bWgTWYv5MwoTj6ySgw9snLS", + base_mint: "A7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaS", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + oracle: "vCDwWKdqPHYAP7q5zXY6xk3XC5Ct5oqCs5fdpoosPNq", + amount_in: 25_109_852, +}; + +const SOL_USDC_MARKET: MarketSpec = MarketSpec { + market: "GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC", + base_vault: "8ncU5YW1CQwvr4gs7buH57bW58e86TDau4STrCJBuz8z", + quote_vault: "EunHLeqeJKvxnCPQSytnBP63HJVk2fbHceiKKpngyAo8", + base_mint: "So11111111111111111111111111111111111111112", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + oracle: "7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3", + amount_in: 1_000_000_000, +}; + +#[derive(Clone)] +struct GoonfiFork { + spec: MarketSpec, + elf: Vec, + global: Account, + market: Account, + base_vault: Account, + quote_vault: Account, + base_mint: Account, + quote_mint: Account, + oracle: Account, +} + +declare_process_instruction!(GoonfiCpiWrapper, 1, |invoke_context| { + let instruction = { + let context = invoke_context + .transaction_context + .get_current_instruction_context()?; + let accounts = (1..context.get_number_of_instruction_accounts()) + .map(|index| { + Ok(AccountMeta { + pubkey: *context.get_key_of_instruction_account(index)?, + is_signer: context.is_instruction_account_signer(index)?, + is_writable: context.is_instruction_account_writable(index)?, + }) + }) + .collect::, solana_instruction::error::InstructionError>>()?; + Instruction { + program_id: Pubkey::from_str_const(GOONFI_PROGRAM), + accounts, + data: context.get_instruction_data().to_vec(), + } + }; + invoke_context.native_invoke_signed(instruction, &[]) +}); + +async fn fetch_accounts(addresses: &[&str]) -> Vec { + let pubkeys: Vec = addresses + .iter() + .map(|address| Pubkey::from_str_const(address)) + .collect(); + live::fetch(&pubkeys).await +} + +async fn goonfi_fork(spec: MarketSpec) -> GoonfiFork { + // The ProgramData and global accounts total near a megabyte, which the public endpoint + // refuses to return alongside the market graph. Fetch the two big slow-moving accounts + // separately and keep the price-coupled market graph in one same-slot batch. + let mut big = fetch_accounts(&[GOONFI_PROGRAMDATA, GOONFI_GLOBAL, ORACLE_PROGRAMDATA]).await; + let mut accounts = fetch_accounts(&[ + spec.market, + spec.base_vault, + spec.quote_vault, + spec.base_mint, + spec.quote_mint, + spec.oracle, + ]) + .await; + let programdata = big.remove(0); + assert_eq!(programdata.data.len(), 252_429, "ProgramData size changed"); + assert_eq!( + u64::from_le_bytes(programdata.data[4..12].try_into().unwrap()), + CURRENT_DEPLOY_SLOT, + "GoonFi was redeployed; revalidate the raw layout" + ); + let elf = programdata.data[45..].to_vec(); + assert_eq!( + hex::encode(Sha256::digest(&elf)), + CURRENT_ELF_SHA256, + "GoonFi ELF changed without a ProgramData address change" + ); + // The publisher's identity is pinned too: its oracle accounts are the price templates' write + // targets, so a redeploy there also voids the layout evidence. + let oracle_programdata = big.pop().expect("oracle programdata fetched"); + assert_eq!( + oracle_programdata.data.len(), + 557, + "oracle publisher ProgramData size changed" + ); + assert_eq!( + u64::from_le_bytes(oracle_programdata.data[4..12].try_into().unwrap()), + ORACLE_DEPLOY_SLOT, + "the oracle publisher was redeployed; revalidate the oracle layout" + ); + assert_eq!( + hex::encode(Sha256::digest(&oracle_programdata.data[45..])), + ORACLE_ELF_SHA256, + "oracle publisher ELF changed without a ProgramData address change" + ); + + GoonfiFork { + spec, + elf, + global: big.remove(0), + market: accounts.remove(0), + base_vault: accounts.remove(0), + quote_vault: accounts.remove(0), + base_mint: accounts.remove(0), + quote_mint: accounts.remove(0), + oracle: accounts.remove(0), + } +} + +fn with_controlled_inventory(mut fork: GoonfiFork) -> GoonfiFork { + // Publishers can drain live vaults to dust. Fund only the local fixture so price and age + // assertions measure those controls rather than unrelated, time-varying inventory limits. + for (address, vault, mint_address, mint) in [ + ( + fork.spec.base_vault, + &mut fork.base_vault, + fork.spec.base_mint, + &fork.base_mint, + ), + ( + fork.spec.quote_vault, + &mut fork.quote_vault, + fork.spec.quote_mint, + &fork.quote_mint, + ), + ] { + assert_eq!(vault.owner, spl_token_interface::ID); + assert_eq!(mint.owner, spl_token_interface::ID); + let mint_state = spl_token_interface::state::Mint::unpack(&mint.data) + .expect("controlled fixture mint must remain valid"); + let mut token = spl_token_interface::state::Account::unpack(&vault.data) + .expect("controlled fixture vault must remain valid"); + assert_eq!(token.mint, Pubkey::from_str_const(mint_address)); + assert_eq!(token.owner, Pubkey::from_str_const(fork.spec.market)); + let minimum_amount = 10u64 + .checked_pow(u32::from(mint_state.decimals)) + .and_then(|unit| unit.checked_mul(10_000)) + .expect("10,000 whole fixture tokens must fit u64"); + let original_amount = token.amount; + token.amount = token.amount.max(minimum_amount); + let original_data = vault.data.clone(); + spl_token_interface::state::Account::pack(token, &mut vault.data) + .expect("pack controlled fixture vault"); + if let solana_program_option::COption::Some(reserve) = token.is_native { + vault.lamports = reserve + .checked_add(token.amount) + .expect("controlled native vault funding fits u64"); + } + assert_only_ranges_changed(&original_data, &vault.data, &[(64, 72)]); + eprintln!( + "GoonFi controlled local inventory {address}: captured {original_amount}, prepared {} raw units; market, oracle and deployed ELF remain captured", + token.amount + ); + } + fork +} + +fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(owner.as_ref()); + data[64..72].copy_from_slice(&amount.to_le_bytes()); + data[108] = 1; + data +} + +fn native_token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut data = token_account(mint, owner, amount); + data[109..113].copy_from_slice(&1u32.to_le_bytes()); + data[113..121].copy_from_slice(&2_039_280u64.to_le_bytes()); + data +} + +fn token_amount(data: &[u8]) -> u64 { + u64::from_le_bytes(data[64..72].try_into().unwrap()) +} + +fn read_u64(data: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) +} + +fn read_u32(data: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) +} + +fn write_u64(data: &mut [u8], offset: usize, value: u64) { + data[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +fn oracle_slot(data: &[u8]) -> u64 { + u64::from(read_u32(data, ORACLE_SLOT_OFFSET)) +} + +fn scale_prices(data: &mut [u8], numerator: u64, denominator: u64) { + for offset in [ORACLE_BID_OFFSET, ORACLE_ASK_OFFSET] { + let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) + / u128::from(denominator)) as u64; + write_u64(data, offset, scaled); + } +} + +fn scale_refs(data: &mut [u8], numerator: u64, denominator: u64) { + for offset in [MARKET_REF_A_OFFSET, MARKET_REF_B_OFFSET] { + let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) + / u128::from(denominator)) as u64; + write_u64(data, offset, scaled); + } +} + +fn assert_only_ranges_changed(before: &[u8], after: &[u8], ranges: &[(usize, usize)]) { + assert_eq!(after.len(), before.len()); + for index in live::diff_indices(before, after) { + assert!( + ranges + .iter() + .any(|(start, end)| (*start..*end).contains(&index)), + "unexpected changed byte at {index}" + ); + } +} + +struct RunConfig { + amount_in: u64, + is_bid: u8, + min_amount_out: u64, + /// Slots past the oracle's snapshot update slot at which the swap executes. + clock_slot_age: u64, + /// Seconds past the oracle's snapshot publish time at which the swap executes. + clock_ts_age: i64, +} + +impl RunConfig { + fn sell(amount_in: u64) -> Self { + Self { + amount_in, + is_bid: 0, + min_amount_out: 1, + clock_slot_age: 1, + clock_ts_age: 1, + } + } + + fn buy(amount_in: u64) -> Self { + Self { + is_bid: 1, + ..Self::sell(amount_in) + } + } + + fn sell_at_age(amount_in: u64, clock_slot_age: u64) -> Self { + Self { + clock_slot_age, + ..Self::sell(amount_in) + } + } +} + +fn goonfi_run( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), +) -> Result { + goonfi_run_full(fork, config, mutate_oracle, |_| {}) +} + +fn goonfi_run_full( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), + mutate_market: impl FnOnce(&mut Vec), +) -> Result { + goonfi_run_capturing_oracle(fork, config, mutate_oracle, mutate_market) + .map(|(amount_out, _)| amount_out) +} + +/// Executes one GoonFi swap in LiteSVM against forked mainnet state: the deployed ELF, driven +/// through a wrapper builtin standing in for Jupiter, reproducing the aggregator-routed shape +/// every live swap has. Returns the fill and the oracle's post-execution bytes. +fn goonfi_run_capturing_oracle( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), + mutate_market: impl FnOnce(&mut Vec), +) -> Result<(u64, Vec), String> { + use litesvm::LiteSVM; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let program_id = Pubkey::from_str_const(GOONFI_PROGRAM); + let global_key = Pubkey::from_str_const(GOONFI_GLOBAL); + let market_key = Pubkey::from_str_const(fork.spec.market); + let base_vault_key = Pubkey::from_str_const(fork.spec.base_vault); + let quote_vault_key = Pubkey::from_str_const(fork.spec.quote_vault); + let base_mint_key = Pubkey::from_str_const(fork.spec.base_mint); + let quote_mint_key = Pubkey::from_str_const(fork.spec.quote_mint); + let oracle_key = Pubkey::from_str_const(fork.spec.oracle); + let token_program = Pubkey::from_str_const(TOKEN_PROGRAM); + + let mut oracle = fork.oracle.data.clone(); + mutate_oracle(&mut oracle); + let mut market = fork.market.data.clone(); + mutate_market(&mut market); + // Ages are measured from the snapshot the fork fetched, not from mutated bytes, so a + // re-stamped freshness field changes the account's age rather than moving the clock. + let oracle_update_slot = oracle_slot(&fork.oracle.data); + let oracle_ts_seconds = (read_u64(&fork.oracle.data, ORACLE_TS_MS_OFFSET) / 1_000) as i64; + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(program_id, &fork.elf) + .map_err(|error| format!("add_program: {error:?}"))?; + svm.add_builtin( + Pubkey::from_str_const(JUPITER_PROGRAM), + GoonfiCpiWrapper::register, + ); + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = oracle_update_slot + config.clock_slot_age; + clock.unix_timestamp = oracle_ts_seconds + config.clock_ts_age; + 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(|error| format!("set last restart slot: {error:?}"))?; + + let mut oracle_account = fork.oracle.clone(); + oracle_account.data = oracle; + let mut market_account = fork.market.clone(); + market_account.data = market; + for (key, account) in [ + (global_key, fork.global.clone()), + (market_key, market_account), + (base_vault_key, fork.base_vault.clone()), + (quote_vault_key, fork.quote_vault.clone()), + (base_mint_key, fork.base_mint.clone()), + (quote_mint_key, fork.quote_mint.clone()), + (oracle_key, oracle_account), + ] { + svm.set_account(key, account) + .map_err(|error| format!("set {key}: {error:?}"))?; + } + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|error| format!("airdrop: {error:?}"))?; + let user_base_key = Pubkey::new_unique(); + let user_quote_key = Pubkey::new_unique(); + let (base_funds, quote_funds) = if config.is_bid == 0 { + (config.amount_in, 0) + } else { + (0, config.amount_in) + }; + let user_account = |mint: &Pubkey, amount: u64| { + let is_native = + mint == &Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + Account { + lamports: if is_native { + amount.saturating_add(2_039_280) + } else { + 10_000_000 + }, + data: if is_native { + native_token_account(mint, &taker.pubkey(), amount) + } else { + token_account(mint, &taker.pubkey(), amount) + }, + owner: token_program, + executable: false, + rent_epoch: 0, + } + }; + svm.set_account(user_base_key, user_account(&base_mint_key, base_funds)) + .map_err(|error| format!("set user base: {error:?}"))?; + svm.set_account(user_quote_key, user_account("e_mint_key, quote_funds)) + .map_err(|error| format!("set user quote: {error:?}"))?; + + let mut data = vec![1u8, config.is_bid]; + data.extend_from_slice(&config.amount_in.to_le_bytes()); + data.extend_from_slice(&config.min_amount_out.to_le_bytes()); + let mut budget = vec![2u8]; + budget.extend_from_slice(&1_400_000u32.to_le_bytes()); + let instructions = vec![ + Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }, + Instruction { + program_id: Pubkey::from_str_const(JUPITER_PROGRAM), + accounts: vec![ + AccountMeta::new_readonly(program_id, false), + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(market_key, false), + AccountMeta::new(user_base_key, false), + AccountMeta::new(user_quote_key, false), + AccountMeta::new(base_vault_key, false), + AccountMeta::new(quote_vault_key, false), + AccountMeta::new_readonly(base_mint_key, false), + AccountMeta::new_readonly(quote_mint_key, false), + AccountMeta::new_readonly(oracle_key, false), + AccountMeta::new_readonly(global_key, false), + AccountMeta::new_readonly( + Pubkey::from_str_const("Sysvar1nstructions1111111111111111111111111"), + false, + ), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(token_program, false), + ], + data, + }, + ]; + let mut message = solana_message::Message::new(&instructions, Some(&taker.pubkey())); + message.recent_blockhash = svm.latest_blockhash(); + let signature_count = message.header.num_required_signatures as usize; + let mut transaction = Transaction::new_unsigned(message); + transaction.signatures = vec![solana_signature::Signature::default(); signature_count]; + transaction.signatures[0] = taker.sign_message(&transaction.message.serialize()); + + svm.send_transaction(transaction) + .map_err(|error| format!("{error:?}"))?; + let destination = if config.is_bid == 0 { + user_quote_key + } else { + user_base_key + }; + let amount_out = token_amount( + &svm.get_account(&destination) + .expect("destination account") + .data, + ); + let oracle_after = svm.get_account(&oracle_key).expect("oracle account").data; + Ok((amount_out, oracle_after)) +} + +/// Forks a market by its address alone, resolving vaults, mints, and oracle from the market +/// account's own pointers. Used where a fixture market outside the two hardcoded specs is needed. +async fn fork_from_market(market: &'static str, amount_in: u64) -> GoonfiFork { + let accounts = fetch_accounts(&[market]).await; + let data = &accounts[0].data; + let field = |offset: usize| -> &'static str { + Box::leak( + Pubkey::new_from_array(data[offset..offset + 32].try_into().unwrap()) + .to_string() + .into_boxed_str(), + ) + }; + let spec = MarketSpec { + market, + base_vault: field(144), + quote_vault: field(176), + base_mint: field(MARKET_BASE_MINT_OFFSET), + quote_mint: field(MARKET_QUOTE_MINT_OFFSET), + oracle: field(MARKET_ORACLE_OFFSET), + amount_in, + }; + goonfi_fork(spec).await +} + +/// Materializes the goonfi-stale-quote template with its default lead onto the fork's live +/// oracle bytes, asserts the exact 4-byte slot it wrote, and proves the deployed program then +/// rejects the swap. This is the template's own default doing the aging, not a hand-picked age. +fn stale_template_default_rejects(fork: &GoonfiFork, amount: u64) { + let registry = TemplateRegistry::new(); + let stale = registry.get("goonfi-stale-quote").expect("stale template"); + let snapshot_slot = oracle_slot(&fork.oracle.data); + let aged = stale + .raw_layout + .as_ref() + .expect("oracle raw layout") + .materialize( + &fork.oracle.data, + &stale.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + snapshot_slot, + ) + .expect("materialize stale default"); + assert_eq!( + oracle_slot(&aged), + snapshot_slot - 2_000, + "the default lead must write exactly slot minus 2000" + ); + assert_only_ranges_changed(&fork.oracle.data, &aged, &[(16, 20)]); + assert_rejects_with( + goonfi_run(fork, RunConfig::sell(amount), |oracle| { + *oracle = aged.clone() + }), + ERROR_STALE_ORACLE, + "a quote aged by the stale template's default lead", + ); +} + +fn assert_rejects_with(result: Result, code: &str, context: &str) { + match result { + Ok(amount) => panic!("{context}: expected {code}, got a fill of {amount}"), + Err(error) => assert!( + error.contains(code), + "{context}: expected {code} in: {error}" + ), + } +} + +#[tokio::test] +async fn goonfi_templates_guard_oracle_and_market_and_preserve_unwritten_bytes() { + let fork = goonfi_fork(PRIMARY_MARKET).await; + let registry = TemplateRegistry::new(); + let price = registry.get("goonfi-price").expect("price template"); + let stale = registry.get("goonfi-stale-quote").expect("stale template"); + let fresh = registry + .get("goonfi-freshness") + .expect("freshness template"); + let band = registry + .get("goonfi-reference-band") + .expect("reference-band template"); + + let oracle_layout = price.raw_layout.as_ref().expect("oracle raw layout"); + let market_layout = band.raw_layout.as_ref().expect("market raw layout"); + assert!(oracle_layout.guard(&fork.oracle.data).is_ok()); + assert!(market_layout.guard(&fork.market.data).is_ok()); + assert!(oracle_layout.guard(&fork.oracle.data[..16]).is_err()); + assert!(market_layout.guard(&fork.market.data[..2000]).is_err()); + let mut flipped = fork.market.data.clone(); + flipped[0] ^= 0xff; + assert!(market_layout.guard(&flipped).is_err()); + + let priced = oracle_layout + .materialize( + &fork.oracle.data, + &price.properties, + &HashMap::from([ + ("bid_price_x1e6".to_string(), serde_json::json!("123456789")), + ("ask_price_x1e6".to_string(), serde_json::json!("123456790")), + ]), + 0, + ) + .expect("materialize price"); + assert_eq!(read_u64(&priced, ORACLE_BID_OFFSET), 123_456_789); + assert_eq!(read_u64(&priced, ORACLE_ASK_OFFSET), 123_456_790); + assert_only_ranges_changed(&fork.oracle.data, &priced, &[(0, 16)]); + + // The freshness slot is 4 bytes wide: the dynamic multiplier right after it must survive. + let target_slot = 500_000_123; + for (template, label) in [(stale, "stale"), (fresh, "freshness")] { + let stamped = template + .raw_layout + .as_ref() + .expect("oracle raw layout") + .materialize( + &fork.oracle.data, + &template.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + target_slot, + ) + .unwrap_or_else(|error| panic!("materialize {label}: {error}")); + assert_only_ranges_changed(&fork.oracle.data, &stamped, &[(16, 20)]); + assert_eq!( + read_u32(&stamped, ORACLE_MULTIPLIER_OFFSET), + read_u32(&fork.oracle.data, ORACLE_MULTIPLIER_OFFSET), + "{label} clobbered the staleness multiplier" + ); + } + + let banded = market_layout + .materialize( + &fork.market.data, + &band.properties, + &HashMap::from([ + ( + "reference_price_a_x1e6".to_string(), + serde_json::json!("123456789"), + ), + ( + "reference_price_b_x1e6".to_string(), + serde_json::json!("123456789"), + ), + ]), + 0, + ) + .expect("materialize reference band"); + assert_eq!(read_u64(&banded, MARKET_REF_A_OFFSET), 123_456_789); + assert_eq!(read_u64(&banded, MARKET_REF_B_OFFSET), 123_456_789); + assert_only_ranges_changed(&fork.market.data, &banded, &[(1712, 1728)]); +} + +/// Proves the exact state the real builder prepares, end to end: `build_goonfi_price_scenario` +/// output registers and materializes through the production path, touching only its declared +/// bytes, and the deployed program then fills at the prepared price. The scenario is anchored at +/// the oracle's snapshot slot so the materialized freshness stamp matches the replay clock. +async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { + let market_key = Pubkey::from_str_const(fork.spec.market); + let oracle_key = Pubkey::from_str_const(fork.spec.oracle); + let market = + GoonfiMarket::validate(market_key, &fork.market, &fork.oracle).expect("validate market"); + let live_bid = read_u64(&fork.oracle.data, ORACLE_BID_OFFSET); + let target = live_bid * 3 / 2; + let price = format!("{}.{:06}", target / 1_000_000, target % 1_000_000); + let preparation = + build_goonfi_price_scenario(&market, &price).expect("build GoonFi price scenario"); + assert_eq!(preparation.price_x1e6, target); + + let base_slot = oracle_slot(&fork.oracle.data); + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account(market_key, fork.market.clone()) + .expect("seed GoonFi market"); + svm.inner + .set_account(oracle_key, fork.oracle.clone()) + .expect("seed GoonFi oracle"); + svm.register_scenario(preparation.scenario, Some(base_slot)) + .expect("register GoonFi scenario"); + svm.materialize_overrides_for_slot(&None, base_slot) + .await + .expect("materialize GoonFi scenario"); + + let oracle = svm + .inner + .get_account(&oracle_key) + .expect("get oracle") + .expect("oracle present") + .data; + let market_data = svm + .inner + .get_account(&market_key) + .expect("get market") + .expect("market present") + .data; + assert_eq!(read_u64(&oracle, ORACLE_BID_OFFSET), target); + assert_eq!(read_u64(&oracle, ORACLE_ASK_OFFSET), target); + assert_eq!(oracle_slot(&oracle), base_slot); + assert_eq!(read_u64(&market_data, MARKET_REF_A_OFFSET), target); + assert_eq!(read_u64(&market_data, MARKET_REF_B_OFFSET), target); + assert_only_ranges_changed(&fork.oracle.data, &oracle, &[(0, 20)]); + assert_only_ranges_changed(&fork.market.data, &market_data, &[(1712, 1728)]); + + // The deployed program fills at the prepared price, against the exact materialized bytes. + let baseline = + goonfi_run(fork, RunConfig::sell(fork.spec.amount_in), |_| {}).expect("baseline sell"); + let prepared = goonfi_run_full( + fork, + RunConfig::sell(fork.spec.amount_in), + |data| *data = oracle.clone(), + |data| *data = market_data.clone(), + ) + .expect("sell against the builder-prepared state"); + let expected = (u128::from(baseline) * u128::from(target) / u128::from(live_bid)) as u64; + assert!( + prepared.abs_diff(expected) <= expected / 500, + "the prepared price must set the fill: {prepared} vs ~{expected}" + ); + + // Only the persistent freshness override re-applies on the next slot. + svm.materialize_overrides_for_slot(&None, base_slot + 1) + .await + .expect("materialize persistent GoonFi freshness"); + let next = svm + .inner + .get_account(&oracle_key) + .expect("get oracle") + .expect("oracle present") + .data; + assert_eq!(oracle_slot(&next), base_slot + 1); + assert_eq!(read_u64(&next, ORACLE_BID_OFFSET), target); + assert_only_ranges_changed(&oracle, &next, &[(16, 20)]); +} + +#[tokio::test] +async fn goonfi_builder_scenario_materializes_and_fills_across_oracle_and_market() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + builder_prepares_and_the_program_fills(&fork).await; +} + +#[tokio::test] +async fn goonfi_price_and_reference_band_control_the_deployed_program() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + let amount = fork.spec.amount_in; + + let baseline = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("baseline sell"); + assert!(baseline > 0); + + // No-op rewrite proves the encoding round-trips; the program cannot tell the bytes moved. + let noop = goonfi_run(&fork, RunConfig::sell(amount), |oracle| { + let restated = read_u64(oracle, ORACLE_BID_OFFSET); + write_u64(oracle, ORACLE_BID_OFFSET, restated); + }) + .expect("no-op sell"); + assert_eq!(noop, baseline); + + // Coupled halve and double move the fill linearly in both directions. + let halved = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("coupled halved sell"); + assert!( + (halved * 2).abs_diff(baseline) <= 4, + "halving the price must halve the fill: {halved} * 2 vs {baseline}" + ); + let doubled = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 2, 1), + |market| scale_refs(market, 2, 1), + ) + .expect("coupled doubled sell"); + assert!( + doubled.abs_diff(baseline * 2) <= baseline / 500, + "doubling the price must double the fill: {doubled} vs 2 * {baseline}" + ); + + // Decoupled moves reject: the band guards each direction against the venue-unfavorable side. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell(amount), |oracle| { + scale_prices(oracle, 2, 1) + }), + ERROR_PRICE_OUT_OF_BAND, + "sell with raised oracle and untouched reference band", + ); + assert_rejects_with( + goonfi_run(&fork, RunConfig::buy(100_000_000), |oracle| { + scale_prices(oracle, 1, 2) + }), + ERROR_PRICE_OUT_OF_BAND, + "buy with lowered oracle and untouched reference band", + ); + let coupled_buy = goonfi_run_full( + &fork, + RunConfig::buy(100_000_000), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("coupled halved buy"); + assert!(coupled_buy > 0); + + assert_rejects_with( + goonfi_run( + &fork, + RunConfig { + min_amount_out: u64::MAX, + ..RunConfig::sell(amount) + }, + |_| {}, + ), + ERROR_MIN_AMOUNT_OUT, + "sell with an impossible min_amount_out", + ); + + // Keep the successful trade size fixed so other input limits cannot mask vault depletion. + let mut limited = fork.clone(); + write_u64(&mut limited.quote_vault.data, 64, baseline); + let exact_inventory = goonfi_run(&limited, RunConfig::sell(amount), |_| {}) + .expect("sell with exactly enough quote inventory"); + assert_eq!(exact_inventory, baseline); + + write_u64(&mut limited.quote_vault.data, 64, baseline - 1); + assert_rejects_with( + goonfi_run(&limited, RunConfig::sell(amount), |_| {}), + ERROR_INSUFFICIENT_LIQUIDITY, + "sell with quote inventory one atomic unit below the measured output", + ); + write_u64(&mut limited.quote_vault.data, 64, 0); + assert_rejects_with( + goonfi_run(&limited, RunConfig::sell(amount), |_| {}), + ERROR_INSUFFICIENT_LIQUIDITY, + "sell against a drained quote vault", + ); +} + +fn stamp_multiplier(data: &mut [u8], multiplier: u32) { + data[ORACLE_MULTIPLIER_OFFSET..ORACLE_MULTIPLIER_OFFSET + 4] + .copy_from_slice(&multiplier.to_le_bytes()); +} + +/// First rejection age in 15..=40 under the given multiplier, asserting fills decay +/// monotonically before it and every rejection carries the staleness error. +fn rejection_boundary(fork: &GoonfiFork, amount: u64, multiplier: u32) -> u64 { + let mut previous = u64::MAX; + let mut first_rejection = None; + for age in 15..=40 { + let result = goonfi_run(fork, RunConfig::sell_at_age(amount, age), |oracle| { + stamp_multiplier(oracle, multiplier) + }); + match result { + Ok(output) => { + assert!( + first_rejection.is_none(), + "age {age} filled after the window closed at {first_rejection:?}" + ); + assert!(output <= previous, "decay reversed at age {age}"); + previous = output; + } + Err(error) => { + assert!( + error.contains(ERROR_STALE_ORACLE), + "age {age}: expected {ERROR_STALE_ORACLE} in: {error}" + ); + first_rejection.get_or_insert(age); + } + } + } + first_rejection.expect("no rejection up to age 40") +} + +#[tokio::test] +async fn goonfi_stale_quote_decays_then_rejects_and_freshness_restores() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + let amount = fork.spec.amount_in; + + let fresh = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("fresh sell"); + let aged = goonfi_run(&fork, RunConfig::sell_at_age(amount, 10), |_| {}).expect("aged sell"); + assert!( + aged < fresh, + "the program decays a quote with age: {aged} at age 10 vs {fresh} at age 1" + ); + + // The boundary's source is per-market and unidentified; this range is a safety canary + // around the observed value, not a fixed protocol constant. + let live_multiplier = read_u32(&fork.oracle.data, ORACLE_MULTIPLIER_OFFSET); + let boundary = rejection_boundary(&fork, amount, live_multiplier); + assert!( + (15..=35).contains(&boundary), + "rejection boundary {boundary} left the observed range" + ); + + // The multiplier at offset 20 scales the decay, not the window: at half and double the live + // value the boundary stays put, the decay rate scales with it, and the program leaves the + // oracle bytes untouched. + let mut decay_per_multiplier = Vec::new(); + for (label, numerator, denominator) in [("half", 1u64, 2u64), ("live", 1, 1), ("double", 2, 1)] + { + let multiplier = + u32::try_from(u64::from(live_multiplier) * numerator / denominator).expect("fits u32"); + let mut expected_oracle = fork.oracle.data.clone(); + stamp_multiplier(&mut expected_oracle, multiplier); + + let (at_age_1, oracle_after) = goonfi_run_capturing_oracle( + &fork, + RunConfig::sell(amount), + |oracle| stamp_multiplier(oracle, multiplier), + |_| {}, + ) + .unwrap_or_else(|error| panic!("sell at {label} multiplier: {error}")); + assert_eq!( + oracle_after, expected_oracle, + "the swap must not write the oracle ({label} multiplier)" + ); + let at_age_10 = goonfi_run(&fork, RunConfig::sell_at_age(amount, 10), |oracle| { + stamp_multiplier(oracle, multiplier) + }) + .unwrap_or_else(|error| panic!("aged sell at {label} multiplier: {error}")); + decay_per_multiplier.push(at_age_1 - at_age_10); + + assert_eq!( + rejection_boundary(&fork, amount, multiplier), + boundary, + "the {label} multiplier must not move the rejection boundary" + ); + } + let [half, live, double] = decay_per_multiplier[..] else { + unreachable!() + }; + assert!( + double.abs_diff(live * 2) <= live / 25, + "doubling the multiplier must double the decay: {double} vs 2 * {live}" + ); + assert!( + (half * 2).abs_diff(live) <= live / 25, + "halving the multiplier must halve the decay: {half} * 2 vs {live}" + ); + + // The wall-clock timestamp beside the slot is not consulted. + let ts_aged = goonfi_run( + &fork, + RunConfig { + clock_ts_age: 3_600, + ..RunConfig::sell(amount) + }, + |_| {}, + ) + .expect("sell an hour of wall-clock later"); + assert_eq!(ts_aged, fresh); + + // Deep staleness rejects; re-stamping the u32 slot alone restores the quote, which is what + // the goonfi-freshness template does at every materialization. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell_at_age(amount, 1_000), |_| {}), + ERROR_STALE_ORACLE, + "sell at age 1000", + ); + stale_template_default_rejects(&fork, amount); + let restamped_slot = oracle_slot(&fork.oracle.data) + 1_000; + let restamped = goonfi_run(&fork, RunConfig::sell_at_age(amount, 1_000), |oracle| { + oracle[ORACLE_SLOT_OFFSET..ORACLE_SLOT_OFFSET + 4] + .copy_from_slice(&(restamped_slot as u32).to_le_bytes()); + }) + .expect("sell at age 1000 with a re-stamped slot"); + assert!( + restamped * 100 >= fresh * 99, + "a re-stamped quote must fill near full price: {restamped} vs {fresh}" + ); +} + +#[tokio::test] +async fn goonfi_second_market_proves_generic_price_and_staleness_layout() { + let fork = with_controlled_inventory(goonfi_fork(SOL_USDC_MARKET).await); + let amount = fork.spec.amount_in; + + let baseline = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("SOL/USDC sell"); + let halved = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("SOL/USDC coupled halved sell"); + assert!( + (halved * 2).abs_diff(baseline) <= 4, + "halving must halve on the second market too: {halved} * 2 vs {baseline}" + ); + + let bought = goonfi_run(&fork, RunConfig::buy(100_000_000), |_| {}).expect("SOL/USDC buy"); + assert!(bought > 0); + + builder_prepares_and_the_program_fills(&fork).await; + + // Well past every observed window on this market tier; the stablecoin tier's deeper windows + // are covered by the stale-template default proof below. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell_at_age(amount, 200), |_| {}), + ERROR_STALE_ORACLE, + "SOL/USDC sell past the staleness window", + ); + + // The stablecoin tier fills at ages that reject every other market (USDT/USDC filled at age + // 100 live), so the stale template's -2000 default must out-age even that window. + let stable = with_controlled_inventory( + fork_from_market("EEUNhHsRoUVgJUFpkupmdF4v7uLUw1zhYLp7u9s8zFqG", 0).await, + ); + let stable_amount = 1_000_000; + let filled = goonfi_run(&stable, RunConfig::sell_at_age(stable_amount, 50), |_| {}) + .expect("USDT/USDC fills at an age that rejects every volatile market"); + assert!(filled > 0); + stale_template_default_rejects(&stable, stable_amount); +} + +#[tokio::test] +async fn goonfi_discovery_fetches_live_market_and_oracle_relationships() { + use std::collections::HashSet; + + let markets = discover_goonfi_markets(&live::client()) + .await + .expect("discover GoonFi markets through the real RPC client"); + assert!( + !markets.is_empty(), + "live GoonFi discovery returned no markets" + ); + let default = markets + .iter() + .find(|market| market.address == Pubkey::from_str_const(SOL_USDC_MARKET.market)) + .expect("live discovery must include the default SOL/USDC market"); + assert_eq!( + default.oracle, + Pubkey::from_str_const(SOL_USDC_MARKET.oracle) + ); + assert_eq!( + default.base_mint, + Pubkey::from_str_const(SOL_USDC_MARKET.base_mint) + ); + assert_eq!( + default.quote_mint, + Pubkey::from_str_const(SOL_USDC_MARKET.quote_mint) + ); + assert_eq!((default.base_decimals, default.quote_decimals), (9, 6)); + let mut addresses = HashSet::new(); + let mut oracles = HashSet::new(); + for market in &markets { + assert!( + addresses.insert(market.address), + "duplicate discovered market {}", + market.address + ); + assert!( + oracles.insert(market.oracle), + "duplicate discovered oracle {}", + market.oracle + ); + } + for chunk in markets.chunks(40) { + let addresses: Vec = chunk + .iter() + .flat_map(|market| [market.address, market.oracle]) + .collect(); + let accounts = live::fetch(&addresses).await; + for (discovered, accounts) in chunk.iter().zip(accounts.chunks_exact(2)) { + let validated = GoonfiMarket::validate(discovered.address, &accounts[0], &accounts[1]) + .expect("discovered market and oracle must retain their live owners and layouts"); + assert_eq!( + validated.oracle, discovered.oracle, + "live market oracle pointer changed" + ); + assert_eq!(&accounts[0].data[80..112], discovered.base_mint.as_ref()); + assert_eq!(&accounts[0].data[112..144], discovered.quote_mint.as_ref()); + } + } + eprintln!( + "GoonFi real RPC discovery verified {} unique live market/oracle pairs", + markets.len() + ); +} diff --git a/crates/core/src/tests/live.rs b/crates/core/src/tests/live.rs new file mode 100644 index 000000000..a9891ecfb --- /dev/null +++ b/crates/core/src/tests/live.rs @@ -0,0 +1,68 @@ +//! Shared plumbing for tests that read mainnet. +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. + +use solana_account::Account; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; + +use crate::surfnet::remote::SurfnetRemoteClient; + +pub const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +pub const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +pub fn client() -> SurfnetRemoteClient { + SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ) +} + +/// Fetches the accounts in one request, so every account returned is from the same slot. +pub async fn fetch(addresses: &[Pubkey]) -> Vec { + // The public endpoint throttles and intermittently 503s, which has nothing to do with what + // the callers assert. Retry a few times with backoff so a transient refusal is not read as a + // failure. + let mut attempt = 0; + let mut errors = Vec::new(); + let results = loop { + match client() + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + { + Ok(results) => break results, + Err(error) if attempt < 4 => { + attempt += 1; + errors.push(format!("attempt {attempt}: {error}")); + tokio::time::sleep(std::time::Duration::from_millis(500 * attempt)).await; + } + Err(error) => { + errors.push(format!("attempt {}: {error}", attempt + 1)); + panic!( + "failed to fetch {addresses:?} from mainnet after {} attempts: {}", + errors.len(), + errors.join("; ") + ); + } + } + }; + + results + .into_iter() + .zip(addresses) + .map(|(result, address)| { + result.map_account().unwrap_or_else(|_| { + panic!("{address} no longer exists on mainnet; the integration needs a new address") + }) + }) + .collect() +} + +/// The offsets at which two buffers differ. +pub fn diff_indices(left: &[u8], right: &[u8]) -> Vec { + left.iter() + .zip(right) + .enumerate() + .filter(|(_, (a, b))| a != b) + .map(|(index, _)| index) + .collect() +} diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index eeb64407a..e239a147f 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,9 +1,13 @@ #[cfg(feature = "integration-tests")] pub mod bisonfi; +#[cfg(feature = "integration-tests")] +pub mod goonfi; pub mod helpers; pub mod integration; #[cfg(feature = "integration-tests")] pub mod kamino; +#[cfg(feature = "integration-tests")] +pub mod live; pub mod plugin; #[cfg(feature = "integration-tests")] pub mod pump; diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 0ef4ebe29..37651d608 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -21,6 +21,8 @@ rmcp = { workspace = true, features = ["transport-io", "transport-sse-server", " serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" +solana-account = { workspace = true } +solana-commitment-config = { workspace = true } solana-keypair = { workspace = true } solana-pubkey = { workspace = true } solana-signer = { workspace = true } diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 7ce4c3e08..6614db201 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -19,8 +19,14 @@ use start_surfnet::StartSurfnetResponse; use surfpool_core::{ scenarios::{ TemplateRegistry, - protocols::pump::v1::graduation_builder::{ - build_pump_graduation_scenario, pump_graduation_addresses, + protocols::{ + goonfi::v1::{ + GoonfiMarket, build_goonfi_liquidity_scenario, build_goonfi_price_scenario, + discover_goonfi_markets, vault_addresses, + }, + pump::v1::graduation_builder::{ + build_pump_graduation_scenario, pump_graduation_addresses, + }, }, }, solana_account::Account, @@ -37,6 +43,48 @@ use crate::helpers::find_next_available_surfnet_port; mod set_token_account; mod start_surfnet; +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListGoonfiMarketsParams { + #[schemars(description = "Port of the selected local Surfnet RPC; default 8899.")] + pub surfnet_port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateGoonfiPriceScenarioParams { + #[schemars( + description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." + )] + pub market: Option, + #[schemars( + description = "The price of one base token in quote tokens, as a positive decimal string such as \"99.74\". Not atomic units: GoonFi prices are decimals-independent." + )] + pub price: String, + #[schemars( + description = "The port of the target running local surfnet instance (e.g., 8899, 18899, 28899, etc.). Omit to use the default port, 8899." + )] + pub surfnet_port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateGoonfiLiquidityScenarioParams { + #[schemars( + description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." + )] + pub market: Option, + #[schemars( + description = "Base vault liquidity to keep, in basis points: 0 drains the base vault so swaps are rejected for insufficient liquidity (0x1), 10000 leaves it unchanged. Defaults to 0." + )] + pub base_remaining_bps: Option, + #[schemars( + description = "Quote vault liquidity to keep, in basis points: 0 drains the quote vault, 10000 leaves it unchanged. Defaults to 0." + )] + pub quote_remaining_bps: Option, + #[schemars( + description = "The port of the target running local surfnet instance (e.g., 8899, 18899, 28899, etc.). Omit to use the default port, 8899." + )] + pub surfnet_port: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct StartSurfnetParams { #[schemars( @@ -399,6 +447,38 @@ impl Surfpool { .collect()) } + async fn fetch_goonfi_market( + &self, + address: Option<&str>, + surfnet_port: Option, + ) -> Result { + let market_address = match address.map(str::trim) { + None | Some("") => { + surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET + } + Some(value) => Pubkey::from_str(value) + .map_err(|error| format!("Invalid GoonFi market pubkey: {error}"))?, + }; + let market_account = self + .fetch_surfnet_accounts(surfnet_port, &[market_address]) + .await? + .into_iter() + .next() + .flatten() + .ok_or_else(|| format!("GoonFi market account {market_address} was not found"))?; + let oracle_address = + GoonfiMarket::oracle_address(&market_account).map_err(|error| error.to_string())?; + let oracle_account = self + .fetch_surfnet_accounts(surfnet_port, &[oracle_address]) + .await? + .into_iter() + .next() + .flatten() + .ok_or_else(|| format!("GoonFi oracle {oracle_address} was not found"))?; + GoonfiMarket::validate(market_address, &market_account, &oracle_account) + .map_err(|error| error.to_string()) + } + async fn stage_scenario(&self, scenario: Scenario) -> Result { let endpoint = format!( "http://127.0.0.1:{}/v1/scenarios", @@ -1001,6 +1081,141 @@ impl Surfpool { self.stage_scenario(preparation.scenario).await } + #[tool( + description = "Lists GoonFi markets discovered from program accounts on the selected Surfnet. Returns market and oracle addresses, pair labels, and base/quote mint identities and decimals. Validates market, oracle and mint accounts. Use address for scenario creation and oracle for oracle templates. Unknown symbols use full mint addresses." + )] + async fn list_goonfi_markets( + &self, + Parameters(params): Parameters, + ) -> Result { + let port = params.surfnet_port.unwrap_or(DEFAULT_RPC_PORT); + let client = SurfnetRemoteClient::new(format!("http://127.0.0.1:{port}")); + let markets = match discover_goonfi_markets(&client).await { + Ok(markets) => markets, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let markets = markets + .iter() + .map(|market| { + serde_json::json!({ + "address": market.address.to_string(), + "oracle": market.oracle.to_string(), + "label": market.label(), + "baseMint": market.base_mint.to_string(), + "quoteMint": market.quote_mint.to_string(), + "baseDecimals": market.base_decimals, + "quoteDecimals": market.quote_decimals, + }) + }) + .collect::>(); + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({"count": markets.len(), "markets": markets}).to_string(), + )])) + } + + #[tool( + description = "Creates one editable GoonFi price scenario for a live market. Reads the market account from the running surfnet, resolves its price oracle by the market's own pointer, and moves the oracle bid/ask together with the market's reference band while keeping the quote fresh. Prepares state; sends no swap. Resolve `market` through list_goonfi_markets." + )] + async fn create_goonfi_price_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market = match self + .fetch_goonfi_market(params.market.as_deref(), params.surfnet_port) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let preparation = match build_goonfi_price_scenario(&market, ¶ms.price) { + Ok(preparation) => preparation, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + self.stage_scenario(preparation.scenario).await + } + + #[tool( + description = "Creates one editable GoonFi liquidity-drain scenario for a live market. Reads the market from the running surfnet, resolves its two token vaults by the market's own pointers, and scales each vault balance to the requested basis points (0 drains it so a swap is rejected for insufficient liquidity, 10000 leaves it unchanged), keeping the quote fresh. Prepares state; sends no swap. Resolve `market` through list_goonfi_markets." + )] + async fn create_goonfi_liquidity_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market_address = match params.market.as_deref().map(str::trim) { + None | Some("") => { + surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET + } + Some(value) => match Pubkey::from_str(value) { + Ok(market) => market, + Err(error) => { + return Ok(scenario_tool_error(format!( + "Invalid GoonFi market pubkey: {error}" + ))); + } + }, + }; + let market_account = match self + .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) + .await + { + Ok(mut accounts) => match accounts.remove(0) { + Some(account) => account, + None => { + return Ok(scenario_tool_error(format!( + "GoonFi market account {market_address} was not found" + ))); + } + }, + Err(error) => return Ok(scenario_tool_error(error)), + }; + // Vaults and oracle are read from the market's own pointers, never taken from the caller. + let [base_vault, quote_vault] = match vault_addresses(&market_account) { + Ok(addresses) => addresses, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let oracle = match GoonfiMarket::oracle_address(&market_account) { + Ok(oracle) => oracle, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let referenced = match self + .fetch_surfnet_accounts(params.surfnet_port, &[base_vault, quote_vault, oracle]) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let account = |index: usize, name: &str| { + referenced[index] + .as_ref() + .ok_or_else(|| format!("GoonFi {name} account was not found")) + }; + let (base_account, quote_account, oracle_account) = match ( + account(0, "base vault"), + account(1, "quote vault"), + account(2, "oracle"), + ) { + (Ok(base), Ok(quote), Ok(oracle)) => (base, quote, oracle), + (Err(error), ..) | (_, Err(error), _) | (.., Err(error)) => { + return Ok(scenario_tool_error(error)); + } + }; + let preparation = match build_goonfi_liquidity_scenario( + market_address, + &market_account, + base_account, + quote_account, + oracle_account, + params.base_remaining_bps.unwrap_or(0), + params.quote_remaining_bps.unwrap_or(0), + ) { + Ok(preparation) => preparation, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + self.stage_scenario(preparation.scenario).await + } + #[tool( description = "Fetches ALL available override templates. MUST be called before create_scenario to get valid templateId values and property names. Constants are summarized as {label, description, optionsCount} - resolve an actual option value with search_constant_options." )] @@ -1315,6 +1530,43 @@ mod tests { }) } + #[tokio::test] + async fn goonfi_price_rejects_a_bad_market_before_any_rpc() { + let surfpool = Surfpool::new(); + let result = surfpool + .create_goonfi_price_scenario(Parameters(CreateGoonfiPriceScenarioParams { + surfnet_port: None, + market: Some("not-a-pubkey".to_string()), + price: "99.74".to_string(), + })) + .await + .expect("the tool reports input errors in its payload, not as a protocol error"); + let text = format!("{:?}", result.content); + assert!( + text.contains("Invalid GoonFi market pubkey"), + "unexpected payload: {text}" + ); + } + + #[tokio::test] + async fn goonfi_liquidity_rejects_a_bad_market_before_any_rpc() { + let surfpool = Surfpool::new(); + let result = surfpool + .create_goonfi_liquidity_scenario(Parameters(CreateGoonfiLiquidityScenarioParams { + surfnet_port: None, + market: Some("not-a-pubkey".to_string()), + base_remaining_bps: Some(0), + quote_remaining_bps: Some(0), + })) + .await + .expect("the tool reports input errors in its payload, not as a protocol error"); + let text = format!("{:?}", result.content); + assert!( + text.contains("Invalid GoonFi market pubkey"), + "unexpected payload: {text}" + ); + } + #[tokio::test] async fn get_override_templates_summarizes_constants_instead_of_inlining_options() { let surfpool = Surfpool::new(); diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index 8c3a52809..9764d7272 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1032,11 +1032,20 @@ pub enum RawEncoding { /// A base58 pubkey, written as 32 bytes. Bytes32, /// The slot the override materializes at, plus `lead` (may be negative). + /// + /// `width` is the byte width of the on-chain slot field: 8, or 4 for programs that store + /// slots as u32 next to unrelated bytes. Only those two widths are accepted. Slot { lead: i64, + #[serde(default = "default_slot_width")] + width: usize, }, } +fn default_slot_width() -> usize { + 8 +} + impl RawEncoding { /// Byte width of this encoding. pub fn width(&self) -> usize { @@ -1044,7 +1053,8 @@ impl RawEncoding { RawEncoding::U8 => 1, RawEncoding::U16 => 2, RawEncoding::U32 | RawEncoding::I32 | RawEncoding::I32Strided { .. } => 4, - RawEncoding::U64 | RawEncoding::I64 | RawEncoding::Slot { .. } => 8, + RawEncoding::U64 | RawEncoding::I64 => 8, + RawEncoding::Slot { width, .. } => *width, RawEncoding::U128 | RawEncoding::I128 => 16, RawEncoding::Bytes32 => 32, } @@ -1107,7 +1117,7 @@ impl RawEncoding { .to_bytes() .to_vec() } - RawEncoding::Slot { lead } => { + RawEncoding::Slot { lead, width } => { let lead = match value { serde_json::Value::Null => *lead, _ => { @@ -1123,7 +1133,14 @@ impl RawEncoding { } else { target_slot.checked_sub(lead.unsigned_abs()).unwrap_or(0) }; - slot.to_le_bytes().to_vec() + match width { + 8 => slot.to_le_bytes().to_vec(), + 4 => u32::try_from(slot) + .map_err(|_| format!("slot {slot} does not fit a 4-byte slot field"))? + .to_le_bytes() + .to_vec(), + other => return Err(format!("slot width must be 4 or 8, not {other}")), + } } }) } @@ -1156,6 +1173,10 @@ pub struct RawLayout { /// Optional type tag. Omit for programs that have none. #[serde(default, skip_serializing_if = "Option::is_none")] pub magic: Option, + /// Base58 program id that must own the account. The byte guard cannot see the owner, so + /// without this a foreign account of the same size and magic passes a raw write. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, } impl RawLayout { @@ -1184,6 +1205,22 @@ impl RawLayout { } /// Writes `values` into a copy of `data` using each property's offset and encoding. + /// Rejects an account owned by the wrong program, when the layout names one. Split from + /// [`RawLayout::guard`] because the byte guard has no access to the owner. + pub fn guard_owner(&self, owner: &Pubkey) -> Result<(), String> { + let Some(required) = &self.owner else { + return Ok(()); + }; + let required = Pubkey::from_str(required) + .map_err(|e| format!("raw layout owner '{required}' is not a valid pubkey: {e}"))?; + if owner != &required { + return Err(format!( + "account owner {owner} is not the layout's program {required}" + )); + } + Ok(()) + } + pub fn materialize( &self, data: &[u8], @@ -1562,51 +1599,123 @@ mod tests { assert_eq!(i64::from_le_bytes(bytes.try_into().unwrap()) >> 32, -25599); // The supplied value is the lead, so one property covers live and stale. - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 500); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .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 } + let bytes = RawEncoding::Slot { lead: -1, width: 8 } .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: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .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 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), large_slot) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), large_slot); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .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 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), u64::MAX) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX); - let err = RawEncoding::Slot { lead: 0 } + let err = RawEncoding::Slot { lead: 0, width: 8 } .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] + fn slot_width_defaults_to_eight_and_narrows_to_four() { + use super::RawEncoding; + + // Manifests that spell no width keep the historical 8-byte slot bytes exactly. + let parsed: RawEncoding = serde_json::from_value(json!({"slot": {"lead": -20}})).unwrap(); + assert_eq!( + parsed, + RawEncoding::Slot { + lead: -20, + width: 8 + } + ); + assert_eq!( + parsed.encode(&json!(null), 500).unwrap(), + 480u64.to_le_bytes().to_vec() + ); + + let narrow: RawEncoding = + serde_json::from_value(json!({"slot": {"lead": 0, "width": 4}})).unwrap(); + assert_eq!(narrow.width(), 4); + assert_eq!( + narrow.encode(&json!(null), 500).unwrap(), + 500u32.to_le_bytes().to_vec() + ); + + let err = narrow + .encode(&json!(null), u64::from(u32::MAX) + 1) + .expect_err("a slot past u32::MAX must not be truncated"); + assert!(err.contains("4-byte"), "unexpected error: {err}"); + + let err = RawEncoding::Slot { lead: 0, width: 2 } + .encode(&json!(null), 500) + .expect_err("only widths 4 and 8 exist"); + assert!(err.contains("must be 4 or 8"), "unexpected error: {err}"); + } + + #[test] + fn raw_layout_owner_predicate_rejects_the_wrong_program() { + use super::{Pubkey, RawLayout}; + + let program = Pubkey::new_unique(); + let layout = RawLayout { + account_size: 32, + magic: None, + owner: Some(program.to_string()), + }; + assert!(layout.guard_owner(&program).is_ok()); + let err = layout + .guard_owner(&Pubkey::new_unique()) + .expect_err("a foreign owner must be refused"); + assert!( + err.contains("is not the layout's program"), + "unexpected error: {err}" + ); + + // No owner in the layout keeps the historical behavior: any owner passes. + let open = RawLayout { + account_size: 32, + magic: None, + owner: None, + }; + assert!(open.guard_owner(&Pubkey::new_unique()).is_ok()); + + let broken = RawLayout { + account_size: 32, + magic: None, + owner: Some("not-a-pubkey".to_string()), + }; + assert!(broken.guard_owner(&program).is_err()); + } + #[test] fn raw_layout_rejects_writes_past_the_end_of_the_account() { use super::{Property, RawEncoding, RawLayout}; @@ -1614,6 +1723,7 @@ mod tests { let layout = RawLayout { account_size: 16, magic: None, + owner: None, }; let mut property = Property::field("tail".to_string()); property.offset = Some(12); @@ -1636,6 +1746,7 @@ mod tests { let layout = RawLayout { account_size: 64, magic: None, + owner: None, }; let mut property = Property::field("ticks".to_string()); property.offset = Some(4); @@ -1679,6 +1790,7 @@ mod tests { let layout = RawLayout { account_size: 32, magic: None, + owner: None, }; let mut property = Property::field("ticks".to_string()); property.offset = Some(4); From 9d698bdc24e19c5ed333e422153bbc3f7d12bf1a Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 9 Sep 2026 16:37:58 +0300 Subject: [PATCH 34/35] fix(goonfi): address review findings - Catalog discovery skips a market that fails validation with a warning instead of aborting the whole scan, so one obsolete market can no longer hide every valid one. An empty catalog from a program that does own markets stays an error. - Vault balances are read through TokenAccount::unpack and checked against the market's own mint: an owner-and-length check also admits an 82-byte mint, whose bytes at the amount offset would be misread as a balance. - GoonfiMarket is non_exhaustive, so a market and its oracle can only be paired through validate, which reads the oracle from the market's own pointer. --- .../protocols/goonfi/v1/liquidity.rs | 120 +++++++++++++----- .../scenarios/protocols/goonfi/v1/markets.rs | 52 +++++--- .../src/scenarios/protocols/goonfi/v1/mod.rs | 4 +- .../scenarios/protocols/goonfi/v1/price.rs | 5 + 4 files changed, 126 insertions(+), 55 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index adf3f58bd..c02fdae3a 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -16,6 +16,7 @@ use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, Scenari use crate::{ error::{SurfpoolError, SurfpoolResult}, scenarios::TemplateRegistry, + types::TokenAccount, }; use super::{ @@ -27,8 +28,6 @@ const BASE_MINT_OFFSET: usize = 80; const QUOTE_MINT_OFFSET: usize = 112; const BASE_VAULT_OFFSET: usize = 144; const QUOTE_VAULT_OFFSET: usize = 176; -/// The SPL token account amount field. -const AMOUNT_OFFSET: usize = 64; const LIQUIDITY_TEMPLATE: &str = "spl-token-account-balance"; const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; @@ -95,11 +94,10 @@ pub fn build_goonfi_liquidity_scenario( let oracle = GoonfiMarket::oracle_address(market_account)?; validate_goonfi_oracle_layout(oracle_account)?; - let base_amount = vault_amount(base_vault_account)?; - let quote_amount = vault_amount(quote_vault_account)?; - let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; + let base_amount = vault_amount(base_vault_account, "base", &base_mint)?; + let quote_amount = vault_amount(quote_vault_account, "quote", "e_mint)?; let label = market_label(&base_mint, "e_mint); let registry = TemplateRegistry::new(); @@ -168,18 +166,27 @@ pub fn build_goonfi_liquidity_scenario( }) } -/// The SPL token vaults are 32 undiscriminated-looking bytes at the front; the owner check is the -/// real discriminator that keeps a balance write out of a foreign account. -fn vault_amount(account: &Account) -> SurfpoolResult { +/// Reads a vault balance, proving first that the account really is that market's token vault. +/// +/// An owner-and-length check is not enough: a mint is also owned by the token program and is long +/// enough to read an amount out of, so it would pass and its bytes would be misread as a balance. +/// Unpacking rejects anything that is not a token account, and the mint comparison ties the vault +/// to the side of the market it is supposed to hold. +fn vault_amount(account: &Account, side: &str, expected_mint: &Pubkey) -> SurfpoolResult { if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { - return Err(invalid("vault is not owned by a supported token program")); + return Err(invalid(format!( + "{side} vault is not owned by a supported token program" + ))); } - let bytes: [u8; 8] = account - .data - .get(AMOUNT_OFFSET..AMOUNT_OFFSET + 8) - .and_then(|slice| slice.try_into().ok()) - .ok_or_else(|| invalid("vault is too small to be an SPL token account"))?; - Ok(u64::from_le_bytes(bytes)) + let vault = TokenAccount::unpack(&account.data) + .map_err(|error| invalid(format!("{side} vault is not a token account: {error}")))?; + if vault.mint() != *expected_mint { + return Err(invalid(format!( + "{side} vault holds mint {} but the market's {side} mint is {expected_mint}", + vault.mint() + ))); + } + Ok(vault.amount()) } fn remaining_label(bps: u16) -> String { @@ -230,9 +237,14 @@ mod tests { } } - fn vault(amount: u64) -> Account { + fn vault(mint: &Pubkey, amount: u64) -> Account { + const AMOUNT_OFFSET: usize = 64; + const STATE_OFFSET: usize = 108; let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(Pubkey::new_unique().as_ref()); data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); + data[STATE_OFFSET] = 1; Account { data, owner: spl_token_interface::ID, @@ -256,8 +268,8 @@ mod tests { let preparation = build_goonfi_liquidity_scenario( market, &market_account(&base_vault, "e_vault), - &vault(2_441_078_070_812), - &vault(216_136_231_615), + &vault(&WSOL, 2_441_078_070_812), + &vault(&USDC, 216_136_231_615), &oracle(), 0, 0, @@ -267,15 +279,15 @@ mod tests { assert_eq!(preparation.base_vault, base_vault); assert_eq!(preparation.quote_vault, quote_vault); // A friendly pair label, not the raw market pubkey. - assert_eq!( - preparation.scenario.name, - "GoonFi SOL/USDC liquidity drain" - ); + assert_eq!(preparation.scenario.name, "GoonFi SOL/USDC liquidity drain"); let [base, quote, freshness] = &preparation.scenario.overrides[..] else { panic!("expected base drain, quote drain and freshness overrides"); }; assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); - assert_eq!(quote.account, AccountAddress::Pubkey(quote_vault.to_string())); + assert_eq!( + quote.account, + AccountAddress::Pubkey(quote_vault.to_string()) + ); assert_eq!(base.values.get("amount"), Some(&serde_json::json!("0"))); assert_eq!(quote.values.get("amount"), Some(&serde_json::json!("0"))); assert!(!base.fetch_before_use); @@ -298,8 +310,8 @@ mod tests { let preparation = build_goonfi_liquidity_scenario( Pubkey::new_unique(), &market_account(&base_vault, "e_vault), - &vault(1_000), - &vault(999), + &vault(&WSOL, 1_000), + &vault(&USDC, 999), &oracle(), 2_500, FULL_BPS, @@ -326,8 +338,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &oracle(), 10_001, 0 @@ -338,8 +350,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &oracle(), FULL_BPS, FULL_BPS @@ -356,8 +368,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &foreign_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &oracle(), 0, 0 @@ -368,14 +380,14 @@ mod tests { // A vault not owned by a token program is not a real vault. let foreign_vault = Account { owner: Pubkey::new_unique(), - ..vault(1) + ..vault(&WSOL, 1) }; assert!( build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, &foreign_vault, - &vault(1), + &vault(&USDC, 1), &oracle(), 0, 0 @@ -392,8 +404,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &foreign_oracle, 0, 0 @@ -402,6 +414,44 @@ mod tests { ); } + /// An owner-and-length check would pass a mint: it is token-program-owned and long enough to + /// misread an amount out of. Unpacking plus the mint comparison is what rejects it. + #[test] + fn rejects_a_vault_that_is_not_this_markets_token_account() { + let market = market_account(&Pubkey::new_unique(), &Pubkey::new_unique()); + let mint_account = Account { + data: vec![0u8; 82], + owner: spl_token_interface::ID, + ..Account::default() + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market, + &mint_account, + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A real token account holding the other side's mint is refused as well. + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market, + &vault(&USDC, 1), + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + } + #[test] fn resolves_vault_addresses_from_the_market() { let base_vault = Pubkey::new_unique(); diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs index e4f4a6ba3..b8c71c728 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -121,19 +121,24 @@ pub async fn discover_goonfi_markets( ) .await? .into_result()?; - let accounts = accounts - .into_iter() - .map(|(address, encoded)| { - let account: Account = encoded.to_account().ok_or_else(|| { - SurfpoolError::internal(format!("Could not decode GoonFi market {address}")) - })?; - market_references(&account)?; - Ok((address, account)) - }) - .collect::>>()?; + // One obsolete or malformed market must not hide every valid one, so a market that fails + // validation is skipped with a warning and the rest of the catalog is still returned. This is + // the same warn-and-continue rule the materializer applies per override. + let candidates = accounts.len(); + let mut retained = Vec::new(); let mut addresses = Vec::new(); - for (_, account) in &accounts { - addresses.extend(market_references(account)?); + for (address, encoded) in accounts { + let Some(account) = encoded.to_account() else { + warn!("Skipping GoonFi market {address}: its account data could not be decoded"); + continue; + }; + match market_references(&account) { + Ok(references) => { + addresses.extend(references); + retained.push((address, account)); + } + Err(error) => warn!("Skipping GoonFi market {address}: {error}"), + } } addresses.sort_unstable(); addresses.dedup(); @@ -143,13 +148,26 @@ pub async fn discover_goonfi_markets( .get_multiple_accounts(batch, CommitmentConfig::confirmed()) .await?; for (address, account) in batch.iter().zip(fetched) { - references.insert(*address, account.map_account()?); + // A reference the fork cannot serve disqualifies only the markets pointing at it, + // which `resolve_market` reports below. + if let Ok(account) = account.map_account() { + references.insert(*address, account); + } } } - let mut markets = accounts - .iter() - .map(|(address, account)| resolve_market(*address, account, &references)) - .collect::>>()?; + let mut markets = Vec::new(); + for (address, account) in &retained { + match resolve_market(*address, account, &references) { + Ok(market) => markets.push(market), + Err(error) => warn!("Skipping GoonFi market {address}: {error}"), + } + } + // An empty catalog from a program that does own markets is a failure, not a partial result. + if markets.is_empty() && candidates > 0 { + return Err(SurfpoolError::internal(format!( + "none of the {candidates} discovered GoonFi markets validated; the integration needs a refresh" + ))); + } markets.sort_by_cached_key(|market| { ( market.address != GOONFI_DEFAULT_MARKET, diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs index c788b126d..b0852c772 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -2,9 +2,7 @@ mod liquidity; mod markets; mod price; -pub use liquidity::{ - GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses, -}; +pub use liquidity::{GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses}; pub use price::{ GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 61cda2863..43d731d81 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -57,7 +57,12 @@ const PREPARATION_SLOT: u64 = 0; /// The parts of a GoonFi market a price move needs: the market account itself and the oracle it /// points at. +/// +/// `non_exhaustive` so the pair can only be built through `validate`, which reads the oracle from +/// the market's own pointer. A caller assembling the two fields independently could otherwise +/// point a price move at one market's band and an unrelated market's oracle. #[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] pub struct GoonfiMarket { pub address: Pubkey, pub oracle: Pubkey, From a3a57bb0d3b5c840a88380db4e4ac3a25b3d6cf8 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 9 Sep 2026 16:52:27 +0300 Subject: [PATCH 35/35] fix(goonfi): make the validated market pair unforgeable `non_exhaustive` only blocked building a GoonfiMarket from scratch outside the crate; the fields stayed public, so a caller could still re-point a validated pair's oracle. Making both private leaves `validate` as the only way to pair a market with an oracle, and it reads that oracle from the market's own pointer. --- .../core/src/scenarios/protocols/goonfi/v1/price.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 43d731d81..d005c9703 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -58,14 +58,15 @@ const PREPARATION_SLOT: u64 = 0; /// The parts of a GoonFi market a price move needs: the market account itself and the oracle it /// points at. /// -/// `non_exhaustive` so the pair can only be built through `validate`, which reads the oracle from -/// the market's own pointer. A caller assembling the two fields independently could otherwise -/// point a price move at one market's band and an unrelated market's oracle. +/// The two are private so the pair can only be built through `validate`, which reads the oracle +/// from the market's own pointer. Public fields would let a caller assemble the pair from scratch +/// or re-point a validated one, aiming a price move at one market's reference band and an +/// unrelated market's oracle - a combination the deployed program rejects with 0x24 at best, and +/// silently misprices at worst. #[derive(Clone, Debug, PartialEq)] -#[non_exhaustive] pub struct GoonfiMarket { - pub address: Pubkey, - pub oracle: Pubkey, + address: Pubkey, + oracle: Pubkey, } impl GoonfiMarket {