From e558c1acbbb4e1cc47671f7bd19c8c107e869722 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:32:05 +0100 Subject: [PATCH 1/4] feat(dapp): run onchain defi and payment venues, and drop the dollar cap Adds venues that the person's own wallet signs, on Avalanche, Monad, Sui and Solana, for swaps, lending, staking and payments, plus paying a name. Every venue is allowlisted and fails closed. No real wallet has signed any of them. Removes the mainnet dollar cap from every route, Stellar and cash-out included, which also ends the refusal of a token with no live price. MAINNET_MAX_TRADE_USD is no longer read. NEAR's 1,000 NEAR and 10,000 token ceilings stay. Haedal is built but on hold: not registered or listed, and haSUI is off the Sui token list. Euler on Avalanche is built but not registered. --- apps/dapp/.env.example | 432 +++--- apps/dapp/app/[chain]/layout.tsx | 12 +- apps/dapp/app/api/agents/compete/route.ts | 6 +- apps/dapp/app/api/cashout/orders/route.ts | 21 - .../app/api/chain/[chain]/prepare/route.ts | 32 +- apps/dapp/app/api/lend/borrow/route.ts | 11 - apps/dapp/app/api/lend/build/route.ts | 12 - apps/dapp/app/api/lend/collateral/route.ts | 14 - .../dapp/app/api/lend/defindex/build/route.ts | 4 - apps/dapp/app/api/lend/repay/route.ts | 15 - apps/dapp/app/api/lend/withdraw/route.ts | 6 +- apps/dapp/app/api/offers/build/route.ts | 11 - apps/dapp/app/api/offramp/build/route.ts | 11 - apps/dapp/app/api/plan/build/route.ts | 4 - apps/dapp/app/api/send/build/route.ts | 11 - apps/dapp/app/api/swap/build/route.ts | 45 +- .../intents/chain-action-confirm.tsx | 30 +- apps/dapp/components/intents/intent-chat.tsx | 2 - apps/dapp/components/intents/swap-confirm.tsx | 10 +- apps/dapp/components/layout/app-shell.tsx | 14 +- .../dapp/components/layout/chain-switcher.tsx | 2 +- apps/dapp/components/layout/header.tsx | 5 +- .../dapp/components/layout/network-status.tsx | 15 +- apps/dapp/components/layout/trade-cap.tsx | 26 - apps/dapp/e2e/chain-action.spec.ts | 4 +- apps/dapp/e2e/network-switch.spec.ts | 2 - apps/dapp/lib/__tests__/aave.live.test.ts | 162 +++ apps/dapp/lib/__tests__/aave.test.ts | 678 +++++++++ .../dapp/lib/__tests__/aftermath.live.test.ts | 90 ++ apps/dapp/lib/__tests__/aftermath.test.ts | 371 +++++ .../__tests__/agents-chain-compete.test.ts | 4 +- apps/dapp/lib/__tests__/beefy.live.test.ts | 194 +++ apps/dapp/lib/__tests__/beefy.test.ts | 544 ++++++++ apps/dapp/lib/__tests__/bulla.live.test.ts | 79 ++ apps/dapp/lib/__tests__/bulla.test.ts | 658 +++++++++ .../lib/__tests__/chain-action-kinds.test.ts | 216 +++ apps/dapp/lib/__tests__/chain-action.test.ts | 20 +- .../dapp/lib/__tests__/chain-balances.test.ts | 16 +- apps/dapp/lib/__tests__/chain-send.test.ts | 45 +- .../dapp/lib/__tests__/chain-sequence.test.ts | 3 +- apps/dapp/lib/__tests__/curvance.live.test.ts | 203 +++ apps/dapp/lib/__tests__/curvance.test.ts | 524 +++++++ apps/dapp/lib/__tests__/deepbook.live.test.ts | 103 ++ apps/dapp/lib/__tests__/deepbook.test.ts | 433 ++++++ apps/dapp/lib/__tests__/erc4626-refusals.ts | 193 +++ apps/dapp/lib/__tests__/euler.live.test.ts | 192 +++ apps/dapp/lib/__tests__/euler.test.ts | 196 +++ apps/dapp/lib/__tests__/evm-call.test.ts | 251 ++++ apps/dapp/lib/__tests__/evm-module.test.ts | 108 +- apps/dapp/lib/__tests__/fastlane.live.test.ts | 222 +++ apps/dapp/lib/__tests__/fastlane.test.ts | 452 ++++++ apps/dapp/lib/__tests__/haedal.live.test.ts | 77 ++ apps/dapp/lib/__tests__/haedal.test.ts | 403 ++++++ apps/dapp/lib/__tests__/jito.test.ts | 391 ++++++ apps/dapp/lib/__tests__/lend-cap.test.ts | 57 - apps/dapp/lib/__tests__/marinade.test.ts | 338 +++++ .../lib/__tests__/market-context-evm.test.ts | 8 +- apps/dapp/lib/__tests__/momentum.live.test.ts | 89 ++ apps/dapp/lib/__tests__/momentum.test.ts | 412 ++++++ apps/dapp/lib/__tests__/morpho.live.test.ts | 184 +++ apps/dapp/lib/__tests__/morpho.test.ts | 289 ++++ .../lib/__tests__/navi-withdraw.live.test.ts | 89 ++ apps/dapp/lib/__tests__/navi-withdraw.test.ts | 202 +++ apps/dapp/lib/__tests__/near-stake.test.ts | 17 +- apps/dapp/lib/__tests__/near-swap.test.ts | 36 +- .../orchestrate-capabilities-chain.test.ts | 26 +- apps/dapp/lib/__tests__/pharaoh.live.test.ts | 156 +++ apps/dapp/lib/__tests__/pharaoh.test.ts | 113 ++ .../__tests__/recipient-names.live.test.ts | 232 ++++ .../lib/__tests__/recipient-names.test.ts | 1213 +++++++++++++++++ apps/dapp/lib/__tests__/route-cap.test.ts | 189 --- apps/dapp/lib/__tests__/sablier.live.test.ts | 248 ++++ apps/dapp/lib/__tests__/sablier.test.ts | 602 ++++++++ apps/dapp/lib/__tests__/scallop.live.test.ts | 84 ++ apps/dapp/lib/__tests__/scallop.test.ts | 236 ++++ .../lib/__tests__/solana-stake-fixtures.ts | 258 ++++ .../lib/__tests__/solana-staking.live.test.ts | 279 ++++ .../__tests__/solana-sui-race-quote.test.ts | 10 +- apps/dapp/lib/__tests__/solana-venues.test.ts | 53 +- apps/dapp/lib/__tests__/solayer.test.ts | 455 +++++++ .../__tests__/springsui-unstake.live.test.ts | 39 + .../lib/__tests__/springsui-unstake.test.ts | 261 ++++ .../lib/__tests__/streamflow.live.test.ts | 129 ++ apps/dapp/lib/__tests__/streamflow.test.ts | 734 ++++++++++ .../lib/__tests__/sui-lending-captured.ts | 108 ++ apps/dapp/lib/__tests__/sui-module.test.ts | 37 +- .../dapp/lib/__tests__/sui-route.live.test.ts | 7 +- .../lib/__tests__/sui-venues.live.test.ts | 8 +- apps/dapp/lib/__tests__/suilend.live.test.ts | 77 ++ apps/dapp/lib/__tests__/suilend.test.ts | 325 +++++ .../dapp/lib/__tests__/trade-cap-copy.test.ts | 21 - apps/dapp/lib/__tests__/trade-cap.test.ts | 268 ---- apps/dapp/lib/__tests__/uniswap.live.test.ts | 164 +++ apps/dapp/lib/__tests__/uniswap.test.ts | 163 +++ apps/dapp/lib/__tests__/usd-value.test.ts | 22 + apps/dapp/lib/__tests__/v3-swap-suite.ts | 610 +++++++++ .../lib/__tests__/venue-integration.test.ts | 16 +- apps/dapp/lib/__tests__/walrus.live.test.ts | 140 ++ apps/dapp/lib/__tests__/walrus.test.ts | 885 ++++++++++++ .../lib/__tests__/weave-orders-route.test.ts | 26 +- apps/dapp/lib/adapters/evm-adapter.ts | 20 +- apps/dapp/lib/agents/competition.ts | 2 +- apps/dapp/lib/chains/evm/aave-listing.ts | 29 + apps/dapp/lib/chains/evm/aave.ts | 661 +++++++++ apps/dapp/lib/chains/evm/beefy-listing.ts | 13 + apps/dapp/lib/chains/evm/beefy.ts | 348 +++++ apps/dapp/lib/chains/evm/bulla-listing.ts | 13 + apps/dapp/lib/chains/evm/bulla.ts | 461 +++++++ apps/dapp/lib/chains/evm/call.ts | 300 ++++ apps/dapp/lib/chains/evm/capabilities.ts | 18 +- apps/dapp/lib/chains/evm/client.ts | 11 +- apps/dapp/lib/chains/evm/curvance-listing.ts | 13 + apps/dapp/lib/chains/evm/curvance.ts | 435 ++++++ apps/dapp/lib/chains/evm/erc4626.ts | 348 +++++ apps/dapp/lib/chains/evm/euler-listing.ts | 27 + apps/dapp/lib/chains/evm/euler.ts | 199 +++ apps/dapp/lib/chains/evm/fastlane-listing.ts | 13 + apps/dapp/lib/chains/evm/fastlane.ts | 368 +++++ apps/dapp/lib/chains/evm/module.ts | 116 +- apps/dapp/lib/chains/evm/morpho-listing.ts | 13 + apps/dapp/lib/chains/evm/morpho.ts | 249 ++++ apps/dapp/lib/chains/evm/neverland-listing.ts | 14 + apps/dapp/lib/chains/evm/pharaoh-listing.ts | 14 + apps/dapp/lib/chains/evm/pharaoh.ts | 106 ++ apps/dapp/lib/chains/evm/sablier-listing.ts | 20 + apps/dapp/lib/chains/evm/sablier.ts | 453 ++++++ apps/dapp/lib/chains/evm/tokens.ts | 36 +- apps/dapp/lib/chains/evm/uniswap-listing.ts | 19 + apps/dapp/lib/chains/evm/uniswap.ts | 118 ++ apps/dapp/lib/chains/evm/v3-swap.ts | 405 ++++++ apps/dapp/lib/chains/evm/venues.ts | 24 + apps/dapp/lib/chains/explain.ts | 7 + .../dapp/lib/chains/names/alldomains-monad.ts | 136 ++ .../lib/chains/names/alldomains-solana.ts | 113 ++ apps/dapp/lib/chains/names/resolve.ts | 139 ++ apps/dapp/lib/chains/names/sns.ts | 277 ++++ apps/dapp/lib/chains/names/solana-rpc.ts | 137 ++ apps/dapp/lib/chains/names/suins.ts | 75 + apps/dapp/lib/chains/names/tlds.ts | 71 + apps/dapp/lib/chains/near/safety.ts | 3 +- apps/dapp/lib/chains/parse-action.ts | 105 +- apps/dapp/lib/chains/prepare.ts | 101 +- apps/dapp/lib/chains/recipient.ts | 43 +- apps/dapp/lib/chains/send.ts | 19 +- apps/dapp/lib/chains/solana/capabilities.ts | 2 + apps/dapp/lib/chains/solana/jito-listing.ts | 15 + apps/dapp/lib/chains/solana/jito.ts | 221 +++ .../lib/chains/solana/marinade-listing.ts | 15 + apps/dapp/lib/chains/solana/marinade.ts | 427 ++++++ apps/dapp/lib/chains/solana/module.ts | 18 +- .../dapp/lib/chains/solana/solayer-listing.ts | 15 + apps/dapp/lib/chains/solana/solayer.ts | 376 +++++ apps/dapp/lib/chains/solana/stake-pool.ts | 888 ++++++++++++ .../lib/chains/solana/streamflow-listing.ts | 13 + apps/dapp/lib/chains/solana/streamflow.ts | 580 ++++++++ apps/dapp/lib/chains/solana/tokens.ts | 14 + apps/dapp/lib/chains/solana/venues.ts | 11 +- apps/dapp/lib/chains/sui/aftermath-listing.ts | 14 + apps/dapp/lib/chains/sui/aftermath.ts | 310 +++++ apps/dapp/lib/chains/sui/capabilities.ts | 2 + apps/dapp/lib/chains/sui/deepbook-listing.ts | 14 + apps/dapp/lib/chains/sui/deepbook.ts | 283 ++++ apps/dapp/lib/chains/sui/direct-swap.ts | 263 ++++ apps/dapp/lib/chains/sui/haedal-listing.ts | 14 + apps/dapp/lib/chains/sui/haedal.ts | 399 ++++++ apps/dapp/lib/chains/sui/lending-calls.ts | 128 ++ apps/dapp/lib/chains/sui/module.ts | 25 +- apps/dapp/lib/chains/sui/momentum-listing.ts | 13 + apps/dapp/lib/chains/sui/momentum.ts | 361 +++++ apps/dapp/lib/chains/sui/navi-withdraw.ts | 231 ++++ apps/dapp/lib/chains/sui/navi.ts | 9 +- apps/dapp/lib/chains/sui/scallop-listing.ts | 13 + apps/dapp/lib/chains/sui/scallop.ts | 350 +++++ apps/dapp/lib/chains/sui/springsui-unstake.ts | 147 ++ apps/dapp/lib/chains/sui/springsui.ts | 17 +- apps/dapp/lib/chains/sui/suilend-listing.ts | 13 + apps/dapp/lib/chains/sui/suilend.ts | 508 +++++++ apps/dapp/lib/chains/sui/tokens.ts | 18 + apps/dapp/lib/chains/sui/venues.ts | 54 +- apps/dapp/lib/chains/sui/walrus-listing.ts | 13 + apps/dapp/lib/chains/sui/walrus.ts | 760 +++++++++++ apps/dapp/lib/chains/types.ts | 39 +- apps/dapp/lib/lend/cap.ts | 22 - apps/dapp/lib/orchestrate/capabilities.ts | 28 +- apps/dapp/lib/other-stellar-network.ts | 4 +- apps/dapp/lib/server/execution-flow.ts | 2 +- apps/dapp/lib/server/trade-cap.ts | 176 --- apps/dapp/lib/server/usd-value.ts | 16 + apps/dapp/lib/trade-cap-copy.ts | 11 - .../public/images/venues/aave-avalanche.webp | Bin 0 -> 2142 bytes .../dapp/public/images/venues/aave-monad.webp | Bin 0 -> 2142 bytes .../public/images/venues/beefy-monad.webp | Bin 0 -> 7260 bytes apps/dapp/public/images/venues/bulla.webp | Bin 0 -> 6258 bytes apps/dapp/public/images/venues/curvance.webp | Bin 0 -> 1150 bytes .../public/images/venues/euler-avalanche.webp | Bin 0 -> 3074 bytes .../public/images/venues/euler-monad.webp | Bin 0 -> 3074 bytes apps/dapp/public/images/venues/fastlane.webp | Bin 0 -> 4098 bytes apps/dapp/public/images/venues/jito.webp | Bin 0 -> 1664 bytes apps/dapp/public/images/venues/marinade.webp | Bin 0 -> 6948 bytes apps/dapp/public/images/venues/momentum.webp | Bin 0 -> 4588 bytes apps/dapp/public/images/venues/morpho.webp | Bin 0 -> 5166 bytes apps/dapp/public/images/venues/neverland.webp | Bin 0 -> 10950 bytes apps/dapp/public/images/venues/pharaoh.webp | Bin 0 -> 10110 bytes .../images/venues/sablier-avalanche.webp | Bin 0 -> 5988 bytes .../public/images/venues/sablier-monad.webp | Bin 0 -> 5988 bytes apps/dapp/public/images/venues/solayer.webp | Bin 0 -> 2482 bytes .../dapp/public/images/venues/streamflow.webp | Bin 0 -> 5070 bytes .../images/venues/uniswap-avalanche.webp | Bin 0 -> 4162 bytes .../public/images/venues/uniswap-monad.webp | Bin 0 -> 4162 bytes apps/dapp/public/images/venues/walrus.webp | Bin 0 -> 1160 bytes 210 files changed, 28865 insertions(+), 1579 deletions(-) delete mode 100644 apps/dapp/components/layout/trade-cap.tsx create mode 100644 apps/dapp/lib/__tests__/aave.live.test.ts create mode 100644 apps/dapp/lib/__tests__/aave.test.ts create mode 100644 apps/dapp/lib/__tests__/aftermath.live.test.ts create mode 100644 apps/dapp/lib/__tests__/aftermath.test.ts create mode 100644 apps/dapp/lib/__tests__/beefy.live.test.ts create mode 100644 apps/dapp/lib/__tests__/beefy.test.ts create mode 100644 apps/dapp/lib/__tests__/bulla.live.test.ts create mode 100644 apps/dapp/lib/__tests__/bulla.test.ts create mode 100644 apps/dapp/lib/__tests__/chain-action-kinds.test.ts create mode 100644 apps/dapp/lib/__tests__/curvance.live.test.ts create mode 100644 apps/dapp/lib/__tests__/curvance.test.ts create mode 100644 apps/dapp/lib/__tests__/deepbook.live.test.ts create mode 100644 apps/dapp/lib/__tests__/deepbook.test.ts create mode 100644 apps/dapp/lib/__tests__/erc4626-refusals.ts create mode 100644 apps/dapp/lib/__tests__/euler.live.test.ts create mode 100644 apps/dapp/lib/__tests__/euler.test.ts create mode 100644 apps/dapp/lib/__tests__/evm-call.test.ts create mode 100644 apps/dapp/lib/__tests__/fastlane.live.test.ts create mode 100644 apps/dapp/lib/__tests__/fastlane.test.ts create mode 100644 apps/dapp/lib/__tests__/haedal.live.test.ts create mode 100644 apps/dapp/lib/__tests__/haedal.test.ts create mode 100644 apps/dapp/lib/__tests__/jito.test.ts delete mode 100644 apps/dapp/lib/__tests__/lend-cap.test.ts create mode 100644 apps/dapp/lib/__tests__/marinade.test.ts create mode 100644 apps/dapp/lib/__tests__/momentum.live.test.ts create mode 100644 apps/dapp/lib/__tests__/momentum.test.ts create mode 100644 apps/dapp/lib/__tests__/morpho.live.test.ts create mode 100644 apps/dapp/lib/__tests__/morpho.test.ts create mode 100644 apps/dapp/lib/__tests__/navi-withdraw.live.test.ts create mode 100644 apps/dapp/lib/__tests__/navi-withdraw.test.ts create mode 100644 apps/dapp/lib/__tests__/pharaoh.live.test.ts create mode 100644 apps/dapp/lib/__tests__/pharaoh.test.ts create mode 100644 apps/dapp/lib/__tests__/recipient-names.live.test.ts create mode 100644 apps/dapp/lib/__tests__/recipient-names.test.ts delete mode 100644 apps/dapp/lib/__tests__/route-cap.test.ts create mode 100644 apps/dapp/lib/__tests__/sablier.live.test.ts create mode 100644 apps/dapp/lib/__tests__/sablier.test.ts create mode 100644 apps/dapp/lib/__tests__/scallop.live.test.ts create mode 100644 apps/dapp/lib/__tests__/scallop.test.ts create mode 100644 apps/dapp/lib/__tests__/solana-stake-fixtures.ts create mode 100644 apps/dapp/lib/__tests__/solana-staking.live.test.ts create mode 100644 apps/dapp/lib/__tests__/solayer.test.ts create mode 100644 apps/dapp/lib/__tests__/springsui-unstake.live.test.ts create mode 100644 apps/dapp/lib/__tests__/springsui-unstake.test.ts create mode 100644 apps/dapp/lib/__tests__/streamflow.live.test.ts create mode 100644 apps/dapp/lib/__tests__/streamflow.test.ts create mode 100644 apps/dapp/lib/__tests__/sui-lending-captured.ts create mode 100644 apps/dapp/lib/__tests__/suilend.live.test.ts create mode 100644 apps/dapp/lib/__tests__/suilend.test.ts delete mode 100644 apps/dapp/lib/__tests__/trade-cap-copy.test.ts delete mode 100644 apps/dapp/lib/__tests__/trade-cap.test.ts create mode 100644 apps/dapp/lib/__tests__/uniswap.live.test.ts create mode 100644 apps/dapp/lib/__tests__/uniswap.test.ts create mode 100644 apps/dapp/lib/__tests__/usd-value.test.ts create mode 100644 apps/dapp/lib/__tests__/v3-swap-suite.ts create mode 100644 apps/dapp/lib/__tests__/walrus.live.test.ts create mode 100644 apps/dapp/lib/__tests__/walrus.test.ts create mode 100644 apps/dapp/lib/chains/evm/aave-listing.ts create mode 100644 apps/dapp/lib/chains/evm/aave.ts create mode 100644 apps/dapp/lib/chains/evm/beefy-listing.ts create mode 100644 apps/dapp/lib/chains/evm/beefy.ts create mode 100644 apps/dapp/lib/chains/evm/bulla-listing.ts create mode 100644 apps/dapp/lib/chains/evm/bulla.ts create mode 100644 apps/dapp/lib/chains/evm/call.ts create mode 100644 apps/dapp/lib/chains/evm/curvance-listing.ts create mode 100644 apps/dapp/lib/chains/evm/curvance.ts create mode 100644 apps/dapp/lib/chains/evm/erc4626.ts create mode 100644 apps/dapp/lib/chains/evm/euler-listing.ts create mode 100644 apps/dapp/lib/chains/evm/euler.ts create mode 100644 apps/dapp/lib/chains/evm/fastlane-listing.ts create mode 100644 apps/dapp/lib/chains/evm/fastlane.ts create mode 100644 apps/dapp/lib/chains/evm/morpho-listing.ts create mode 100644 apps/dapp/lib/chains/evm/morpho.ts create mode 100644 apps/dapp/lib/chains/evm/neverland-listing.ts create mode 100644 apps/dapp/lib/chains/evm/pharaoh-listing.ts create mode 100644 apps/dapp/lib/chains/evm/pharaoh.ts create mode 100644 apps/dapp/lib/chains/evm/sablier-listing.ts create mode 100644 apps/dapp/lib/chains/evm/sablier.ts create mode 100644 apps/dapp/lib/chains/evm/uniswap-listing.ts create mode 100644 apps/dapp/lib/chains/evm/uniswap.ts create mode 100644 apps/dapp/lib/chains/evm/v3-swap.ts create mode 100644 apps/dapp/lib/chains/names/alldomains-monad.ts create mode 100644 apps/dapp/lib/chains/names/alldomains-solana.ts create mode 100644 apps/dapp/lib/chains/names/resolve.ts create mode 100644 apps/dapp/lib/chains/names/sns.ts create mode 100644 apps/dapp/lib/chains/names/solana-rpc.ts create mode 100644 apps/dapp/lib/chains/names/suins.ts create mode 100644 apps/dapp/lib/chains/names/tlds.ts create mode 100644 apps/dapp/lib/chains/solana/jito-listing.ts create mode 100644 apps/dapp/lib/chains/solana/jito.ts create mode 100644 apps/dapp/lib/chains/solana/marinade-listing.ts create mode 100644 apps/dapp/lib/chains/solana/marinade.ts create mode 100644 apps/dapp/lib/chains/solana/solayer-listing.ts create mode 100644 apps/dapp/lib/chains/solana/solayer.ts create mode 100644 apps/dapp/lib/chains/solana/stake-pool.ts create mode 100644 apps/dapp/lib/chains/solana/streamflow-listing.ts create mode 100644 apps/dapp/lib/chains/solana/streamflow.ts create mode 100644 apps/dapp/lib/chains/sui/aftermath-listing.ts create mode 100644 apps/dapp/lib/chains/sui/aftermath.ts create mode 100644 apps/dapp/lib/chains/sui/deepbook-listing.ts create mode 100644 apps/dapp/lib/chains/sui/deepbook.ts create mode 100644 apps/dapp/lib/chains/sui/direct-swap.ts create mode 100644 apps/dapp/lib/chains/sui/haedal-listing.ts create mode 100644 apps/dapp/lib/chains/sui/haedal.ts create mode 100644 apps/dapp/lib/chains/sui/lending-calls.ts create mode 100644 apps/dapp/lib/chains/sui/momentum-listing.ts create mode 100644 apps/dapp/lib/chains/sui/momentum.ts create mode 100644 apps/dapp/lib/chains/sui/navi-withdraw.ts create mode 100644 apps/dapp/lib/chains/sui/scallop-listing.ts create mode 100644 apps/dapp/lib/chains/sui/scallop.ts create mode 100644 apps/dapp/lib/chains/sui/springsui-unstake.ts create mode 100644 apps/dapp/lib/chains/sui/suilend-listing.ts create mode 100644 apps/dapp/lib/chains/sui/suilend.ts create mode 100644 apps/dapp/lib/chains/sui/walrus-listing.ts create mode 100644 apps/dapp/lib/chains/sui/walrus.ts delete mode 100644 apps/dapp/lib/lend/cap.ts delete mode 100644 apps/dapp/lib/server/trade-cap.ts create mode 100644 apps/dapp/lib/server/usd-value.ts delete mode 100644 apps/dapp/lib/trade-cap-copy.ts create mode 100644 apps/dapp/public/images/venues/aave-avalanche.webp create mode 100644 apps/dapp/public/images/venues/aave-monad.webp create mode 100644 apps/dapp/public/images/venues/beefy-monad.webp create mode 100644 apps/dapp/public/images/venues/bulla.webp create mode 100644 apps/dapp/public/images/venues/curvance.webp create mode 100644 apps/dapp/public/images/venues/euler-avalanche.webp create mode 100644 apps/dapp/public/images/venues/euler-monad.webp create mode 100644 apps/dapp/public/images/venues/fastlane.webp create mode 100644 apps/dapp/public/images/venues/jito.webp create mode 100644 apps/dapp/public/images/venues/marinade.webp create mode 100644 apps/dapp/public/images/venues/momentum.webp create mode 100644 apps/dapp/public/images/venues/morpho.webp create mode 100644 apps/dapp/public/images/venues/neverland.webp create mode 100644 apps/dapp/public/images/venues/pharaoh.webp create mode 100644 apps/dapp/public/images/venues/sablier-avalanche.webp create mode 100644 apps/dapp/public/images/venues/sablier-monad.webp create mode 100644 apps/dapp/public/images/venues/solayer.webp create mode 100644 apps/dapp/public/images/venues/streamflow.webp create mode 100644 apps/dapp/public/images/venues/uniswap-avalanche.webp create mode 100644 apps/dapp/public/images/venues/uniswap-monad.webp create mode 100644 apps/dapp/public/images/venues/walrus.webp diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index 1912e04a..5cbfe749 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -1,218 +1,214 @@ -NEXT_PUBLIC_APP_URL=http://localhost:3001 -NEXT_PUBLIC_API_URL=http://localhost:8080 -NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws - -# WalletConnect project id from https://cloud.reown.com -# Injected wallets (MetaMask, Rabby) work without this; WalletConnect and -# mobile wallets do not. -NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= - -# Arc testnet RPC. Defaults to the public endpoint when unset. -NEXT_PUBLIC_ARC_RPC_URL=https://rpc.testnet.arc.io - -# --- Stellar network --- -# Which Stellar network the dapp executes on: testnet (the default) or mainnet. -# Anything else refuses to boot. Flipping to mainnet needs, at the least: -# - a funded mainnet sponsor in SPONSOR_SECRET_KEY: there is no friendbot, -# and an unfunded sponsor means users pay their own fees; -# - an RPC you trust in NEXT_PUBLIC_SOROBAN_RPC_URL (the public -# mainnet.sorobanrpc.com is fine to start) and, if you run one, a Horizon -# in NEXT_PUBLIC_STELLAR_HORIZON_URL — both override the active network only; -# - wallets on the public network: the app compares passphrases and asks -# for Stellar Mainnet; -# - knowing that Soroswap, its aggregator (needs SOROSWAP_API_KEY), Aquarius, -# the classic DEX, the network's liquidity pools, Blend and Soroban -# Domains are on mainnet, plus Etherfuse's CETES and USTRY bonds. DeFindex, -# Noether and every anchor stay testnet-only until their mainnet details -# are verified in the code; -# - no fiat off-ramp unless MoneyGram's production domain is set below. -# Every trade is capped in dollars on mainnet; see MAINNET_MAX_TRADE_USD. -NEXT_PUBLIC_STELLAR_NETWORK=testnet -# The Stellar networks this deployment serves, as a comma list that includes the -# default above, for example testnet,mainnet. Unset, only the default is served -# and nothing about the app changes. With more than one: -# - pages live under /stellar-mainnet and /stellar-testnet, and the old -# /stellar address redirects to the default network's; -# - the chain menu lists each network and switching loads the page afresh; -# - every network has its own fee sponsor (SPONSOR_SECRET_KEY_TESTNET and -# SPONSOR_SECRET_KEY_MAINNET; the plain SPONSOR_SECRET_KEY serves only the -# default network), its own spend ledger, standing rules, rate-limit -# buckets, saved wallet sessions and health report (/api/health?network=); -# - the access gate is paused: anyone can connect a wallet. Set ACCESS_GATE=on -# to bring back the email-code login; -# - the scheduled rule check runs each network on its own. -# Needs a funded mainnet sponsor and a database before mainnet is usable. -NEXT_PUBLIC_STELLAR_NETWORKS= -# Where the other Stellar network is deployed, for a deployment that serves only -# one (a mainnet deployment points at the testnet one, and the other way round). -# Adds it to the chain menu as a link. Ignored when NEXT_PUBLIC_STELLAR_NETWORKS -# lists both networks, and on mainnet, which never links to testnet. Leave empty -# and the menu does not mention the other network. -# -# Production serves mainnet only, so testnet is not reachable there: -# NEXT_PUBLIC_STELLAR_NETWORK=mainnet -# NEXT_PUBLIC_STELLAR_NETWORKS=mainnet -# Locally, list both to switch between them: testnet,mainnet -NEXT_PUBLIC_STELLAR_OTHER_NETWORK_URL= -# The most one trade may be worth on mainnet, in dollars, checked against the -# same prices the agents reason from. Ignored on testnet. Default 500. -MAINNET_MAX_TRADE_USD=500 -# MoneyGram's production home domain (stellar.moneygram.com), which turns the -# anchor on for mainnet. Needs MoneyGram's commercial agreement and a -# client_domain this deployment does not yet have; unset, mainnet has no -# off-ramp and says so. -MONEYGRAM_PRODUCTION_HOME_DOMAIN= - -# --- Access gate --- -# Postgres holds the waitlist, the sign-in codes, the rate limits and the usage log. -# On a serverless host use a hosted database's pooled connection string (Supabase: -# the transaction pooler, port 6543); localhost only works on your own machine. -DATABASE_URL=postgresql://intent:intent_local_dev@localhost:55432/intent -# Min 32 chars. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -# Also signs the 1Click deposits issued on Solana, Sui, Avalanche and Monad. Without it that route -# isn't offered, which leaves Avalanche and Monad with no swaps at all. -AUTH_SECRET= -# on | off. Whether the OTP gate in front of the app pages runs. PAUSED: only -# ACCESS_GATE=on turns it on, on any network. The API is outside -# the gate, but with it on the fee sponsor pays only for a request that -# carries the session it issues; anything else submits paying its own fee. -ACCESS_GATE= - -# --- Rate limits --- -# Per client address, per family of route, as count/seconds. Counted in -# Postgres so the limit holds across instances; with no reachable database -# every request is allowed and one warning is logged. Families: build -# (quotes and transaction builds), submit, compete (intent parsing and the -# agent race), resolve (name lookups), standing (rules and inbox) and auth -# (otp, waitlist, admin, perps key session). These are the defaults from -# lib/server/rate-limit.ts; set one to change it. -# The address comes from the platform's own header: on Netlify -# x-nf-client-connection-ip, on Vercel (VERCEL is set there) x-vercel-forwarded-for -# then x-real-ip. The Netlify header is ignored on Vercel, which does not strip -# the client's copy. Behind any other proxy that only appends to -# x-forwarded-for, a client can choose its own bucket. -# RATE_LIMIT_BUILD=30/60 -# RATE_LIMIT_READ=120/60 -# RATE_LIMIT_SUBMIT=10/60 -# RATE_LIMIT_COMPETE=10/60 -# RATE_LIMIT_RESOLVE=30/60 -# RATE_LIMIT_STANDING=30/60 -# RATE_LIMIT_AUTH=10/60 - -# Fee sponsorship. With a secret key here the server wraps every signed -# transaction in a fee-bump it pays for, so a user holding only USDC can still -# trade. On testnet the account funds itself from friendbot on first use. -# Users still need the 1 XLM base reserve to hold an account at all; this -# covers fees, not reserves. -# Needs DATABASE_URL: the budget below is kept there, and with no database -# configured the key pays for nothing (every submission goes out unsponsored -# with reason no_ledger, and GET /api/sponsor says so). -SPONSOR_SECRET_KEY= -# When several Stellar networks are served, a key per network. The plain key above -# serves the default network only, so a testnet key can never pay on mainnet. -SPONSOR_SECRET_KEY_TESTNET= -SPONSOR_SECRET_KEY_MAINNET= -# The most the sponsor pays in a day, in XLM, and how many sponsored -# submissions any one account gets in a day. Past either, the transaction -# goes out paying its own fee. 0 sponsors nothing without removing the key. -SPONSOR_DAILY_BUDGET_XLM=50 -SPONSOR_DAILY_PER_ACCOUNT=20 -# Min 16 chars. Guards /api/admin/waitlist and the /admin/waitlist page. -ADMIN_TOKEN= -# Optional in dev (codes log to console); required in production. -RESEND_API_KEY= -EMAIL_FROM= - -# The line-up. One entry per agent, in display order; the number of entries is -# the number of agents that race. An entry is a provider, or provider/model -# where model is a short name from the provider's table or a raw catalogue id. -# Leave unset to race every configured model: each provider's default plus all -# five curated OpenRouter models when that key is set (seven with the three -# hosted keys). Groq holds one agent: its free tier allows 8,000 tokens a -# minute and one prompt costs ~2,300. An entry whose key is missing is dropped -# with a warning, never swapped. -# AGENT_BRAINS=deepseek,groq,openrouter/ling-fin,openrouter/nemotron-super,openrouter/gemma-4 - -DEEPSEEK_API_KEY= -DEEPSEEK_MODEL=deepseek-v4-flash -# Strict tool schemas live on the /beta endpoint. Set to false to use the -# standard one; responses are validated application-side either way. -DEEPSEEK_STRICT_TOOLS=true - -# Groq's free plan — no card required. https://console.groq.com/keys -# The binding limit is 8,000 tokens per minute, not the 30 requests: four -# agents racing at once can exceed it and come back 429. -GROQ_API_KEY= -GROQ_MODEL=qwen/qwen3.8-27b - -# OpenRouter's free tier — no card required. https://openrouter.ai/keys -# One key fronts every free model, and that is where the variety comes from: -# name a model after the provider in AGENT_BRAINS and each agent gets its own. -# Short names: ling-fin, nemotron-super, gemma-4, laguna-s, north-mini (see -# lib/agents/brains/providers.ts). Anything else is passed through as a raw -# catalogue id, e.g. openrouter/nvidia/nemotron-3-ultra-550b-a55b:free. -# AGENT_BRAINS=deepseek,groq,openrouter/ling-fin,openrouter/nemotron-super,openrouter/gemma-4 -# Limits are per key across every free model: 20 requests a minute, and -# 50 a day until 10 credits have ever been bought (1,000 a day after). The -# default roster puts five agents here: ten races a day untopped, 200 after. -OPENROUTER_API_KEY= -OPENROUTER_MODEL=ling-fin - -# A model running locally via Ollama. Needs no key, and is unreachable from a -# deployed build — 127.0.0.1 is not the deploy host. Never selected unless -# named in AGENT_BRAINS, because a daemon that is not running looks exactly -# like one that is until every agent fails at once. -OLLAMA_MODEL=qwen3.5:latest -OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 -# Client flag: routes the competition through the agent endpoint. -NEXT_PUBLIC_USE_AI=false - -# Soroswap's aggregator API. Free key (starts with sk_) from -# https://api.soroswap.finance/register. Adds one more quote source that splits -# a swap across Soroswap, Aquarius and the classic DEX and returns the unsigned -# transaction, which is re-read against the quote before a wallet sees it. -# Unset, the aggregator is simply absent from the line-up. -SOROSWAP_API_KEY= - -# DeFindex yield vaults: a second lending venue beside Blend. Key from -# https://console.defindex.io (one key serves both networks; this app asks -# only for testnet). Unset, DeFindex is absent from what the agents are -# offered and nothing else changes. -DEFINDEX_API_KEY= - -# Noether perps gateway. The SDK default api.noether.exchange does not -# resolve; this Azure host was taken from the production bundle and answers. -# Testnet only; the gateway reports version 0.0.0-dev and is unaudited. -# NOETHER_API_URL=https://noether-api.proudmeadow-533cf0d8.germanywestcentral.azurecontainerapps.io -NOETHER_API_URL= - -# --- Standing rules --- -# Origin the fired-rule emails link into, e.g. https://intent.example (no trailing slash). -APP_URL=http://localhost:3001 -# Min 16 chars. Guards POST /api/standing/tick, which evaluates every armed rule. -# In development run `pnpm standing:tick`; in production call the route from a cron or scheduled function. -STANDING_TICK_SECRET= - -# --- Monitoring --- -# Sentry. With neither set, nothing is loaded and nothing is sent. The server -# DSN covers API routes and the tick; the public one covers the browser -# bundle. The build never uploads source maps, so no auth token is needed. -SENTRY_DSN= -NEXT_PUBLIC_SENTRY_DSN= -# Where operator alerts go. Unset, none are sent. The one alert today: the fee -# sponsor's balance has fallen below SPONSOR_ALERT_XLM — sent by the tick, once -# a day while it stays there. -ALERT_EMAIL= -SPONSOR_ALERT_XLM=20 - -# --- Cash-outs through Weave --- -# Server-only key for api.paywithweave.com. It can create real orders; never prefix it NEXT_PUBLIC. Unset, cash-outs are off. -WEAVE_SECRET_KEY= -# Signs Weave's webhooks to /api/cashout/webhook. Unset, every webhook is refused with a 401. -WEAVE_WEBHOOK_SECRET= -# Comma list from NGN,KES,UGX,TZS. Unset means NGN only, because no real payout has been tested for the others. -WEAVE_CASHOUT_CURRENCIES= -# Avalanche C-Chain RPC used to build and confirm USDC deposits, and to confirm 1Click swaps there. -AVALANCHE_RPC_URL= -# Monad RPC used to read balances and confirm 1Click swaps. Unset, viem's default public endpoint is used. -MONAD_RPC_URL= +NEXT_PUBLIC_APP_URL=http://localhost:3001 +NEXT_PUBLIC_API_URL=http://localhost:8080 +NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws + +# WalletConnect project id from https://cloud.reown.com +# Injected wallets (MetaMask, Rabby) work without this; WalletConnect and +# mobile wallets do not. +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= + +# Arc testnet RPC. Defaults to the public endpoint when unset. +NEXT_PUBLIC_ARC_RPC_URL=https://rpc.testnet.arc.io + +# --- Stellar network --- +# Which Stellar network the dapp executes on: testnet (the default) or mainnet. +# Anything else refuses to boot. Flipping to mainnet needs, at the least: +# - a funded mainnet sponsor in SPONSOR_SECRET_KEY: there is no friendbot, +# and an unfunded sponsor means users pay their own fees; +# - an RPC you trust in NEXT_PUBLIC_SOROBAN_RPC_URL (the public +# mainnet.sorobanrpc.com is fine to start) and, if you run one, a Horizon +# in NEXT_PUBLIC_STELLAR_HORIZON_URL — both override the active network only; +# - wallets on the public network: the app compares passphrases and asks +# for Stellar Mainnet; +# - knowing that Soroswap, its aggregator (needs SOROSWAP_API_KEY), Aquarius, +# the classic DEX, the network's liquidity pools, Blend and Soroban +# Domains are on mainnet, plus Etherfuse's CETES and USTRY bonds. DeFindex, +# Noether and every anchor stay testnet-only until their mainnet details +# are verified in the code; +# - no fiat off-ramp unless MoneyGram's production domain is set below. +NEXT_PUBLIC_STELLAR_NETWORK=testnet +# The Stellar networks this deployment serves, as a comma list that includes the +# default above, for example testnet,mainnet. Unset, only the default is served +# and nothing about the app changes. With more than one: +# - pages live under /stellar-mainnet and /stellar-testnet, and the old +# /stellar address redirects to the default network's; +# - the chain menu lists each network and switching loads the page afresh; +# - every network has its own fee sponsor (SPONSOR_SECRET_KEY_TESTNET and +# SPONSOR_SECRET_KEY_MAINNET; the plain SPONSOR_SECRET_KEY serves only the +# default network), its own spend ledger, standing rules, rate-limit +# buckets, saved wallet sessions and health report (/api/health?network=); +# - the access gate is paused: anyone can connect a wallet. Set ACCESS_GATE=on +# to bring back the email-code login; +# - the scheduled rule check runs each network on its own. +# Needs a funded mainnet sponsor and a database before mainnet is usable. +NEXT_PUBLIC_STELLAR_NETWORKS= +# Where the other Stellar network is deployed, for a deployment that serves only +# one (a mainnet deployment points at the testnet one, and the other way round). +# Adds it to the chain menu as a link. Ignored when NEXT_PUBLIC_STELLAR_NETWORKS +# lists both networks, and on mainnet, which never links to testnet. Leave empty +# and the menu does not mention the other network. +# +# Production serves mainnet only, so testnet is not reachable there: +# NEXT_PUBLIC_STELLAR_NETWORK=mainnet +# NEXT_PUBLIC_STELLAR_NETWORKS=mainnet +# Locally, list both to switch between them: testnet,mainnet +NEXT_PUBLIC_STELLAR_OTHER_NETWORK_URL= +# MoneyGram's production home domain (stellar.moneygram.com), which turns the +# anchor on for mainnet. Needs MoneyGram's commercial agreement and a +# client_domain this deployment does not yet have; unset, mainnet has no +# off-ramp and says so. +MONEYGRAM_PRODUCTION_HOME_DOMAIN= + +# --- Access gate --- +# Postgres holds the waitlist, the sign-in codes, the rate limits and the usage log. +# On a serverless host use a hosted database's pooled connection string (Supabase: +# the transaction pooler, port 6543); localhost only works on your own machine. +DATABASE_URL=postgresql://intent:intent_local_dev@localhost:55432/intent +# Min 32 chars. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# Also signs the 1Click deposits issued on Solana, Sui, Avalanche and Monad. Without it that route +# isn't offered, which leaves Avalanche and Monad with no swaps at all. +AUTH_SECRET= +# on | off. Whether the OTP gate in front of the app pages runs. PAUSED: only +# ACCESS_GATE=on turns it on, on any network. The API is outside +# the gate, but with it on the fee sponsor pays only for a request that +# carries the session it issues; anything else submits paying its own fee. +ACCESS_GATE= + +# --- Rate limits --- +# Per client address, per family of route, as count/seconds. Counted in +# Postgres so the limit holds across instances; with no reachable database +# every request is allowed and one warning is logged. Families: build +# (quotes and transaction builds), submit, compete (intent parsing and the +# agent race), resolve (name lookups), standing (rules and inbox) and auth +# (otp, waitlist, admin, perps key session). These are the defaults from +# lib/server/rate-limit.ts; set one to change it. +# The address comes from the platform's own header: on Netlify +# x-nf-client-connection-ip, on Vercel (VERCEL is set there) x-vercel-forwarded-for +# then x-real-ip. The Netlify header is ignored on Vercel, which does not strip +# the client's copy. Behind any other proxy that only appends to +# x-forwarded-for, a client can choose its own bucket. +# RATE_LIMIT_BUILD=30/60 +# RATE_LIMIT_READ=120/60 +# RATE_LIMIT_SUBMIT=10/60 +# RATE_LIMIT_COMPETE=10/60 +# RATE_LIMIT_RESOLVE=30/60 +# RATE_LIMIT_STANDING=30/60 +# RATE_LIMIT_AUTH=10/60 + +# Fee sponsorship. With a secret key here the server wraps every signed +# transaction in a fee-bump it pays for, so a user holding only USDC can still +# trade. On testnet the account funds itself from friendbot on first use. +# Users still need the 1 XLM base reserve to hold an account at all; this +# covers fees, not reserves. +# Needs DATABASE_URL: the budget below is kept there, and with no database +# configured the key pays for nothing (every submission goes out unsponsored +# with reason no_ledger, and GET /api/sponsor says so). +SPONSOR_SECRET_KEY= +# When several Stellar networks are served, a key per network. The plain key above +# serves the default network only, so a testnet key can never pay on mainnet. +SPONSOR_SECRET_KEY_TESTNET= +SPONSOR_SECRET_KEY_MAINNET= +# The most the sponsor pays in a day, in XLM, and how many sponsored +# submissions any one account gets in a day. Past either, the transaction +# goes out paying its own fee. 0 sponsors nothing without removing the key. +SPONSOR_DAILY_BUDGET_XLM=50 +SPONSOR_DAILY_PER_ACCOUNT=20 +# Min 16 chars. Guards /api/admin/waitlist and the /admin/waitlist page. +ADMIN_TOKEN= +# Optional in dev (codes log to console); required in production. +RESEND_API_KEY= +EMAIL_FROM= + +# The line-up. One entry per agent, in display order; the number of entries is +# the number of agents that race. An entry is a provider, or provider/model +# where model is a short name from the provider's table or a raw catalogue id. +# Leave unset to race every configured model: each provider's default plus all +# five curated OpenRouter models when that key is set (seven with the three +# hosted keys). Groq holds one agent: its free tier allows 8,000 tokens a +# minute and one prompt costs ~2,300. An entry whose key is missing is dropped +# with a warning, never swapped. +# AGENT_BRAINS=deepseek,groq,openrouter/ling-fin,openrouter/nemotron-super,openrouter/gemma-4 + +DEEPSEEK_API_KEY= +DEEPSEEK_MODEL=deepseek-v4-flash +# Strict tool schemas live on the /beta endpoint. Set to false to use the +# standard one; responses are validated application-side either way. +DEEPSEEK_STRICT_TOOLS=true + +# Groq's free plan — no card required. https://console.groq.com/keys +# The binding limit is 8,000 tokens per minute, not the 30 requests: four +# agents racing at once can exceed it and come back 429. +GROQ_API_KEY= +GROQ_MODEL=qwen/qwen3.8-27b + +# OpenRouter's free tier — no card required. https://openrouter.ai/keys +# One key fronts every free model, and that is where the variety comes from: +# name a model after the provider in AGENT_BRAINS and each agent gets its own. +# Short names: ling-fin, nemotron-super, gemma-4, laguna-s, north-mini (see +# lib/agents/brains/providers.ts). Anything else is passed through as a raw +# catalogue id, e.g. openrouter/nvidia/nemotron-3-ultra-550b-a55b:free. +# AGENT_BRAINS=deepseek,groq,openrouter/ling-fin,openrouter/nemotron-super,openrouter/gemma-4 +# Limits are per key across every free model: 20 requests a minute, and +# 50 a day until 10 credits have ever been bought (1,000 a day after). The +# default roster puts five agents here: ten races a day untopped, 200 after. +OPENROUTER_API_KEY= +OPENROUTER_MODEL=ling-fin + +# A model running locally via Ollama. Needs no key, and is unreachable from a +# deployed build — 127.0.0.1 is not the deploy host. Never selected unless +# named in AGENT_BRAINS, because a daemon that is not running looks exactly +# like one that is until every agent fails at once. +OLLAMA_MODEL=qwen3.5:latest +OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 +# Client flag: routes the competition through the agent endpoint. +NEXT_PUBLIC_USE_AI=false + +# Soroswap's aggregator API. Free key (starts with sk_) from +# https://api.soroswap.finance/register. Adds one more quote source that splits +# a swap across Soroswap, Aquarius and the classic DEX and returns the unsigned +# transaction, which is re-read against the quote before a wallet sees it. +# Unset, the aggregator is simply absent from the line-up. +SOROSWAP_API_KEY= + +# DeFindex yield vaults: a second lending venue beside Blend. Key from +# https://console.defindex.io (one key serves both networks; this app asks +# only for testnet). Unset, DeFindex is absent from what the agents are +# offered and nothing else changes. +DEFINDEX_API_KEY= + +# Noether perps gateway. The SDK default api.noether.exchange does not +# resolve; this Azure host was taken from the production bundle and answers. +# Testnet only; the gateway reports version 0.0.0-dev and is unaudited. +# NOETHER_API_URL=https://noether-api.proudmeadow-533cf0d8.germanywestcentral.azurecontainerapps.io +NOETHER_API_URL= + +# --- Standing rules --- +# Origin the fired-rule emails link into, e.g. https://intent.example (no trailing slash). +APP_URL=http://localhost:3001 +# Min 16 chars. Guards POST /api/standing/tick, which evaluates every armed rule. +# In development run `pnpm standing:tick`; in production call the route from a cron or scheduled function. +STANDING_TICK_SECRET= + +# --- Monitoring --- +# Sentry. With neither set, nothing is loaded and nothing is sent. The server +# DSN covers API routes and the tick; the public one covers the browser +# bundle. The build never uploads source maps, so no auth token is needed. +SENTRY_DSN= +NEXT_PUBLIC_SENTRY_DSN= +# Where operator alerts go. Unset, none are sent. The one alert today: the fee +# sponsor's balance has fallen below SPONSOR_ALERT_XLM — sent by the tick, once +# a day while it stays there. +ALERT_EMAIL= +SPONSOR_ALERT_XLM=20 + +# --- Cash-outs through Weave --- +# Server-only key for api.paywithweave.com. It can create real orders; never prefix it NEXT_PUBLIC. Unset, cash-outs are off. +WEAVE_SECRET_KEY= +# Signs Weave's webhooks to /api/cashout/webhook. Unset, every webhook is refused with a 401. +WEAVE_WEBHOOK_SECRET= +# Comma list from NGN,KES,UGX,TZS. Unset means NGN only, because no real payout has been tested for the others. +WEAVE_CASHOUT_CURRENCIES= +# Avalanche C-Chain RPC used to build and confirm USDC deposits, and to confirm 1Click swaps there. +AVALANCHE_RPC_URL= +# Monad RPC used to read balances and confirm 1Click swaps. Unset, viem's default public endpoint is used. +MONAD_RPC_URL= diff --git a/apps/dapp/app/[chain]/layout.tsx b/apps/dapp/app/[chain]/layout.tsx index 7d5571ff..9a86a79b 100644 --- a/apps/dapp/app/[chain]/layout.tsx +++ b/apps/dapp/app/[chain]/layout.tsx @@ -5,7 +5,6 @@ import { notFound } from 'next/navigation' import { WalletGate } from '../../components/connect/wallet-gate' import { ChatHost } from '../../components/intents/chat-host' import { AppShell } from '../../components/layout/app-shell' -import { tradeCapUsd } from '../../lib/server/trade-cap' import { ChainProvider } from '../../providers/chain-provider' /** @@ -29,15 +28,6 @@ export default async function ChainLayout({ // showing a different chain than the URL names would be worse than an error. if (parseChainSegment(chain) === undefined) notFound() - // A misconfigured cap must not take the whole frame down with it; the - // routes that enforce it report the problem where it matters. - let cap: number | undefined - try { - cap = tradeCapUsd() - } catch { - cap = undefined - } - // For the browser tests, which have no wallet. Needs the server's own setting // as well as the cookie, so a visitor cannot switch the gate off in production. const bypass = @@ -47,7 +37,7 @@ export default async function ChainLayout({ return ( - + {children} diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 804e6586..c44264da 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -17,7 +17,7 @@ import { getRoster } from '../../../../lib/agents/registry' import type { PlanContext } from '../../../../lib/agents/plan-scoring' import { pickWinner, scoreProposals, unanimousChoice } from '../../../../lib/agents/scoring' import { - assertAmountWithinCap, + assertValidAmount, assertKnownTokens, assertNativeReserve, } from '../../../../lib/chains/prepare' @@ -138,8 +138,8 @@ async function chainRaceFacts( } } try { - const known = assertKnownTokens(chainApi, action) - await assertAmountWithinCap(chainApi, action, known) + assertKnownTokens(chainApi, action) + assertValidAmount(action) } catch (e) { if (e instanceof ChainActionRefused) return { refusal: `${e.message} Nothing was signed or sent.` } diff --git a/apps/dapp/app/api/cashout/orders/route.ts b/apps/dapp/app/api/cashout/orders/route.ts index 6692fe8d..f5da4c8e 100644 --- a/apps/dapp/app/api/cashout/orders/route.ts +++ b/apps/dapp/app/api/cashout/orders/route.ts @@ -6,7 +6,6 @@ import { databaseConfigured } from '../../../../lib/server/db' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { reportError } from '../../../../lib/server/report' import { requesterOf } from '../../../../lib/server/requester' -import { assertWithinCap, TradeCapExceeded, tradeCapUsd } from '../../../../lib/server/trade-cap' import { createWeaveClient, quoteCostPercent } from '../../../../lib/weave/client' import { isCurrencyAllowed } from '../../../../lib/weave/corridors' import { depositChain } from '../../../../lib/weave/deposit' @@ -56,23 +55,6 @@ function refusalFor(path: (string | number)[]): string { } } -/** - * The cap always reads as mainnet, because a cash-out always is. Its own wording is - * for whoever runs the deployment, so a person gets this instead. - */ -function overCap(usd: number): Response | undefined { - try { - assertWithinCap(usd, process.env, 'mainnet') - return undefined - } catch (e) { - if (!(e instanceof TradeCapExceeded)) throw e - return refusal( - 'over_limit', - `This app caps each cash-out at $${tradeCapUsd(process.env, 'mainnet')}, and this one is about $${usd.toFixed(2)}. Try a smaller amount.` - ) - } -} - /** This requester's cash-outs, newest first. Empty rather than an error when there is no record to read. */ export async function GET(request: Request): Promise { const who = requesterOf(request) @@ -172,9 +154,6 @@ export async function POST(request: Request): Promise { ) } - const capped = overCap(Number(amount)) - if (capped !== undefined) return capped - const weave = createWeaveClient() const fail = (code: string, message: string): Response => refusal(code, message, code === 'rejected' ? 422 : 503) diff --git a/apps/dapp/app/api/chain/[chain]/prepare/route.ts b/apps/dapp/app/api/chain/[chain]/prepare/route.ts index 2162df90..0ea829e5 100644 --- a/apps/dapp/app/api/chain/[chain]/prepare/route.ts +++ b/apps/dapp/app/api/chain/[chain]/prepare/route.ts @@ -14,7 +14,19 @@ export const dynamic = 'force-dynamic' const Body = z.object({ account: z.string().min(1).max(128), request: z.object({ - kind: z.enum(['swap', 'supply', 'stake', 'send']), + kind: z.enum([ + 'swap', + 'supply', + 'stake', + 'send', + 'withdraw', + 'unstake', + 'borrow', + 'repay', + 'stream', + 'invoice', + 'pay_invoice', + ]), amount: z .string() .regex(/^\d+(\.\d+)?$/) @@ -23,6 +35,18 @@ const Body = z.object({ tokenOut: z.string().min(1).max(16).optional(), venue: z.string().min(1).max(40).optional(), recipient: z.string().min(1).max(128).optional(), + durationDays: z + .string() + .regex(/^[1-9]\d{0,4}$/) + .optional(), + dueDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + invoiceId: z + .string() + .regex(/^\d{1,78}$/) + .optional(), }), }) @@ -44,7 +68,8 @@ export async function POST( } try { - const { kind, amount, tokenIn, tokenOut, venue, recipient } = parsed.data.request + const { kind, amount, tokenIn, tokenOut, venue, recipient, durationDays, dueDate, invoiceId } = + parsed.data.request const prepared = await prepareChainAction( chainApi, { @@ -54,6 +79,9 @@ export async function POST( ...(tokenOut !== undefined ? { tokenOut } : {}), ...(venue !== undefined ? { venue } : {}), ...(recipient !== undefined ? { recipient } : {}), + ...(durationDays !== undefined ? { durationDays } : {}), + ...(dueDate !== undefined ? { dueDate } : {}), + ...(invoiceId !== undefined ? { invoiceId } : {}), }, parsed.data.account ) diff --git a/apps/dapp/app/api/lend/borrow/route.ts b/apps/dapp/app/api/lend/borrow/route.ts index 58044d7a..d42f80cb 100644 --- a/apps/dapp/app/api/lend/borrow/route.ts +++ b/apps/dapp/app/api/lend/borrow/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server' import { buildBlendBorrow, prepareBlendWithdraw } from '../../../../lib/lend/blend-client' -import { assertReserveWithinCap } from '../../../../lib/lend/cap' import { explainPoolError, isTransientPoolError } from '../../../../lib/lend/pool-errors' import { readReserveList } from '../../../../lib/lend/reserves' import { enforceRateLimit } from '../../../../lib/server/rate-limit' @@ -72,16 +71,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) } - // A borrow adds exposure the same as a supply does, so it is capped the same. - try { - await assertReserveWithinCap(body.asset, body.amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - try { const built = await buildBlendBorrow({ account: body.account, diff --git a/apps/dapp/app/api/lend/build/route.ts b/apps/dapp/app/api/lend/build/route.ts index 7d4ca164..1698a2a1 100644 --- a/apps/dapp/app/api/lend/build/route.ts +++ b/apps/dapp/app/api/lend/build/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from 'next/server' import { buildBlendSupply, prepareBlendSupply } from '../../../../lib/lend/blend-client' import { explainPoolError } from '../../../../lib/lend/pool-errors' -import { assertReserveWithinCap } from '../../../../lib/lend/cap' import { readReserveList } from '../../../../lib/lend/reserves' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { reportError } from '../../../../lib/server/report' @@ -64,17 +63,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) } - // Against the mainnet cap; see `assertReserveWithinCap` for how a reserve is - // priced. - try { - await assertReserveWithinCap(body.asset, body.amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - try { const built = await buildBlendSupply({ account: body.account, diff --git a/apps/dapp/app/api/lend/collateral/route.ts b/apps/dapp/app/api/lend/collateral/route.ts index 40d09db1..4b4f916f 100644 --- a/apps/dapp/app/api/lend/collateral/route.ts +++ b/apps/dapp/app/api/lend/collateral/route.ts @@ -5,7 +5,6 @@ import { buildBlendCollateralWithdraw, prepareBlendWithdraw, } from '../../../../lib/lend/blend-client' -import { assertReserveWithinCap } from '../../../../lib/lend/cap' import { explainPoolError } from '../../../../lib/lend/pool-errors' import { readReserveList } from '../../../../lib/lend/reserves' import { enforceRateLimit } from '../../../../lib/server/rate-limit' @@ -75,19 +74,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) } - // Posting moves the user's funds into the pool, so it is capped. Reclaiming - // returns them to a plain supply balance of the same account and is not. - if (body.direction === 'post') { - try { - await assertReserveWithinCap(body.asset, body.amount as string) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - } - try { const built = body.direction === 'post' diff --git a/apps/dapp/app/api/lend/defindex/build/route.ts b/apps/dapp/app/api/lend/defindex/build/route.ts index 5b0a3300..fbc0d335 100644 --- a/apps/dapp/app/api/lend/defindex/build/route.ts +++ b/apps/dapp/app/api/lend/defindex/build/route.ts @@ -29,10 +29,6 @@ interface Body { amount?: unknown } -// No mainnet trade cap here. This venue is testnet-only (`networks` in -// lib/venues.ts) and its builder refuses any other network, so nothing is -// spent on mainnet through this route. Give it `assertTradeWithinCap` before -// the venue gains `networks: ['mainnet']`. export async function POST(request: Request): Promise { const limited = await enforceRateLimit(request, 'build') if (limited !== undefined) return limited diff --git a/apps/dapp/app/api/lend/repay/route.ts b/apps/dapp/app/api/lend/repay/route.ts index 44f9985a..1298f65f 100644 --- a/apps/dapp/app/api/lend/repay/route.ts +++ b/apps/dapp/app/api/lend/repay/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server' import { buildBlendRepay, prepareBlendWithdraw } from '../../../../lib/lend/blend-client' -import { assertReserveWithinCap } from '../../../../lib/lend/cap' import { explainPoolError } from '../../../../lib/lend/pool-errors' import { readReserveList } from '../../../../lib/lend/reserves' import { enforceRateLimit } from '../../../../lib/server/rate-limit' @@ -64,20 +63,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) } - // An omitted amount clears the whole liability, which cannot be valued ahead - // of time. That liability can only have come from a borrow this cap already - // bounded, so repaying it is not a way to move more than the cap. - if (body.amount !== undefined) { - try { - await assertReserveWithinCap(body.asset, body.amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - } - try { const built = await buildBlendRepay({ account: body.account, diff --git a/apps/dapp/app/api/lend/withdraw/route.ts b/apps/dapp/app/api/lend/withdraw/route.ts index 19055185..5b780797 100644 --- a/apps/dapp/app/api/lend/withdraw/route.ts +++ b/apps/dapp/app/api/lend/withdraw/route.ts @@ -37,9 +37,9 @@ interface WithdrawBody { amount?: string } -// No mainnet trade cap here: a withdrawal returns the account's own position -// to the account (`assertSelfPoolCall` pins `from`, `spender` and `to` to the -// signer), so it cannot move value anywhere the user did not already hold it. +// A withdrawal returns the account's own position to the account +// (`assertSelfPoolCall` pins `from`, `spender` and `to` to the signer), so it +// cannot move value anywhere the user did not already hold it. export async function POST(request: Request): Promise { const limited = await enforceRateLimit(request, 'build') if (limited !== undefined) return limited diff --git a/apps/dapp/app/api/offers/build/route.ts b/apps/dapp/app/api/offers/build/route.ts index eca31235..eff77eb5 100644 --- a/apps/dapp/app/api/offers/build/route.ts +++ b/apps/dapp/app/api/offers/build/route.ts @@ -5,7 +5,6 @@ import { resolveAsset } from '../../../../lib/swap/assets' import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { buildOfferTransaction } from '../../../../lib/swap/build-offer' import { fetchOrderBookTop, offerPriceFromUsd } from '../../../../lib/swap/limit-price' -import { assertTradeWithinCap } from '../../../../lib/server/trade-cap' import { enforceRateLimit } from '../../../../lib/server/rate-limit' /** @@ -100,16 +99,6 @@ export async function POST(req: Request): Promise { return NextResponse.json({ error: 'limit_price_required' }, { status: 400 }) } - // What the order gives up, against the mainnet cap. - try { - await assertTradeWithinCap(selling.code, amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - // The book is read for the pair in its quoted orientation — XLM against // USDC — regardless of which way this particular order runs, because that is // the orientation the prices are expressed in. diff --git a/apps/dapp/app/api/offramp/build/route.ts b/apps/dapp/app/api/offramp/build/route.ts index c1952a0d..f58d713e 100644 --- a/apps/dapp/app/api/offramp/build/route.ts +++ b/apps/dapp/app/api/offramp/build/route.ts @@ -4,7 +4,6 @@ import { buildOfframpPayment } from '../../../../lib/offramp/build-payment' import { readExpectation } from '../../../../lib/offramp/read-expectation' import { USDC, fromBaseUnits, toBaseUnits } from '../../../../lib/swap/assets' import { balanceOf } from '../../../../lib/swap/delivered-balance' -import { assertTradeWithinCap } from '../../../../lib/server/trade-cap' import { enforceRateLimit } from '../../../../lib/server/rate-limit' /** @@ -70,16 +69,6 @@ export async function POST(request: Request): Promise { ) } - // The amount the anchor asks for, against the mainnet cap. - try { - await assertTradeWithinCap(USDC.code, read.expectation.amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - // The anchor may ask for more than the swap delivered. The app never rounds // up to meet it: the figures are named and the user decides. try { diff --git a/apps/dapp/app/api/plan/build/route.ts b/apps/dapp/app/api/plan/build/route.ts index 9bdaa5d2..d73942ad 100644 --- a/apps/dapp/app/api/plan/build/route.ts +++ b/apps/dapp/app/api/plan/build/route.ts @@ -8,7 +8,6 @@ import { applySlippage } from '../../../../lib/swap/assets' import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { collectQuotes } from '../../../../lib/swap/quote' import { createHorizonQuoter } from '../../../../lib/swap/sources/horizon-quoter' -import { assertPlanWithinCap } from '../../../../lib/server/trade-cap' import { enforceRateLimit } from '../../../../lib/server/rate-limit' /** @@ -66,9 +65,6 @@ export async function POST(request: Request): Promise { try { const built = await buildPlan({ account: body.account, actions }) - // Step by step against the mainnet cap, once the plan has been built, so - // a step whose venue is not on this network is refused by name first. - await assertPlanWithinCap(actions) return NextResponse.json({ xdr: built.xdr, steps: built.steps, diff --git a/apps/dapp/app/api/send/build/route.ts b/apps/dapp/app/api/send/build/route.ts index 21549a1d..edd57f65 100644 --- a/apps/dapp/app/api/send/build/route.ts +++ b/apps/dapp/app/api/send/build/route.ts @@ -15,7 +15,6 @@ import { resolveAsset } from '../../../../lib/swap/assets' import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { derivePreview } from '../../../../lib/swap/preview' import { fetchMarketPrices } from '../../../../lib/swap/prices' -import { assertTradeWithinCap } from '../../../../lib/server/trade-cap' import { enforceRateLimit } from '../../../../lib/server/rate-limit' /** @@ -100,16 +99,6 @@ export async function POST(request: Request): Promise { } } - // Against the mainnet cap, sized from the same table as the amount above. - try { - await assertTradeWithinCap(asset.code, amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - let expectation try { expectation = expectationFor(resolved, symbol, amount, memo) diff --git a/apps/dapp/app/api/swap/build/route.ts b/apps/dapp/app/api/swap/build/route.ts index 01db7d43..e937f2a3 100644 --- a/apps/dapp/app/api/swap/build/route.ts +++ b/apps/dapp/app/api/swap/build/route.ts @@ -18,10 +18,9 @@ import { buildSorobanSwap, prepareSorobanSwap } from '../../../../lib/swap/build import { createSoroswapApi } from '../../../../lib/swap/soroswap-api' import { createSoroswapAggregatorQuoter } from '../../../../lib/swap/sources/soroswap-aggregator-quoter' import { builderFor, type VenueKind } from '../../../../lib/swap/venue-routing' -import { applySlippage, fromBaseUnits } from '../../../../lib/swap/assets' +import { applySlippage } from '../../../../lib/swap/assets' import { ensureLiveAssets } from '../../../../lib/swap/live-assets' import { checkSlippageBps } from '../../../../lib/swap/slippage' -import { assertTradeWithinCap } from '../../../../lib/server/trade-cap' import { DEFAULT_SLIPPAGE_BPS } from '../../../../lib/swap/build-tx' import { enforceRateLimit } from '../../../../lib/server/rate-limit' @@ -140,18 +139,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'no_route', reason: fresh.failure.reason }, { status: 200 }) } - // The cap reads the fresh quote, never the client's figure. On a fixed - // output the client's send amount is a claim about nothing — the envelope - // spends up to the quoted input widened by the tolerance, so that ceiling - // is what is capped. - const slippageBps = slippage.bps ?? DEFAULT_SLIPPAGE_BPS - const spends = - fresh.quote.kind === 'strict_receive' - ? widen(fresh.quote.sendAmount, slippageBps) - : fresh.quote.sendAmount - const capped = await overCap(fresh.quote.from.code, spends) - if (capped !== undefined) return capped - try { const built = await buildSwapTransaction({ account: body.account, @@ -185,24 +172,6 @@ export async function POST(request: Request): Promise { } } -/** - * The mainnet cap, checked on what a fresh quote says will leave the account - * — in base units of the asset sent — and never on a figure the client sent. - * A no-op on testnet. Answers the refusal so each venue's builder can return - * it as is. - */ -async function overCap(symbol: string, baseAmount: string): Promise { - try { - await assertTradeWithinCap(symbol, fromBaseUnits(baseAmount)) - return undefined - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } -} - /** * Builds a Soroswap swap and prepares it for signature. * @@ -235,9 +204,6 @@ async function buildViaSoroban( return NextResponse.json({ error: 'no_route', reason: fresh.failure.reason }, { status: 200 }) } - const capped = await overCap(fresh.quote.from.code, fresh.quote.sendAmount) - if (capped !== undefined) return capped - const minReceive = applySlippage(fresh.quote.destAmount, slippageBps) let built @@ -333,9 +299,6 @@ async function buildViaAggregator( return NextResponse.json({ error: 'no_route', reason: fresh.failure.reason }, { status: 200 }) } - const capped = await overCap(fresh.quoted.quote.from.code, fresh.quoted.quote.sendAmount) - if (capped !== undefined) return capped - let built try { built = await buildAggregatorSwap({ account, quoted: fresh.quoted, api: createSoroswapApi() }) @@ -429,9 +392,6 @@ async function buildViaAquarius( ) } - const capped = await overCap(fresh.from.code, fresh.sendAmount) - if (capped !== undefined) return capped - const minReceive = applySlippage(fresh.destAmount, slippageBps) let built @@ -506,9 +466,6 @@ async function buildViaPhoenix( ) } - const capped = await overCap(fresh.from.code, fresh.sendAmount) - if (capped !== undefined) return capped - const minReceive = applySlippage(fresh.destAmount, slippageBps) let built diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx index ceb093f1..cb03efbf 100644 --- a/apps/dapp/components/intents/chain-action-confirm.tsx +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -5,6 +5,28 @@ import { ExternalLink, Loader2 } from 'lucide-react' import type { ChainAction } from '../../hooks/use-chain-action' import { stepErrorMessage } from '../../lib/agents/chain-race' +import type { PreparedAction } from '../../lib/chains/types' + +function cardLine({ kind, pay, venueName }: PreparedAction): string { + const what = `${pay.amount} ${pay.symbol}` + switch (kind) { + case 'send': + return `This moves ${what} from your wallet` + case 'withdraw': + case 'unstake': + return `You take ${what} out of ${venueName}` + case 'borrow': + return `You borrow ${what} from ${venueName}, and owe it back with interest` + case 'repay': + return `You repay ${what} to ${venueName}` + case 'stream': + return `This commits ${what} from your wallet to a stream on ${venueName}` + case 'invoice': + return `This bills ${what} on ${venueName}, and moves nothing from your wallet` + default: + return `You pay ${what} on ${venueName}` + } +} /** Review, sign and result for an action on a chain other than Stellar. */ export function ChainActionConfirm({ @@ -27,7 +49,9 @@ export function ChainActionConfirm({ {phase === 'building' ? (

) : null} @@ -62,9 +86,7 @@ export function ChainActionConfirm({
{prepared.summary} - {prepared.kind === 'send' - ? `This moves ${prepared.pay.amount} ${prepared.pay.symbol} from your wallet` - : `You pay ${prepared.pay.amount} ${prepared.pay.symbol} on ${prepared.venueName}`} + {cardLine(prepared)} {prepared.receive !== undefined ? `, and receive ${prepared.receive.approximate ? 'about ' : ''}${prepared.receive.amount} ${prepared.receive.symbol}` : ''} diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 5fd2301e..06cd944e 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -182,7 +182,6 @@ import { resolveAsset, toBaseUnits } from '../../lib/swap/assets' import { toPriceFraction } from '../../lib/swap/limit-price' import { LimitConfirm } from './limit-confirm' import { OpenOrders } from './open-orders' -import { TradeCapNote } from '../layout/trade-cap' import { ComposerInput } from './composer-input' import { NativeCoinTicker } from './native-coin-ticker' import { PriceTicker } from './price-ticker' @@ -3599,7 +3598,6 @@ export function IntentChat({ : {})} {...(parsed ? { onReset: handleReset } : {})} /> - {createIntent.isError ? (

{(createIntent.error as Error).message || 'Something went wrong. No funds moved.'} diff --git a/apps/dapp/components/intents/swap-confirm.tsx b/apps/dapp/components/intents/swap-confirm.tsx index c1a321ff..c52ad079 100644 --- a/apps/dapp/components/intents/swap-confirm.tsx +++ b/apps/dapp/components/intents/swap-confirm.tsx @@ -44,14 +44,8 @@ import { LedgerPreview } from './ledger-preview' /** * The threshold itself, inlined at build time like every other - * `NEXT_PUBLIC_` value in this app. Not read from `trade-cap.ts`'s - * `MAINNET_MAX_TRADE_USD`: that is a server-side hard cap enforced at submit - * time, checked against the live price table rather than the client's - * `usdPrices` snapshot, and it exists to refuse a trade outright. This is a - * client-side pause that exists to slow a human down, and the two moving - * independently is deliberate — raising the hard cap to let bigger trades - * through should not silently also raise how much friction a human sees - * first, and vice versa. + * `NEXT_PUBLIC_` value in this app. A client-side pause that exists to slow a + * human down, and it refuses nothing. */ const CONFIRM_PAUSE_THRESHOLD_USD = Number( process.env.NEXT_PUBLIC_SWAP_CONFIRM_THRESHOLD_USD ?? '500' diff --git a/apps/dapp/components/layout/app-shell.tsx b/apps/dapp/components/layout/app-shell.tsx index b5529c66..c476daf8 100644 --- a/apps/dapp/components/layout/app-shell.tsx +++ b/apps/dapp/components/layout/app-shell.tsx @@ -8,7 +8,6 @@ import { useEffect, useState, type ReactNode } from 'react' import { Header } from './header' import { NetworkMismatchDialog } from './network-mismatch-dialog' import { Sidebar } from './sidebar' -import { TradeCapProvider } from './trade-cap' /** * The application frame, and where it adapts to a narrow screen. @@ -23,13 +22,7 @@ import { TradeCapProvider } from './trade-cap' * stacking rules at each breakpoint, and the version that reads clearly is two * declarations that each do one thing. */ -export function AppShell({ - children, - tradeCapUsd, -}: { - children: ReactNode - tradeCapUsd?: number | undefined -}): JSX.Element { +export function AppShell({ children }: { children: ReactNode }): JSX.Element { const [menuOpen, setMenuOpen] = useState(false) const pathname = usePathname() @@ -73,7 +66,6 @@ export function AppShell({ header and page sit on. See globals.css. */}

setMenuOpen(true)} menuButton={
diff --git a/apps/dapp/components/layout/chain-switcher.tsx b/apps/dapp/components/layout/chain-switcher.tsx index 0d08f90c..5c5d294f 100644 --- a/apps/dapp/components/layout/chain-switcher.tsx +++ b/apps/dapp/components/layout/chain-switcher.tsx @@ -75,7 +75,7 @@ function entriesFor(): Entry[] { export function ChainSwitcher({ caption, }: { - /** A line under the pill, such as the trade cap. The menu opens below it. */ + /** A line under the pill. The menu opens below it. */ caption?: ReactNode } = {}): JSX.Element { const { slug, network, descriptor } = useChain() diff --git a/apps/dapp/components/layout/header.tsx b/apps/dapp/components/layout/header.tsx index 4437aa14..e86d39d8 100644 --- a/apps/dapp/components/layout/header.tsx +++ b/apps/dapp/components/layout/header.tsx @@ -19,14 +19,11 @@ import { ThemeToggle } from './theme-toggle' */ export function Header({ menuButton, - tradeCapUsd, }: { /** The drawer trigger, supplied by the shell that owns the drawer's state. */ menuButton?: ReactNode /** Unused here; the shell passes the handler through `menuButton`. */ onOpenMenu?: () => void - /** The mainnet per-trade cap, shown beside the network. */ - tradeCapUsd?: number | undefined }): JSX.Element { return ( // Clear, so the canvas glow behind it reads through. The page scrolls in @@ -40,7 +37,7 @@ export function Header({ - + } /> diff --git a/apps/dapp/components/layout/network-status.tsx b/apps/dapp/components/layout/network-status.tsx index c633664c..5f6eb8ff 100644 --- a/apps/dapp/components/layout/network-status.tsx +++ b/apps/dapp/components/layout/network-status.tsx @@ -22,12 +22,7 @@ const ARC_FAUCET_URL = 'https://faucet.circle.com' * Everything here is derived from the connection and the active chain, so the * badge cannot claim a network the wallet is not on. */ -export function NetworkStatus({ - tradeCapUsd, -}: { - /** The per-trade cap on mainnet, when there is one. Decided on the server. */ - tradeCapUsd?: number | undefined -}): JSX.Element { +export function NetworkStatus(): JSX.Element { const { slug, descriptor } = useChain() const { isConnected, @@ -104,13 +99,7 @@ export function NetworkStatus({ {FAUCET_LABEL[slug]} ) - ) : isMainnet() || (slug !== 'stellar' && slug !== 'arc') ? ( - tradeCapUsd !== undefined && slug === 'stellar' ? ( - - Trades up to ${tradeCapUsd} - - ) : null - ) : ( + ) : isMainnet() || (slug !== 'stellar' && slug !== 'arc') ? null : ( pre-mainnet · rails may change diff --git a/apps/dapp/components/layout/trade-cap.tsx b/apps/dapp/components/layout/trade-cap.tsx deleted file mode 100644 index 09d3b762..00000000 --- a/apps/dapp/components/layout/trade-cap.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client' - -import { createContext, useContext, type ReactNode } from 'react' - -import { tradeCapNote } from '../../lib/trade-cap-copy' -import { useChain } from '../../providers/chain-provider' - -const TradeCapContext = createContext(undefined) - -/** Carries the server-decided mainnet cap to any screen that wants to say it. */ -export function TradeCapProvider({ - capUsd, - children, -}: { - capUsd: number | undefined - children: ReactNode -}): JSX.Element { - return {children} -} - -/** One quiet line under a trade input. Renders nothing where there is no cap. */ -export function TradeCapNote(): JSX.Element | null { - const { slug } = useChain() - const note = tradeCapNote(useContext(TradeCapContext), slug) - return note === undefined ? null :

{note}

-} diff --git a/apps/dapp/e2e/chain-action.spec.ts b/apps/dapp/e2e/chain-action.spec.ts index e400f2e5..d9d64de6 100644 --- a/apps/dapp/e2e/chain-action.spec.ts +++ b/apps/dapp/e2e/chain-action.spec.ts @@ -171,10 +171,10 @@ test('a transaction with no outcome yet says so, and is not shown as a failure', test('a refusal from the server is shown as written', async ({ page }) => { await connectFakeSolanaWallet(page) await page.route('**/api/chain/solana/prepare', (route) => - route.fulfill({ status: 422, json: { error: 'This app caps each trade at $500.' } }) + route.fulfill({ status: 422, json: { error: 'Nothing here can swap on this chain yet.' } }) ) await say(page, 'Swap 9000 USDC to SOL') - await expect(page.getByRole('alert').filter({ hasText: 'caps each trade' })).toBeVisible() + await expect(page.getByRole('alert').filter({ hasText: 'Nothing here can swap' })).toBeVisible() }) test('an instruction it cannot read gets told what works, and nothing is built', async ({ diff --git a/apps/dapp/e2e/network-switch.spec.ts b/apps/dapp/e2e/network-switch.spec.ts index af8919bc..ff12d88a 100644 --- a/apps/dapp/e2e/network-switch.spec.ts +++ b/apps/dapp/e2e/network-switch.spec.ts @@ -13,13 +13,11 @@ test.skip( test('the menu switches the page from testnet to mainnet and back', async ({ page }) => { await page.goto('/stellar-testnet/apps') await expect(page.getByRole('button', { name: /^Chain: Stellar testnet/ })).toBeVisible() - await expect(page.locator('header')).not.toContainText('Trades up to') await page.getByRole('button', { name: /^Chain: / }).click() await page.getByRole('menuitem', { name: /Stellar mainnet/ }).click() await page.waitForURL('**/stellar-mainnet/apps') await expect(page.getByRole('button', { name: /^Chain: Stellar mainnet/ })).toBeVisible() - await expect(page.locator('header')).toContainText('Trades up to') await page.getByRole('button', { name: /^Chain: / }).click() await page.getByRole('menuitem', { name: /Stellar testnet/ }).click() diff --git a/apps/dapp/lib/__tests__/aave.live.test.ts b/apps/dapp/lib/__tests__/aave.live.test.ts new file mode 100644 index 00000000..1808c365 --- /dev/null +++ b/apps/dapp/lib/__tests__/aave.live.test.ts @@ -0,0 +1,162 @@ +import { http, parseAbi, type Address } from 'viem' +import { describe, expect, it } from 'vitest' + +import { + AAVE_AVALANCHE_MARKET, + AAVE_MONAD_MARKET, + createAaveVenue, + NEVERLAND_MARKET, + type AaveMarket, +} from '../chains/evm/aave' +import { evmReader } from '../chains/evm/client' +import { sameAddress } from '../chains/evm/submit' +import type { EvmChain } from '../chains/evm/tokens' + +/** + * That the pinned contracts still are what they were checked to be. Read-only: nothing signs or + * sends. Set the chain's RPC env where the public one rate limits. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const SLOW_MS = 60_000 +// Burned, so it can never move. It holds about 0.05 USDC supplied on Aave on Avalanche. +const BURN = '0x000000000000000000000000000000000000dEaD' + +const RPC: Record = { + avalanche: process.env['AVALANCHE_RPC_URL'] ?? 'https://avalanche-c-chain-rpc.publicnode.com', + monad: process.env['MONAD_RPC_URL'] ?? 'https://rpc.monad.xyz', +} + +function reader(chain: EvmChain) { + return evmReader(chain, http(RPC[chain], { timeout: 20_000, retryCount: 2 })) +} + +const PROVIDER_ABI = parseAbi([ + 'function getPool() view returns (address)', + 'function getPoolDataProvider() view returns (address)', + 'function getPriceOracle() view returns (address)', +]) +const POOL_ABI = parseAbi([ + 'function ADDRESSES_PROVIDER() view returns (address)', + 'function getReservesList() view returns (address[])', +]) +const ORACLE_ABI = parseAbi(['function BASE_CURRENCY_UNIT() view returns (uint256)']) +const DECIMALS_ABI = parseAbi(['function decimals() view returns (uint8)']) + +describe.skipIf(SKIP).each<[string, AaveMarket]>([ + ['Aave on Avalanche', AAVE_AVALANCHE_MARKET], + ['Aave on Monad', AAVE_MONAD_MARKET], + ['Neverland on Monad', NEVERLAND_MARKET], +])('%s, live', (_name, market) => { + const client = reader(market.chain) + const provider = market.addressesProvider as Address + const pool = market.pool as Address + + it( + 'has code at every pinned contract', + async () => { + for (const address of [market.pool, market.addressesProvider, market.dataProvider]) { + const code = await client.getCode({ address: address as Address }) + expect((code?.length ?? 0) > 2, address).toBe(true) + } + }, + SLOW_MS + ) + + it( + 'has a provider and pool that name each other, and the same data provider', + async () => { + const read = (functionName: T) => + client.readContract({ address: provider, abi: PROVIDER_ABI, functionName }) + expect(sameAddress(await read('getPool'), market.pool)).toBe(true) + expect(sameAddress(await read('getPoolDataProvider'), market.dataProvider)).toBe(true) + const named = await client.readContract({ + address: pool, + abi: POOL_ABI, + functionName: 'ADDRESSES_PROVIDER', + }) + expect(sameAddress(named, provider)).toBe(true) + }, + SLOW_MS + ) + + it( + 'lists every token here as a reserve, with the decimals here', + async () => { + const reserves = await client.readContract({ + address: pool, + abi: POOL_ABI, + functionName: 'getReservesList', + }) + for (const token of market.tokens) { + expect( + reserves.some((reserve) => sameAddress(reserve, token.id)), + token.symbol + ).toBe(true) + const decimals = await client.readContract({ + address: token.id as Address, + abi: DECIMALS_ABI, + functionName: 'decimals', + }) + expect(decimals, token.symbol).toBe(token.decimals) + } + }, + SLOW_MS + ) + + it( + 'prices in dollars with eight decimals', + async () => { + const oracle = await client.readContract({ + address: provider, + abi: PROVIDER_ABI, + functionName: 'getPriceOracle', + }) + const unit = await client.readContract({ + address: oracle, + abi: ORACLE_ABI, + functionName: 'BASE_CURRENCY_UNIT', + }) + expect(unit).toBe(BigInt(100_000_000)) + }, + SLOW_MS + ) + + it( + 'reads a supply rate for USDC through the venue', + async () => { + const venue = createAaveVenue(market, client) + const quote = await venue.quote!({ kind: 'supply', amount: '1', tokenIn: 'USDC' }, BURN) + expect(quote.apyPercent).toBeGreaterThanOrEqual(0) + expect(quote.apyPercent).toBeLessThan(100) + }, + SLOW_MS + ) + + it( + 'refuses a borrow with no collateral behind it', + async () => { + const empty = '0x000000000000000000000000000000000000bEEF' + const venue = createAaveVenue(market, client) + await expect( + venue.prepare({ kind: 'borrow', amount: '1', tokenIn: 'USDC' }, empty) + ).rejects.toThrow(/Nothing was signed or sent/) + }, + SLOW_MS + ) +}) + +describe.skipIf(SKIP)('A withdraw built against the real Pool', () => { + it( + 'passes the chain’s own trial of the call', + async () => { + const venue = createAaveVenue(AAVE_AVALANCHE_MARKET, reader('avalanche')) + const prepared = await venue.prepare( + { kind: 'withdraw', amount: '0.01', tokenIn: 'USDC' }, + BURN + ) + expect(() => venue.assertSafe(prepared.signing, BURN)).not.toThrow() + }, + SLOW_MS + ) +}) diff --git a/apps/dapp/lib/__tests__/aave.test.ts b/apps/dapp/lib/__tests__/aave.test.ts new file mode 100644 index 00000000..964ac01f --- /dev/null +++ b/apps/dapp/lib/__tests__/aave.test.ts @@ -0,0 +1,678 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, type Address } from 'viem' +import { describe, expect, it } from 'vitest' + +import { + AAVE_AVALANCHE_MARKET, + AAVE_MONAD_MARKET, + aaveAvalancheVenue, + aaveMonadVenue, + aaveOraclePriceUsd, + createAaveVenue, + neverlandVenue, + NEVERLAND_MARKET, + MIN_HEALTH_FACTOR, + type AaveMarket, +} from '../chains/evm/aave' +import { aaveListings } from '../chains/evm/aave-listing' +import type { EvmReaderClient } from '../chains/evm/call' +import { neverlandListing } from '../chains/evm/neverland-listing' +import { ChainActionRefused, type ChainActionKind, type SigningPayload } from '../chains/types' + +const ACCOUNT = '0x1111111111111111111111111111111111111111' +const STRANGER = '0x2222222222222222222222222222222222222222' +const ORACLE = '0x4444444444444444444444444444444444444444' +const NOT_A_RESERVE = '0x5555555555555555555555555555555555555555' +const USDC = AAVE_AVALANCHE_MARKET.tokens.find((t) => t.symbol === 'USDC')!.id as Address +const USDT = AAVE_AVALANCHE_MARKET.tokens.find((t) => t.symbol === 'USDT')!.id as Address +const POOL = AAVE_AVALANCHE_MARKET.pool as Address +const AVALANCHE_ID = 43114 +const UNITS = BigInt(10_000_000) +const RAY = BigInt(10) ** BigInt(27) + +const POOL_ABI = parseAbi([ + 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)', + 'function deposit(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)', + 'function withdraw(address asset, uint256 amount, address to) returns (uint256)', + 'function borrow(address asset, uint256 amount, uint256 interestRateMode, uint16 referralCode, address onBehalfOf)', + 'function repay(address asset, uint256 amount, uint256 interestRateMode, address onBehalfOf) returns (uint256)', + 'function setUserEMode(uint8 categoryId)', +]) + +function supplyData(asset: Address = USDC, onBehalfOf = ACCOUNT, amount = UNITS, referral = 0) { + return encodeFunctionData({ + abi: POOL_ABI, + functionName: 'supply', + args: [asset, amount, onBehalfOf as Address, referral], + }) +} + +function withdrawData(to = ACCOUNT, amount = UNITS) { + return encodeFunctionData({ + abi: POOL_ABI, + functionName: 'withdraw', + args: [USDC, amount, to as Address], + }) +} + +function borrowData(onBehalfOf = ACCOUNT, mode = BigInt(2), amount = UNITS) { + return encodeFunctionData({ + abi: POOL_ABI, + functionName: 'borrow', + args: [USDC, amount, mode, 0, onBehalfOf as Address], + }) +} + +function repayData(onBehalfOf = ACCOUNT, mode = BigInt(2), amount = UNITS) { + return encodeFunctionData({ + abi: POOL_ABI, + functionName: 'repay', + args: [USDC, amount, mode, onBehalfOf as Address], + }) +} + +function approveData(spender: string = POOL, units = UNITS) { + return encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender as Address, units], + }) +} + +function payload(overrides: Partial> = {}) { + return { + family: 'evm' as const, + chainId: AVALANCHE_ID, + to: POOL, + data: supplyData(), + value: '0', + ...overrides, + } +} + +function refusesAvalanche(signing: SigningPayload, account: string = ACCOUNT) { + expect(() => aaveAvalancheVenue.assertSafe(signing, account)).toThrow(ChainActionRefused) +} + +describe('what each market takes', () => { + it('is what it lists, whatever else the chain’s table holds', () => { + const symbols = (market: { tokens: readonly { symbol: string }[] }): string[] => + market.tokens.map((token) => token.symbol) + expect(symbols(AAVE_AVALANCHE_MARKET)).toEqual(['USDC', 'USDT', 'WAVAX', 'WETH.e']) + expect(symbols(AAVE_MONAD_MARKET)).toEqual(['USDC', 'USDT0', 'WETH']) + expect(symbols(NEVERLAND_MARKET)).toEqual(['USDC', 'USDT0']) + }) +}) + +describe('Aave and Neverland assertSafe', () => { + it.each<[string, SigningPayload]>([ + ['a supply for the person', payload()], + ['a supply with an exact approval', payload({ approval: { to: USDC, data: approveData() } })], + ['a withdraw to the person', payload({ data: withdrawData() })], + ['a variable borrow for the person', payload({ data: borrowData() })], + ['a repay for the person', payload({ data: repayData() })], + [ + 'a repay with an exact approval', + payload({ data: repayData(), approval: { to: USDC, data: approveData() } }), + ], + ])('passes %s', (_name, signing) => { + expect(() => aaveAvalancheVenue.assertSafe(signing, ACCOUNT)).not.toThrow() + }) + + it.each<[string, SigningPayload]>([ + ['another family', { family: 'sui', transaction: 'AA==' }], + ['another chain', payload({ chainId: 143 })], + ['an unknown contract', payload({ to: STRANGER })], + ["Neverland's pool on Aave's venue", payload({ to: NEVERLAND_MARKET.pool })], + [ + 'the legacy deposit function', + payload({ + data: encodeFunctionData({ + abi: POOL_ABI, + functionName: 'deposit', + args: [USDC, UNITS, ACCOUNT as Address, 0], + }), + }), + ], + [ + 'a function outside the list', + payload({ + data: encodeFunctionData({ abi: POOL_ABI, functionName: 'setUserEMode', args: [1] }), + }), + ], + ['extra calldata', payload({ data: `${supplyData()}00` })], + ['native value', payload({ value: '1' })], + ['a supply credited to someone else', payload({ data: supplyData(USDC, STRANGER) })], + ['a withdraw paid to someone else', payload({ data: withdrawData(STRANGER) })], + ['a borrow on someone else’s credit', payload({ data: borrowData(STRANGER) })], + ['a repay of someone else’s debt', payload({ data: repayData(STRANGER) })], + ['a stable-rate borrow', payload({ data: borrowData(ACCOUNT, BigInt(1)) })], + ['a stable-rate repay', payload({ data: repayData(ACCOUNT, BigInt(1)) })], + ['a token that is not listed', payload({ data: supplyData(NOT_A_RESERVE) })], + ['a referral code', payload({ data: supplyData(USDC, ACCOUNT, UNITS, 7) })], + ['a zero amount', payload({ data: supplyData(USDC, ACCOUNT, BigInt(0)) })], + ['a repay of everything owed', payload({ data: repayData(ACCOUNT, BigInt(2), maxUint256) })], + ['a withdraw of everything', payload({ data: withdrawData(ACCOUNT, maxUint256) })], + [ + 'an unlimited approval', + payload({ approval: { to: USDC, data: approveData(POOL, maxUint256) } }), + ], + ['an approval to a stranger', payload({ approval: { to: USDC, data: approveData(STRANGER) } })], + [ + 'an approval of an unlisted token', + payload({ approval: { to: NOT_A_RESERVE, data: approveData() } }), + ], + [ + 'an approval of another token than the supply', + payload({ approval: { to: USDT, data: approveData() } }), + ], + [ + 'an approval above the amount', + payload({ approval: { to: USDC, data: approveData(POOL, UNITS + BigInt(1)) } }), + ], + [ + 'an approval below the amount', + payload({ approval: { to: USDC, data: approveData(POOL, UNITS - BigInt(1)) } }), + ], + [ + 'an approval beside a withdraw', + payload({ data: withdrawData(), approval: { to: USDC, data: approveData() } }), + ], + [ + 'an approval beside a borrow', + payload({ data: borrowData(), approval: { to: USDC, data: approveData() } }), + ], + ])('refuses %s', (_name, signing) => { + refusesAvalanche(signing) + }) + + it('refuses a call built for a different signer', () => { + refusesAvalanche(payload(), STRANGER) + }) + + it('keeps each venue to its own pool', () => { + const monadSupply = { + family: 'evm' as const, + chainId: 143, + to: AAVE_MONAD_MARKET.pool, + data: supplyData(tokenOf(AAVE_MONAD_MARKET, 'USDC')), + value: '0', + } + expect(() => aaveMonadVenue.assertSafe(monadSupply, ACCOUNT)).not.toThrow() + expect(() => neverlandVenue.assertSafe(monadSupply, ACCOUNT)).toThrow(ChainActionRefused) + expect(() => + neverlandVenue.assertSafe({ ...monadSupply, to: NEVERLAND_MARKET.pool }, ACCOUNT) + ).not.toThrow() + }) +}) + +function tokenOf(market: AaveMarket, symbol: string): Address { + return market.tokens.find((t) => t.symbol === symbol)!.id as Address +} + +interface Fixture { + frozen?: boolean + paused?: boolean + active?: boolean + borrowingEnabled?: boolean + liquidityRate?: bigint + variableBorrowRate?: bigint + totalAToken?: bigint + totalDebt?: bigint + supplyCap?: bigint + borrowCap?: bigint + allowance?: bigint + balance?: bigint + aTokenBalance?: bigint + variableDebt?: bigint + usedAsCollateral?: boolean + collateralBase?: bigint + debtBase?: bigint + availableBase?: bigint + thresholdBps?: bigint + eMode?: bigint + reserveThresholdBps?: bigint + price?: bigint + simulationFails?: boolean + failRead?: string +} + +/** Stands in for the chain, with an ordinary healthy account: $1000 of collateral and no debt. */ +function fakeReader(fixture: Fixture = {}) { + const calls: { to: string; data: string }[] = [] + const reads: string[] = [] + const f = { + frozen: false, + paused: false, + active: true, + borrowingEnabled: true, + liquidityRate: RAY / BigInt(25), + variableBorrowRate: RAY / BigInt(10), + totalAToken: BigInt(1_000_000_000_000), + totalDebt: BigInt(500_000_000_000), + supplyCap: BigInt(0), + borrowCap: BigInt(0), + allowance: BigInt(0), + balance: BigInt(1_000_000_000), + aTokenBalance: BigInt(100_000_000), + variableDebt: BigInt(0), + usedAsCollateral: true, + collateralBase: BigInt(100_000_000_000), + debtBase: BigInt(0), + availableBase: BigInt(75_000_000_000), + thresholdBps: BigInt(8000), + eMode: BigInt(0), + reserveThresholdBps: BigInt(8000), + price: BigInt(100_000_000), + ...fixture, + } + const reader = { + readContract: async ({ functionName }: { functionName: string }) => { + reads.push(functionName) + if (f.failRead === functionName) throw new Error('rpc down') + switch (functionName) { + case 'getReserveConfigurationData': + return [ + BigInt(6), + BigInt(7500), + f.reserveThresholdBps, + BigInt(10500), + BigInt(1000), + true, + f.borrowingEnabled, + false, + f.active, + f.frozen, + ] + case 'getReserveData': + return [ + BigInt(0), + BigInt(0), + f.totalAToken, + BigInt(0), + f.totalDebt, + f.liquidityRate, + f.variableBorrowRate, + BigInt(0), + BigInt(0), + RAY, + RAY, + 1, + ] + case 'getPaused': + return f.paused + case 'getReserveCaps': + return [f.borrowCap, f.supplyCap] + case 'getUserReserveData': + return [ + f.aTokenBalance, + BigInt(0), + f.variableDebt, + BigInt(0), + f.variableDebt, + BigInt(0), + f.liquidityRate, + 0, + f.usedAsCollateral, + ] + case 'getUserAccountData': + return [ + f.collateralBase, + f.debtBase, + f.availableBase, + f.thresholdBps, + BigInt(7500), + maxUint256, + ] + case 'getUserEMode': + return f.eMode + case 'getPriceOracle': + return ORACLE + case 'getAssetPrice': + return f.price + case 'allowance': + return f.allowance + case 'balanceOf': + return f.balance + default: + throw new Error(`Unexpected read: ${functionName}`) + } + }, + call: async (call: { to: string; data: string }) => { + calls.push(call) + if (f.simulationFails === true) throw new Error('reverted') + return { data: '0x' } + }, + } + return { reader: reader as unknown as EvmReaderClient, calls, reads } +} + +function venueWith(fixture: Fixture = {}, market: AaveMarket = AAVE_AVALANCHE_MARKET) { + const fake = fakeReader(fixture) + return { venue: createAaveVenue(market, fake.reader), ...fake } +} + +function ask(kind: ChainActionKind, amount = '10', tokenIn = 'USDC') { + return { kind, amount, tokenIn } +} + +function evmSigning(signing: SigningPayload) { + if (signing.family !== 'evm') throw new Error('Expected an EVM payload.') + return signing +} + +describe('supply', () => { + it('builds the supply with an exact approval and reads the base rate', async () => { + const { venue } = venueWith() + const prepared = await venue.prepare(ask('supply'), ACCOUNT) + const signing = evmSigning(prepared.signing) + expect(signing.to).toBe(POOL) + expect(signing.data).toBe(supplyData()) + expect(signing.approval).toEqual({ to: USDC, data: approveData() }) + expect(prepared.apy?.basePercent).toBeCloseTo(4.08, 2) + expect(prepared.apy?.rewardPercent).toBeUndefined() + expect(prepared.pay).toEqual({ amount: '10', symbol: 'USDC' }) + expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow() + }) + + it('skips the approval when the allowance covers it, and tries the call', async () => { + const { venue, calls } = venueWith({ allowance: UNITS }) + const prepared = await venue.prepare(ask('supply'), ACCOUNT) + expect(evmSigning(prepared.signing).approval).toBeUndefined() + expect(calls).toHaveLength(1) + }) + + it('tells the person the wallet asks twice only when it does', async () => { + const twice = await venueWith().venue.prepare(ask('supply'), ACCOUNT) + const once = await venueWith({ allowance: UNITS }).venue.prepare(ask('supply'), ACCOUNT) + expect(twice.notes.join(' ')).toContain('asks twice') + expect(once.notes.join(' ')).not.toContain('asks twice') + }) + + it('quotes the rate with nothing built or tried', async () => { + const { venue, calls, reads } = venueWith() + const quote = await venue.quote!(ask('supply'), ACCOUNT) + expect(quote.apyPercent).toBeCloseTo(4.08, 2) + expect(calls).toHaveLength(0) + expect(reads).not.toContain('allowance') + }) + + it.each<[string, Fixture, RegExp]>([ + ['a frozen reserve', { frozen: true }, /frozen/], + ['a paused reserve', { paused: true }, /paused/], + ['an inactive reserve', { active: false }, /isn't open/], + [ + 'a full supply cap', + { supplyCap: BigInt(1_000_000), totalAToken: BigInt(1_000_000_000_000) }, + /supply cap/, + ], + ['too little in the wallet', { balance: BigInt(1) }, /hold/], + ['an unreadable reserve', { failRead: 'getReserveConfigurationData' }, /read/], + ['an unreadable balance', { failRead: 'balanceOf' }, /read/], + ['an unreadable allowance', { failRead: 'allowance' }, /read/], + ])('refuses %s', async (_name, fixture, words) => { + const { venue } = venueWith(fixture) + const asked = venue.prepare(ask('supply'), ACCOUNT) + await expect(asked).rejects.toBeInstanceOf(ChainActionRefused) + await expect(asked).rejects.toThrow(words) + await expect(asked).rejects.toThrow(/Nothing was signed or sent\.$/) + }) + + it('refuses a token this market does not take', async () => { + const { venue } = venueWith() + await expect(venue.prepare(ask('supply', '1', 'DOGE'), ACCOUNT)).rejects.toBeInstanceOf( + ChainActionRefused + ) + }) + + it('refuses an account that is not an address', async () => { + const { venue } = venueWith() + await expect(venue.prepare(ask('supply'), 'not-an-address')).rejects.toBeInstanceOf( + ChainActionRefused + ) + }) + + it('refuses a call the chain says would fail', async () => { + const { venue } = venueWith({ allowance: UNITS, simulationFails: true }) + await expect(venue.prepare(ask('supply'), ACCOUNT)).rejects.toBeInstanceOf(ChainActionRefused) + }) +}) + +describe('borrow', () => { + it('sets the floor at 1.5', () => { + expect(MIN_HEALTH_FACTOR).toBe(1.5) + }) + + it('builds a variable-rate borrow for the person, tried against the chain', async () => { + const { venue, calls } = venueWith() + const prepared = await venue.prepare(ask('borrow'), ACCOUNT) + const signing = evmSigning(prepared.signing) + expect(signing.data).toBe(borrowData()) + expect(signing.approval).toBeUndefined() + expect(calls).toHaveLength(1) + expect(prepared.pay).toEqual({ amount: '10', symbol: 'USDC' }) + expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow() + }) + + it('says plainly the loan can be liquidated, and shows the health it would leave', async () => { + const { venue } = venueWith() + const { notes } = await venue.prepare(ask('borrow'), ACCOUNT) + const text = notes.join(' ') + expect(text).toMatch(/liquidat/i) + expect(text).toContain('80.00') + expect(text).toMatch(/rate/i) + }) + + it('reads the health before it builds', async () => { + const { venue, reads } = venueWith() + await venue.prepare(ask('borrow'), ACCOUNT) + expect(reads).toContain('getUserAccountData') + expect(reads).toContain('getAssetPrice') + }) + + it('passes a borrow landing on the floor', async () => { + // $1000 of collateral at an 80% threshold, over a $533 loan, is just above 1.5. + const { venue } = venueWith({ debtBase: BigInt(0) }) + const prepared = await venue.prepare(ask('borrow', '533'), ACCOUNT) + expect(prepared.kind).toBe('borrow') + }) + + it('refuses a borrow that would land just under the floor', async () => { + const { venue } = venueWith({ debtBase: BigInt(0) }) + await expect(venue.prepare(ask('borrow', '534'), ACCOUNT)).rejects.toThrow(/1\.5/) + }) + + it('counts existing debt against the floor', async () => { + const { venue } = venueWith({ debtBase: BigInt(50_000_000_000) }) + await expect(venue.prepare(ask('borrow', '40'), ACCOUNT)).rejects.toThrow(/1\.5/) + }) + + it.each<[string, Fixture, RegExp]>([ + ['no collateral', { collateralBase: BigInt(0), availableBase: BigInt(0) }, /collateral/], + ['collateral that can back no new loan', { availableBase: BigInt(0) }, /back/], + ['a frozen reserve', { frozen: true }, /frozen/], + ['a paused reserve', { paused: true }, /paused/], + ['borrowing switched off', { borrowingEnabled: false }, /borrow/], + ['no price', { price: BigInt(0) }, /price/], + ['an unreadable account', { failRead: 'getUserAccountData' }, /read/], + ['an unreadable price', { failRead: 'getAssetPrice' }, /read/], + ['an unreadable oracle', { failRead: 'getPriceOracle' }, /read/], + ['a full borrow cap', { borrowCap: BigInt(1), totalDebt: BigInt(1_000_000) }, /borrow cap/], + ['too little free in the pool', { totalAToken: BigInt(1), totalDebt: BigInt(1) }, /free/], + ])('refuses %s', async (_name, fixture, words) => { + const { venue } = venueWith(fixture) + const asked = venue.prepare(ask('borrow'), ACCOUNT) + await expect(asked).rejects.toBeInstanceOf(ChainActionRefused) + await expect(asked).rejects.toThrow(words) + }) +}) + +describe('repay', () => { + it('builds a repay of the person’s own variable debt', async () => { + const { venue } = venueWith({ variableDebt: UNITS }) + const prepared = await venue.prepare(ask('repay'), ACCOUNT) + const signing = evmSigning(prepared.signing) + expect(signing.data).toBe(repayData()) + expect(signing.approval).toEqual({ to: USDC, data: approveData() }) + expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow() + }) + + it('stays open on a frozen reserve', async () => { + const { venue } = venueWith({ variableDebt: UNITS, frozen: true }) + await expect(venue.prepare(ask('repay'), ACCOUNT)).resolves.toMatchObject({ kind: 'repay' }) + }) + + it('says only what is owed is taken when asked to pay more', async () => { + const { venue } = venueWith({ variableDebt: BigInt(5_000_000) }) + const { notes } = await venue.prepare(ask('repay'), ACCOUNT) + expect(notes.join(' ')).toContain('5') + expect(notes.join(' ')).toMatch(/only what you owe/i) + }) + + it('lets a wallet holding only what is owed pay more than it holds', async () => { + const { venue } = venueWith({ variableDebt: BigInt(5_000_000), balance: BigInt(5_000_000) }) + await expect(venue.prepare(ask('repay'), ACCOUNT)).resolves.toMatchObject({ kind: 'repay' }) + }) + + it.each<[string, Fixture, RegExp]>([ + ['no debt', { variableDebt: BigInt(0) }, /owe/], + ['too little in the wallet', { variableDebt: UNITS, balance: BigInt(1) }, /hold/], + ['a paused reserve', { variableDebt: UNITS, paused: true }, /paused/], + ['an unreadable debt', { failRead: 'getUserReserveData' }, /read/], + ])('refuses %s', async (_name, fixture, words) => { + const { venue } = venueWith(fixture) + const asked = venue.prepare(ask('repay'), ACCOUNT) + await expect(asked).rejects.toBeInstanceOf(ChainActionRefused) + await expect(asked).rejects.toThrow(words) + }) +}) + +describe('withdraw', () => { + it('builds a withdraw to the person, with no approval', async () => { + const { venue, calls } = venueWith() + const prepared = await venue.prepare(ask('withdraw'), ACCOUNT) + const signing = evmSigning(prepared.signing) + expect(signing.data).toBe(withdrawData()) + expect(signing.approval).toBeUndefined() + expect(calls).toHaveLength(1) + expect(prepared.pay).toEqual({ amount: '10', symbol: 'USDC' }) + }) + + it('stays open on a frozen reserve', async () => { + const { venue } = venueWith({ frozen: true }) + await expect(venue.prepare(ask('withdraw'), ACCOUNT)).resolves.toMatchObject({ + kind: 'withdraw', + }) + }) + + it('passes a withdraw from a position with no debt, whatever it does to the collateral', async () => { + const { venue } = venueWith({ aTokenBalance: BigInt(100_000_000_000), debtBase: BigInt(0) }) + await expect(venue.prepare(ask('withdraw', '900'), ACCOUNT)).resolves.toMatchObject({ + kind: 'withdraw', + }) + }) + + it('passes a withdraw of a supply that backs no loan', async () => { + const { venue } = venueWith({ debtBase: BigInt(50_000_000_000), usedAsCollateral: false }) + await expect(venue.prepare(ask('withdraw'), ACCOUNT)).resolves.toMatchObject({ + kind: 'withdraw', + }) + }) + + it.each<[string, Fixture, string, RegExp]>([ + ['more than is supplied', { aTokenBalance: BigInt(1) }, '10', /supplied/], + ['nothing supplied', { aTokenBalance: BigInt(0) }, '10', /supplied/], + ['a paused reserve', { paused: true }, '10', /paused/], + ['too little free in the pool', { totalAToken: BigInt(1), totalDebt: BigInt(1) }, '10', /free/], + [ + 'taking collateral a loan needs below the floor', + { + aTokenBalance: BigInt(1_000_000_000), + debtBase: BigInt(50_000_000_000), + }, + '400', + /1\.5/, + ], + [ + 'collateral backing a loan under E-Mode', + { debtBase: BigInt(1_000_000_000), eMode: BigInt(1) }, + '10', + /E-Mode/, + ], + ['an unreadable account', { failRead: 'getUserAccountData' }, '10', /read/], + ])('refuses %s', async (_name, fixture, amount, words) => { + const { venue } = venueWith(fixture) + const asked = venue.prepare(ask('withdraw', amount), ACCOUNT) + await expect(asked).rejects.toBeInstanceOf(ChainActionRefused) + await expect(asked).rejects.toThrow(words) + }) +}) + +describe('aaveOraclePriceUsd', () => { + const usdc = AAVE_AVALANCHE_MARKET.tokens.find((t) => t.symbol === 'USDC')! + + it('reads dollars from the market’s oracle', async () => { + const { reader } = fakeReader({ price: BigInt(242_500_000_000) }) + await expect(aaveOraclePriceUsd(AAVE_AVALANCHE_MARKET, usdc, reader)).resolves.toBe(2425) + }) + + it.each<[string, Fixture]>([ + ['a failed read', { failRead: 'getAssetPrice' }], + ['a zero price', { price: BigInt(0) }], + ])('gives no price on %s', async (_name, fixture) => { + const { reader } = fakeReader(fixture) + await expect(aaveOraclePriceUsd(AAVE_AVALANCHE_MARKET, usdc, reader)).resolves.toBeUndefined() + }) +}) + +describe('listings and logos', () => { + it.each([...aaveListings, neverlandListing].map((listing) => [listing.id, listing] as const))( + '%s says it executes, and has its logo', + (id, listing) => { + expect(listing.integration).toBe('executes') + expect(listing.family).toBe('evm') + expect(existsSync(join(__dirname, '../../public/images/venues', `${id}.webp`))).toBe(true) + } + ) + + it('lists the ids the venues answer to', () => { + expect(aaveListings.map((l) => l.id)).toEqual([aaveAvalancheVenue.id, aaveMonadVenue.id]) + expect(neverlandListing.id).toBe(neverlandVenue.id) + }) +}) + +describe('notes', () => { + it('names admin risk, the freeze and the KelpDAO bad debt on Aave', async () => { + const { notes } = await venueWith().venue.prepare(ask('supply'), ACCOUNT) + const text = notes.join(' ') + expect(text).toMatch(/upgrade/i) + expect(text).toMatch(/KelpDAO/) + expect(text).toMatch(/frozen|froze/i) + }) + + it('says Neverland is separate from Aave, and its upgrade delay', async () => { + const { notes } = await venueWith({}, NEVERLAND_MARKET).venue.prepare( + ask('supply', '10', 'USDC'), + ACCOUNT + ) + const text = notes.join(' ') + expect(text).toMatch(/not run by Aave|not Aave/i) + expect(text).toMatch(/24-hour/) + expect(text).not.toMatch(/KelpDAO/) + }) + + it('tells a supplier how to get out', async () => { + const { notes } = await venueWith().venue.prepare(ask('supply'), ACCOUNT) + expect(notes.join(' ')).toMatch(/withdraw/i) + }) + + it('has no em dash in anything the person reads', async () => { + const { notes, summary } = await venueWith({ variableDebt: UNITS }).venue.prepare( + ask('repay'), + ACCOUNT + ) + expect([...notes, summary].join(' ')).not.toContain('—') + }) +}) diff --git a/apps/dapp/lib/__tests__/aftermath.live.test.ts b/apps/dapp/lib/__tests__/aftermath.live.test.ts new file mode 100644 index 00000000..d35438b9 --- /dev/null +++ b/apps/dapp/lib/__tests__/aftermath.live.test.ts @@ -0,0 +1,90 @@ +import { fromBase64 } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { + AFSUI_TYPE, + AFTERMATH_PACKAGE, + AFTERMATH_REFERRAL_VAULT, + AFTERMATH_SAFE, + AFTERMATH_VALIDATOR, + AFTERMATH_VAULT, + AFTERMATH_VAULT_STATE, + aftermathVenue, +} from '../chains/sui/aftermath' +import { suiClient } from '../chains/sui/client' +import { readU64 } from '../chains/sui/haedal' + +/** + * Pinned ids, and real unsigned transactions against mainnet, read-only. Nothing here signs or + * sends: each transaction is only built and dry-run, for public addresses whose keys nobody here + * has. When an address empties these fail on balance, so swap it for another funded one. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const SUI_HOLDER = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4' +const AFSUI_HOLDER = '0xd491566f3add78e8fbc3328590c4d967a91f55dcdd4f84e58f76e08f8bd458eb' + +async function simulateOk(transaction: string): Promise { + const result = await suiClient().core.simulateTransaction({ + transaction: fromBase64(transaction), + include: { effects: true }, + }) + return result.Transaction?.effects?.status.success === true +} + +describe.skipIf(SKIP)('Aftermath against mainnet', () => { + it('stakes to a validator that takes no fee: what it mints is what the rate gives', async () => { + const prepared = await aftermathVenue.prepare( + { kind: 'stake', amount: '1', tokenIn: 'SUI' }, + SUI_HOLDER + ) + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + expect(() => aftermathVenue.assertSafe(prepared.signing, SUI_HOLDER)).not.toThrow() + expect(await simulateOk(prepared.signing.transaction)).toBe(true) + + const byRate = await readU64( + SUI_HOLDER, + `${AFTERMATH_PACKAGE}::staked_sui_vault::sui_to_afsui`, + (tx) => [ + tx.object(AFTERMATH_VAULT), + tx.object(AFTERMATH_SAFE), + tx.pure.u64(BigInt(1_000_000_000)), + ] + ) + expect(prepared.receive?.symbol).toBe('afSUI') + expect(Math.abs(Number(prepared.receive?.amount) - Number(byRate) / 1e9)).toBeLessThan(0.00001) + }, 60_000) + + it('builds a delayed unstake that passes its check and dry-runs clean', async () => { + const prepared = await aftermathVenue.prepare( + { kind: 'unstake', amount: '2', tokenIn: 'afSUI' }, + AFSUI_HOLDER + ) + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + expect(() => aftermathVenue.assertSafe(prepared.signing, AFSUI_HOLDER)).not.toThrow() + expect(await simulateOk(prepared.signing.transaction)).toBe(true) + expect(prepared.receive?.symbol).toBe('SUI') + expect(Number(prepared.receive?.amount)).toBeGreaterThan(2) + expect(prepared.notes.join(' ')).toMatch(/in about \d+ hours?/) + }, 60_000) + + it("still matches Aftermath's published addresses, or the ids need a fresh look", async () => { + const response = await fetch('https://aftermath.finance/api/addresses') + const { staking } = (await response.json()) as { + staking: { packages: Record; objects: Record } + } + expect(staking.packages['lsd']).toBe(AFTERMATH_PACKAGE) + expect(staking.objects['stakedSuiVault']).toBe(AFTERMATH_VAULT) + expect(staking.objects['safe']).toBe(AFTERMATH_SAFE) + expect(staking.objects['referralVault']).toBe(AFTERMATH_REFERRAL_VAULT) + expect(staking.objects['stakedSuiVaultState']).toBe(AFTERMATH_VAULT_STATE) + expect(staking.objects['aftermathValidator']).toBe(AFTERMATH_VALIDATOR) + expect(AFSUI_TYPE.startsWith(`${staking.packages['afsui']}::`)).toBe(true) + const { coinMetadata } = await suiClient().core.getCoinMetadata({ coinType: AFSUI_TYPE }) + expect(coinMetadata?.decimals).toBe(9) + const { objects } = await suiClient().core.getObjects({ objectIds: [AFTERMATH_SAFE] }) + const safe = objects[0] + if (safe === undefined || safe instanceof Error) throw new Error('no safe') + expect(safe.type).toContain(AFSUI_TYPE) + }, 30_000) +}) diff --git a/apps/dapp/lib/__tests__/aftermath.test.ts b/apps/dapp/lib/__tests__/aftermath.test.ts new file mode 100644 index 00000000..a7eccefb --- /dev/null +++ b/apps/dapp/lib/__tests__/aftermath.test.ts @@ -0,0 +1,371 @@ +import { bcs } from '@mysten/sui/bcs' +import { Inputs, Transaction } from '@mysten/sui/transactions' +import { toBase64 } from '@mysten/sui/utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + AFSUI_TYPE, + AFTERMATH_PACKAGE, + AFTERMATH_REFERRAL_VAULT, + AFTERMATH_SAFE, + AFTERMATH_TOKENS, + AFTERMATH_VALIDATOR, + AFTERMATH_VAULT, + aftermathVenue, +} from '../chains/sui/aftermath' +import { ChainActionRefused } from '../chains/types' + +const ME = '0x1111111111111111111111111111111111111111111111111111111111111111' +const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead' +const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01' +const DIGEST = '11111111111111111111111111111111' + +const mocks = vi.hoisted(() => ({ + simulate: vi.fn(), + getObjects: vi.fn(), + systemState: vi.fn(), + built: [] as Transaction[], + bytes: '', +})) + +vi.mock('../chains/sui/client', async (original) => ({ + ...(await original()), + suiClient: () => ({ + core: { + simulateTransaction: mocks.simulate, + getObjects: mocks.getObjects, + getCurrentSystemState: mocks.systemState, + }, + }), + buildUnsigned: async (tx: Transaction) => { + mocks.built.push(tx) + return mocks.bytes + }, +})) + +function shared(tx: Transaction, objectId: string) { + return tx.object(Inputs.SharedObjectRef({ objectId, initialSharedVersion: 1, mutable: true })) +} + +async function built(edit: (tx: Transaction) => void, sender: string = ME): Promise { + const tx = new Transaction() + tx.setSender(sender) + tx.setGasOwner(sender) + tx.setGasPrice(1000) + tx.setGasBudget(10_000_000) + tx.setGasPayment([{ objectId: FOREIGN, version: 1, digest: DIGEST }]) + edit(tx) + return toBase64(await tx.build()) +} + +const VAULT_CALLS = `${AFTERMATH_PACKAGE}::staked_sui_vault` + +interface StakeOptions { + validator?: string + vault?: string + safe?: string + referral?: string + to?: string +} + +function goodStake(tx: Transaction, options: StakeOptions = {}): void { + const [sui] = tx.splitCoins(tx.gas, [BigInt(1_000_000_000)]) + const [minted] = tx.moveCall({ + target: `${VAULT_CALLS}::request_stake`, + arguments: [ + shared(tx, options.vault ?? AFTERMATH_VAULT), + shared(tx, options.safe ?? AFTERMATH_SAFE), + shared(tx, '0x5'), + shared(tx, options.referral ?? AFTERMATH_REFERRAL_VAULT), + sui as never, + tx.pure.address(options.validator ?? AFTERMATH_VALIDATOR), + ], + }) + tx.transferObjects([minted as never], options.to ?? ME) +} + +function goodUnstake(tx: Transaction, vault: string = AFTERMATH_VAULT): void { + tx.moveCall({ + target: `${VAULT_CALLS}::request_unstake`, + arguments: [ + shared(tx, vault), + shared(tx, AFTERMATH_SAFE), + tx.object(Inputs.ObjectRef({ objectId: THIEF, version: 1, digest: DIGEST })), + ], + }) +} + +function check(transaction: string, account: string = ME): void { + aftermathVenue.assertSafe({ family: 'sui', transaction }, account) +} + +const STATE = { + total_rewards_amount: '198164944580303', + total_sui_amount: '2944756051147434', + protocol_config: { + min_staking_threshold: '1000000000', + default_unstake_protocol_fee: { total_fee: '50000000000000000' }, + }, +} + +beforeEach(() => { + mocks.simulate.mockReset() + mocks.getObjects.mockReset() + mocks.systemState.mockReset() + mocks.built.length = 0 + mocks.getObjects.mockResolvedValue({ objects: [{ json: STATE }] }) + mocks.systemState.mockResolvedValue({ + systemState: { + epochStartTimestampMs: String(Date.now() - 20 * 3_600_000), + parameters: { epochDurationMs: '86400000' }, + }, + }) +}) + +describe('Aftermath safety on what the wallet would sign', () => { + it('passes the stake and the unstake it builds', async () => { + await expect(built(goodStake).then(check)).resolves.toBeUndefined() + await expect(built(goodUnstake).then(check)).resolves.toBeUndefined() + }) + + it('refuses the wrong account', async () => { + const transaction = await built(goodStake) + expect(() => check(transaction, THIEF)).toThrow(/different account/) + }) + + it('refuses an unknown contract', async () => { + const transaction = await built((tx) => { + goodUnstake(tx) + tx.moveCall({ target: `${FOREIGN}::drain::everything` }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + }) + + it.each([ + 'request_unstake_atomic', + 'request_unstake_atomic_and_keep', + 'request_stake_and_keep', + 'request_stake_staked_sui', + 'epoch_was_changed', + 'update_default_unstake_total_fee', + ])('refuses %s, which this flow never uses', async (fn) => { + const transaction = await built((tx) => { + tx.moveCall({ target: `${VAULT_CALLS}::${fn}` }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + }) + + it('refuses perps and pools, which are other contracts', async () => { + const transaction = await built((tx) => { + tx.moveCall({ + target: '0xf948935b111990c2b604900c9b2eeb8f24dcf9868a45d1ea1653a5f282c10e29::pool::swap', + }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + }) + + it('refuses the same call in the old package', async () => { + const transaction = await built((tx) => { + tx.moveCall({ + target: + '0x7f6ce7ade63857c4fd16ef7783fed2dfc4d7fb7e40615abdb653030b76aef0c6::staked_sui_vault::request_unstake', + }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + }) + + it('refuses extra steps', async () => { + const transaction = await built((tx) => { + goodUnstake(tx) + tx.publish({ modules: ['AA=='], dependencies: ['0x1', '0x2'] }) + }) + expect(() => check(transaction)).toThrow(/Publish/) + }) + + it('refuses afSUI paid to someone else', async () => { + const transaction = await built((tx) => goodStake(tx, { to: THIEF })) + expect(() => check(transaction)).toThrow(/another address/) + }) + + it.each([ + ['vault', { vault: FOREIGN }], + ['treasury safe', { safe: FOREIGN }], + ['referral vault', { referral: FOREIGN }], + ])('refuses a %s other than Aftermath’s', async (_name, options) => { + const transaction = await built((tx) => goodStake(tx, options)) + expect(() => check(transaction)).toThrow(/object/) + }) + + it('refuses an unstake against another vault', async () => { + const transaction = await built((tx) => goodUnstake(tx, FOREIGN)) + expect(() => check(transaction)).toThrow(/object/) + }) + + it('refuses a validator it did not choose, which could take a fee of up to 5%', async () => { + const transaction = await built((tx) => goodStake(tx, { validator: THIEF })) + expect(() => check(transaction)).toThrow(/validator/) + }) + + it('refuses a call that names a token', async () => { + const transaction = await built((tx) => { + tx.moveCall({ target: `${VAULT_CALLS}::request_unstake`, typeArguments: ['0x2::sui::SUI'] }) + }) + expect(() => check(transaction)).toThrow(ChainActionRefused) + }) +}) + +describe('building an Aftermath stake', () => { + it('refuses a token that is not SUI', async () => { + await expect( + aftermathVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'USDC' }, ME) + ).rejects.toThrow(/stakes SUI/) + }) + + it('refuses under the minimum the vault reports', async () => { + await expect( + aftermathVenue.prepare({ kind: 'stake', amount: '0.5', tokenIn: 'SUI' }, ME) + ).rejects.toThrow(/at least 1 SUI/) + expect(mocks.built).toHaveLength(0) + }) + + it('refuses when the vault cannot be read', async () => { + mocks.getObjects.mockResolvedValue({ objects: [{ json: {} }] }) + await expect( + aftermathVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME) + ).rejects.toThrow(/fees/) + }) + + it('reads the minted afSUI from the dry run, and says what getting out costs', async () => { + mocks.bytes = await built(goodStake) + mocks.simulate.mockResolvedValue({ + Transaction: { + balanceChanges: [{ coinType: AFSUI_TYPE, address: ME, amount: '928000000' }], + }, + }) + const prepared = await aftermathVenue.prepare( + { kind: 'stake', amount: '1', tokenIn: 'SUI' }, + ME + ) + expect(prepared.receive).toEqual({ amount: '0.928', symbol: 'afSUI', approximate: true }) + expect(prepared.apy).toBeUndefined() + const notes = prepared.notes.join(' ') + expect(notes).toMatch(/about 0.34%/) + expect(notes).toMatch(/next Sui epoch/) + expect(notes).toMatch(/\$1.14M in April 2026/) + expect(notes).toMatch(/single signer/) + }) + + it('refuses when the dry run credits no afSUI', async () => { + mocks.bytes = await built(goodStake) + mocks.simulate.mockResolvedValue({ Transaction: { balanceChanges: [] } }) + await expect( + aftermathVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME) + ).rejects.toThrow(/couldn't confirm/) + }) +}) + +describe('building an Aftermath unstake', () => { + const u64 = (n: string) => bcs.u64().serialize(n).toBytes() + + function answers(value: string | undefined, succeeds = true): void { + mocks.simulate.mockImplementation(async ({ transaction }: { transaction: unknown }) => + transaction instanceof Transaction + ? { + Transaction: {}, + commandResults: [{ returnValues: value === undefined ? [] : [{ bcs: u64(value) }] }], + } + : succeeds + ? { Transaction: {}, commandResults: [] } + : { FailedTransaction: {}, commandResults: [] } + ) + } + + it('refuses a token that is not afSUI', async () => { + await expect( + aftermathVenue.prepare({ kind: 'unstake', amount: '1', tokenIn: 'sSUI' }, ME) + ).rejects.toThrow(/unstakes afSUI/) + }) + + it('refuses under 1 SUI of value', async () => { + answers('999999999') + await expect( + aftermathVenue.prepare({ kind: 'unstake', amount: '0.9', tokenIn: 'afSUI' }, ME) + ).rejects.toThrow(/at least 1 SUI worth/) + }) + + it('refuses when the value cannot be read', async () => { + answers(undefined) + await expect( + aftermathVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'afSUI' }, ME) + ).rejects.toThrow(/couldn't read/) + }) + + it('takes the fee the contract would take, as a real payout did', async () => { + mocks.bytes = await built(goodUnstake) + // A real delayed payout: 28.144751941 SUI by the rate, 28.050100069 reached the wallet. + answers('28144751941') + const prepared = await aftermathVenue.prepare( + { kind: 'unstake', amount: '26.142139341', tokenIn: 'afSUI' }, + ME + ) + const shown = Number(prepared.receive?.amount) + expect(Math.abs(shown - 28.050100069)).toBeLessThan(0.001) + expect(prepared.pay).toEqual({ amount: '26.142139341', symbol: 'afSUI' }) + expect(prepared.receive?.approximate).toBe(true) + }) + + it('says the SUI comes later, and how long, from the chain’s epoch', async () => { + mocks.bytes = await built(goodUnstake) + answers('2000000000') + const prepared = await aftermathVenue.prepare( + { kind: 'unstake', amount: '2', tokenIn: 'afSUI' }, + ME + ) + const notes = prepared.notes.join(' ') + expect(notes).toMatch(/does not come in this transaction/) + expect(notes).toMatch(/in about 4 hours/) + expect(notes).toMatch(/can't be cancelled/) + expect(notes).toMatch(/You sign nothing more/) + expect(notes).toMatch(/about 0.34%/) + const data = mocks.built[0]?.getData() + const targets = data?.commands.flatMap((c) => + c.$kind === 'MoveCall' ? [c.MoveCall.function] : [] + ) + expect(targets).toEqual(['request_unstake']) + expect(data?.commands.some((c) => c.$kind === 'TransferObjects')).toBe(false) + }) + + it('refuses when the dry run fails, such as while the vault processes an epoch', async () => { + mocks.bytes = await built(goodUnstake) + answers('2000000000', false) + await expect( + aftermathVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'afSUI' }, ME) + ).rejects.toThrow(/would fail/) + }) + + it('refuses when the epoch cannot be read', async () => { + mocks.bytes = await built(goodUnstake) + answers('2000000000') + mocks.systemState.mockResolvedValue({ + systemState: { epochStartTimestampMs: 'x', parameters: { epochDurationMs: '0' } }, + }) + await expect( + aftermathVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'afSUI' }, ME) + ).rejects.toThrow(/epoch/) + }) +}) + +describe('after a swap', () => { + it('stakes SUI only, and never unstakes', async () => { + await expect( + aftermathVenue.assertCanFollow?.({ kind: 'stake', tokenIn: 'SUI' }, ME) + ).resolves.toBeUndefined() + await expect( + aftermathVenue.assertCanFollow?.({ kind: 'unstake', tokenIn: 'SUI' }, ME) + ).rejects.toThrow(/only stake/) + }) +}) + +it('lists afSUI with the decimals its metadata gives', () => { + expect(AFTERMATH_TOKENS).toEqual([expect.objectContaining({ symbol: 'afSUI', decimals: 9 })]) +}) diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts index 118d057b..7fdb813b 100644 --- a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -115,7 +115,7 @@ describe('POST /api/agents/compete on a chain', () => { expect(given.market.routes).toHaveLength(1) expect(given.market.routes[0]).toMatchObject({ id: 'ref-1', source: 'Ref' }) expect(given.market.venues).toEqual([{ id: 'ref', name: 'Ref', category: 'dex' }]) - }) + }, 60_000) it('still refuses near without a chain action', async () => { const { POST } = await import('../../app/api/agents/compete/route') @@ -273,8 +273,6 @@ describe('POST /api/agents/compete on a chain', () => { ['a non-numeric amount', 'lots', 4], ['a zero amount', '0', 4], ['a negative amount', '-3', 4], - ['an amount over the trade cap', '1000000000', 4], - ['a token with no live price', '5', null], ] it.each(cases)('%s', async (_name, amount, price) => { const quote = vi.fn(async () => ({ receive: { amount: '1', symbol: 'USDC' } })) diff --git a/apps/dapp/lib/__tests__/beefy.live.test.ts b/apps/dapp/lib/__tests__/beefy.live.test.ts new file mode 100644 index 00000000..412facc4 --- /dev/null +++ b/apps/dapp/lib/__tests__/beefy.live.test.ts @@ -0,0 +1,194 @@ +import { + encodeAbiParameters, + encodeFunctionData, + keccak256, + pad, + parseAbi, + toHex, + type Address, + type Hex, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import { BEEFY_MONAD_STRATEGY, BEEFY_MONAD_USDC, BEEFY_MONAD_VAULT } from '../chains/evm/beefy' +import { evmReader } from '../chains/evm/client' + +/** + * The real contracts on Monad, read-only: nothing signs or sends. Deposit and withdrawal are only + * tried with eth_call, for an empty address given the balance it needs by a state override. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const THROWAWAY = '0x00000000000000000000000000000000000abcde' +const VAULT = BEEFY_MONAD_VAULT as Address +const STRATEGY = BEEFY_MONAD_STRATEGY as Address +const VAULT_IMPLEMENTATION = '91bb303e972995ebe5f593bcddbb6f5ef49dbcbd' +const AAVE_POOL = '0x69a5F9AD4f96ebf0a0C792dD42a01cC5C0102fef' +const AAVE_USDC = '0x35a73BAcb179d3740395A3ceCc87FF2e581d6042' +const SIX_HOURS = BigInt(21_600) +const STRATEGY_TIMELOCK = '0x09D19184F46A32213DF06b981122e06882B61309' +const STRATEGY_FACTORY = '0xE6e5732245b3e886DD8897a93D21D29bb652d683' +const STRATEGY_BEACON = '0xe419fb039022d9b9192ebd7d2ba4fe7ad73d9e8b' +/** The depth under which this vault would be too thin to offer. It held about $3.01M when reviewed. */ +const MIN_VAULT_USDC = BigInt(500_000) * BigInt(1_000_000) +// Circle's FiatToken keeps balances at slot 9 and allowances at slot 10; the vault's ERC20 at slot 51. +const USDC_BALANCE_SLOT = BigInt(9) +const USDC_ALLOWANCE_SLOT = BigInt(10) +const SHARE_BALANCE_SLOT = BigInt(51) +const AMOUNT = BigInt(25_000_000) + +const VAULT_ABI = parseAbi([ + 'function want() view returns (address)', + 'function strategy() view returns (address)', + 'function approvalDelay() view returns (uint256)', + 'function stratCandidate() view returns (address implementation, uint256 proposedTime)', + 'function balance() view returns (uint256)', + 'function symbol() view returns (string)', + 'function decimals() view returns (uint8)', + 'function deposit(uint256 amount)', + 'function withdraw(uint256 shares)', +]) +const STRATEGY_ABI = parseAbi([ + 'function vault() view returns (address)', + 'function want() view returns (address)', + 'function aToken() view returns (address)', + 'function lendingPool() view returns (address)', + 'function owner() view returns (address)', + 'function paused() view returns (bool)', + 'function withdrawFee() view returns (uint256)', + 'function getAllFees() view returns (((uint256 total, uint256 beefy, uint256 call, uint256 strategist, string label, bool active) performance, uint256 deposit, uint256 withdraw))', +]) +const ADMIN_ABI = parseAbi([ + 'function getMinDelay() view returns (uint256)', + 'function owner() view returns (address)', +]) + +const reader = evmReader('monad') + +function mappingSlot(key: string, slot: bigint): Hex { + return keccak256( + encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [key as Address, slot]) + ) +} + +function word(value: bigint): Hex { + return pad(toHex(value), { size: 32 }) +} + +describe.skipIf(SKIP)('the pinned Beefy vault on Monad', () => { + it('is a minimal proxy of the reviewed implementation, so it can’t be upgraded', async () => { + const code = await reader.getCode({ address: VAULT }) + expect(code).toBe(`0x363d3d373d3d3d363d73${VAULT_IMPLEMENTATION}5af43d82803e903d91602b57fd5bf3`) + }, 30_000) + + it('holds USDC with the strategy that was reviewed', async () => { + const vault = { address: VAULT, abi: VAULT_ABI } + const strategy = { address: STRATEGY, abi: STRATEGY_ABI } + expect(await reader.readContract({ ...vault, functionName: 'want' })).toBe(BEEFY_MONAD_USDC) + expect(await reader.readContract({ ...vault, functionName: 'strategy' })).toBe(STRATEGY) + expect(await reader.readContract({ ...vault, functionName: 'symbol' })).toBe( + 'mooAaveV3MonadUSDC' + ) + expect(await reader.readContract({ ...strategy, functionName: 'vault' })).toBe(VAULT) + expect(await reader.readContract({ ...strategy, functionName: 'want' })).toBe(BEEFY_MONAD_USDC) + expect(await reader.readContract({ ...strategy, functionName: 'lendingPool' })).toBe(AAVE_POOL) + expect(await reader.readContract({ ...strategy, functionName: 'aToken' })).toBe(AAVE_USDC) + expect(await reader.readContract({ ...strategy, functionName: 'paused' })).toBe(false) + }, 30_000) + + it('is not too thin to offer', async () => { + const held = await reader.readContract({ + address: VAULT, + abi: VAULT_ABI, + functionName: 'balance', + }) + expect(held).toBeGreaterThan(MIN_VAULT_USDC) + }, 30_000) + + it('still has the shares and fees the card states', async () => { + expect( + await reader.readContract({ address: VAULT, abi: VAULT_ABI, functionName: 'decimals' }) + ).toBe(18) + const fees = await reader.readContract({ + address: STRATEGY, + abi: STRATEGY_ABI, + functionName: 'getAllFees', + }) + expect(fees.performance.total).toBe(BigInt(95_000_000_000_000_000)) + expect(fees.deposit).toBe(BigInt(0)) + expect(fees.withdraw).toBe(BigInt(0)) + }, 30_000) + + it('still has the 6-hour delays the card states', async () => { + expect( + await reader.readContract({ address: VAULT, abi: VAULT_ABI, functionName: 'approvalDelay' }) + ).toBe(SIX_HOURS) + expect( + await reader.readContract({ address: STRATEGY, abi: STRATEGY_ABI, functionName: 'owner' }) + ).toBe(STRATEGY_TIMELOCK) + expect( + await reader.readContract({ + address: STRATEGY_TIMELOCK, + abi: ADMIN_ABI, + functionName: 'getMinDelay', + }) + ).toBe(SIX_HOURS) + expect( + await reader.readContract({ + address: STRATEGY_BEACON, + abi: ADMIN_ABI, + functionName: 'owner', + }) + ).toBe(STRATEGY_FACTORY) + expect( + await reader.readContract({ + address: STRATEGY_FACTORY, + abi: ADMIN_ABI, + functionName: 'owner', + }) + ).toBe(STRATEGY_TIMELOCK) + }, 30_000) + + it('takes a deposit, tried and not sent', async () => { + const allowance = keccak256( + encodeAbiParameters( + [{ type: 'address' }, { type: 'bytes32' }], + [VAULT, mappingSlot(THROWAWAY, USDC_ALLOWANCE_SLOT)] + ) + ) + await expect( + reader.call({ + account: THROWAWAY, + to: VAULT, + data: encodeFunctionData({ abi: VAULT_ABI, functionName: 'deposit', args: [AMOUNT] }), + stateOverride: [ + { + address: BEEFY_MONAD_USDC, + stateDiff: [ + { slot: mappingSlot(THROWAWAY, USDC_BALANCE_SLOT), value: word(AMOUNT) }, + { slot: allowance, value: word(AMOUNT) }, + ], + }, + ], + }) + ).resolves.toBeDefined() + }, 30_000) + + it('pays out a withdrawal, tried and not sent', async () => { + const data = encodeFunctionData({ abi: VAULT_ABI, functionName: 'withdraw', args: [AMOUNT] }) + await expect(reader.call({ account: THROWAWAY, to: VAULT, data })).rejects.toThrow() + await expect( + reader.call({ + account: THROWAWAY, + to: VAULT, + data, + stateOverride: [ + { + address: VAULT, + stateDiff: [{ slot: mappingSlot(THROWAWAY, SHARE_BALANCE_SLOT), value: word(AMOUNT) }], + }, + ], + }) + ).resolves.toBeDefined() + }, 30_000) +}) diff --git a/apps/dapp/lib/__tests__/beefy.test.ts b/apps/dapp/lib/__tests__/beefy.test.ts new file mode 100644 index 00000000..851a4186 --- /dev/null +++ b/apps/dapp/lib/__tests__/beefy.test.ts @@ -0,0 +1,544 @@ +import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, zeroAddress, type Address } from 'viem' +import { describe, expect, it } from 'vitest' + +import { + BEEFY_MONAD_STRATEGY, + BEEFY_MONAD_USDC, + BEEFY_MONAD_VAULT, + createBeefyMonadVenue, +} from '../chains/evm/beefy' +import { beefyMonadListing } from '../chains/evm/beefy-listing' +import type { EvmReaderClient } from '../chains/evm/call' +import type { EvmClient, EvmReceipt } from '../chains/evm/client' +import { MONAD_TOKENS } from '../chains/evm/tokens' +import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types' + +const ACCOUNT = '0x1111111111111111111111111111111111111111' +const STRANGER = '0x2222222222222222222222222222222222222222' +/** Beefy's Morpho USDC vault on Monad: real, but not the one this venue is confirmed for. */ +const OTHER_VAULT = '0xA3d999114BCE3d9771703f06C490e77EE83E589a' +const USDT0 = '0xe7cd86e13AC4309349F30B3435a9d337750fC82D' +const MONAD_ID = 143 +const HASH = `0x${'a'.repeat(64)}` +const FIVE_USDC = BigInt(5_000_000) + +const CALLS = parseAbi([ + 'function deposit(uint256 amount)', + 'function withdraw(uint256 shares)', + 'function depositAll()', + 'function withdrawAll()', + 'function earn()', + 'function transfer(address to, uint256 amount) returns (bool)', + 'function approve(address spender, uint256 amount) returns (bool)', +]) + +function deposit(amount = FIVE_USDC): string { + return encodeFunctionData({ abi: CALLS, functionName: 'deposit', args: [amount] }) +} + +function withdraw(shares = FIVE_USDC): string { + return encodeFunctionData({ abi: CALLS, functionName: 'withdraw', args: [shares] }) +} + +function approve(units = FIVE_USDC, spender: string = BEEFY_MONAD_VAULT): string { + return encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender as Address, units], + }) +} + +function payload(overrides: Partial> = {}) { + return { + family: 'evm' as const, + chainId: MONAD_ID, + to: BEEFY_MONAD_VAULT, + data: deposit(), + value: '0', + ...overrides, + } +} + +interface ChainState { + usdc: bigint + allowance: bigint + shares: bigint + vaultBalance: bigint + totalSupply: bigint + want: string + strategy: string + candidate: string + paused: boolean + callFails: boolean + readFails: string | undefined +} + +/** One share to 1.02 USDC, so shares and USDC differ as they do live. */ +const HEALTHY: ChainState = { + usdc: BigInt(100_000_000), + allowance: BigInt(0), + shares: BigInt(50_000_000), + vaultBalance: BigInt(3_060_000_000_000), + totalSupply: BigInt(3_000_000_000_000), + want: BEEFY_MONAD_USDC, + strategy: BEEFY_MONAD_STRATEGY, + candidate: zeroAddress, + paused: false, + callFails: false, + readFails: undefined, +} + +interface FakeRead { + address: string + functionName: string +} + +function fakeReader(overrides: Partial = {}): EvmReaderClient & { calls: unknown[] } { + const state = { ...HEALTHY, ...overrides } + const calls: unknown[] = [] + const reads: Record unknown> = { + balanceOf: (address) => (address === BEEFY_MONAD_VAULT ? state.shares : state.usdc), + allowance: () => state.allowance, + want: () => state.want, + strategy: () => state.strategy, + stratCandidate: () => [state.candidate, BigInt(0)], + balance: () => state.vaultBalance, + totalSupply: () => state.totalSupply, + paused: () => state.paused, + } + return { + calls, + readContract: async ({ address, functionName }: FakeRead) => { + if (state.readFails === functionName) throw new Error('rpc down') + const read = reads[functionName] + if (read === undefined) throw new Error(`unexpected read ${functionName}`) + return read(address) + }, + call: async (request: unknown) => { + calls.push(request) + if (state.callFails) throw new Error('reverted') + return { data: '0x' } + }, + } as unknown as EvmReaderClient & { calls: unknown[] } +} + +const RATES = { + 'aavev3-monad-usdc': { totalApy: 0.05, lendingApr: 0.04, vaultApr: 0.0095 }, +} + +function fakeFetch(body: unknown = RATES, ok = true): typeof fetch { + return (async () => ({ ok, json: async () => body })) as unknown as typeof fetch +} + +function venueWith( + overrides: Partial = {}, + fetcher: typeof fetch = fakeFetch(), + client?: EvmClient +) { + return createBeefyMonadVenue(() => fakeReader(overrides), client, fetcher) +} + +function supply(overrides: Partial = {}): ChainActionRequest { + return { kind: 'supply', amount: '5', tokenIn: 'USDC', ...overrides } +} + +function takeOut(overrides: Partial = {}): ChainActionRequest { + return { kind: 'withdraw', amount: '5.1', tokenIn: 'USDC', ...overrides } +} + +function evm(signing: SigningPayload) { + if (signing.family !== 'evm') throw new Error('not evm') + return signing +} + +describe('pinned ids', () => { + it('uses the Monad token table’s USDC', () => { + expect(MONAD_TOKENS.find((t) => t.symbol === 'USDC')?.id).toBe(BEEFY_MONAD_USDC) + }) + + it('lists itself as executing on Monad under the id the logo is named for', () => { + expect(beefyMonadListing).toMatchObject({ + id: 'beefy-monad', + family: 'evm', + chains: ['Monad'], + integration: 'executes', + }) + expect(createBeefyMonadVenue().id).toBe(beefyMonadListing.id) + }) +}) + +describe('assertSafe', () => { + const { assertSafe } = venueWith() + + it.each<[string, SigningPayload]>([ + ['a deposit', payload()], + [ + 'a deposit with its exact approval', + payload({ approval: { to: BEEFY_MONAD_USDC, data: approve() } }), + ], + ['a withdrawal', payload({ data: withdraw() })], + ])('passes %s', (_label, signing) => { + expect(() => assertSafe(signing, ACCOUNT)).not.toThrow() + }) + + it.each<[string, SigningPayload]>([ + ['another family', { family: 'sui', transaction: 'AA==' }], + ['another chain', payload({ chainId: 43114 })], + ['Beefy’s other USDC vault on Monad', payload({ to: OTHER_VAULT })], + ['the USDC contract as the target', payload({ to: BEEFY_MONAD_USDC })], + ['the strategy as the target', payload({ to: BEEFY_MONAD_STRATEGY })], + [ + 'depositAll, which takes whatever is there', + payload({ data: encodeFunctionData({ abi: CALLS, functionName: 'depositAll' }) }), + ], + [ + 'withdrawAll', + payload({ data: encodeFunctionData({ abi: CALLS, functionName: 'withdrawAll' }) }), + ], + ['earn', payload({ data: encodeFunctionData({ abi: CALLS, functionName: 'earn' }) })], + [ + 'sending shares to someone else', + payload({ + data: encodeFunctionData({ + abi: CALLS, + functionName: 'transfer', + args: [STRANGER as Address, FIVE_USDC], + }), + }), + ], + [ + 'letting someone else spend the shares', + payload({ + data: encodeFunctionData({ + abi: CALLS, + functionName: 'approve', + args: [STRANGER as Address, FIVE_USDC], + }), + }), + ], + ['calldata that decodes as nothing', payload({ data: '0xdeadbeef' })], + ['anything after a deposit’s arguments', payload({ data: `${deposit()}00` })], + ['anything after a withdrawal’s arguments', payload({ data: `${withdraw()}00` })], + ['an empty deposit', payload({ data: deposit(BigInt(0)) })], + ['an empty withdrawal', payload({ data: withdraw(BigInt(0)) })], + ['native value', payload({ value: '1' })], + [ + 'an unlimited approval', + payload({ approval: { to: BEEFY_MONAD_USDC, data: approve(maxUint256) } }), + ], + [ + 'an approval to a stranger', + payload({ approval: { to: BEEFY_MONAD_USDC, data: approve(FIVE_USDC, STRANGER) } }), + ], + ['an approval of another token', payload({ approval: { to: USDT0, data: approve() } })], + [ + 'an approval larger than the deposit', + payload({ approval: { to: BEEFY_MONAD_USDC, data: approve(FIVE_USDC + BigInt(1)) } }), + ], + [ + 'an approval smaller than the deposit', + payload({ approval: { to: BEEFY_MONAD_USDC, data: approve(FIVE_USDC - BigInt(1)) } }), + ], + [ + 'an approval on a withdrawal', + payload({ data: withdraw(), approval: { to: BEEFY_MONAD_USDC, data: approve() } }), + ], + [ + 'a first step that pays someone else', + payload({ + approval: { + to: BEEFY_MONAD_USDC, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [STRANGER as Address, FIVE_USDC], + }), + }, + }), + ], + ])('refuses %s', (_label, signing) => { + expect(() => assertSafe(signing, ACCOUNT)).toThrow(ChainActionRefused) + }) + + it('refuses an account that is not an address', () => { + expect(() => assertSafe(payload(), 'not-an-address')).toThrow(ChainActionRefused) + }) +}) + +describe('supply', () => { + it('asks for an exact approval and a deposit of what was typed', async () => { + const prepared = await venueWith().prepare(supply(), ACCOUNT) + const signing = evm(prepared.signing) + expect(signing.to).toBe(BEEFY_MONAD_VAULT) + expect(signing.data).toBe(deposit()) + expect(signing.approval).toEqual({ to: BEEFY_MONAD_USDC, data: approve() }) + expect(prepared.kind).toBe('supply') + expect(prepared.pay).toEqual({ amount: '5', symbol: 'USDC' }) + expect(prepared.notes.join(' ')).toContain('exactly 5 USDC') + }) + + it('leaves off the approval when one is in place, and tries the call', async () => { + const reader = fakeReader({ allowance: FIVE_USDC }) + const prepared = await createBeefyMonadVenue(() => reader, undefined, fakeFetch()).prepare( + supply(), + ACCOUNT + ) + expect(evm(prepared.signing).approval).toBeUndefined() + expect(reader.calls).toHaveLength(1) + expect(reader.calls[0]).toMatchObject({ account: ACCOUNT, to: BEEFY_MONAD_VAULT }) + }) + + it('passes what it built through its own assertSafe', async () => { + const venue = venueWith() + const prepared = await venue.prepare(supply(), ACCOUNT) + expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + }) + + it('tells the person a withdrawal pays what the strategy returns', async () => { + const { notes } = await venueWith().prepare(supply(), ACCOUNT) + expect(notes.join(' ')).toContain('pays what the strategy returns') + }) + + it('says who can change the vault, and how quickly', async () => { + const { notes } = await venueWith().prepare(supply(), ACCOUNT) + expect(notes.join(' ')).toContain('6-hour') + }) + + it('keeps Aave’s rate apart from the rewards', async () => { + const { apy } = await venueWith().prepare(supply(), ACCOUNT) + expect(apy).toEqual({ basePercent: 4, rewardPercent: 1 }) + }) + + it.each<[string, typeof fetch]>([ + [ + 'the rate service fails', + (async () => { + throw new Error('down') + }) as unknown as typeof fetch, + ], + ['it answers an error', fakeFetch(RATES, false)], + ['this vault is missing', fakeFetch({})], + [ + 'the rate is not a number', + fakeFetch({ 'aavev3-monad-usdc': { totalApy: 'lots', lendingApr: 0.04 } }), + ], + [ + 'the rate is negative', + fakeFetch({ 'aavev3-monad-usdc': { totalApy: -0.1, lendingApr: 0.04 } }), + ], + ['the rate is absurd', fakeFetch({ 'aavev3-monad-usdc': { totalApy: 40, lendingApr: 0.04 } })], + [ + 'the parts exceed the whole', + fakeFetch({ 'aavev3-monad-usdc': { totalApy: 0.01, lendingApr: 0.04 } }), + ], + ['the parts are not given', fakeFetch({ 'aavev3-monad-usdc': { totalApy: 0.05 } })], + ])('shows no rate when %s, and still builds', async (_label, fetcher) => { + const prepared = await venueWith({}, fetcher).prepare(supply(), ACCOUNT) + expect(prepared.apy).toBeUndefined() + expect(prepared.signing.family).toBe('evm') + }) + + it.each<[string, Partial]>([ + ['another token', { tokenIn: 'USDT0' }], + ['the native coin', { tokenIn: 'MON' }], + ['a token this chain doesn’t list', { tokenIn: 'AUSD' }], + ['a zero amount', { amount: '0' }], + ['more precision than USDC has', { amount: '1.0000001' }], + ['a recipient other than the signer', { recipient: STRANGER }], + ])('refuses %s', async (_label, request) => { + await expect(venueWith().prepare(supply(request), ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) + + it.each<[string, Partial]>([ + ['less USDC than asked', { usdc: BigInt(1) }], + ['a vault that no longer takes USDC', { want: USDT0 }], + ['a strategy nobody reviewed', { strategy: STRANGER }], + ['a proposed strategy change', { candidate: STRANGER }], + ['a paused strategy', { paused: true }], + ['an amount too small to earn a share', { vaultBalance: BigInt(10) ** BigInt(30) }], + ['an unreadable vault', { readFails: 'strategy' }], + ['an unreadable balance', { readFails: 'balanceOf' }], + ['a call that would fail', { allowance: FIVE_USDC, callFails: true }], + ])('refuses %s', async (_label, state) => { + await expect(venueWith(state).prepare(supply(), ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) + + it('refuses an account that is not an address', async () => { + await expect(venueWith().prepare(supply(), 'nope')).rejects.toThrow(ChainActionRefused) + }) + + it('refuses a kind it doesn’t do', async () => { + await expect( + venueWith().prepare({ kind: 'borrow', amount: '5', tokenIn: 'USDC' }, ACCOUNT) + ).rejects.toThrow(ChainActionRefused) + }) +}) + +describe('withdraw', () => { + it('burns the shares that pay what was typed, rounded in the person’s favour', async () => { + const prepared = await venueWith().prepare(takeOut(), ACCOUNT) + expect(evm(prepared.signing).data).toBe(withdraw(FIVE_USDC)) + expect(prepared.kind).toBe('withdraw') + expect(prepared.pay).toEqual({ amount: '5.1', symbol: 'USDC' }) + expect(prepared.receive).toEqual({ amount: '5.1', symbol: 'USDC', approximate: true }) + + const uneven = await venueWith().prepare(takeOut({ amount: '1' }), ACCOUNT) + // 1 USDC at 1.02 is 980392.15 shares, so 980393 and never fewer. + expect(evm(uneven.signing).data).toBe(withdraw(BigInt(980_393))) + }) + + it('takes all of a holding at exactly its worth, and refuses one unit more', async () => { + // 980392 shares at 1.02 are worth 999999.84 units, so 0.999999 USDC. + const shares = BigInt(980_392) + const all = await venueWith({ shares }).prepare(takeOut({ amount: '0.999999' }), ACCOUNT) + expect(evm(all.signing).data).toBe(withdraw(shares)) + await expect(venueWith({ shares }).prepare(takeOut({ amount: '1' }), ACCOUNT)).rejects.toThrow( + ChainActionRefused + ) + }) + + it('needs no approval, and tries the call as this person', async () => { + const reader = fakeReader() + const prepared = await createBeefyMonadVenue(() => reader, undefined, fakeFetch()).prepare( + takeOut(), + ACCOUNT + ) + expect(evm(prepared.signing).approval).toBeUndefined() + expect(reader.calls[0]).toMatchObject({ account: ACCOUNT, to: BEEFY_MONAD_VAULT }) + }) + + it('passes what it built through its own assertSafe', async () => { + const venue = venueWith() + const prepared = await venue.prepare(takeOut(), ACCOUNT) + expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + }) + + it('says a withdrawal pays what the strategy returns', async () => { + const { notes } = await venueWith().prepare(takeOut(), ACCOUNT) + expect(notes.join(' ')).toContain('pays what the strategy returns') + }) + + it.each<[string, Partial]>([ + ['a strategy nobody reviewed', { strategy: STRANGER }], + ['a proposed strategy change', { candidate: STRANGER }], + ['a paused strategy', { paused: true }], + ])('still lets a person out with %s', async (_label, state) => { + const prepared = await venueWith(state).prepare(takeOut(), ACCOUNT) + expect(prepared.kind).toBe('withdraw') + }) + + it.each<[string, Partial, Partial]>([ + ['another token', { tokenIn: 'USDT0' }, {}], + ['more than the person holds here', { amount: '51.1' }, {}], + ['a person with nothing here', {}, { shares: BigInt(0) }], + ['an empty vault', {}, { totalSupply: BigInt(0) }], + ['a vault that no longer pays USDC', {}, { want: USDT0 }], + ['an unreadable vault', {}, { readFails: 'balance' }], + ['a call that would fail', {}, { callFails: true }], + ['a recipient other than the signer', { recipient: STRANGER }, {}], + ])('refuses %s', async (_label, request, state) => { + await expect(venueWith(state).prepare(takeOut(request), ACCOUNT)).rejects.toThrow( + ChainActionRefused + ) + }) + + it('accepts the signer named as the recipient', async () => { + const prepared = await venueWith().prepare(takeOut({ recipient: ACCOUNT }), ACCOUNT) + expect(prepared.kind).toBe('withdraw') + }) +}) + +describe('quote', () => { + it('gives the two rates together for the race, and reads no wallet', async () => { + const reader = fakeReader({ readFails: 'balanceOf' }) + const venue = createBeefyMonadVenue(() => reader, undefined, fakeFetch()) + await expect(venue.quote?.(supply(), ACCOUNT)).resolves.toEqual({ apyPercent: 5 }) + expect(reader.calls).toHaveLength(0) + }) + + it('gives no rate when none can be read', async () => { + const venue = venueWith({}, fakeFetch({})) + await expect(venue.quote?.(supply(), ACCOUNT)).resolves.toEqual({}) + }) + + it.each<[string, Partial]>([ + ['a strategy nobody reviewed', { strategy: STRANGER }], + ['a paused strategy', { paused: true }], + ])('refuses to race with %s', async (_label, state) => { + await expect(venueWith(state).quote?.(supply(), ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) + + it('refuses another token and another kind', async () => { + await expect(venueWith().quote?.(supply({ tokenIn: 'MON' }), ACCOUNT)).rejects.toThrow( + ChainActionRefused + ) + await expect(venueWith().quote?.(takeOut(), ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) +}) + +describe('following a swap', () => { + it('takes USDC and nothing else', async () => { + const { assertCanFollow } = venueWith() + await expect(assertCanFollow?.({ kind: 'supply', tokenIn: 'USDC' }, ACCOUNT)).resolves.toBe( + undefined + ) + await expect(assertCanFollow?.({ kind: 'supply', tokenIn: 'MON' }, ACCOUNT)).rejects.toThrow( + ChainActionRefused + ) + }) +}) + +function fakeClient( + receipt: Partial, + sent: { input: string; value: bigint } = { input: deposit(), value: BigInt(0) } +): EvmClient { + return { + tokenBalance: async () => BigInt(0), + nativeBalance: async () => BigInt(0), + waitForReceipt: async () => + ({ + status: 'success', + from: ACCOUNT, + to: BEEFY_MONAD_VAULT, + logs: [], + ...receipt, + }) as EvmReceipt, + transaction: async () => sent, + } +} + +describe('submit', () => { + const signed = { + account: ACCOUNT, + venueId: 'beefy-monad', + signed: { family: 'evm' as const, hash: HASH }, + } + + function submitWith(client: EvmClient) { + return createBeefyMonadVenue(() => fakeReader(), client, fakeFetch()).submit?.(signed) + } + + it('settles a landed deposit from this person', async () => { + await expect(submitWith(fakeClient({}))).resolves.toMatchObject({ ok: true, hash: HASH }) + }) + + it('says funds did not move for a proven revert of this very call', async () => { + const result = await submitWith(fakeClient({ status: 'reverted' })) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + }) + + it.each<[string, EvmClient]>([ + ['the wrong signer', fakeClient({ from: STRANGER })], + ['another contract', fakeClient({ to: OTHER_VAULT })], + [ + 'a call to something else on the vault', + fakeClient( + {}, + { input: encodeFunctionData({ abi: CALLS, functionName: 'withdrawAll' }), value: BigInt(0) } + ), + ], + ])('stays pending for %s, since the money may have moved', async (_label, client) => { + await expect(submitWith(client)).resolves.toMatchObject({ ok: false, pending: true }) + }) +}) diff --git a/apps/dapp/lib/__tests__/bulla.live.test.ts b/apps/dapp/lib/__tests__/bulla.live.test.ts new file mode 100644 index 00000000..58e97cd5 --- /dev/null +++ b/apps/dapp/lib/__tests__/bulla.live.test.ts @@ -0,0 +1,79 @@ +import { http, type Address, type Hex } from 'viem' +import { describe, expect, it } from 'vitest' + +import { + BULLA_CLAIM, + BULLA_INVOICE, + BULLA_OWNER, + BULLA_READ_ABI, + createBullaVenue, +} from '../chains/evm/bulla' +import { evmReader } from '../chains/evm/client' + +/** + * Read-only against Avalanche mainnet: nothing is signed or sent. These hold the card's claims to + * the chain, so a moved owner, a new fee or a lock shows up here before a person reads a stale note. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const RPC = + process.env['AVALANCHE_RPC_URL']?.trim() || 'https://avalanche-c-chain-rpc.publicnode.com' +const reader = evmReader('avalanche', http(RPC, { timeout: 20_000, retryCount: 2 })) +const BILLER = '0x1111111111111111111111111111111111111111' +const CLIENT = '0x2222222222222222222222222222222222222222' + +describe.skipIf(SKIP)('Bulla on Avalanche', { timeout: 60_000 }, () => { + const read = (address: Address, functionName: never) => + reader.readContract({ address, abi: BULLA_READ_ABI, functionName } as never) as Promise + + it('has code at each pinned contract', async () => { + for (const address of [BULLA_CLAIM, BULLA_INVOICE]) { + const code = await reader.getCode({ address: address as Address }) + expect((code ?? '0x').length).toBeGreaterThan(2) + } + }) + + it('wires BullaInvoice to this claim contract', async () => { + expect(await read(BULLA_INVOICE as Address, '_bullaClaim' as never)).toBe(BULLA_CLAIM) + }) + + it('is owned by the single key the card names', async () => { + expect(await read(BULLA_CLAIM as Address, 'owner' as never)).toBe(BULLA_OWNER) + expect(await read(BULLA_INVOICE as Address, 'owner' as never)).toBe(BULLA_OWNER) + expect(await reader.getCode({ address: BULLA_OWNER as Address })).toBeUndefined() + }) + + it('is unlocked, and charges no fee', async () => { + expect(await read(BULLA_CLAIM as Address, 'lockState' as never)).toBe(0) + expect(await read(BULLA_CLAIM as Address, 'CORE_PROTOCOL_FEE' as never)).toBe(BigInt(0)) + expect(await read(BULLA_INVOICE as Address, 'protocolFeeBPS' as never)).toBe(0) + }) + + it('refuses to pay an invoice that is not there', async () => { + await expect( + createBullaVenue(reader).prepare( + { kind: 'pay_invoice', amount: '1', tokenIn: 'USDC', invoiceId: '999999999' }, + BILLER + ) + ).rejects.toThrow(/no invoice 999999999/) + }) + + it('accepts the bill this venue builds, and refuses it from anyone else', async () => { + const prepared = await createBullaVenue(reader).prepare( + { kind: 'invoice', amount: '500', tokenIn: 'USDC', recipient: CLIENT, dueDate: '2099-12-01' }, + BILLER + ) + if (prepared.signing.family !== 'evm') throw new Error('not evm') + const data = prepared.signing.data as Hex + await expect( + reader.call({ account: BILLER, to: BULLA_CLAIM as Address, data }) + ).resolves.toBeDefined() + await expect( + reader.call({ + account: '0x3333333333333333333333333333333333333333', + to: BULLA_CLAIM as Address, + data, + }) + ).rejects.toThrow() + }) +}) diff --git a/apps/dapp/lib/__tests__/bulla.test.ts b/apps/dapp/lib/__tests__/bulla.test.ts new file mode 100644 index 00000000..1dc6bc4f --- /dev/null +++ b/apps/dapp/lib/__tests__/bulla.test.ts @@ -0,0 +1,658 @@ +import { + decodeFunctionData, + encodeFunctionData, + erc20Abi, + maxUint256, + zeroAddress, + type Address, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import { + BULLA_CLAIM, + BULLA_CREATE_ABI, + BULLA_DESCRIPTION, + BULLA_INVOICE, + BULLA_PAY_ABI, + BULLA_INVOICE_PAY_ABI, + createBullaVenue, +} from '../chains/evm/bulla' +import { bullaListing } from '../chains/evm/bulla-listing' +import type { EvmReaderClient } from '../chains/evm/call' +import { AVALANCHE_TOKENS } from '../chains/evm/tokens' +import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types' + +const ME = '0x1111111111111111111111111111111111111111' +const CLIENT = '0x2222222222222222222222222222222222222222' +const STRANGER = '0x3333333333333333333333333333333333333333' +const USDC = AVALANCHE_TOKENS.find((t) => t.symbol === 'USDC')?.id as Address +const USDT = AVALANCHE_TOKENS.find((t) => t.symbol === 'USDT')?.id as Address +const AVALANCHE_ID = 43114 +const FIVE_HUNDRED = BigInt(500_000_000) + +interface ClaimFake { + claimAmount: bigint + paidAmount: bigint + dueBy: bigint + impairmentGracePeriod: bigint + originalCreditor: Address + debtor: Address + creditor: Address + token: Address + controller: Address + status: number + binding: number +} + +const claimOf = (over: Partial = {}): ClaimFake => ({ + claimAmount: FIVE_HUNDRED, + paidAmount: BigInt(0), + dueBy: BigInt(4_000_000_000), + impairmentGracePeriod: BigInt(0), + originalCreditor: CLIENT, + debtor: ME, + creditor: CLIENT, + token: USDC, + controller: zeroAddress, + status: 0, + binding: 0, + ...over, +}) + +interface InvoiceFake { + deliveryDate?: bigint + interestRateBps?: number + accruedInterest?: bigint + protocolFeeBps?: number +} + +interface FakeChain { + lockState?: number + fee?: bigint + count?: bigint + claim?: ClaimFake + invoice?: InvoiceFake + callback?: Address + allowance?: bigint + callFails?: boolean + readFails?: boolean +} + +function readerFor(chain: FakeChain = {}) { + const calls: { to: string; data: string }[] = [] + const claim = chain.claim ?? claimOf() + const reader = { + readContract: async ({ functionName, address }: { functionName: string; address: string }) => { + if (chain.readFails === true) throw new Error('rpc down') + switch (functionName) { + case 'lockState': + return chain.lockState ?? 0 + case 'CORE_PROTOCOL_FEE': + return chain.fee ?? BigInt(0) + case 'currentClaimId': + return chain.count ?? BigInt(100) + case 'getClaim': + return claim + case 'getPaidClaimCallback': + return { + callbackContract: chain.callback ?? zeroAddress, + callbackSelector: chain.callback === undefined ? '0x00000000' : '0x12345678', + } + case 'getInvoice': + return { + ...claim, + purchaseOrder: { + deliveryDate: chain.invoice?.deliveryDate ?? BigInt(0), + depositAmount: BigInt(0), + isDelivered: false, + }, + lateFeeConfig: { + interestRateBps: chain.invoice?.interestRateBps ?? 0, + numberOfPeriodsPerYear: 0, + }, + interestComputationState: { + accruedInterest: chain.invoice?.accruedInterest ?? BigInt(0), + latestPeriodNumber: BigInt(0), + protocolFeeBps: chain.invoice?.protocolFeeBps ?? 0, + totalGrossInterestPaid: BigInt(0), + }, + } + case 'allowance': + return chain.allowance ?? BigInt(0) + default: + throw new Error(`unexpected read ${functionName} on ${address}`) + } + }, + call: async (args: { to: string; data: string }) => { + calls.push({ to: args.to, data: args.data }) + if (chain.callFails === true) throw new Error('reverted') + return { data: '0x' } + }, + } as unknown as EvmReaderClient + return { reader, calls } +} + +const bill = (over: Partial = {}): ChainActionRequest => ({ + kind: 'invoice', + amount: '500', + tokenIn: 'USDC', + recipient: CLIENT, + dueDate: '2099-12-01', + ...over, +}) + +const pay = (over: Partial = {}): ChainActionRequest => ({ + kind: 'pay_invoice', + amount: '500', + tokenIn: 'USDC', + invoiceId: '12', + ...over, +}) + +function evmPayload(signing: SigningPayload) { + if (signing.family !== 'evm') throw new Error('not evm') + return signing +} + +const refusal = (reason: RegExp) => + expect.objectContaining({ + constructor: ChainActionRefused, + message: expect.stringMatching(reason), + }) + +function billingCalldata(over: Record = {}): string { + return encodeFunctionData({ + abi: BULLA_CREATE_ABI, + functionName: 'createClaim', + args: [ + { + creditor: ME, + debtor: CLIENT, + claimAmount: FIVE_HUNDRED, + description: BULLA_DESCRIPTION, + token: USDC, + binding: 0, + dueBy: BigInt(4_000_000_000), + impairmentGracePeriod: BigInt(0), + ...over, + }, + ], + } as never) +} + +const payCalldata = (id = BigInt(12), units = FIVE_HUNDRED) => + encodeFunctionData({ abi: BULLA_PAY_ABI, functionName: 'payClaim', args: [id, units] }) + +const approveData = (spender: string, units: bigint) => + encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [spender as Address, units] }) + +const payload = ( + over: Partial> = {} +): SigningPayload => ({ + family: 'evm', + chainId: AVALANCHE_ID, + to: BULLA_CLAIM, + data: billingCalldata(), + value: '0', + ...over, +}) + +describe('billing with Bulla', () => { + it('bills the typed address, in the person’s name, with nothing but gas', async () => { + const { reader } = readerFor() + const prepared = await createBullaVenue(reader).prepare(bill(), ME) + const signing = evmPayload(prepared.signing) + expect(signing.to).toBe(BULLA_CLAIM) + expect(signing.value).toBe('0') + expect(signing.approval).toBeUndefined() + const decoded = decodeFunctionData({ + abi: BULLA_CREATE_ABI, + data: signing.data as `0x${string}`, + }) + expect(decoded.functionName).toBe('createClaim') + expect(decoded.args[0]).toMatchObject({ + creditor: ME, + debtor: CLIENT, + claimAmount: FIVE_HUNDRED, + token: USDC, + binding: 0, + description: BULLA_DESCRIPTION, + impairmentGracePeriod: BigInt(0), + }) + expect(decoded.args[0].dueBy).toBe(BigInt(Date.UTC(2099, 11, 1, 23, 59, 59) / 1000)) + expect(prepared.kind).toBe('invoice') + expect(prepared.pay).toEqual({ amount: '500', symbol: 'USDC' }) + }) + + it('names the auditor and the owner’s powers on the card', async () => { + const { reader } = readerFor() + const { notes } = await createBullaVenue(reader).prepare(bill(), ME) + const text = notes.join(' ') + expect(text).toMatch(/LPHAC/) + expect(text).toMatch(/v2\.2/) + expect(text).toMatch(/v2\.4/) + expect(text).toMatch(/lock/i) + expect(text).toMatch(/fee/i) + expect(text).toMatch(/reject/i) + }) + + it('tries the call before offering it', async () => { + const { reader, calls } = readerFor() + await createBullaVenue(reader).prepare(bill(), ME) + expect(calls).toHaveLength(1) + await expect( + createBullaVenue(readerFor({ callFails: true }).reader).prepare(bill(), ME) + ).rejects.toThrow(/fail on the chain/) + }) + + it('refuses a due date in the past', async () => { + const { reader } = readerFor() + await expect( + createBullaVenue(reader).prepare(bill({ dueDate: '2020-01-01' }), ME) + ).rejects.toThrow(/already passed.*Nothing was signed or sent\./) + }) + + it('takes a due date of today', async () => { + const today = new Date().toISOString().slice(0, 10) + const prepared = await createBullaVenue(readerFor().reader).prepare( + bill({ dueDate: today }), + ME + ) + expect(prepared.kind).toBe('invoice') + }) + + it.each(['2099-02-30', '2099-13-01', 'next week', '20991201', undefined])( + 'refuses %s as a due date', + async (dueDate) => { + await expect( + createBullaVenue(readerFor().reader).prepare(bill({ dueDate }), ME) + ).rejects.toThrow(/due date/) + } + ) + + it('refuses a fee instead of hiding one, and names it', async () => { + const fee = BigInt('20000000000000000') + await expect(createBullaVenue(readerFor({ fee }).reader).prepare(bill(), ME)).rejects.toThrow( + /0\.02 AVAX.*Nothing was signed or sent\./ + ) + }) + + it.each([1, 2])('refuses when Bulla is locked (state %i)', async (lockState) => { + await expect( + createBullaVenue(readerFor({ lockState }).reader).prepare(bill(), ME) + ).rejects.toThrow(/locked/) + }) + + it('refuses to bill in AVAX, which this venue does not handle', async () => { + await expect( + createBullaVenue(readerFor().reader).prepare(bill({ tokenIn: 'AVAX' }), ME) + ).rejects.toThrow(/USDC or USDT/) + }) + + it('refuses a listed token that isn’t a stablecoin it bills in', async () => { + await expect( + createBullaVenue(readerFor().reader).prepare(bill({ tokenIn: 'WAVAX' }), ME) + ).rejects.toThrow(/USDC or USDT/) + }) + + it('refuses a token not on the chain’s list', async () => { + await expect( + createBullaVenue(readerFor().reader).prepare(bill({ tokenIn: 'SCAM' }), ME) + ).rejects.toThrow(/USDC or USDT/) + }) + + it('refuses a shortened, zero or missing address, and the person’s own', async () => { + const venue = createBullaVenue(readerFor().reader) + await expect(venue.prepare(bill({ recipient: '0xabc' }), ME)).rejects.toThrow( + ChainActionRefused + ) + await expect(venue.prepare(bill({ recipient: zeroAddress }), ME)).rejects.toThrow( + ChainActionRefused + ) + await expect(venue.prepare(bill({ recipient: undefined }), ME)).rejects.toThrow( + ChainActionRefused + ) + await expect(venue.prepare(bill({ recipient: ME }), ME)).rejects.toThrow(ChainActionRefused) + }) + + it('refuses when the chain can’t be read', async () => { + await expect( + createBullaVenue(readerFor({ readFails: true }).reader).prepare(bill(), ME) + ).rejects.toThrow(/couldn't read/) + }) + + it('refuses an account that is not an address', async () => { + await expect( + createBullaVenue(readerFor().reader).prepare(bill(), 'alice.near') + ).rejects.toThrow(/not an Avalanche address/) + }) + + it('refuses a kind it does not do', async () => { + await expect( + createBullaVenue(readerFor().reader).prepare(bill({ kind: 'send' }), ME) + ).rejects.toThrow(ChainActionRefused) + }) +}) + +describe('paying with Bulla', () => { + it('pays a plain claim through the claim contract, with an exact approval when needed', async () => { + const { reader, calls } = readerFor() + const prepared = await createBullaVenue(reader).prepare(pay(), ME) + const signing = evmPayload(prepared.signing) + expect(signing.to).toBe(BULLA_CLAIM) + expect(signing.value).toBe('0') + expect(signing.data).toBe(payCalldata()) + expect(signing.approval).toEqual({ to: USDC, data: approveData(BULLA_CLAIM, FIVE_HUNDRED) }) + expect(calls).toHaveLength(0) + expect(prepared.notes.join(' ')).toMatch(/asks twice/) + expect(prepared.summary).toContain(CLIENT) + }) + + it('skips the approval and tries the call when the allowance covers it', async () => { + const { reader, calls } = readerFor({ allowance: FIVE_HUNDRED }) + const signing = evmPayload((await createBullaVenue(reader).prepare(pay(), ME)).signing) + expect(signing.approval).toBeUndefined() + expect(calls).toHaveLength(1) + }) + + it('pays an invoice made through BullaInvoice on that contract', async () => { + const claim = claimOf({ controller: BULLA_INVOICE }) + const { reader } = readerFor({ claim }) + const signing = evmPayload((await createBullaVenue(reader).prepare(pay(), ME)).signing) + expect(signing.to).toBe(BULLA_INVOICE) + expect(signing.data).toBe( + encodeFunctionData({ + abi: BULLA_INVOICE_PAY_ABI, + functionName: 'payInvoice', + args: [BigInt(12), FIVE_HUNDRED], + }) + ) + expect(signing.approval).toEqual({ to: USDC, data: approveData(BULLA_INVOICE, FIVE_HUNDRED) }) + }) + + it('counts late-fee interest as owed, and shows the fee Bulla keeps of it', async () => { + const claim = claimOf({ controller: BULLA_INVOICE }) + const invoice = { + interestRateBps: 1000, + accruedInterest: BigInt(10_000_000), + protocolFeeBps: 500, + } + const venue = createBullaVenue(readerFor({ claim, invoice }).reader) + await expect(venue.prepare(pay(), ME)).rejects.toThrow(/510 USDC/) + const prepared = await venue.prepare(pay({ amount: '510' }), ME) + const text = prepared.notes.join(' ') + expect(text).toMatch(/10 USDC.*late/i) + expect(text).toMatch(/5%/) + }) + + it('refuses a purchase order', async () => { + const claim = claimOf({ controller: BULLA_INVOICE }) + await expect( + createBullaVenue( + readerFor({ claim, invoice: { deliveryDate: BigInt(4_000_000_000) } }).reader + ).prepare(pay(), ME) + ).rejects.toThrow(/purchase order/) + }) + + it('refuses a claim run by any other contract', async () => { + const claim = claimOf({ controller: STRANGER as Address }) + await expect(createBullaVenue(readerFor({ claim }).reader).prepare(pay(), ME)).rejects.toThrow( + /isn't an invoice/ + ) + }) + + it('refuses an amount that is not exactly what is owed', async () => { + const venue = createBullaVenue(readerFor().reader) + await expect(venue.prepare(pay({ amount: '499.99' }), ME)).rejects.toThrow( + /500 USDC, not 499\.99/ + ) + await expect(venue.prepare(pay({ amount: '500.01' }), ME)).rejects.toThrow( + /500 USDC, not 500\.01/ + ) + }) + + it('counts what has already been paid', async () => { + const claim = claimOf({ paidAmount: BigInt(200_000_000), status: 1 }) + const venue = createBullaVenue(readerFor({ claim }).reader) + await expect(venue.prepare(pay(), ME)).rejects.toThrow(/300 USDC/) + const signing = evmPayload((await venue.prepare(pay({ amount: '300' }), ME)).signing) + expect(signing.data).toBe(payCalldata(BigInt(12), BigInt(300_000_000))) + }) + + it('refuses the wrong token', async () => { + const venue = createBullaVenue(readerFor({ claim: claimOf({ token: USDT }) }).reader) + await expect(venue.prepare(pay(), ME)).rejects.toThrow(/USDT, not USDC/) + }) + + it('refuses a claim in a token this app does not list', async () => { + const venue = createBullaVenue( + readerFor({ claim: claimOf({ token: STRANGER as Address }) }).reader + ) + await expect(venue.prepare(pay(), ME)).rejects.toThrow(/different token/) + }) + + it.each([ + [2, /already been paid/], + [3, /rejected/], + [4, /cancelled/], + [5, /impaired/], + [9, /can't be paid/], + ])('refuses status %i', async (status, reason) => { + const venue = createBullaVenue(readerFor({ claim: claimOf({ status }) }).reader) + await expect(venue.prepare(pay(), ME)).rejects.toThrow(reason) + }) + + it('refuses an invoice billed to someone else', async () => { + const venue = createBullaVenue( + readerFor({ claim: claimOf({ debtor: STRANGER as Address }) }).reader + ) + await expect(venue.prepare(pay(), ME)).rejects.toThrow(/billed to .*not this wallet/) + }) + + it('refuses an invoice that does not exist', async () => { + const venue = createBullaVenue(readerFor({ count: BigInt(12) }).reader) + await expect(venue.prepare(pay(), ME)).rejects.toThrow(/no invoice 12/) + }) + + it.each(['12a', '-1', '', undefined])('refuses %s as an invoice id', async (invoiceId) => { + const venue = createBullaVenue(readerFor().reader) + await expect(venue.prepare(pay({ invoiceId }), ME)).rejects.toThrow(/invoice number/) + }) + + it('refuses a claim with a paid callback, which runs code nobody here has reviewed', async () => { + const venue = createBullaVenue(readerFor({ callback: STRANGER as Address }).reader) + await expect(venue.prepare(pay(), ME)).rejects.toThrow(/runs another contract/) + }) + + it('refuses when fully locked, but pays when only new claims are stopped', async () => { + await expect( + createBullaVenue(readerFor({ lockState: 2 }).reader).prepare(pay(), ME) + ).rejects.toThrow(/locked/) + const prepared = await createBullaVenue(readerFor({ lockState: 1 }).reader).prepare(pay(), ME) + expect(prepared.kind).toBe('pay_invoice') + }) + + it('states who gets paid, and the audit gap', async () => { + const { notes } = await createBullaVenue(readerFor().reader).prepare(pay(), ME) + const text = notes.join(' ') + expect(text).toContain(CLIENT) + expect(text).toMatch(/LPHAC/) + expect(text).toMatch(/v2\.4/) + }) +}) + +describe('assertSafe', () => { + const venue = createBullaVenue(readerFor().reader) + const check = (signing: SigningPayload, account = ME) => venue.assertSafe(signing, account) + + it('passes what the venue builds', async () => { + const built = await venue.prepare(bill(), ME) + expect(() => venue.assertSafe(built.signing, ME)).not.toThrow() + const paid = await venue.prepare(pay(), ME) + expect(() => venue.assertSafe(paid.signing, ME)).not.toThrow() + const viaInvoice = await createBullaVenue( + readerFor({ claim: claimOf({ controller: BULLA_INVOICE }) }).reader + ).prepare(pay(), ME) + expect(() => venue.assertSafe(viaInvoice.signing, ME)).not.toThrow() + }) + + it('refuses the wrong account', () => { + expect(() => check(payload(), STRANGER)).toThrow(/you as the one owed/) + }) + + it('refuses credit going to anyone else', () => { + expect(() => check(payload({ data: billingCalldata({ creditor: STRANGER }) }))).toThrow( + /you as the one owed/ + ) + }) + + it('refuses billing yourself or nobody', () => { + expect(() => check(payload({ data: billingCalldata({ debtor: ME }) }))).toThrow(/bills/) + expect(() => check(payload({ data: billingCalldata({ debtor: zeroAddress }) }))).toThrow( + /bills/ + ) + }) + + it('refuses an unknown contract', () => { + expect(() => check(payload({ to: STRANGER }))).toThrow(/haven't confirmed for Bulla/) + expect(() => check(payload({ to: USDC }))).toThrow(/haven't confirmed for Bulla/) + }) + + it('refuses the wrong chain and another family', () => { + expect(() => check(payload({ chainId: 143 }))).toThrow(/not for Avalanche/) + expect(() => check({ family: 'solana', transaction: 'AA==' })).toThrow( + /not a Avalanche|not an Avalanche/ + ) + }) + + it('refuses an unknown function', () => { + const data = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [STRANGER as Address, FIVE_HUNDRED], + }) + expect(() => check(payload({ data }))).toThrow(/haven't confirmed for Bulla/) + expect(() => check(payload({ to: BULLA_INVOICE, data: billingCalldata() }))).toThrow( + /haven't confirmed for Bulla/ + ) + }) + + it('refuses extra calldata', () => { + expect(() => check(payload({ data: `${billingCalldata()}00` }))).toThrow(/extra data/) + expect(() => check(payload({ data: `${payCalldata()}deadbeef` }))).toThrow(/extra data/) + }) + + it('refuses any value on either call', () => { + expect(() => check(payload({ value: '1' }))).toThrow(/spends AVAX/) + expect(() => check(payload({ data: payCalldata(), value: '1' }))).toThrow(/spends AVAX/) + }) + + it('refuses a token it does not bill in', () => { + expect(() => check(payload({ data: billingCalldata({ token: STRANGER }) }))).toThrow(/token/) + expect(() => check(payload({ data: billingCalldata({ token: zeroAddress }) }))).toThrow(/token/) + }) + + it('refuses a bound claim, a late grace period, stray description or no due date', () => { + expect(() => check(payload({ data: billingCalldata({ binding: 1 }) }))).toThrow(/terms/) + expect(() => + check(payload({ data: billingCalldata({ impairmentGracePeriod: BigInt(1) }) })) + ).toThrow(/terms/) + expect(() => + check(payload({ data: billingCalldata({ description: 'pay here: evil.example' }) })) + ).toThrow(/terms/) + expect(() => check(payload({ data: billingCalldata({ dueBy: BigInt(0) }) }))).toThrow(/terms/) + expect(() => check(payload({ data: billingCalldata({ claimAmount: BigInt(0) }) }))).toThrow( + /terms/ + ) + expect(() => check(payload({ data: billingCalldata({ claimAmount: maxUint256 }) }))).toThrow( + /terms/ + ) + }) + + it('refuses any approval alongside a bill, which moves nothing', () => { + expect(() => + check(payload({ approval: { to: USDC, data: approveData(BULLA_CLAIM, FIVE_HUNDRED) } })) + ).toThrow(/moves nothing/) + }) + + it('refuses an unlimited approval', () => { + expect(() => + check( + payload({ + data: payCalldata(), + approval: { to: USDC, data: approveData(BULLA_CLAIM, maxUint256) }, + }) + ) + ).toThrow(/unlimited/) + }) + + it('refuses an approval for the wrong amount', () => { + expect(() => + check( + payload({ + data: payCalldata(), + approval: { to: USDC, data: approveData(BULLA_CLAIM, FIVE_HUNDRED + BigInt(1)) }, + }) + ) + ).toThrow(/exactly/) + }) + + it('refuses an approval to someone else, or to the other Bulla contract', () => { + expect(() => + check( + payload({ + data: payCalldata(), + approval: { to: USDC, data: approveData(STRANGER, FIVE_HUNDRED) }, + }) + ) + ).toThrow(/isn't confirmed for Bulla/) + expect(() => + check( + payload({ + data: payCalldata(), + approval: { to: USDC, data: approveData(BULLA_INVOICE, FIVE_HUNDRED) }, + }) + ) + ).toThrow(/exactly/) + }) + + it('refuses approving a token it does not take', () => { + expect(() => + check( + payload({ + data: payCalldata(), + approval: { to: STRANGER, data: approveData(BULLA_CLAIM, FIVE_HUNDRED) }, + }) + ) + ).toThrow(/approve a token/) + }) + + it('refuses a payment of nothing', () => { + expect(() => check(payload({ data: payCalldata(BigInt(12), BigInt(0)) }))).toThrow(/nothing/) + }) +}) + +describe('venue shape', () => { + it('does only the two invoice kinds, and is never raced', () => { + const venue = createBullaVenue(readerFor().reader) + expect(venue.id).toBe('bulla') + expect(venue.kinds).toEqual(['invoice', 'pay_invoice']) + expect(venue.quote).toBeUndefined() + expect(venue.submit).toBeTypeOf('function') + }) + + it('lists as executing on Avalanche', () => { + expect(bullaListing).toMatchObject({ + id: 'bulla', + family: 'evm', + chains: ['Avalanche'], + integration: 'executes', + }) + }) +}) + +it('is a refusal type, not a bare error', async () => { + await expect( + createBullaVenue(readerFor({ lockState: 2 }).reader).prepare(pay(), ME) + ).rejects.toEqual(refusal(/locked/)) +}) diff --git a/apps/dapp/lib/__tests__/chain-action-kinds.test.ts b/apps/dapp/lib/__tests__/chain-action-kinds.test.ts new file mode 100644 index 00000000..64e36548 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-action-kinds.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest' + +import { chainHelp } from '../chains/explain' +import { looksLikeChainAction, parseChainAction, racesFirst } from '../chains/parse-action' +import { assertValidAmount, prepareChainAction } from '../chains/prepare' +import { + ChainActionRefused, + type ChainActionKind, + type ChainModule, + type ChainToken, + type ChainVenue, + type PreparedAction, +} from '../chains/types' + +const ADDRESS = '0xAbC0000000000000000000000000000000000001' +const TOKEN: ChainToken = { symbol: 'USDC', id: '0xusdc', decimals: 6 } +const OTHER: ChainToken = { symbol: 'WAL', id: '0xwal', decimals: 9 } + +function venueNamed(id: string, kinds: readonly ChainActionKind[]): ChainVenue { + return { + id, + name: id, + kinds, + prepare: async (request): Promise => ({ + venueId: id, + venueName: id, + kind: request.kind, + summary: id, + pay: { amount: request.amount, symbol: request.tokenIn }, + notes: [], + signing: { family: 'sui', transaction: 'AA==' }, + }), + assertSafe: () => undefined, + } +} + +function moduleWith(overrides: Partial = {}): ChainModule { + return { + chain: 'sui', + tokens: [TOKEN, OTHER], + venues: [], + defaultVenue: {}, + priceUsd: async () => undefined, + submit: async () => ({ ok: false, error: 'unused' }), + balances: async () => ({ tokens: [], complete: true }), + ...overrides, + } +} + +describe('reading position actions', () => { + it.each([ + [ + 'withdraw 25 USDC from Aave', + { kind: 'withdraw', amount: '25', tokenIn: 'USDC', venue: 'Aave' }, + ], + ['redeem 1,000 USDC', { kind: 'withdraw', amount: '1000', tokenIn: 'USDC' }], + [ + 'unstake 1 sSUI from SpringSui', + { kind: 'unstake', amount: '1', tokenIn: 'sSUI', venue: 'SpringSui' }, + ], + ['borrow 50 USDT0 on Aave', { kind: 'borrow', amount: '50', tokenIn: 'USDT0', venue: 'Aave' }], + ['repay 50 USDT0 on Aave', { kind: 'repay', amount: '50', tokenIn: 'USDT0', venue: 'Aave' }], + ['please pay back 5 USDC', { kind: 'repay', amount: '5', tokenIn: 'USDC' }], + ])('reads %s', (text, expected) => { + expect(parseChainAction(text)).toMatchObject(expected) + }) + + it('reads a chain’s name as its token', () => { + expect(parseChainAction('unstake 2 monad')).toMatchObject({ kind: 'unstake', tokenIn: 'MON' }) + }) +}) + +describe('reading payment actions', () => { + it('reads a stream in days, with the address as typed', () => { + expect(parseChainAction(`stream 500 USDC to ${ADDRESS} over 30 days`)).toEqual({ + kind: 'stream', + amount: '500', + tokenIn: 'USDC', + recipient: ADDRESS, + durationDays: '30', + venue: undefined, + }) + }) + + it('reads a month as 30 days', () => { + expect(parseChainAction(`pay 500 USDC to ${ADDRESS} over 2 months`)).toMatchObject({ + kind: 'stream', + durationDays: '60', + }) + }) + + it('reads a stream to a name and through a named venue', () => { + expect(parseChainAction('stream 5 USDC to ada.sol over 7 days with Streamflow')).toMatchObject({ + recipient: 'ada.sol', + venue: 'Streamflow', + }) + }) + + it.each([ + `stream 500 USDC to ${ADDRESS}`, + `stream 500 USDC to ${ADDRESS} over 0 days`, + `stream 500 USDC to ${ADDRESS} over soon`, + `pay 500 USDC to ${ADDRESS}`, + ])('reads no stream from %s, since nothing is left to a default', (text) => { + expect(parseChainAction(text)).toBeNull() + }) + + it('reads an invoice', () => { + expect(parseChainAction(`invoice ${ADDRESS} 500 USDC due 2026-12-01`)).toMatchObject({ + kind: 'invoice', + recipient: ADDRESS, + amount: '500', + tokenIn: 'USDC', + dueDate: '2026-12-01', + }) + }) + + it('needs a due date on an invoice', () => { + expect(parseChainAction(`invoice ${ADDRESS} 500 USDC`)).toBeNull() + }) + + it('reads paying an invoice, and needs its amount said', () => { + expect(parseChainAction('pay invoice #12 with 500 USDC')).toMatchObject({ + kind: 'pay_invoice', + invoiceId: '12', + amount: '500', + tokenIn: 'USDC', + }) + expect(parseChainAction('pay invoice 12')).toBeNull() + }) + + it('still reads a plain send as a send', () => { + expect(parseChainAction(`send 5 USDC to ${ADDRESS}`)).toMatchObject({ kind: 'send' }) + }) +}) + +describe('choosing a venue when none is named', () => { + const chainApi = moduleWith({ + venues: [venueNamed('general', ['stake']), venueNamed('only-wal', ['stake'])], + defaultVenue: { stake: 'general' }, + venueForToken: { stake: { WAL: 'only-wal' } }, + priceUsd: async () => 1, + }) + + it('sends a token only one venue takes to that venue, in any case', async () => { + const built = await prepareChainAction( + chainApi, + { kind: 'stake', amount: '1', tokenIn: 'wal' }, + ADDRESS + ) + expect(built.venueId).toBe('only-wal') + }) + + it('leaves every other token on the default', async () => { + const built = await prepareChainAction( + chainApi, + { kind: 'stake', amount: '1', tokenIn: 'USDC' }, + ADDRESS + ) + expect(built.venueId).toBe('general') + }) + + it('still lets the person name another venue', async () => { + const built = await prepareChainAction( + chainApi, + { kind: 'stake', amount: '1', tokenIn: 'WAL', venue: 'general' }, + ADDRESS + ) + expect(built.venueId).toBe('general') + }) +}) + +describe('the amount', () => { + it.each(['1', '1000000', '0.000001'])('takes %s, whatever it is worth', (amount) => { + expect(() => assertValidAmount({ amount })).not.toThrow() + }) + + it.each(['0', '-1', 'lots', ''])('refuses %j, which is not an amount', (amount) => { + expect(() => assertValidAmount({ amount })).toThrow(ChainActionRefused) + }) +}) + +describe('what these kinds do around the chat', () => { + it('races only a choice between venues', () => { + expect(racesFirst({ kind: 'swap' })).toBe(true) + expect(racesFirst({ kind: 'supply' })).toBe(true) + expect(racesFirst({ kind: 'stake' })).toBe(true) + for (const kind of [ + 'send', + 'withdraw', + 'unstake', + 'borrow', + 'repay', + 'stream', + 'invoice', + 'pay_invoice', + ] as const) { + expect(racesFirst({ kind })).toBe(false) + } + }) + + it('tells someone what works when it can’t read the verbs', () => { + for (const verb of ['withdraw', 'unstake', 'repay', 'stream', 'invoice', 'redeem']) { + expect(looksLikeChainAction(`${verb} something`)).toBe(true) + } + }) + + it('offers each kind as words someone can copy', () => { + const help = chainHelp('Monad', [ + { kind: 'withdraw', venue: 'Aave', symbol: 'USDC' }, + { kind: 'stream', venue: 'Sablier', symbol: 'USDC' }, + ]) + expect(help).toContain('withdraw 25 USDC from Aave') + expect(help).toContain('stream 100 USDC to an address over 30 days') + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-action.test.ts b/apps/dapp/lib/__tests__/chain-action.test.ts index eb2efba1..d9d11605 100644 --- a/apps/dapp/lib/__tests__/chain-action.test.ts +++ b/apps/dapp/lib/__tests__/chain-action.test.ts @@ -169,30 +169,30 @@ describe('prepareChainAction', () => { const swap = { kind: 'swap', amount: '10', tokenIn: 'usdc', tokenOut: 'SOL' } as const it('prepares a valid action', async () => { - await expect(prepareChainAction(moduleWith(), swap, 'acct', {})).resolves.toBe(prepared) + await expect(prepareChainAction(moduleWith(), swap, 'acct')).resolves.toBe(prepared) }) it('refuses a token it does not know', async () => { await expect( - prepareChainAction(moduleWith(), { ...swap, tokenOut: 'SCAM' }, 'acct', {}) + prepareChainAction(moduleWith(), { ...swap, tokenOut: 'SCAM' }, 'acct') ).rejects.toThrow(/verified token/) }) - it('refuses a trade it cannot price, even when the Stellar default is testnet', async () => { + it('builds a trade it cannot price, since nothing here depends on a dollar value', async () => { await expect( - prepareChainAction(moduleWith({ priceUsd: async () => undefined }), swap, 'acct', {}) - ).rejects.toThrow(/could not be valued/) + prepareChainAction(moduleWith({ priceUsd: async () => undefined }), swap, 'acct') + ).resolves.toBeDefined() }) - it('refuses a trade over the cap', async () => { + it('takes a trade of any size', async () => { await expect( - prepareChainAction(moduleWith(), { ...swap, amount: '501' }, 'acct', {}) - ).rejects.toThrow(/caps each trade/) + prepareChainAction(moduleWith(), { ...swap, amount: '5000000' }, 'acct') + ).resolves.toBeDefined() }) it('refuses a venue that cannot do this', async () => { await expect( - prepareChainAction(moduleWith(), { ...swap, venue: 'nope' }, 'acct', {}) + prepareChainAction(moduleWith(), { ...swap, venue: 'nope' }, 'acct') ).rejects.toThrow(/can't swap here/) }) @@ -200,7 +200,7 @@ describe('prepareChainAction', () => { const unsafe = (): void => { throw new ChainActionRefused('unexpected program') } - await expect(prepareChainAction(moduleWith({}, unsafe), swap, 'acct', {})).rejects.toThrow( + await expect(prepareChainAction(moduleWith({}, unsafe), swap, 'acct')).rejects.toThrow( /unexpected program/ ) }) diff --git a/apps/dapp/lib/__tests__/chain-balances.test.ts b/apps/dapp/lib/__tests__/chain-balances.test.ts index 7cd32bbd..46085f87 100644 --- a/apps/dapp/lib/__tests__/chain-balances.test.ts +++ b/apps/dapp/lib/__tests__/chain-balances.test.ts @@ -19,7 +19,7 @@ const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' const ODD_MINT = 'AbCdEfGhJkLmNpQrStUvWxYzAbCdEfGhJkLmNpQrStUv' const SUI_OWNER = `0x${'ab'.repeat(32)}` const SUI_USDC = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' -const WAL = '0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL' +const UNLISTED = '0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP' const EVM_OWNER = `0x${'12'.repeat(20)}` type Handler = (url: string, body: unknown) => unknown @@ -184,11 +184,11 @@ describe('Sui balances', () => { it('reads address.balances, naming an unlisted coin by its last segment', async () => { stubFetch((_url, body) => { const q = (body as { query: string }).query - if (q.includes('coinMetadata')) return { data: { a0: { decimals: 9, symbol: 'WAL' } } } + if (q.includes('coinMetadata')) return { data: { a0: { decimals: 9, symbol: 'DEEP' } } } return page([ node(`0x${'0'.repeat(63)}2::sui::SUI`, '1031937015'), node(SUI_USDC, '304803'), - node(WAL, '1500000'), + node(UNLISTED, '1500000'), ]) }) const result = await readSuiBalances(SUI_OWNER) @@ -200,7 +200,7 @@ describe('Sui balances', () => { expect.objectContaining({ symbol: 'USDC', amount: '0.304803', listed: true }) ) expect(result.tokens).toContainEqual( - expect.objectContaining({ symbol: 'WAL', id: WAL, amount: '0.0015', listed: false }) + expect.objectContaining({ symbol: 'DEEP', id: UNLISTED, amount: '0.0015', listed: false }) ) }) @@ -208,10 +208,10 @@ describe('Sui balances', () => { stubFetch((_url, body) => { const q = (body as { query: string }).query if (q.includes('coinMetadata')) return { data: { a0: null } } - return page([node(WAL, '1500000')]) + return page([node(UNLISTED, '1500000')]) }) const result = await readSuiBalances(SUI_OWNER) - expect(result.tokens.find((t) => t.id === WAL)).toBeUndefined() + expect(result.tokens.find((t) => t.id === UNLISTED)).toBeUndefined() expect(result.complete).toBe(false) }) @@ -246,7 +246,7 @@ describe('Sui balances', () => { stubFetch((_url, body) => { const q = (body as { query: string }).query if (q.includes('coinMetadata')) return { data: { a0: null } } - return page([node(WAL, '0')]) + return page([node(UNLISTED, '0')]) }) const result = await readSuiBalances(SUI_OWNER) expect(result.complete).toBe(true) @@ -501,7 +501,7 @@ describe('chain balances route', () => { it('is bounded to 20 seconds', async () => { const route = await import('../../app/api/chain/[chain]/balances/route') expect(route.maxDuration).toBe(20) - }) + }, 60_000) }) describe('chainBalanceReply', () => { diff --git a/apps/dapp/lib/__tests__/chain-send.test.ts b/apps/dapp/lib/__tests__/chain-send.test.ts index b40e6a91..87992e72 100644 --- a/apps/dapp/lib/__tests__/chain-send.test.ts +++ b/apps/dapp/lib/__tests__/chain-send.test.ts @@ -1151,33 +1151,7 @@ describe('prepareChainAction on a send', () => { SOL_ME ) ) - expect(said).toMatch(/only a send/i) - }) - - it('applies the dollar cap, in words for a transfer', async () => { - const said = await refusalOf( - prepareChainAction( - moduleFor('solana'), - request({ amount: '600', recipient: SOL_TO }), - SOL_ME, - {} - ) - ) - expect(said).toMatch(/caps each transfer/) - expect(said).not.toMatch(/trade/) - expect(sim.rpc).toEqual([]) - }) - - it('refuses a token it can’t price, in words for a transfer', async () => { - const said = await refusalOf( - prepareChainAction( - moduleFor('solana', { priceUsd: async () => undefined }), - request({ recipient: SOL_TO }), - SOL_ME, - {} - ) - ) - expect(said).toMatch(/This transfer could not be valued/) + expect(said).toMatch(/doesn't take an address/i) }) it('keeps the fee reserve on a native send', async () => { @@ -1199,8 +1173,7 @@ describe('prepareChainAction on a send', () => { }), }), request({ recipient: EVM_TO, tokenIn: 'AVAX' }), - EVM_ME, - {} + EVM_ME ) ) expect(said).toMatch(/Keep about 0\.01 AVAX/) @@ -1216,7 +1189,7 @@ describe('prepareChainAction on a send', () => { listed: true, }) const send = (amount: string, chain: ChainModule): Promise => - prepareChainAction(chain, request({ amount, recipient: EVM_TO }), EVM_ME, {}) + prepareChainAction(chain, request({ amount, recipient: EVM_TO }), EVM_ME) it('refuses a known shortfall in a plain sentence', async () => { const chain = moduleFor('avalanche', { @@ -1284,8 +1257,7 @@ describe('prepareChainAction on a send', () => { await prepareChainAction( moduleFor('avalanche', { balances }), request({ tokenIn: 'AVAX', recipient: EVM_TO }), - EVM_ME, - {} + EVM_ME ) expect(balances).toHaveBeenCalledTimes(1) }) @@ -1302,8 +1274,7 @@ describe('prepareChainAction on a send', () => { await prepareChainAction( moduleFor('avalanche', { balances, venues: [venue], defaultVenue: { supply: 'lend' } }), { kind: 'supply', amount: '50', tokenIn: 'USDC' }, - EVM_ME, - {} + EVM_ME ) expect(balances).not.toHaveBeenCalled() }) @@ -1315,8 +1286,7 @@ describe('prepareChainAction on a send', () => { prepareChainAction( moduleFor('solana', { balances }), request({ recipient: SOL_TO, tokenIn: 'SOL' }), - SOL_ME, - {} + SOL_ME ) ) expect(said).toContain('Sending SOL') @@ -1327,8 +1297,7 @@ describe('prepareChainAction on a send', () => { const prepared: PreparedAction = await prepareChainAction( moduleFor('avalanche'), request({ recipient: EVM_TO }), - EVM_ME, - {} + EVM_ME ) expect(prepared.kind).toBe('send') expect(prepared.context?.['recipient']).toBe(EVM_TO) diff --git a/apps/dapp/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts index abfbe460..8a1cd04c 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -541,7 +541,8 @@ describe('chainHelp in the new forms', () => { expect(help('near', 'NEAR')).not.toContain('then cash out') expect(help('near', 'NEAR')).not.toContain('then stake') expect(help('monad', 'Monad')).toContain('"swap 5 MON to USDC then send to an address"') - expect(help('monad', 'Monad')).not.toMatch(/then (cash out|deposit)/) + expect(help('monad', 'Monad')).toContain('"swap 5 MON to USDC then deposit"') + expect(help('monad', 'Monad')).not.toContain('then cash out') expect(help('monad', 'Monad')).toContain('"send 5 USDC to an address"') }) }) diff --git a/apps/dapp/lib/__tests__/curvance.live.test.ts b/apps/dapp/lib/__tests__/curvance.live.test.ts new file mode 100644 index 00000000..38a2a848 --- /dev/null +++ b/apps/dapp/lib/__tests__/curvance.live.test.ts @@ -0,0 +1,203 @@ +import { + encodeAbiParameters, + encodeFunctionData, + keccak256, + parseAbi, + toHex, + type Address, + type Hex, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import { evmReader } from '../chains/evm/client' +import { + CURVANCE_CUSDC, + CURVANCE_MARKET_MANAGER, + CURVANCE_USDC, + curvanceVenue, +} from '../chains/evm/curvance' + +/** + * The real contracts on Monad, read-only: nothing signs or sends. A withdrawal is only built and + * tried for Curvance's DAO multisig, which holds cUSDC and whose key nobody here has. When it leaves + * the market that case fails on balance, so swap it for another holder. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const REGISTRY = '0x1310f352f1389969Ece6741671c4B919523912fF' +const DAO = '0x0Acb7eF4D8733C719d60e0992B489b629bc55C02' +const EMERGENCY_COUNCIL = '0x379D4a8FBc23A8Fd8c2b3738Dbf1fEBe9a64399c' +const RAPID_RESPONSE = '0x25D4134861b30Ba7215d2A280DD84C57c5780902' +const RAPID_RESPONSE_PAUSER = '0x89D2253b13c718d06B78fCcE3d973a520bD07803' +const THROWAWAY = '0x00000000000000000000000000000000000abcde' +const EIP1967_IMPLEMENTATION = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbd' +const EMPTY_SLOT = `0x${'0'.repeat(64)}` +// Circle's FiatToken keeps balances at slot 9 and allowances at slot 10. +const USDC_BALANCE_SLOT = BigInt(9) +const USDC_ALLOWANCE_SLOT = BigInt(10) + +const CTOKEN = parseAbi([ + 'function asset() view returns (address)', + 'function marketManager() view returns (address)', + 'function decimals() view returns (uint8)', + 'function symbol() view returns (string)', + 'function centralRegistry() view returns (address)', + 'function MAX_INTEREST_ACCRUAL_FEE() view returns (uint256)', + 'function deposit(uint256 assets, address receiver) returns (uint256)', +]) +const MANAGER = parseAbi([ + 'function isListed(address cToken) view returns (bool)', + 'function queryTokensListed() view returns (address[])', + 'function MIN_HOLD_PERIOD() view returns (uint256)', +]) +const SAFE = parseAbi(['function getThreshold() view returns (uint256)']) +const PAUSER = parseAbi(['function owner() view returns (address)']) +const REGISTRY_ABI = parseAbi([ + 'function hasMarketPermissions(address account) view returns (bool)', + 'function emergencyCouncil() view returns (address)', +]) + +const reader = evmReader('monad') + +function mappingSlot(key: Address, slot: bigint): Hex { + return keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [key, slot])) +} + +function allowanceSlot(owner: Address, spender: Address): Hex { + const inner = mappingSlot(owner, USDC_ALLOWANCE_SLOT) + return keccak256( + encodeAbiParameters([{ type: 'address' }, { type: 'bytes32' }], [spender, inner]) + ) +} + +describe.skipIf(SKIP)('the pinned Curvance contracts', () => { + it('has code at each', async () => { + for (const address of [CURVANCE_CUSDC, CURVANCE_MARKET_MANAGER, CURVANCE_USDC]) { + const code = await reader.getCode({ address: address as Address }) + expect(code?.length ?? 0, address).toBeGreaterThan(2) + } + }, 30_000) + + it('holds the market together as the docs say', async () => { + const cToken = { address: CURVANCE_CUSDC as Address, abi: CTOKEN } + const manager = { address: CURVANCE_MARKET_MANAGER as Address, abi: MANAGER } + expect(await reader.readContract({ ...cToken, functionName: 'asset' })).toBe(CURVANCE_USDC) + expect(await reader.readContract({ ...cToken, functionName: 'marketManager' })).toBe( + CURVANCE_MARKET_MANAGER + ) + expect(await reader.readContract({ ...cToken, functionName: 'decimals' })).toBe(6) + expect(await reader.readContract({ ...cToken, functionName: 'symbol' })).toBe('cUSDC') + expect(await reader.readContract({ ...cToken, functionName: 'centralRegistry' })).toBe(REGISTRY) + expect( + await reader.readContract({ ...manager, functionName: 'isListed', args: [CURVANCE_CUSDC] }) + ).toBe(true) + expect(await reader.readContract({ ...manager, functionName: 'queryTokensListed' })).toContain( + CURVANCE_CUSDC + ) + }, 30_000) + + it('still has the 20 minute hold the card states', async () => { + const hold = await reader.readContract({ + address: CURVANCE_MARKET_MANAGER, + abi: MANAGER, + functionName: 'MIN_HOLD_PERIOD', + }) + expect(hold).toBe(BigInt(1200)) + }, 30_000) + + it('still has the admins the card states', async () => { + const threshold = (address: string) => + reader.readContract({ address: address as Address, abi: SAFE, functionName: 'getThreshold' }) + expect( + await reader.readContract({ + address: CURVANCE_CUSDC as Address, + abi: CTOKEN, + functionName: 'MAX_INTEREST_ACCRUAL_FEE', + }) + ).toBe(BigInt(6000)) + expect(await threshold(RAPID_RESPONSE)).toBe(BigInt(1)) + expect(await threshold(EMERGENCY_COUNCIL)).toBe(BigInt(4)) + expect( + await reader.readContract({ + address: RAPID_RESPONSE_PAUSER, + abi: PAUSER, + functionName: 'owner', + }) + ).toBe(RAPID_RESPONSE) + expect( + await reader.readContract({ + address: REGISTRY, + abi: REGISTRY_ABI, + functionName: 'hasMarketPermissions', + args: [RAPID_RESPONSE_PAUSER], + }) + ).toBe(true) + expect( + await reader.readContract({ + address: REGISTRY, + abi: REGISTRY_ABI, + functionName: 'emergencyCouncil', + }) + ).toBe(EMERGENCY_COUNCIL) + }, 30_000) + + it('is not behind a standard upgrade proxy', async () => { + for (const address of [CURVANCE_CUSDC, CURVANCE_MARKET_MANAGER]) { + const slot = await reader.getStorageAt({ + address: address as Address, + slot: EIP1967_IMPLEMENTATION, + }) + expect(slot ?? EMPTY_SLOT, address).toBe(EMPTY_SLOT) + } + }, 30_000) +}) + +describe.skipIf(SKIP)('Curvance against the live market', () => { + it('reads a real rate for the race', async () => { + const quote = await curvanceVenue.quote?.( + { kind: 'supply', amount: '1', tokenIn: 'USDC', venue: 'curvance' }, + THROWAWAY + ) + expect(quote?.apyPercent).toBeGreaterThan(0) + expect(quote?.apyPercent).toBeLessThan(200) + }, 30_000) + + it('accepts its deposit from an account given USDC and an allowance', async () => { + const account = THROWAWAY as Address + const spender = CURVANCE_CUSDC as Address + const value = toHex(BigInt(1_000_000_000), { size: 32 }) + const units = BigInt(100_000_000) + const { data } = await reader.call({ + account, + to: spender, + data: encodeFunctionData({ abi: CTOKEN, functionName: 'deposit', args: [units, account] }), + stateOverride: [ + { + address: CURVANCE_USDC as Address, + stateDiff: [ + { slot: mappingSlot(account, USDC_BALANCE_SLOT), value }, + { slot: allowanceSlot(account, spender), value }, + ], + }, + ], + }) + expect(BigInt(data ?? '0x0')).toBeGreaterThan(BigInt(0)) + }, 30_000) + + it('builds a withdrawal a real holder could send, and it passes its own check', async () => { + const prepared = await curvanceVenue.prepare( + { kind: 'withdraw', amount: '1', tokenIn: 'USDC', venue: 'curvance' }, + DAO + ) + expect(() => curvanceVenue.assertSafe(prepared.signing, DAO)).not.toThrow() + }, 30_000) + + it('refuses a withdrawal for an account that lent nothing', async () => { + await expect( + curvanceVenue.prepare( + { kind: 'withdraw', amount: '1', tokenIn: 'USDC', venue: 'curvance' }, + THROWAWAY + ) + ).rejects.toThrow(/no USDC lent/) + }, 30_000) +}) diff --git a/apps/dapp/lib/__tests__/curvance.test.ts b/apps/dapp/lib/__tests__/curvance.test.ts new file mode 100644 index 00000000..1cad0130 --- /dev/null +++ b/apps/dapp/lib/__tests__/curvance.test.ts @@ -0,0 +1,524 @@ +import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, type Address } from 'viem' +import { describe, expect, it } from 'vitest' + +import type { EvmReaderClient } from '../chains/evm/call' +import type { EvmClient, EvmReceipt } from '../chains/evm/client' +import { + createCurvanceVenue, + CURVANCE_CUSDC, + CURVANCE_MARKET_MANAGER, + CURVANCE_USDC, +} from '../chains/evm/curvance' +import { curvanceListing } from '../chains/evm/curvance-listing' +import { MONAD_TOKENS } from '../chains/evm/tokens' +import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types' + +const ACCOUNT = '0x1111111111111111111111111111111111111111' +const STRANGER = '0x2222222222222222222222222222222222222222' +/** Curvance's cUSDC in its WBTC market: real, but not the one this venue is confirmed for. */ +const OTHER_MARKET = '0x7C9d4f1695C6282Da5e5509Aa51fC9fb417C6f1d' +const USDT0 = '0xe7cd86e13AC4309349F30B3435a9d337750fC82D' +const MONAD_ID = 143 +const HASH = `0x${'a'.repeat(64)}` +const NOW = BigInt(1_800_000_000) + +const CALLS = parseAbi([ + 'function deposit(uint256 assets, address receiver) returns (uint256)', + 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256)', + 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)', + 'function depositAsCollateral(uint256 assets, address receiver) returns (uint256)', + 'function withdrawCollateral(uint256 assets, address receiver, address owner) returns (uint256)', + 'function borrow(uint256 assets, address receiver)', + 'function repay(uint256 assets)', + 'function transfer(address to, uint256 amount) returns (bool)', +]) + +function deposit(assets = BigInt(5_000_000), receiver: string = ACCOUNT): string { + return encodeFunctionData({ + abi: CALLS, + functionName: 'deposit', + args: [assets, receiver as Address], + }) +} + +function withdraw( + assets = BigInt(5_000_000), + receiver: string = ACCOUNT, + owner: string = ACCOUNT +): string { + return encodeFunctionData({ + abi: CALLS, + functionName: 'withdraw', + args: [assets, receiver as Address, owner as Address], + }) +} + +function approve(spender: string = CURVANCE_CUSDC, units = BigInt(5_000_000)): string { + return encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender as Address, units], + }) +} + +function payload(overrides: Partial> = {}) { + return { + family: 'evm' as const, + chainId: MONAD_ID, + to: CURVANCE_CUSDC, + data: deposit(), + value: '0', + ...overrides, + } +} + +interface ChainState { + usdc: bigint + allowance: bigint + shares: bigint + posted: bigint + held: bigint + debt: bigint + mintPaused: boolean + listed: boolean + redeemPaused: number + cooldown: bigint + rate: bigint | Error + callFails: boolean + readFails: string | undefined +} + +const HEALTHY: ChainState = { + usdc: BigInt(100_000_000), + allowance: BigInt(0), + shares: BigInt(50_000_000), + posted: BigInt(0), + held: BigInt(30_000_000_000), + debt: BigInt(270_000_000_000), + mintPaused: false, + listed: true, + redeemPaused: 1, + cooldown: BigInt(0), + // 10% a year, per second, in WAD. + rate: BigInt(3_170_979_198), + callFails: false, + readFails: undefined, +} + +interface FakeRead { + address: string + functionName: string + args?: readonly unknown[] +} + +/** The rate of the real market, one cUSDC to 1.04 USDC, so shares and assets differ as they do live. */ +function fakeReader(overrides: Partial = {}): EvmReaderClient & { calls: unknown[] } { + const state = { ...HEALTHY, ...overrides } + const calls: unknown[] = [] + const reads: Record unknown> = { + balanceOf: (_args, address) => (address === CURVANCE_CUSDC ? state.shares : state.usdc), + allowance: () => state.allowance, + collateralPosted: () => state.posted, + previewDeposit: ([assets]) => ((assets as bigint) * BigInt(100)) / BigInt(104), + previewWithdraw: ([assets]) => ((assets as bigint) * BigInt(100)) / BigInt(104) + BigInt(1), + convertToAssets: ([shares]) => ((shares as bigint) * BigInt(104)) / BigInt(100), + assetsHeld: () => state.held, + marketOutstandingDebt: () => state.debt, + interestFee: () => 1000, + IRM: () => '0x1622D77E311638C77779e6D9B212E3e7D73AaFB4', + actionsPaused: () => [state.mintPaused, false, false], + isListed: () => state.listed, + redeemPaused: () => state.redeemPaused, + accountAssets: () => state.cooldown, + supplyRate: () => { + if (state.rate instanceof Error) throw state.rate + return state.rate + }, + } + return { + calls, + readContract: async ({ address, functionName, args = [] }: FakeRead) => { + if (state.readFails === functionName) throw new Error('rpc down') + const read = reads[functionName] + if (read === undefined) throw new Error(`unexpected read ${functionName}`) + return read(args, address) + }, + call: async (request: unknown) => { + calls.push(request) + if (state.callFails) throw new Error('reverted') + return { data: '0x' } + }, + getBlock: async () => ({ timestamp: NOW }), + } as unknown as EvmReaderClient & { calls: unknown[] } +} + +function supply(amount = '5', tokenIn = 'USDC'): ChainActionRequest { + return { kind: 'supply', amount, tokenIn, venue: 'curvance' } +} + +function takeOut(amount = '5', tokenIn = 'USDC'): ChainActionRequest { + return { kind: 'withdraw', amount, tokenIn, venue: 'curvance' } +} + +function venue(overrides: Partial = {}) { + return createCurvanceVenue(() => fakeReader(overrides)) +} + +function evmSigning(signing: SigningPayload) { + if (signing.family !== 'evm') throw new Error('expected evm') + return signing +} + +describe('pinned ids', () => { + it('uses the same USDC as the chain table', () => { + expect(MONAD_TOKENS.find((t) => t.symbol === 'USDC')?.id).toBe(CURVANCE_USDC) + }) + + it('offers only what it builds', () => { + expect(venue().kinds).toEqual(['supply', 'withdraw']) + }) + + it('lists the venue as executing on Monad', () => { + expect(curvanceListing).toMatchObject({ + id: 'curvance', + family: 'evm', + category: 'lending', + chains: ['Monad'], + integration: 'executes', + }) + }) +}) + +describe('assertSafe', () => { + const safe = venue() + + it('passes the deposit and withdrawal it builds', () => { + expect(() => safe.assertSafe(payload(), ACCOUNT)).not.toThrow() + expect(() => safe.assertSafe(payload({ data: withdraw() }), ACCOUNT)).not.toThrow() + }) + + it('passes a deposit with its exact approval', () => { + const signing = payload({ approval: { to: CURVANCE_USDC, data: approve() } }) + expect(() => safe.assertSafe(signing, ACCOUNT)).not.toThrow() + }) + + it('refuses a deposit the wrong account would sign', () => { + expect(() => safe.assertSafe(payload(), STRANGER)).toThrow(ChainActionRefused) + }) + + it('refuses a withdrawal the wrong account would sign', () => { + expect(() => safe.assertSafe(payload({ data: withdraw() }), STRANGER)).toThrow( + ChainActionRefused + ) + }) + + it.each<[string, SigningPayload]>([ + ['another family', { family: 'sui', transaction: 'AA==' }], + ['another chain', payload({ chainId: 43114 })], + ['an unknown contract', payload({ to: STRANGER })], + ["cUSDC in another of Curvance's markets", payload({ to: OTHER_MARKET })], + ['the market manager', payload({ to: CURVANCE_MARKET_MANAGER })], + ['native value', payload({ value: '1' })], + ['calldata that decodes as nothing', payload({ data: '0xdeadbeef' })], + ['extra calldata on a deposit', payload({ data: `${deposit()}00` })], + ['extra calldata on a withdrawal', payload({ data: `${withdraw()}00` })], + ['a deposit credited to someone else', payload({ data: deposit(BigInt(5), STRANGER) })], + ['a withdrawal paid to someone else', payload({ data: withdraw(BigInt(5), STRANGER) })], + [ + "a withdrawal of someone else's shares", + payload({ data: withdraw(BigInt(5), ACCOUNT, STRANGER) }), + ], + ['an empty deposit', payload({ data: deposit(BigInt(0)) })], + ['an empty withdrawal', payload({ data: withdraw(BigInt(0)) })], + [ + 'an unlimited approval', + payload({ approval: { to: CURVANCE_USDC, data: approve(CURVANCE_CUSDC, maxUint256) } }), + ], + [ + 'an approval larger than the deposit', + payload({ + approval: { to: CURVANCE_USDC, data: approve(CURVANCE_CUSDC, BigInt(6_000_000)) }, + }), + ], + [ + 'an approval smaller than the deposit', + payload({ + approval: { to: CURVANCE_USDC, data: approve(CURVANCE_CUSDC, BigInt(4_000_000)) }, + }), + ], + [ + 'an approval to a stranger', + payload({ approval: { to: CURVANCE_USDC, data: approve(STRANGER) } }), + ], + ['an approval of the wrong token', payload({ approval: { to: USDT0, data: approve() } })], + [ + 'an approval with trailing data', + payload({ approval: { to: CURVANCE_USDC, data: `${approve()}00` } }), + ], + [ + 'a withdrawal carrying an approval', + payload({ data: withdraw(), approval: { to: CURVANCE_USDC, data: approve() } }), + ], + [ + 'a first step that is a transfer', + payload({ + approval: { + to: CURVANCE_USDC, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [STRANGER as Address, BigInt(5_000_000)], + }), + }, + }), + ], + ])('refuses %s', (_label, signing) => { + expect(() => safe.assertSafe(signing, ACCOUNT)).toThrow(ChainActionRefused) + }) + + it.each<[string, string]>([ + [ + 'redeem', + encodeFunctionData({ + abi: CALLS, + functionName: 'redeem', + args: [BigInt(5), ACCOUNT as Address, ACCOUNT as Address], + }), + ], + [ + 'depositAsCollateral', + encodeFunctionData({ + abi: CALLS, + functionName: 'depositAsCollateral', + args: [BigInt(5), ACCOUNT as Address], + }), + ], + [ + 'withdrawCollateral', + encodeFunctionData({ + abi: CALLS, + functionName: 'withdrawCollateral', + args: [BigInt(5), ACCOUNT as Address, ACCOUNT as Address], + }), + ], + [ + 'borrow', + encodeFunctionData({ + abi: CALLS, + functionName: 'borrow', + args: [BigInt(5), ACCOUNT as Address], + }), + ], + ['repay', encodeFunctionData({ abi: CALLS, functionName: 'repay', args: [BigInt(0)] })], + [ + 'transfer', + encodeFunctionData({ + abi: CALLS, + functionName: 'transfer', + args: [STRANGER as Address, BigInt(5)], + }), + ], + ])('refuses the unconfirmed function %s', (_label, data) => { + expect(() => safe.assertSafe(payload({ data }), ACCOUNT)).toThrow(ChainActionRefused) + }) +}) + +describe('supply', () => { + it('builds a plain deposit to the person with an exact approval', async () => { + const prepared = await venue().prepare(supply(), ACCOUNT) + expect(prepared).toMatchObject({ + venueId: 'curvance', + kind: 'supply', + pay: { amount: '5', symbol: 'USDC' }, + receive: { symbol: 'cUSDC', approximate: true }, + }) + const signing = evmSigning(prepared.signing) + expect(signing.to).toBe(CURVANCE_CUSDC) + expect(signing.data).toBe(deposit()) + expect(signing.value).toBe('0') + expect(signing.approval).toEqual({ to: CURVANCE_USDC, data: approve() }) + }) + + it('leaves the approval off when one is in place, and tries the call', async () => { + const reader = fakeReader({ allowance: BigInt(5_000_000) }) + const prepared = await createCurvanceVenue(() => reader).prepare(supply(), ACCOUNT) + expect(evmSigning(prepared.signing).approval).toBeUndefined() + expect(reader.calls).toHaveLength(1) + expect(prepared.notes.join(' ')).not.toContain('asks twice') + }) + + it('says the wallet asks twice when it does', async () => { + const prepared = await venue().prepare(supply(), ACCOUNT) + expect(prepared.notes.join(' ')).toContain('exactly 5 USDC') + }) + + it('reads the rate from the chain as a yearly figure, with no reward part', async () => { + const prepared = await venue().prepare(supply(), ACCOUNT) + expect(prepared.apy?.basePercent).toBeCloseTo(10, 1) + expect(prepared.apy?.rewardPercent).toBeUndefined() + }) + + it('shows no rate when it cannot be read, rather than a guess', async () => { + const prepared = await venue({ rate: new Error('rpc') }).prepare(supply(), ACCOUNT) + expect(prepared.apy).toBeUndefined() + }) + + it('tells the person about the hold, the market, the admins and what is free', async () => { + const { notes } = await venue().prepare(supply(), ACCOUNT) + const text = notes.join(' ') + expect(text).toContain('WMON') + expect(text).toContain('20 minutes') + expect(text).toMatch(/pause/i) + expect(text).toContain('30000 USDC') + expect(text).not.toMatch(/—/) + }) + + it.each<[string, ChainActionRequest, Partial]>([ + ['another token', supply('5', 'USDT0'), {}], + ['the native coin', supply('5', 'MON'), {}], + ['a zero amount', supply('0'), {}], + ['more decimals than USDC has', supply('1.0000001'), {}], + ['more than the wallet holds', supply(), { usdc: BigInt(4_999_999) }], + ['a market with deposits paused', supply(), { mintPaused: true }], + ['a token no longer listed', supply(), { listed: false }], + ['an amount too small to mint a share', supply('0.000001'), {}], + ['an unreadable pause flag', supply(), { readFails: 'actionsPaused' }], + ['an unreadable balance', supply(), { readFails: 'balanceOf' }], + ['a call that would fail', supply(), { allowance: BigInt(5_000_000), callFails: true }], + ])('refuses %s', async (_label, request, state) => { + await expect(venue(state).prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) + + it('refuses an account that is not an address', async () => { + await expect(venue().prepare(supply(), 'not-an-address')).rejects.toThrow(ChainActionRefused) + }) +}) + +describe('quote', () => { + it('gives the rate and calls nothing', async () => { + const reader = fakeReader() + const quote = await createCurvanceVenue(() => reader).quote?.(supply(), ACCOUNT) + expect(quote?.apyPercent).toBeCloseTo(10, 1) + expect(reader.calls).toHaveLength(0) + }) + + it('gives no rate when it cannot be read', async () => { + const quote = await venue({ rate: new Error('rpc') }).quote?.(supply(), ACCOUNT) + expect(quote).toEqual({}) + }) + + it('refuses a paused market', async () => { + await expect(venue({ mintPaused: true }).quote?.(supply(), ACCOUNT)).rejects.toThrow( + ChainActionRefused + ) + }) +}) + +describe('withdraw', () => { + it('builds a withdrawal to and from the person, with no approval', async () => { + const reader = fakeReader() + const prepared = await createCurvanceVenue(() => reader).prepare(takeOut(), ACCOUNT) + expect(prepared).toMatchObject({ + venueId: 'curvance', + kind: 'withdraw', + pay: { amount: '5', symbol: 'USDC' }, + }) + const signing = evmSigning(prepared.signing) + expect(signing.to).toBe(CURVANCE_CUSDC) + expect(signing.data).toBe(withdraw()) + expect(signing.approval).toBeUndefined() + expect(reader.calls).toHaveLength(1) + }) + + it('refuses during the hold, saying how long is left', async () => { + const cooldown = NOW - BigInt(300) + await expect(venue({ cooldown }).prepare(takeOut(), ACCOUNT)).rejects.toThrow(/15 minutes/) + }) + + it('goes ahead the moment the hold ends', async () => { + const cooldown = NOW - BigInt(1200) + await expect(venue({ cooldown }).prepare(takeOut(), ACCOUNT)).resolves.toMatchObject({ + kind: 'withdraw', + }) + }) + + it('never takes posted collateral, which could weaken a loan', async () => { + const state = { shares: BigInt(10_000_000), posted: BigInt(8_000_000) } + await expect(venue(state).prepare(takeOut(), ACCOUNT)).rejects.toThrow(/collateral/) + }) + + it('takes idle shares while collateral stays posted', async () => { + const state = { shares: BigInt(10_000_000), posted: BigInt(2_000_000) } + const prepared = await venue(state).prepare(takeOut(), ACCOUNT) + expect(evmSigning(prepared.signing).data).toBe(withdraw()) + }) + + it('says what is there when asked for more', async () => { + const state = { shares: BigInt(1_000_000) } + await expect(venue(state).prepare(takeOut(), ACCOUNT)).rejects.toThrow(/1\.04 USDC/) + }) + + it('says what the market has free when it has too little', async () => { + const state = { held: BigInt(4_000_000) } + await expect(venue(state).prepare(takeOut(), ACCOUNT)).rejects.toThrow(/4 USDC/) + }) + + it.each<[string, ChainActionRequest, Partial]>([ + ['another token', takeOut('5', 'USDT0'), {}], + ['a zero amount', takeOut('0'), {}], + ['no position', takeOut(), { shares: BigInt(0) }], + ['a market with withdrawals paused', takeOut(), { redeemPaused: 2 }], + ['an unknown pause flag', takeOut(), { redeemPaused: 0 }], + ['a token no longer listed', takeOut(), { listed: false }], + ['an unreadable hold', takeOut(), { readFails: 'accountAssets' }], + ['unreadable collateral', takeOut(), { readFails: 'collateralPosted' }], + ['a call that would fail', takeOut(), { callFails: true }], + ])('refuses %s', async (_label, request, state) => { + await expect(venue(state).prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) + + it('refuses the kinds it does not offer', async () => { + const borrow: ChainActionRequest = { kind: 'borrow', amount: '5', tokenIn: 'USDC' } + await expect(venue().prepare(borrow, ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) +}) + +function fakeClient(receipt: Partial, input: string = deposit()): EvmClient { + return { + tokenBalance: async () => BigInt(0), + nativeBalance: async () => BigInt(0), + waitForReceipt: async () => + ({ + status: 'success', + from: ACCOUNT, + to: CURVANCE_CUSDC, + logs: [], + ...receipt, + }) as EvmReceipt, + transaction: async () => ({ input, value: BigInt(0) }), + } +} + +describe('submit', () => { + const signed = { + account: ACCOUNT, + venueId: 'curvance', + signed: { family: 'evm' as const, hash: HASH }, + } + + function submitWith(client: EvmClient) { + return createCurvanceVenue(() => fakeReader(), client).submit?.(signed) + } + + it('settles a landed deposit from the person', async () => { + await expect(submitWith(fakeClient({}))).resolves.toMatchObject({ ok: true, hash: HASH }) + }) + + it.each<[string, EvmClient]>([ + ['another signer', fakeClient({ from: STRANGER })], + ['another market', fakeClient({ to: OTHER_MARKET })], + ['a withdrawal paid to someone else', fakeClient({}, withdraw(BigInt(5), STRANGER))], + ])('stays pending for %s', async (_label, client) => { + await expect(submitWith(client)).resolves.toMatchObject({ ok: false, pending: true }) + }) +}) diff --git a/apps/dapp/lib/__tests__/deepbook.live.test.ts b/apps/dapp/lib/__tests__/deepbook.live.test.ts new file mode 100644 index 00000000..481ca9b7 --- /dev/null +++ b/apps/dapp/lib/__tests__/deepbook.live.test.ts @@ -0,0 +1,103 @@ +import { bcs } from '@mysten/sui/bcs' +import { Transaction } from '@mysten/sui/transactions' +import { fromBase64, normalizeStructTag } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { suiClient } from '../chains/sui/client' +import { DEEP_TYPE, DEEPBOOK_PACKAGE, DEEPBOOK_POOLS, deepbookVenue } from '../chains/sui/deepbook' +import { tokenType } from '../chains/sui/direct-swap' + +/** + * Read-only against mainnet: everything is built and dry-run, for a public address holding USDC + * and SUI whose key nobody here has. When that address empties the swaps fail on balance, so swap + * it for another funded one. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const ACCOUNT = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4' +const REGISTRY = '0xaf16199a2dff736e9f07a845f23c5da6df6f756eddb631aed9d24a93efc4549d' +const UPGRADE_CAP = '0xdadf253cea3b91010e64651b03da6d56166a4f44b43bdd4e185c277658634483' +const USDC = tokenType('USDC') + +describe.skipIf(SKIP)('DeepBook against mainnet', () => { + it('still matches its upgrade cap, or the package needs a fresh look', async () => { + const { objects } = await suiClient().core.getObjects({ + objectIds: [UPGRADE_CAP], + include: { json: true }, + }) + const [cap] = objects as unknown as [{ json?: { package?: string } }] + expect(cap.json?.package).toBe(DEEPBOOK_PACKAGE) + }, 30_000) + + it.each(DEEPBOOK_POOLS.map((p) => [`${p.base}/${p.quote}`, p] as const))( + '%s is still the pool the registry names, of those coins', + async (_name, pool) => { + const typeArguments = [tokenType(pool.base), tokenType(pool.quote)] + const { objects } = await suiClient().core.getObjects({ objectIds: [pool.id] }) + const [object] = objects as unknown as [ + { type?: string; owner?: { Shared?: { initialSharedVersion: string } } }, + ] + expect(object.type).toContain(`pool::Pool<${typeArguments[0]},${typeArguments[1]}>`) + expect(object.owner?.Shared?.initialSharedVersion).toBe(String(pool.initialSharedVersion)) + + const tx = new Transaction() + tx.setSender(ACCOUNT) + tx.moveCall({ + target: `${DEEPBOOK_PACKAGE}::pool::get_pool_id_by_asset`, + typeArguments, + arguments: [tx.object(REGISTRY)], + }) + const result = await suiClient().core.simulateTransaction({ + transaction: tx, + checksEnabled: false, + include: { commandResults: true }, + }) + const id = result.commandResults?.[0]?.returnValues[0]?.bcs + expect(id && bcs.Address.parse(id)).toBe(pool.id) + }, + 30_000 + ) + + it('still names the DEEP coin this file pins in its swap signature', async () => { + const shape = await suiClient().core.getMoveFunction({ + packageId: DEEPBOOK_PACKAGE, + moduleName: 'pool', + name: 'swap_exact_base_for_quote', + }) + expect(JSON.stringify(shape)).toContain(normalizeStructTag(DEEP_TYPE)) + }, 30_000) + + it('quotes USDC for SUI, builds it, passes the check, and the dry run pays near the quote', async () => { + const request = { kind: 'swap', amount: '20', tokenIn: 'SUI', tokenOut: 'USDC' } as const + const quoted = await deepbookVenue.quote?.(request, ACCOUNT) + const prepared = await deepbookVenue.prepare(request, ACCOUNT) + expect(() => deepbookVenue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + + const result = await suiClient().core.simulateTransaction({ + transaction: fromBase64(prepared.signing.transaction), + include: { balanceChanges: true, effects: true }, + }) + expect(result.Transaction?.effects?.status.success).toBe(true) + const paid = result.Transaction?.balanceChanges?.find( + (c) => normalizeStructTag(c.coinType) === USDC && c.address === ACCOUNT + ) + const got = Number(paid?.amount) / 1e6 + expect(got).toBeGreaterThan(Number(quoted?.receive?.amount) * 0.995) + expect(got).toBeLessThan(Number(quoted?.receive?.amount) * 1.005) + }, 60_000) + + it('builds SUI for USDC the other way, and the dry run passes', async () => { + const prepared = await deepbookVenue.prepare( + { kind: 'swap', amount: '20', tokenIn: 'USDC', tokenOut: 'SUI' }, + ACCOUNT + ) + expect(() => deepbookVenue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + const result = await suiClient().core.simulateTransaction({ + transaction: fromBase64(prepared.signing.transaction), + include: { effects: true }, + }) + expect(result.Transaction?.effects?.status.success).toBe(true) + }, 60_000) +}) diff --git a/apps/dapp/lib/__tests__/deepbook.test.ts b/apps/dapp/lib/__tests__/deepbook.test.ts new file mode 100644 index 00000000..29dfcf63 --- /dev/null +++ b/apps/dapp/lib/__tests__/deepbook.test.ts @@ -0,0 +1,433 @@ +import { bcs } from '@mysten/sui/bcs' +import { Transaction } from '@mysten/sui/transactions' +import { fromBase64, toBase64 } from '@mysten/sui/utils' +import { beforeAll, describe, expect, it } from 'vitest' + +import { + createDeepbookVenue, + DEEP_TYPE, + DEEPBOOK_PACKAGE, + DEEPBOOK_POOLS, +} from '../chains/sui/deepbook' +import { deepbookListing } from '../chains/sui/deepbook-listing' +import type { SwapIo } from '../chains/sui/direct-swap' +import { ChainActionRefused, type ChainActionRequest } from '../chains/types' + +const ME = '0x00000000000000000000000000000000000000000000000000000000000000a1' +const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead' +const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01' +const COIN = '0x00000000000000000000000000000000000000000000000000000000000000c1' +const GAS = '0x00000000000000000000000000000000000000000000000000000000000000c2' +const DIGEST = '11111111111111111111111111111111' +const USDC = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' +const SCAM = '0x778e2ae6096389cfad3e0b1c6e8080a9287f8792a08d3df1f9025251d820be93::usdt::USDT' + +const u64 = (n: string): Uint8Array => bcs.u64().serialize(n).toBytes() + +interface BookReading { + sold?: [string, string] + bought?: [string, string] + mid?: string + taker?: string +} + +/** 10 SUI into 9.29 USDC with 0.02 SUI left over, and 10 USDC into 10.7 SUI with 0.03 USDC left. */ +function fakeIo(reading: BookReading = {}, prices: Record = {}) { + const views: Transaction[] = [] + const built: Transaction[] = [] + const io: SwapIo = { + view: async (tx) => { + views.push(tx) + const quantities = tx + .getData() + .commands.some((c) => c.MoveCall?.function === 'get_base_quantity_out_input_fee') + ? (reading.bought ?? ['10700000000', '30000']) + : (reading.sold ?? ['20000000', '9290000']) + return [ + [u64(quantities[0]), u64(quantities[1]), u64('0')], + [u64(reading.mid ?? '931800')], + [u64(reading.taker ?? '200000'), u64('0'), u64('100000000000')], + ] + }, + build: async (tx) => { + built.push(tx) + tx.setGasPrice(1000) + tx.setGasBudget(50_000_000) + tx.setGasPayment([{ objectId: GAS, version: '1', digest: DIGEST }]) + return toBase64(await tx.build()) + }, + price: async (token) => + token.symbol in prices ? prices[token.symbol] : token.symbol === 'SUI' ? 0.932 : 1, + spend: (tx) => tx.objectRef({ objectId: COIN, version: '1', digest: DIGEST }), + } + return { io, views, built } +} + +const SELL: ChainActionRequest = { kind: 'swap', amount: '10', tokenIn: 'SUI', tokenOut: 'USDC' } +const BUY: ChainActionRequest = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'SUI' } + +async function preparedBytes(request: ChainActionRequest = SELL): Promise { + const prepared = await createDeepbookVenue(fakeIo().io).prepare(request, ME) + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + return prepared.signing.transaction +} + +const venue = createDeepbookVenue(fakeIo().io) +let SOLD = '' + +beforeAll(async () => { + SOLD = await preparedBytes(SELL) +}) + +const check = (transaction: string, account = ME) => + venue.assertSafe({ family: 'sui', transaction }, account) + +async function edited(base: string, edit: (tx: Transaction) => void): Promise { + const tx = Transaction.from(base) + edit(tx) + return toBase64(await tx.build()) +} + +interface JsonArg { + Input?: number + Result?: number + NestedResult?: [number, number] +} +interface JsonTx { + inputs: { Pure?: { bytes: string }; Object?: { SharedObject?: { objectId: string } } }[] + commands: { + MoveCall?: { + package: string + module: string + function: string + typeArguments: string[] + arguments: JsonArg[] + } + }[] +} + +async function rewritten(base: string, edit: (data: JsonTx) => void): Promise { + const data = JSON.parse(await Transaction.from(base).toJSON()) as JsonTx + edit(data) + return toBase64(await Transaction.from(JSON.stringify(data)).build()) +} + +function swapCall(data: JsonTx) { + const call = data.commands.find((c) => c.MoveCall?.function.startsWith('swap_exact'))?.MoveCall + if (call === undefined) throw new Error('no swap in this transaction') + return call +} + +function moveCalls(transaction: string) { + return Transaction.from(transaction) + .getData() + .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall] : [])) +} + +describe('the DeepBook quote', () => { + it('reads what the book gives, and builds nothing', async () => { + const { io, views, built } = fakeIo() + const quoted = await createDeepbookVenue(io).quote?.(SELL, ME) + expect(quoted?.receive).toEqual({ amount: '9.29', symbol: 'USDC' }) + expect(built).toHaveLength(0) + expect(views).toHaveLength(1) + const names = views[0]?.getData().commands.map((c) => c.MoveCall?.function) + expect(names).toEqual(['get_quote_quantity_out_input_fee', 'mid_price', 'pool_trade_params']) + }) + + it('asks the input-fee path, so no DEEP is needed to read a price', async () => { + const { io, views } = fakeIo() + await createDeepbookVenue(io).quote?.(BUY, ME) + const first = views[0]?.getData().commands[0]?.MoveCall + expect(first?.function).toBe('get_base_quantity_out_input_fee') + }) + + it('refuses a pair with no direct pool', async () => { + await expect( + venue.quote?.({ ...SELL, tokenIn: 'USDsui', tokenOut: 'USDT' }, ME) + ).rejects.toThrow(/no direct pool/i) + }) + + it('refuses a token outside the list', async () => { + await expect(venue.quote?.({ ...SELL, tokenOut: 'FDUSD' }, ME)).rejects.toThrow( + ChainActionRefused + ) + }) + + it('refuses when the read fails, rather than guessing', async () => { + const { io } = fakeIo() + io.view = () => Promise.reject(new Error('fullnode down')) + const failing = createDeepbookVenue(io).quote?.(SELL, ME) + await expect(failing).rejects.toThrow(ChainActionRefused) + await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(/couldn't read/i) + }) + + it('refuses an amount below the smallest order, where nothing would trade', async () => { + const { io } = fakeIo({ sold: ['1000000000', '0'] }) + await expect(createDeepbookVenue(io).quote?.({ ...SELL, amount: '1' }, ME)).rejects.toThrow( + /can't fill/i + ) + }) + + it('refuses when more than 1% would stay unspent', async () => { + const { io } = fakeIo({ sold: ['200000000', '9110000'] }) + await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(/part of this amount/i) + }) + + it('refuses a stale book whose fill is far from its own mid price', async () => { + const { io } = fakeIo({ sold: ['20000000', '100000'] }) + await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(ChainActionRefused) + }) + + it('refuses a fill worth far less than the market, even at the mid price', async () => { + const { io } = fakeIo({}, { SUI: 2 }) + await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(/worse than the market/) + }) + + it('refuses when no outside price can be read', async () => { + const { io } = fakeIo({}, { USDC: undefined }) + await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(/live price/) + }) + + it('refuses an account that is not a Sui address', async () => { + await expect(venue.quote?.(SELL, 'not-an-address')).rejects.toThrow(/Sui address/) + }) +}) + +describe('what DeepBook prepare builds', () => { + it('sells base with a minimum 0.5% under the quote, and no DEEP from the wallet', async () => { + const transaction = await preparedBytes(SELL) + const data = Transaction.from(transaction).getData() + expect(data.sender).toBe(ME) + const calls = moveCalls(transaction) + expect(calls.map((c) => `${c.module}::${c.function}`)).toEqual([ + 'coin::zero', + 'pool::swap_exact_base_for_quote', + 'coin::destroy_zero', + ]) + const swap = calls[1] + expect(swap?.package).toBe(DEEPBOOK_PACKAGE) + const minimum = swap?.arguments[3] + if (minimum?.$kind !== 'Input') throw new Error('minimum is not stated') + const input = data.inputs[minimum.Input] + expect(bcs.u64().parse(fromBase64(input?.Pure?.bytes ?? ''))).toBe('9243550') + expect(calls[0]?.typeArguments).toEqual([DEEP_TYPE]) + expect(() => check(transaction)).not.toThrow() + }) + + it('buys base through the other function, and passes its own check', async () => { + const transaction = await preparedBytes(BUY) + expect(moveCalls(transaction)[1]?.function).toBe('swap_exact_quote_for_base') + expect(() => check(transaction)).not.toThrow() + }) + + it('sends both leftovers and the output to the person', async () => { + const data = Transaction.from(await preparedBytes(SELL)).getData() + const transfer = data.commands.find((c) => c.$kind === 'TransferObjects')?.TransferObjects + expect(transfer?.objects).toHaveLength(2) + const to = transfer?.address + if (to?.$kind !== 'Input') throw new Error('recipient is not stated') + const bytes = data.inputs[to.Input]?.Pure?.bytes ?? '' + expect(bcs.Address.parse(fromBase64(bytes))).toBe(ME) + }) + + it('tells the person the price protection, the fee it read, the leftover and the risks', async () => { + const prepared = await venue.prepare(SELL, ME) + const notes = prepared.notes.join(' ') + expect(notes).toMatch(/more than 0\.5%, the swap cancels/) + expect(notes).toMatch(/fee is about 0\.025%/) + expect(notes).toMatch(/0\.02 SUI/) + expect(notes).toMatch(/can upgrade DeepBook's contracts/) + expect(prepared.receive).toEqual({ amount: '9.29', symbol: 'USDC', approximate: true }) + expect(prepared.pay).toEqual({ amount: '10', symbol: 'SUI' }) + expect(prepared.venueId).toBe('deepbook') + expect(prepared.kind).toBe('swap') + }) + + it('says a bridged USDT depends on the bridge', async () => { + const { io } = fakeIo({ sold: ['1000', '9990000'], mid: '999000000', taker: '10000' }) + const prepared = await createDeepbookVenue(io).prepare( + { ...SELL, tokenIn: 'USDT', tokenOut: 'USDC' }, + ME + ) + expect(prepared.notes.join(' ')).toMatch(/Sui Bridge/) + }) +}) + +describe('the DeepBook safety check refuses', () => { + it('a different account', async () => { + expect(() => check(SOLD, THIEF)).toThrow(/different account/) + }) + + it('a transaction sent by someone else', async () => { + const tampered = await edited(SOLD, (tx) => tx.setSender(THIEF)) + expect(() => check(tampered)).toThrow(/different account/) + }) + + it('an unknown contract', async () => { + const tampered = await edited(SOLD, (tx) => { + tx.moveCall({ target: `${FOREIGN}::drain::everything` }) + }) + expect(() => check(tampered)).toThrow(/confirmed/) + }) + + it('an upgraded package id, until someone checks it', async () => { + const tampered = await rewritten(SOLD, (data) => { + swapCall(data).package = FOREIGN + }) + expect(() => check(tampered)).toThrow(/confirmed/) + }) + + it('a DeepBook function this flow never needs', async () => { + const tampered = await rewritten(SOLD, (data) => { + swapCall(data).function = 'place_market_order' + }) + expect(() => check(tampered)).toThrow(/confirmed/) + }) + + it('an extra argument', async () => { + const tampered = await rewritten(SOLD, (data) => { + const call = swapCall(data) + call.arguments.push(call.arguments[3] as JsonArg) + }) + expect(() => check(tampered)).toThrow(/does not match/) + }) + + it('payment to someone else', async () => { + const tampered = await edited(SOLD, (tx) => { + const [coin] = tx.splitCoins(tx.gas, [BigInt(1)]) + tx.transferObjects([coin as never], THIEF) + }) + expect(() => check(tampered)).toThrow(/another address/) + }) + + it('a framework transfer that hides the recipient', async () => { + const tampered = await edited(SOLD, (tx) => { + const [coin] = tx.splitCoins(tx.gas, [BigInt(1)]) + tx.moveCall({ + target: '0x2::transfer::public_transfer', + typeArguments: ['0x2::coin::Coin<0x2::sui::SUI>'], + arguments: [coin as never, tx.pure.address(THIEF)], + }) + }) + expect(() => check(tampered)).toThrow(/confirmed/) + }) + + it('no minimum, which is what an unlimited approval would be here', async () => { + const tampered = await rewritten(SOLD, (data) => { + const at = swapCall(data).arguments[3]?.Input + const input = at === undefined ? undefined : data.inputs[at] + if (input?.Pure === undefined) throw new Error('no minimum') + input.Pure.bytes = toBase64(u64('0')) + }) + expect(() => check(tampered)).toThrow(/minimum/) + }) + + it('a minimum computed elsewhere in the transaction', async () => { + const tampered = await rewritten(SOLD, (data) => { + const call = swapCall(data) + call.arguments[3] = { NestedResult: [0, 0] } + }) + expect(() => check(tampered)).toThrow(/minimum/) + }) + + it('a token outside the pool', async () => { + const tampered = await rewritten(SOLD, (data) => { + swapCall(data).typeArguments[1] = SCAM + }) + expect(() => check(tampered)).toThrow(/pool/) + }) + + it('a pool that is not pinned', async () => { + const tampered = await rewritten(SOLD, (data) => { + const at = swapCall(data).arguments[0]?.Input + const pool = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject + if (pool === undefined) throw new Error('no pool') + pool.objectId = FOREIGN + }) + expect(() => check(tampered)).toThrow(/pool/) + }) + + it("one pool's id with another pair's types", async () => { + const other = DEEPBOOK_POOLS.find((p) => p.base === 'USDT') + if (other === undefined) throw new Error('no USDT pool') + const tampered = await rewritten(SOLD, (data) => { + const at = swapCall(data).arguments[0]?.Input + const pool = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject + if (pool === undefined) throw new Error('no pool') + pool.objectId = other.id + }) + expect(() => check(tampered)).toThrow(/pool/) + }) + + it('DEEP drawn from the wallet', async () => { + const tampered = await rewritten(SOLD, (data) => { + const call = swapCall(data) + call.arguments[2] = call.arguments[1] as JsonArg + }) + expect(() => check(tampered)).toThrow(/DEEP/) + }) + + it('a zero coin of anything but DEEP', async () => { + const tampered = await rewritten(SOLD, (data) => { + const zero = data.commands.find((c) => c.MoveCall?.function === 'zero')?.MoveCall + if (zero === undefined) throw new Error('no zero coin') + zero.typeArguments = [USDC] + }) + expect(() => check(tampered)).toThrow(/DEEP/) + }) + + it('a clock that is not the clock', async () => { + const tampered = await rewritten(SOLD, (data) => { + const at = swapCall(data).arguments[4]?.Input + const clock = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject + if (clock === undefined) throw new Error('no clock') + clock.objectId = FOREIGN + }) + expect(() => check(tampered)).toThrow(/does not match/) + }) + + it('two swaps in one transaction', async () => { + const tampered = await rewritten(SOLD, (data) => { + const swap = data.commands.find((c) => c.MoveCall?.function.startsWith('swap_exact')) + if (swap === undefined) throw new Error('no swap') + data.commands.push(JSON.parse(JSON.stringify(swap)) as JsonTx['commands'][number]) + }) + expect(() => check(tampered)).toThrow(/one swap/) + }) + + it('a transaction with no swap at all', async () => { + const tampered = await rewritten(SOLD, (data) => { + data.commands = data.commands.filter((c) => !c.MoveCall?.function.startsWith('swap_exact')) + }) + expect(() => check(tampered)).toThrow(/one swap/) + }) + + it('a payload for another chain', () => { + expect(() => venue.assertSafe({ family: 'solana', transaction: SOLD }, ME)).toThrow( + ChainActionRefused + ) + }) + + it('bytes that are not a transaction', () => { + expect(() => check('not base64 at all!')).toThrow(ChainActionRefused) + }) +}) + +describe('the DeepBook pins and listing', () => { + it('pins exactly the pools for tokens in the table', () => { + expect(DEEPBOOK_POOLS.map((p) => `${p.base}/${p.quote}`).sort()).toEqual([ + 'SUI/USDC', + 'SUI/USDsui', + 'USDT/USDC', + 'USDsui/USDC', + ]) + }) + + it('lists as executing on Sui, and says limit orders are not connected', () => { + expect(deepbookListing.id).toBe('deepbook') + expect(deepbookListing.family).toBe('sui') + expect(deepbookListing.integration).toBe('executes') + expect(deepbookListing.capability).toMatch(/limit orders/i) + }) +}) diff --git a/apps/dapp/lib/__tests__/erc4626-refusals.ts b/apps/dapp/lib/__tests__/erc4626-refusals.ts new file mode 100644 index 00000000..c756723b --- /dev/null +++ b/apps/dapp/lib/__tests__/erc4626-refusals.ts @@ -0,0 +1,193 @@ +import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, type Address } from 'viem' +import { describe, expect, it } from 'vitest' + +import type { ReviewedVault } from '../chains/evm/erc4626' +import { ChainActionRefused, type ChainVenue, type SigningPayload } from '../chains/types' + +export const ACCOUNT = '0x1111111111111111111111111111111111111111' +export const STRANGER = '0x2222222222222222222222222222222222222222' +const OTHER_TOKEN = '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7' +const FIVE = BigInt(5_000_000) + +const ABI = parseAbi([ + 'function deposit(uint256 assets, address receiver) returns (uint256)', + 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256)', + 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)', + 'function mint(uint256 shares, address receiver) returns (uint256)', + 'function transfer(address to, uint256 amount) returns (bool)', + 'function approve(address spender, uint256 amount) returns (bool)', +]) + +export function depositData(receiver: string = ACCOUNT, assets: bigint = FIVE): string { + return encodeFunctionData({ + abi: ABI, + functionName: 'deposit', + args: [assets, receiver as Address], + }) +} + +export function withdrawData(receiver: string = ACCOUNT, owner: string = ACCOUNT): string { + return encodeFunctionData({ + abi: ABI, + functionName: 'withdraw', + args: [FIVE, receiver as Address, owner as Address], + }) +} + +export function approveData(spender: string, units: bigint = FIVE): string { + return encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender as Address, units], + }) +} + +/** The same refusals for every vault on this path, proved on each venue's own policy. */ +export function describeVaultRefusals( + venue: ChainVenue, + vault: ReviewedVault, + chainId: number, + offList: readonly string[] +): void { + const payload = (overrides: Partial> = {}) => ({ + family: 'evm' as const, + chainId, + to: vault.address, + data: depositData(), + value: '0', + ...overrides, + }) + const approval = (to: string, data: string) => payload({ approval: { to, data } }) + + describe(`${venue.id} assertSafe`, () => { + it('passes a deposit and a withdrawal for the person', () => { + expect(() => venue.assertSafe(payload(), ACCOUNT)).not.toThrow() + expect(() => venue.assertSafe(payload({ data: withdrawData() }), ACCOUNT)).not.toThrow() + }) + + it('passes a deposit with an exact approval of the vault', () => { + const signing = approval(vault.asset.id, approveData(vault.address)) + expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow() + }) + + it.each<[string, SigningPayload]>([ + ['another family', { family: 'sui', transaction: 'AA==' }], + ['another chain', payload({ chainId: chainId + 1 })], + ['native value', payload({ value: '1' })], + ['a deposit crediting someone else', payload({ data: depositData(STRANGER) })], + ['a withdrawal paying someone else', payload({ data: withdrawData(STRANGER) })], + ['a withdrawal from someone else', payload({ data: withdrawData(ACCOUNT, STRANGER) })], + ['a deposit of nothing', payload({ data: depositData(ACCOUNT, BigInt(0)) })], + ['extra calldata', payload({ data: `${depositData()}00` })], + ['calldata that decodes as nothing', payload({ data: '0xdeadbeef' })], + [ + 'redeem, which is not allowed', + payload({ + data: encodeFunctionData({ + abi: ABI, + functionName: 'redeem', + args: [FIVE, ACCOUNT as Address, ACCOUNT as Address], + }), + }), + ], + [ + 'mint, which is not allowed', + payload({ + data: encodeFunctionData({ + abi: ABI, + functionName: 'mint', + args: [FIVE, ACCOUNT as Address], + }), + }), + ], + [ + 'moving the shares', + payload({ + data: encodeFunctionData({ + abi: ABI, + functionName: 'transfer', + args: [STRANGER as Address, FIVE], + }), + }), + ], + [ + 'approving someone to take the shares', + payload({ + data: encodeFunctionData({ + abi: ABI, + functionName: 'approve', + args: [STRANGER as Address, FIVE], + }), + }), + ], + ['the token itself as the target', payload({ to: vault.asset.id })], + ['an unlimited approval', approval(vault.asset.id, approveData(vault.address, maxUint256))], + ['an approval of a stranger', approval(vault.asset.id, approveData(STRANGER))], + ['an approval of the wrong token', approval(OTHER_TOKEN, approveData(vault.address))], + [ + 'an approval for another amount', + approval(vault.asset.id, approveData(vault.address, BigInt(6))), + ], + ['an approval with extra data', approval(vault.asset.id, `${approveData(vault.address)}00`)], + [ + 'a first step that pays someone', + approval( + vault.asset.id, + encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [STRANGER as Address, FIVE], + }) + ), + ], + [ + 'an approval beside a withdrawal', + payload({ + data: withdrawData(), + approval: { to: vault.asset.id, data: approveData(vault.address) }, + }), + ], + ])('refuses %s', (_label, signing) => { + expect(() => venue.assertSafe(signing, ACCOUNT)).toThrow(ChainActionRefused) + }) + + it('refuses a transaction built for another account', () => { + expect(() => venue.assertSafe(payload(), STRANGER)).toThrow(ChainActionRefused) + expect(() => venue.assertSafe(payload({ data: withdrawData() }), STRANGER)).toThrow( + ChainActionRefused + ) + }) + + it.each(offList.map((address) => [address]))('refuses the vault off the list at %s', (to) => { + expect(() => venue.assertSafe(payload({ to }), ACCOUNT)).toThrow(ChainActionRefused) + }) + + it('refuses a token this vault does not take', async () => { + const request = { kind: 'supply' as const, amount: '5', tokenIn: 'USDT' } + await expect(venue.prepare(request, ACCOUNT)).rejects.toThrow(/takes USDC/) + await expect(venue.quote?.(request, ACCOUNT)).rejects.toThrow(/takes USDC/) + }) + + it('refuses an account that is not an address', async () => { + const request = { kind: 'supply' as const, amount: '5', tokenIn: 'USDC' } + await expect(venue.prepare(request, 'not-an-address')).rejects.toThrow(ChainActionRefused) + }) + + it('refuses a kind it does not run', async () => { + const request = { kind: 'borrow' as const, amount: '5', tokenIn: 'USDC' } + await expect(venue.prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) + + it('can only supply after a swap, and only its own token', async () => { + await expect( + venue.assertCanFollow?.({ kind: 'supply', tokenIn: 'USDC' }, ACCOUNT) + ).resolves.toBeUndefined() + await expect( + venue.assertCanFollow?.({ kind: 'supply', tokenIn: 'USDT' }, ACCOUNT) + ).rejects.toThrow(ChainActionRefused) + await expect( + venue.assertCanFollow?.({ kind: 'withdraw', tokenIn: 'USDC' }, ACCOUNT) + ).rejects.toThrow(ChainActionRefused) + }) + }) +} diff --git a/apps/dapp/lib/__tests__/euler.live.test.ts b/apps/dapp/lib/__tests__/euler.live.test.ts new file mode 100644 index 00000000..4220160a --- /dev/null +++ b/apps/dapp/lib/__tests__/euler.live.test.ts @@ -0,0 +1,192 @@ +import { + encodeAbiParameters, + encodeFunctionData, + http, + keccak256, + pad, + parseAbi, + toHex, + type Address, + type Hex, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import { evmReader } from '../chains/evm/client' +import { + createEulerAvalancheVenue, + createEulerMonadVenue, + EULER_AVALANCHE_COLLATERAL, + EULER_AVALANCHE_VAULT, + EULER_FACTORY_AVALANCHE, + EULER_FACTORY_MONAD, + EULER_MONAD_COLLATERAL, + EULER_MONAD_VAULT, +} from '../chains/evm/euler' +import type { ReviewedVault } from '../chains/evm/erc4626' +import { sameAddress } from '../chains/evm/submit' +import type { ChainVenue } from '../chains/types' + +/** + * Whether what's pinned still holds, read-only. Nothing here signs or sends: a deposit is tried + * with `eth_call` and overridden balances, and a withdrawal from a vault that holds these shares, + * whose key nobody has. If a holder leaves, swap it for another. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const NOBODY = '0x000000000000000000000000000000000000dEaD' +const SLOW_MS = 90_000 +/** Where Circle's USDC keeps balances and allowances, found by probing with an override. */ +const USDC_BALANCE_SLOT = BigInt(9) +const USDC_ALLOWANCE_SLOT = BigInt(10) +/** Stream's and Elixir's tokens, which left other Euler vaults on Avalanche stuck. */ +const STREAM_TOKENS = ['XUSD', 'DEUSD', 'SDEUSD'] + +const ABI = parseAbi([ + 'function asset() view returns (address)', + 'function symbol() view returns (string)', + 'function decimals() view returns (uint8)', + 'function governorAdmin() view returns (address)', + 'function isProxy(address) view returns (bool)', + 'function LTVList() view returns (address[])', + 'function cash() view returns (uint256)', + 'function totalAssets() view returns (uint256)', + 'function hookConfig() view returns (address, uint32)', + 'function deposit(uint256, address) returns (uint256)', +]) + +const slotOf = (key: string, slot: bigint): Hex => + keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [key as Address, slot])) +const allowanceSlot = (owner: string, spender: string): Hex => + keccak256( + encodeAbiParameters( + [{ type: 'address' }, { type: 'bytes32' }], + [spender as Address, slotOf(owner, USDC_ALLOWANCE_SLOT)] + ) + ) + +interface Case { + label: string + chain: 'monad' | 'avalanche' + rpc: string + vault: ReviewedVault + factory: string + governor: string + collateral: readonly string[] + holder: string + create: (reader: ReturnType) => ChainVenue +} + +const CASES: Case[] = [ + { + label: 'Monad', + chain: 'monad', + rpc: process.env['MONAD_RPC_URL']?.trim() || 'https://rpc.monad.xyz', + vault: EULER_MONAD_VAULT, + factory: EULER_FACTORY_MONAD, + governor: '0xb3CF59A5f12cA319861376C5e63Eef4790a42B44', + collateral: EULER_MONAD_COLLATERAL, + holder: '0xE1BcA19baA63894D374578320551633320436523', + create: (reader) => createEulerMonadVenue({ reader }), + }, + { + label: 'Avalanche', + chain: 'avalanche', + rpc: process.env['AVALANCHE_RPC_URL']?.trim() || 'https://avalanche-c-chain-rpc.publicnode.com', + vault: EULER_AVALANCHE_VAULT, + factory: EULER_FACTORY_AVALANCHE, + governor: '0x23E6aecB76675462Ad8f2B31eC7C492060c2fAEF', + collateral: EULER_AVALANCHE_COLLATERAL, + holder: '0x4aF3aBE954259fb70b97C57EBD7Ac1eb822028Ef', + create: (reader) => createEulerAvalancheVenue({ reader }), + }, +] + +describe.skipIf(SKIP).each(CASES)('Euler on $label, live', (item) => { + const reader = evmReader(item.chain, http(item.rpc, { timeout: 30_000, retryCount: 2 })) + const vault = item.vault.address as Address + const read = (address: string, functionName: string, args: unknown[] = []) => + reader.readContract({ + address: address as Address, + abi: ABI, + functionName, + args, + } as never) as Promise + + it('finds code at the factory and the vault', { timeout: SLOW_MS }, async () => { + for (const address of [item.factory, vault]) { + const code = await reader.getCode({ address: address as Address }) + expect(code?.length ?? 0).toBeGreaterThan(2) + } + }) + + it('is still the reviewed vault', { timeout: SLOW_MS }, async () => { + expect(await read(item.factory, 'isProxy', [vault])).toBe(true) + expect(sameAddress((await read(vault, 'asset')) as string, item.vault.asset.id)).toBe(true) + expect(await read(vault, 'symbol')).toBe(item.vault.shareSymbol) + expect(await read(vault, 'decimals')).toBe(item.vault.shareDecimals) + expect(sameAddress((await read(vault, 'governorAdmin')) as string, item.governor)).toBe(true) + }) + + it('has no hook that could block a deposit or a withdrawal', { timeout: SLOW_MS }, async () => { + const [hook, ops] = (await read(vault, 'hookConfig')) as readonly [string, number] + expect(sameAddress(hook, `0x${'0'.repeat(40)}`)).toBe(true) + expect(ops).toBe(0) + }) + + it('takes only reviewed collateral, with no Stream token', { timeout: SLOW_MS }, async () => { + const listed = (await read(vault, 'LTVList')) as readonly string[] + expect([...listed].sort()).toEqual([...item.collateral].sort()) + for (const entry of listed) { + const asset = (await read(entry, 'asset')) as string + const symbol = ((await read(asset, 'symbol')) as string).toUpperCase() + expect(STREAM_TOKENS).not.toContain(symbol) + } + }) + + it('still has money free', { timeout: SLOW_MS }, async () => { + const [cash, total] = await Promise.all([read(vault, 'cash'), read(vault, 'totalAssets')]) + expect((cash as bigint) * BigInt(100)).toBeGreaterThan((total as bigint) * BigInt(5)) + }) + + it('prepares a deposit with a believable rate', { timeout: SLOW_MS }, async () => { + const venue = item.create(reader) + // The vault's own cash stands in for a person with USDC. Nothing is signed. + const prepared = await venue.prepare({ kind: 'supply', amount: '5', tokenIn: 'USDC' }, vault) + expect(() => venue.assertSafe(prepared.signing, vault)).not.toThrow() + expect(prepared.apy?.basePercent).toBeGreaterThan(0) + expect(prepared.apy?.basePercent).toBeLessThan(50) + }) + + it( + 'accepts the deposit this code builds, with funds overridden', + { timeout: SLOW_MS }, + async () => { + const units = BigInt(5_000_000) + await expect( + reader.call({ + account: NOBODY, + to: vault, + data: encodeFunctionData({ abi: ABI, functionName: 'deposit', args: [units, NOBODY] }), + stateOverride: [ + { + address: item.vault.asset.id as Address, + stateDiff: [ + { slot: slotOf(NOBODY, USDC_BALANCE_SLOT), value: pad(toHex(units)) }, + { slot: allowanceSlot(NOBODY, vault), value: pad(toHex(units)) }, + ], + }, + ], + }) + ).resolves.toBeDefined() + } + ) + + it('prepares and tries a withdrawal for a real holder', { timeout: SLOW_MS }, async () => { + const venue = item.create(reader) + const prepared = await venue.prepare( + { kind: 'withdraw', amount: '5', tokenIn: 'USDC' }, + item.holder + ) + expect(() => venue.assertSafe(prepared.signing, item.holder)).not.toThrow() + }) +}) diff --git a/apps/dapp/lib/__tests__/euler.test.ts b/apps/dapp/lib/__tests__/euler.test.ts new file mode 100644 index 00000000..11c4cbf7 --- /dev/null +++ b/apps/dapp/lib/__tests__/euler.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' + +import type { EvmReaderClient } from '../chains/evm/call' +import { + createEulerAvalancheVenue, + createEulerMonadVenue, + EULER_AVALANCHE_COLLATERAL, + EULER_AVALANCHE_VAULT, + EULER_MONAD_COLLATERAL, + EULER_MONAD_VAULT, +} from '../chains/evm/euler' +import { eulerAvalancheListing, eulerMonadListing } from '../chains/evm/euler-listing' +import { AVALANCHE_TOKENS, MONAD_TOKENS } from '../chains/evm/tokens' +import type { ChainActionRequest } from '../chains/types' +import { ACCOUNT, describeVaultRefusals } from './erc4626-refusals' + +const SUPPLY: ChainActionRequest = { kind: 'supply', amount: '100', tokenIn: 'USDC' } +const WITHDRAW: ChainActionRequest = { kind: 'withdraw', amount: '100', tokenIn: 'USDC' } + +const units = (usdc: number) => BigInt(usdc) * BigInt(1_000_000) +const RATE = BigInt('2500000000000000000') + +interface Scene { + cash?: bigint + total?: bigint + borrows?: bigint + held?: bigint + shares?: bigint + room?: bigint + collateral?: readonly string[] + rateFails?: boolean + cashFails?: boolean +} + +function fakeReader(scene: Scene, collateral: readonly string[]) { + const reader = { + call: async () => ({}), + readContract: async ({ + address, + functionName, + args, + }: { + address: string + functionName: string + args?: readonly unknown[] + }) => { + switch (functionName) { + case 'balanceOf': + return address === EULER_MONAD_VAULT.address || address === EULER_AVALANCHE_VAULT.address + ? (scene.shares ?? BigInt(0)) + : (scene.held ?? units(1_000)) + case 'allowance': + return BigInt(0) + case 'totalAssets': + return scene.total ?? units(1_000_000) + case 'totalBorrows': + return scene.borrows ?? units(900_000) + case 'cash': + if (scene.cashFails) throw new Error('no reading') + return scene.cash ?? units(100_000) + case 'interestRate': + if (scene.rateFails) throw new Error('no reading') + return RATE + case 'interestFee': + return 1000 + case 'LTVList': + return scene.collateral ?? collateral + case 'maxDeposit': + return scene.room ?? units(10_000_000) + case 'previewDeposit': + return (args?.[0] as bigint) - BigInt(1) + case 'convertToAssets': + return args?.[0] as bigint + default: + throw new Error(`Unexpected read: ${functionName}`) + } + }, + } + return reader as unknown as EvmReaderClient +} + +const monadWith = (scene: Scene = {}) => + createEulerMonadVenue({ reader: fakeReader(scene, EULER_MONAD_COLLATERAL) }) +const avalancheWith = (scene: Scene = {}) => + createEulerAvalancheVenue({ reader: fakeReader(scene, EULER_AVALANCHE_COLLATERAL) }) + +describeVaultRefusals(monadWith(), EULER_MONAD_VAULT, 143, [ + // K3's yzPrime and PT vaults: structured collateral, reviewed and left out. + '0xa3B64e2674463c98CbD21807055D8C1E008b6e79', + '0x06047833d9aAC144f7F4e9B8234D2c7DDA1a8BAf', + // The vault for Avalanche, which isn't one on this chain. + EULER_AVALANCHE_VAULT.address, +]) + +describeVaultRefusals(avalancheWith(), EULER_AVALANCHE_VAULT, 43114, [ + // Re7 USDC and K3 USDT Earn: deprecated for exposure to Stream in euler-labels. + '0xEAF77Df5D03306bCA4EE8b58B6821E6acA76309d', + '0xE1A62FDcC6666847d5EA752634E45e134B2F824B', + // Re7's eUSDC-2, which lends against deUSD and sdeUSD. + '0x39dE0f00189306062D79eDEC6DcA5bb6bFd108f9', + // 9Summits' own Earn vault, whose queue includes that one. + '0x4aF3aBE954259fb70b97C57EBD7Ac1eb822028Ef', +]) + +describe('Euler vaults', () => { + it('pins the USDC in each chain table', () => { + expect(MONAD_TOKENS.find((t) => t.symbol === 'USDC')).toEqual(EULER_MONAD_VAULT.asset) + expect(AVALANCHE_TOKENS.find((t) => t.symbol === 'USDC')).toEqual(EULER_AVALANCHE_VAULT.asset) + }) + + it('lists one venue per chain', () => { + expect(eulerMonadListing).toMatchObject({ + id: 'euler-monad', + chains: ['Monad'], + integration: 'executes', + }) + expect(eulerAvalancheListing).toMatchObject({ + id: 'euler-avalanche', + chains: ['Avalanche'], + integration: 'executes', + }) + }) + + it('names the curator and the collateral on each card', async () => { + const monad = await monadWith().prepare(SUPPLY, ACCOUNT) + expect(monad.summary).toContain('Clearstar') + expect(monad.notes.join(' ')).toMatch(/only to borrowers holding FXRP/) + const avalanche = await avalancheWith().prepare(SUPPLY, ACCOUNT) + expect(avalanche.summary).toContain('9 Summits') + expect(avalanche.notes.join(' ')).toMatch(/savUSD/) + expect(avalanche.notes.join(' ')).toMatch(/\$93 million/) + }) + + it('works the supply rate out from the borrow rate, what is lent and the fee', async () => { + const venue = monadWith() + const { apy } = await venue.prepare(SUPPLY, ACCOUNT) + const perSecond = 2.5e-9 * 0.9 * 0.9 + const expected = (Math.pow(1 + perSecond, 365.2425 * 86_400) - 1) * 100 + expect(apy?.basePercent).toBeCloseTo(expected, 6) + expect(apy?.rewardPercent).toBeUndefined() + await expect(venue.quote?.(SUPPLY, ACCOUNT)).resolves.toEqual({ apyPercent: apy?.basePercent }) + }) + + it('shows no rate when it cannot be read, or nothing is borrowed', async () => { + expect(await monadWith({ rateFails: true }).prepare(SUPPLY, ACCOUNT)).not.toHaveProperty('apy') + expect(await monadWith({ borrows: BigInt(0) }).prepare(SUPPLY, ACCOUNT)).not.toHaveProperty( + 'apy' + ) + await expect(monadWith({ rateFails: true }).quote?.(SUPPLY, ACCOUNT)).resolves.toEqual({}) + }) + + it('refuses a vault taking collateral that was not reviewed, as a Stream token would be', async () => { + const collateral = [...EULER_AVALANCHE_COLLATERAL, '0xFECebF56f150A68dDd197c3a6101da35C46693Ac'] + await expect(avalancheWith({ collateral }).prepare(SUPPLY, ACCOUNT)).rejects.toThrow( + /new collateral/ + ) + await expect(avalancheWith({ collateral }).quote?.(SUPPLY, ACCOUNT)).rejects.toThrow( + /new collateral/ + ) + }) + + it('refuses a stuck vault, the way Stream left others on Avalanche', async () => { + const venue = avalancheWith({ cash: units(1_000), total: units(1_000_000) }) + await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/won't add to it/) + await expect(venue.quote?.(SUPPLY, ACCOUNT)).rejects.toThrow(/won't add to it/) + }) + + it('refuses more than the vault has room for', async () => { + await expect(monadWith({ room: units(10) }).prepare(SUPPLY, ACCOUNT)).rejects.toThrow( + /room for about 10 more USDC/ + ) + }) + + it('refuses when the vault cannot be read', async () => { + await expect(monadWith({ cashFails: true }).prepare(SUPPLY, ACCOUNT)).rejects.toThrow( + /couldn't read/ + ) + }) + + it('builds a withdrawal that stays in the person’s hands', async () => { + const venue = avalancheWith({ shares: units(500) }) + const prepared = await venue.prepare(WITHDRAW, ACCOUNT) + expect(prepared.signing).toMatchObject({ family: 'evm', chainId: 43114 }) + expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + }) + + it('says plainly when the vault cannot pay out that much now', async () => { + const venue = avalancheWith({ shares: units(500), cash: units(40) }) + await expect(venue.prepare(WITHDRAW, ACCOUNT)).rejects.toThrow(/can pay out about 40 USDC/) + }) + + it('leaves a way out when the cash cannot be read', async () => { + const venue = monadWith({ shares: units(500), cashFails: true }) + await expect(venue.prepare(WITHDRAW, ACCOUNT)).resolves.toMatchObject({ kind: 'withdraw' }) + }) +}) diff --git a/apps/dapp/lib/__tests__/evm-call.test.ts b/apps/dapp/lib/__tests__/evm-call.test.ts new file mode 100644 index 00000000..40621294 --- /dev/null +++ b/apps/dapp/lib/__tests__/evm-call.test.ts @@ -0,0 +1,251 @@ +import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, type Address } from 'viem' +import { describe, expect, it } from 'vitest' + +import { + approvalNote, + assertEvmCallSafe, + buildEvmCall, + confirmEvmCall, + type EvmCallPolicy, + type EvmReaderClient, +} from '../chains/evm/call' +import type { EvmClient, EvmReceipt } from '../chains/evm/client' +import { sameAddress } from '../chains/evm/submit' +import { ChainActionRefused, type SigningPayload } from '../chains/types' + +const ACCOUNT = '0x1111111111111111111111111111111111111111' +const STRANGER = '0x2222222222222222222222222222222222222222' +const CONTRACT = '0x3333333333333333333333333333333333333333' +const OTHER_CONTRACT = '0x4444444444444444444444444444444444444444' +const TOKEN = '0x5555555555555555555555555555555555555555' +const AVALANCHE_ID = 43114 +const HASH = `0x${'a'.repeat(64)}` + +const ABI = parseAbi([ + 'function put(address token, uint256 amount, address onBehalfOf)', + 'function sweep(address to)', +]) + +const POLICY: EvmCallPolicy = { + venueName: 'Test', + chain: 'avalanche', + contracts: { + [CONTRACT]: { + abi: ABI, + functions: ['put'], + check: (call, { account, approval }) => { + const [, amount, onBehalfOf] = call.args as [string, bigint, string] + if (!sameAddress(onBehalfOf, account)) { + throw new ChainActionRefused('This credits someone else.') + } + if (approval !== undefined && approval.units !== amount) { + throw new ChainActionRefused('The approval does not match.') + } + }, + }, + }, + spenders: [CONTRACT], + approvable: [TOKEN], +} + +function put(onBehalfOf: string = ACCOUNT, amount = BigInt(5)): string { + return encodeFunctionData({ + abi: ABI, + functionName: 'put', + args: [TOKEN as Address, amount, onBehalfOf as Address], + }) +} + +function approve(spender: string = CONTRACT, units = BigInt(5)): string { + return encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender as Address, units], + }) +} + +function payload(overrides: Partial> = {}) { + return { + family: 'evm' as const, + chainId: AVALANCHE_ID, + to: CONTRACT, + data: put(), + value: '0', + ...overrides, + } +} + +describe('assertEvmCallSafe', () => { + it('passes the call the venue is confirmed to make', () => { + expect(() => assertEvmCallSafe(payload(), ACCOUNT, POLICY)).not.toThrow() + }) + + it('passes an exact approval to a confirmed spender', () => { + const signing = payload({ approval: { to: TOKEN, data: approve() } }) + expect(() => assertEvmCallSafe(signing, ACCOUNT, POLICY)).not.toThrow() + }) + + it.each<[string, SigningPayload]>([ + ['another family', { family: 'sui', transaction: 'AA==' }], + ['another chain', payload({ chainId: 143 })], + ['an unknown contract', payload({ to: OTHER_CONTRACT })], + [ + 'a function it may not call', + payload({ + data: encodeFunctionData({ abi: ABI, functionName: 'sweep', args: [STRANGER as Address] }), + }), + ], + ['calldata that decodes as nothing', payload({ data: '0xdeadbeef' })], + ['anything after the arguments', payload({ data: `${put()}00` })], + ['credit to someone else', payload({ data: put(STRANGER) })], + ['native value it may not take', payload({ value: '1' })], + ['a value that is not digits', payload({ value: '0x1' })], + [ + 'an unlimited approval', + payload({ approval: { to: TOKEN, data: approve(CONTRACT, maxUint256) } }), + ], + ['an approval to a stranger', payload({ approval: { to: TOKEN, data: approve(STRANGER) } })], + [ + 'an approval of an unlisted token', + payload({ approval: { to: OTHER_CONTRACT, data: approve() } }), + ], + [ + 'an approval with trailing data', + payload({ approval: { to: TOKEN, data: `${approve()}00` } }), + ], + [ + 'a first step that is a transfer', + payload({ + approval: { + to: TOKEN, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [STRANGER as Address, BigInt(5)], + }), + }, + }), + ], + [ + 'an approval larger than the call', + payload({ approval: { to: TOKEN, data: approve(CONTRACT, BigInt(6)) } }), + ], + ])('refuses %s', (_label, signing) => { + expect(() => assertEvmCallSafe(signing, ACCOUNT, POLICY)).toThrow(ChainActionRefused) + }) + + it('lets value through only where the venue takes the native coin', () => { + const native = { ...POLICY, mayPayNative: true } + expect(() => assertEvmCallSafe(payload({ value: '1' }), ACCOUNT, native)).not.toThrow() + }) +}) + +function fakeReader(allowance: bigint, callFails = false): EvmReaderClient { + return { + readContract: async () => allowance, + call: async () => { + if (callFails) throw new Error('reverted') + return { data: '0x' } + }, + } as unknown as EvmReaderClient +} + +const PLAN = { + chain: 'avalanche' as const, + account: ACCOUNT, + to: CONTRACT, + data: put(), + pull: { token: TOKEN, spender: CONTRACT, units: BigInt(5) }, +} + +describe('buildEvmCall', () => { + it('asks for an exact approval only when the allowance falls short', async () => { + const { signing, needsApproval } = await buildEvmCall(PLAN, fakeReader(BigInt(4))) + expect(needsApproval).toBe(true) + expect(signing).toMatchObject({ approval: { to: TOKEN, data: approve() } }) + }) + + it('leaves the approval off when one is in place', async () => { + const { signing, needsApproval } = await buildEvmCall(PLAN, fakeReader(BigInt(5))) + expect(needsApproval).toBe(false) + expect(signing).not.toHaveProperty('approval') + }) + + it('refuses a call that would fail, rather than costing the gas', async () => { + await expect(buildEvmCall(PLAN, fakeReader(BigInt(5), true))).rejects.toThrow( + ChainActionRefused + ) + }) + + it("doesn't try a call whose approval hasn't landed, which would fail for that alone", async () => { + await expect(buildEvmCall(PLAN, fakeReader(BigInt(0), true))).resolves.toMatchObject({ + needsApproval: true, + }) + }) + + it('says the wallet will ask twice, for how much', () => { + expect(approvalNote('Test', '5', 'USDC')).toContain('exactly 5 USDC') + }) +}) + +function fakeClient( + receipt: Partial | Error, + sent: { input: string; value: bigint } | Error = { input: put(), value: BigInt(0) } +): EvmClient { + return { + tokenBalance: async () => BigInt(0), + nativeBalance: async () => BigInt(0), + waitForReceipt: async () => { + if (receipt instanceof Error) throw receipt + return { status: 'success', from: ACCOUNT, to: CONTRACT, logs: [], ...receipt } as EvmReceipt + }, + transaction: async () => { + if (sent instanceof Error) throw sent + return sent + }, + } +} + +const SIGNED = { family: 'evm' as const, hash: HASH } + +describe('confirmEvmCall', () => { + it('settles a landed call that is this venue’s, from this person', async () => { + const result = await confirmEvmCall(POLICY, ACCOUNT, SIGNED, fakeClient({})) + expect(result).toMatchObject({ ok: true, hash: HASH }) + }) + + it('says funds did not move only for a proven revert of this very call', async () => { + const result = await confirmEvmCall(POLICY, ACCOUNT, SIGNED, fakeClient({ status: 'reverted' })) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + }) + + it.each<[string, EvmClient]>([ + ['a receipt that has not come', fakeClient(new Error('timeout'))], + ['another person’s transaction', fakeClient({ from: STRANGER })], + ['a contract creation', fakeClient({ to: null })], + ['another contract', fakeClient({ to: OTHER_CONTRACT })], + ['a transaction that can’t be read', fakeClient({}, new Error('rpc'))], + [ + 'a call that credits someone else', + fakeClient({}, { input: put(STRANGER), value: BigInt(0) }), + ], + [ + 'a revert that is not this call', + fakeClient({ status: 'reverted' }, { input: put(STRANGER), value: BigInt(0) }), + ], + ])('stays pending for %s, since the money may have moved', async (_label, client) => { + const result = await confirmEvmCall(POLICY, ACCOUNT, SIGNED, client) + expect(result).toMatchObject({ ok: false, pending: true }) + }) + + it('stays pending when the wallet gave no usable hash', async () => { + const result = await confirmEvmCall( + POLICY, + ACCOUNT, + { family: 'evm', hash: 'nope' }, + fakeClient({}) + ) + expect(result).toMatchObject({ ok: false, pending: true }) + }) +}) diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index 583b30e9..730b1c74 100644 --- a/apps/dapp/lib/__tests__/evm-module.test.ts +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -326,21 +326,54 @@ describe('the chain modules', () => { expect(EVM_CHAINS.avalanche.chain.id).toBe(43114) }) - it('finds a module for both, each with its own swap venue and a wallet transfer only', () => { + it('finds a module for both, each with its own venues and a wallet transfer', () => { const avalancheModule = chainModule('avalanche') const monadModule = chainModule('monad') expect(avalancheModule?.chain).toBe('avalanche') expect(monadModule?.chain).toBe('monad') - expect(avalancheModule?.venues.map((v) => v.id)).toEqual(['1click-avalanche', 'send']) - expect(monadModule?.venues.map((v) => v.id)).toEqual(['1click-monad', 'send']) + expect(avalancheModule?.venues.map((v) => v.id)).toEqual([ + '1click-avalanche', + 'uniswap-avalanche', + 'pharaoh', + 'aave-avalanche', + 'sablier-avalanche', + 'bulla', + 'send', + ]) + expect(monadModule?.venues.map((v) => v.id)).toEqual([ + '1click-monad', + 'uniswap-monad', + 'aave-monad', + 'neverland', + 'morpho', + 'euler-monad', + 'curvance', + 'beefy-monad', + 'fastlane', + 'sablier-monad', + 'send', + ]) expect(avalancheModule?.defaultVenue.swap).toBe('1click-avalanche') expect(monadModule?.defaultVenue.swap).toBe('1click-monad') expect(chainModule('arc')).toBeUndefined() }) it('lists the tokens the brief names, with the contracts 1Click lists', () => { - expect(AVALANCHE_TOKENS.map((t) => t.symbol)).toEqual(['AVAX', 'USDC', 'USDT']) - expect(MONAD_TOKENS.map((t) => t.symbol)).toEqual(['MON', 'USDC', 'USDT0']) + expect(AVALANCHE_TOKENS.map((t) => t.symbol)).toEqual([ + 'AVAX', + 'USDC', + 'USDT', + 'WAVAX', + 'WETH.e', + ]) + expect(MONAD_TOKENS.map((t) => t.symbol)).toEqual([ + 'MON', + 'USDC', + 'USDT0', + 'WMON', + 'WETH', + 'shMON', + ]) expect(AVAX_USDC.id.toLowerCase().startsWith('0xb97ef9ef8734')).toBe(true) expect(MONAD_USDC.id.toLowerCase().startsWith('0x754704bc059f')).toBe(true) expect(AVAX.id).toBe(EVM_NATIVE_ID) @@ -349,11 +382,25 @@ describe('the chain modules', () => { expect(MONAD_USDC.decimals).toBe(6) }) - it('offers no venue without the secret that protects what would be issued', () => { - expect(buildEvmVenues('avalanche', {})).toEqual([]) - expect(buildEvmVenues('monad', { AUTH_SECRET: 'short' })).toEqual([]) - expect(buildEvmVenues('monad', { AUTH_SECRET: 'x'.repeat(32) }).map((v) => v.id)).toEqual([ + it('keeps 1Click and sends behind the secret, and the onchain venues out from behind it', () => { + const ids = (chain: 'avalanche' | 'monad', env: Record): string[] => + buildEvmVenues(chain, env).map((v) => v.id) + expect(ids('avalanche', {})).not.toContain('1click-avalanche') + expect(ids('avalanche', {})).not.toContain('send') + expect(ids('avalanche', {})).toContain('aave-avalanche') + expect(ids('monad', { AUTH_SECRET: 'short' })).not.toContain('1click-monad') + expect(ids('monad', { AUTH_SECRET: 'short' })).toContain('aave-monad') + expect(ids('monad', { AUTH_SECRET: 'x'.repeat(32) })).toEqual([ '1click-monad', + 'uniswap-monad', + 'aave-monad', + 'neverland', + 'morpho', + 'euler-monad', + 'curvance', + 'beefy-monad', + 'fastlane', + 'sablier-monad', 'send', ]) }) @@ -438,7 +485,9 @@ describe('the race on these chains', () => { }) expect(capabilitySheet('avalanche')).toContain('Agents compete') expect(capabilitySheet('monad')).toContain('Agents compete') - expect(capabilitySheet('monad')).not.toMatch(/Supply:|Stake:|limit order/) + expect(capabilitySheet('monad')).toContain('Supply:') + expect(capabilitySheet('monad')).toContain('Stake:') + expect(capabilitySheet('monad')).not.toMatch(/limit order/) expect(capabilitySheet('arc')).not.toContain('Agents compete') }) @@ -536,7 +585,7 @@ describe('naming the chain you are on', () => { ['monad', 'Monad'], ])('builds on %s when told "on %s"', async (slug, venue) => { const request = swap({ tokenOut: toNative[slug], venue }) - const prepared = await prepareChainAction(moduleFor(slug), request, ACCOUNT, {}) + const prepared = await prepareChainAction(moduleFor(slug), request, ACCOUNT) expect(prepared.venueId).toBe(`1click-${slug}`) }) @@ -545,7 +594,7 @@ describe('naming the chain you are on', () => { async (venue) => { const request = swap({ tokenOut: 'MON', venue }) await expect(quoteChainRoutes(moduleFor('monad'), request, ACCOUNT)).resolves.toEqual([]) - await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT, {})).rejects.toThrow( + await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT)).rejects.toThrow( /can't swap here/ ) expect(sim.quotes.filter((q) => q['dry'] === false)).toEqual([]) @@ -558,7 +607,7 @@ describe('naming the chain you are on', () => { const request = swap({ tokenOut: 'MON', venue }) const routes = await quoteChainRoutes(moduleFor('monad'), request, ACCOUNT) expect(routes.map((r) => r.id)).toEqual(['1click-monad-1']) - const prepared = await prepareChainAction(moduleFor('monad'), request, ACCOUNT, {}) + const prepared = await prepareChainAction(moduleFor('monad'), request, ACCOUNT) expect(prepared.venueId).toBe('1click-monad') } ) @@ -566,7 +615,7 @@ describe('naming the chain you are on', () => { it('refuses a venue that isn’t here', async () => { const request = swap({ tokenOut: 'MON', venue: 'jupiter' }) await expect(quoteChainRoutes(moduleFor('monad'), request, ACCOUNT)).resolves.toEqual([]) - await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT, {})).rejects.toThrow( + await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT)).rejects.toThrow( /"jupiter" can't swap here/ ) }) @@ -579,7 +628,7 @@ describe('naming the chain you are on', () => { const api = twoVenues(chain) const routes = await quoteChainRoutes(api, ab(venue), ACCOUNT) expect(routes.map((r) => r.id)).toEqual(['alpha-1', 'beta-1']) - await expect(prepareChainAction(api, ab(venue), ACCOUNT, {})).resolves.toMatchObject({ + await expect(prepareChainAction(api, ab(venue), ACCOUNT)).resolves.toMatchObject({ venueId: 'beta', }) }) @@ -588,11 +637,11 @@ describe('naming the chain you are on', () => { const api = twoVenues('solana') const routes = await quoteChainRoutes(api, ab('alpha'), ACCOUNT) expect(routes.map((r) => r.id)).toEqual(['alpha-1']) - await expect(prepareChainAction(api, ab('alpha'), ACCOUNT, {})).resolves.toMatchObject({ + await expect(prepareChainAction(api, ab('alpha'), ACCOUNT)).resolves.toMatchObject({ venueId: 'alpha', }) await expect(quoteChainRoutes(api, ab('monad'), ACCOUNT)).resolves.toEqual([]) - await expect(prepareChainAction(api, ab('monad'), ACCOUNT, {})).rejects.toThrow( + await expect(prepareChainAction(api, ab('monad'), ACCOUNT)).rejects.toThrow( /"monad" can't swap here/ ) }) @@ -603,8 +652,26 @@ describe('the Apps page rows', () => { venues.filter((v) => venueOnChain(v, CHAIN_DESCRIPTORS[slug])).map((v) => v.id) it('shows each chain its own row and no venue that does not name it', () => { - expect(on('avalanche')).toEqual(['1click-avalanche']) - expect(on('monad')).toEqual(['1click-monad']) + expect(on('avalanche')).toEqual([ + '1click-avalanche', + 'uniswap-avalanche', + 'pharaoh', + 'aave-avalanche', + 'sablier-avalanche', + 'bulla', + ]) + expect(on('monad')).toEqual([ + '1click-monad', + 'uniswap-monad', + 'aave-monad', + 'neverland', + 'morpho', + 'euler-monad', + 'curvance', + 'beefy-monad', + 'fastlane', + 'sablier-monad', + ]) }) it('changes nothing for another family or for Arc', () => { @@ -815,8 +882,7 @@ describe('prepare', () => { const prepared = await prepareChainAction( monadModule, swap({ tokenIn: 'MON', tokenOut: 'USDT0', amount: '100' }), - ACCOUNT, - {} + ACCOUNT ) expect(prepared.venueId).toBe('1click-monad') expect(prepared.signing).toMatchObject({ diff --git a/apps/dapp/lib/__tests__/fastlane.live.test.ts b/apps/dapp/lib/__tests__/fastlane.live.test.ts new file mode 100644 index 00000000..7bba51ad --- /dev/null +++ b/apps/dapp/lib/__tests__/fastlane.live.test.ts @@ -0,0 +1,222 @@ +import { + encodeFunctionData, + getAddress, + parseAbi, + parseAbiItem, + zeroAddress, + type Address, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import { evmReader } from '../chains/evm/client' +import { createFastlaneVenue, SHMON, shmonPriceUsd } from '../chains/evm/fastlane' + +/** + * Read-only, against Monad mainnet: nothing here signs or sends. These fail when FastLane upgrades + * or moves its keys, which is the cue to review the new code and the card's notes. + * + * The account is whoever recently received shMON and still holds it with some MON, found from the + * chain's logs, since any fixed holder can sell out. It is only ever read and dry-run. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const SLOW_MS = 120_000 +const LOG_WINDOW = BigInt(100) +const WINDOWS_TO_SCAN = 200 +const ENOUGH = BigInt(10) ** BigInt(17) + +const IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' +const ADMIN_SLOT = '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103' + +const IMPLEMENTATION = '0x8Ee4FF5FF50198dcbB7B882A0781F42724Dd095f' +const PROXY_ADMIN = '0x00b16590295092f12F4cec655296B6129C49489C' as Address +const TIMELOCK = '0x4B7f4A4316c44B6928FB449B14Bd21b9718932F3' as Address +const OPERATIONS_SAFE = '0x48a3267BCc4Cf230e9Bc4cae0c11EEbDb9a6A687' as Address + +const ABI = parseAbi([ + 'function symbol() view returns (string)', + 'function decimals() view returns (uint8)', + 'function owner() view returns (address)', + 'function balanceOf(address) view returns (uint256)', + 'function previewDeposit(uint256) view returns (uint256)', + 'function getMinDelay() view returns (uint256)', + 'function getThreshold() view returns (uint256)', + 'function getFeeCurveParams() view returns (uint256, uint256)', + 'function getOwners() view returns (address[])', + 'function deposit(uint256 assets, address receiver) payable returns (uint256)', + 'function redeemWithSlippageProtection(uint256 shares, address receiver, address owner, uint256 minAssetsOut) returns (uint256)', +]) + +const reader = evmReader('monad') +const shmon = SHMON as Address + +function addressIn(word: string | undefined): string { + return getAddress(`0x${(word ?? '').slice(-40)}`) +} + +const TRANSFER = parseAbiItem( + 'event Transfer(address indexed from, address indexed to, uint256 value)' +) + +let holder: Promise
| undefined + +/** A plain account: a contract may refuse MON, which would say nothing about FastLane. */ +async function scanForHolder(): Promise
{ + const latest = await reader.getBlockNumber() + const seen = new Set
() + for (let i = 0; i < WINDOWS_TO_SCAN; i++) { + const toBlock = latest - BigInt(i) * LOG_WINDOW + const logs = await reader.getLogs({ + address: shmon, + event: TRANSFER, + fromBlock: toBlock - LOG_WINDOW + BigInt(1), + toBlock, + }) + for (const { args } of logs) { + const to = args.to + if (to === undefined || to === zeroAddress || seen.has(to)) continue + seen.add(to) + const [code, shares, coin] = await Promise.all([ + reader.getCode({ address: to }), + reader.readContract({ address: shmon, abi: ABI, functionName: 'balanceOf', args: [to] }), + reader.getBalance({ address: to }), + ]) + if ((code === undefined || code === '0x') && shares >= ENOUGH && coin >= ENOUGH) return to + } + } + throw new Error('No plain account holding shMON and MON turned up in the logs scanned.') +} + +const findHolder = (): Promise
=> (holder ??= scanForHolder()) + +describe.skipIf(SKIP)('FastLane on Monad', () => { + it( + 'is still the token we pinned', + async () => { + const [symbol, decimals] = await Promise.all([ + reader.readContract({ address: shmon, abi: ABI, functionName: 'symbol' }), + reader.readContract({ address: shmon, abi: ABI, functionName: 'decimals' }), + ]) + expect(symbol).toBe('shMON') + expect(decimals).toBe(18) + }, + SLOW_MS + ) + + it( + 'still runs the implementation we read', + async () => { + const word = await reader.getStorageAt({ address: shmon, slot: IMPLEMENTATION_SLOT }) + expect(addressIn(word)).toBe(IMPLEMENTATION) + }, + SLOW_MS + ) + + it( + 'still has the keys the card describes', + async () => { + const [admin, adminOwner, delay, owner, threshold, owners] = await Promise.all([ + reader.getStorageAt({ address: shmon, slot: ADMIN_SLOT }), + reader.readContract({ address: PROXY_ADMIN, abi: ABI, functionName: 'owner' }), + reader.readContract({ address: TIMELOCK, abi: ABI, functionName: 'getMinDelay' }), + reader.readContract({ address: shmon, abi: ABI, functionName: 'owner' }), + reader.readContract({ address: OPERATIONS_SAFE, abi: ABI, functionName: 'getThreshold' }), + reader.readContract({ address: OPERATIONS_SAFE, abi: ABI, functionName: 'getOwners' }), + ]) + expect(addressIn(admin)).toBe(PROXY_ADMIN) + expect(adminOwner).toBe(TIMELOCK) + expect(delay).toBe(BigInt(3 * 24 * 60 * 60)) + expect(owner).toBe(OPERATIONS_SAFE) + expect(threshold).toBe(BigInt(5)) + expect(owners).toHaveLength(7) + }, + SLOW_MS + ) + + it( + 'still charges the instant exit the fee range the card states', + async () => { + const [slope, base] = await reader.readContract({ + address: shmon, + abi: ABI, + functionName: 'getFeeCurveParams', + }) + const ray = BigInt(10) ** BigInt(27) + expect(slope).toBe(ray / BigInt(100)) + expect(base).toBe((ray * BigInt(5)) / BigInt(100_000)) + }, + SLOW_MS + ) + + it( + 'mints in a dry run what the preview says, and refuses value that does not match', + async () => { + const account = await findHolder() + const amount = BigInt(10) ** BigInt(15) + const data = encodeFunctionData({ + abi: ABI, + functionName: 'deposit', + args: [amount, account], + }) + const [preview, dry] = await Promise.all([ + reader.readContract({ + address: shmon, + abi: ABI, + functionName: 'previewDeposit', + args: [amount], + }), + reader.call({ account, to: shmon, data, value: amount }), + ]) + expect(BigInt(dry.data ?? '0x0')).toBe(preview) + await expect( + reader.call({ account, to: shmon, data, value: amount - BigInt(1) }) + ).rejects.toThrow() + }, + SLOW_MS + ) + + it( + 'builds a stake, and passes its own check', + async () => { + const account = await findHolder() + const venue = createFastlaneVenue() + const prepared = await venue.prepare( + { kind: 'stake', amount: '0.001', tokenIn: 'MON' }, + account + ) + expect(() => venue.assertSafe(prepared.signing, account)).not.toThrow() + expect(Number(prepared.receive?.amount)).toBeGreaterThan(0) + }, + SLOW_MS + ) + + it( + 'builds an unstake that dry-runs, and the chain refuses a minimum above what it pays', + async () => { + const account = await findHolder() + const venue = createFastlaneVenue() + const prepared = await venue.prepare( + { kind: 'unstake', amount: '0.01', tokenIn: 'shMON' }, + account + ) + expect(() => venue.assertSafe(prepared.signing, account)).not.toThrow() + expect(Number(prepared.receive?.amount)).toBeGreaterThan(0) + const tooMuch = encodeFunctionData({ + abi: ABI, + functionName: 'redeemWithSlippageProtection', + args: [BigInt(10) ** BigInt(16), account, account, BigInt(10) ** BigInt(18)], + }) + await expect(reader.call({ account, to: shmon, data: tooMuch })).rejects.toThrow() + }, + SLOW_MS + ) + + it( + 'values a shMON above a MON', + async () => { + const price = await shmonPriceUsd(1) + expect(price).toBeGreaterThan(1) + }, + SLOW_MS + ) +}) diff --git a/apps/dapp/lib/__tests__/fastlane.test.ts b/apps/dapp/lib/__tests__/fastlane.test.ts new file mode 100644 index 00000000..a1919aac --- /dev/null +++ b/apps/dapp/lib/__tests__/fastlane.test.ts @@ -0,0 +1,452 @@ +import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, type Address } from 'viem' +import { describe, expect, it, vi } from 'vitest' + +import type { EvmReaderClient } from '../chains/evm/call' +import type { EvmClient, EvmReceipt } from '../chains/evm/client' +import { createFastlaneVenue, FASTLANE_TOKENS, SHMON, shmonPriceUsd } from '../chains/evm/fastlane' +import { EVM_CHAINS } from '../chains/evm/tokens' +import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types' + +const ACCOUNT = '0x1111111111111111111111111111111111111111' as Address +const STRANGER = '0x2222222222222222222222222222222222222222' as Address +const OTHER_CONTRACT = '0x4444444444444444444444444444444444444444' +const USDC = '0x754704Bc059F8C67012fEd69BC8A327a5aafb603' +const HASH = `0x${'a'.repeat(64)}` +const MONAD_ID = EVM_CHAINS.monad.chain.id + +const E18 = BigInt(10) ** BigInt(18) +const ONE = E18 +const TWO = BigInt(2) * E18 + +const ABI = parseAbi([ + 'function deposit(uint256 assets, address receiver) payable returns (uint256)', + 'function redeemWithSlippageProtection(uint256 shares, address receiver, address owner, uint256 minAssetsOut) returns (uint256)', + 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)', + 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256)', + 'function mint(uint256 shares, address receiver) payable returns (uint256)', + 'function requestUnstake(uint256 shares) returns (uint64)', + 'function completeUnstake()', + 'function boostYield(uint256 shares, address from, address yieldOriginator)', +]) + +function deposit(receiver: Address = ACCOUNT, assets = ONE): string { + return encodeFunctionData({ abi: ABI, functionName: 'deposit', args: [assets, receiver] }) +} + +function redeem( + receiver: Address = ACCOUNT, + owner: Address = ACCOUNT, + minOut = ONE, + shares = ONE +): string { + return encodeFunctionData({ + abi: ABI, + functionName: 'redeemWithSlippageProtection', + args: [shares, receiver, owner, minOut], + }) +} + +function stakePayload(overrides: Partial> = {}) { + return { + family: 'evm' as const, + chainId: MONAD_ID, + to: SHMON, + data: deposit(), + value: ONE.toString(), + ...overrides, + } +} + +function unstakePayload(overrides: Partial> = {}) { + return stakePayload({ data: redeem(), value: '0', ...overrides }) +} + +interface Chain { + preview?: bigint | Error + shares?: bigint + redeemable?: bigint + fee?: bigint + net?: bigint + coin?: bigint | Error + rate?: bigint | Error + callFails?: boolean +} + +function fakeReader(chain: Chain = {}) { + const call = vi.fn(async () => { + if (chain.callFails === true) throw new Error('reverted') + return { data: '0x' } + }) + const reader = { + call, + getBalance: async () => { + const coin = chain.coin ?? BigInt(100) * E18 + if (coin instanceof Error) throw coin + return coin + }, + readContract: async ({ functionName }: { functionName: string }) => { + switch (functionName) { + case 'previewDeposit': { + const preview = chain.preview ?? (BigInt(6) * E18) / BigInt(10) + if (preview instanceof Error) throw preview + return preview + } + case 'balanceOf': + return chain.shares ?? TWO + case 'maxRedeem': + return chain.redeemable ?? TWO + case 'previewRedeemDetailed': + return [ + BigInt(0), + chain.fee ?? BigInt(5) * BigInt(10) ** BigInt(15), + chain.net ?? (BigInt(16) * E18) / BigInt(10), + ] + case 'previewUnstake': { + const rate = chain.rate ?? (BigInt(165) * E18) / BigInt(100) + if (rate instanceof Error) throw rate + return rate + } + default: + throw new Error(`unexpected read of ${functionName}`) + } + }, + } as unknown as EvmReaderClient + return { reader, call } +} + +function fakeClient( + status: EvmReceipt['status'], + sent: { input: string; value: bigint } +): EvmClient { + return { + tokenBalance: async () => BigInt(0), + nativeBalance: async () => BigInt(0), + waitForReceipt: async () => + ({ status, from: ACCOUNT, to: SHMON, logs: [] }) as unknown as EvmReceipt, + transaction: async () => sent, + } +} + +const venueWith = (chain: Chain = {}, client?: EvmClient) => + createFastlaneVenue( + fakeReader(chain).reader, + client ?? fakeClient('success', { input: deposit(), value: ONE }) + ) + +const STAKE: ChainActionRequest = { kind: 'stake', amount: '1', tokenIn: 'MON' } +const UNSTAKE: ChainActionRequest = { kind: 'unstake', amount: '1', tokenIn: 'shMON' } + +const evm = (payload: SigningPayload) => { + if (payload.family !== 'evm') throw new Error('Expected an EVM payload.') + return payload +} + +describe('the FastLane policy', () => { + const venue = venueWith() + + it('passes a stake and an unstake for this person', () => { + expect(() => venue.assertSafe(stakePayload(), ACCOUNT)).not.toThrow() + expect(() => venue.assertSafe(unstakePayload(), ACCOUNT)).not.toThrow() + }) + + const call = (functionName: string, args: readonly unknown[]): string => + encodeFunctionData({ abi: ABI, functionName, args } as Parameters[0]) + + it.each<[string, SigningPayload]>([ + ['another family', { family: 'sui', transaction: 'AA==' }], + ['another chain', stakePayload({ chainId: 43114 })], + ['an unknown contract', stakePayload({ to: OTHER_CONTRACT })], + [ + 'the shMON address on another function: redeem', + unstakePayload({ data: call('redeem', [ONE, ACCOUNT, ACCOUNT]) }), + ], + ['withdraw', unstakePayload({ data: call('withdraw', [ONE, ACCOUNT, ACCOUNT]) })], + ['mint', stakePayload({ data: call('mint', [ONE, ACCOUNT]) })], + ['the queue', unstakePayload({ data: call('requestUnstake', [ONE]) })], + ['a claim', unstakePayload({ data: call('completeUnstake', []) })], + ['a burn as a donation', unstakePayload({ data: call('boostYield', [ONE, ACCOUNT, ACCOUNT]) })], + [ + 'a shMON transfer', + unstakePayload({ + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [STRANGER, ONE], + }), + }), + ], + ['calldata that decodes as nothing', stakePayload({ data: '0xdeadbeef' })], + ['extra calldata on a stake', stakePayload({ data: `${deposit()}00` })], + ['extra calldata on an unstake', unstakePayload({ data: `${redeem()}00` })], + ['shMON credited to someone else', stakePayload({ data: deposit(STRANGER) })], + ['MON paid to someone else', unstakePayload({ data: redeem(STRANGER) })], + ['shMON burned from someone else', unstakePayload({ data: redeem(ACCOUNT, STRANGER) })], + ['value below the stake', stakePayload({ value: (ONE - BigInt(1)).toString() })], + ['value above the stake', stakePayload({ value: (ONE + BigInt(1)).toString() })], + ['a stake of nothing', stakePayload({ data: deposit(ACCOUNT, BigInt(0)), value: '0' })], + ['value on an unstake', unstakePayload({ value: '1' })], + ['an unstake with no minimum', unstakePayload({ data: redeem(ACCOUNT, ACCOUNT, BigInt(0)) })], + ['a value that is not digits', stakePayload({ value: '0x1' })], + [ + 'an unlimited approval', + unstakePayload({ + approval: { + to: USDC, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [SHMON as Address, maxUint256], + }), + }, + }), + ], + [ + 'an approval of the shMON itself', + unstakePayload({ + approval: { + to: SHMON, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [SHMON as Address, ONE], + }), + }, + }), + ], + ])('refuses %s', (_label, payload) => { + expect(() => venue.assertSafe(payload, ACCOUNT)).toThrow(ChainActionRefused) + }) + + it.each<[string, SigningPayload, RegExp]>([ + ['the shMON', stakePayload({ data: deposit(STRANGER) }), /credits the shMON to someone else/], + ['the MON', unstakePayload({ data: redeem(STRANGER) }), /pays the MON to someone else/], + ['the burn', unstakePayload({ data: redeem(ACCOUNT, STRANGER) }), /belongs to someone else/], + ['the value', stakePayload({ value: '1' }), /different amount of MON/], + ['the unstake value', unstakePayload({ value: '1' }), /does not spend MON/], + ['the minimum', unstakePayload({ data: redeem(ACCOUNT, ACCOUNT, BigInt(0)) }), /no minimum/], + [ + 'the function', + unstakePayload({ data: call('redeem', [ONE, ACCOUNT, ACCOUNT]) }), + /does something I haven't confirmed/, + ], + ['the contract', stakePayload({ to: OTHER_CONTRACT }), /contract I haven't confirmed/], + ['the extra data', stakePayload({ data: `${deposit()}00` }), /extra data/], + [ + 'the approval', + unstakePayload({ + approval: { + to: SHMON, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [SHMON as Address, ONE], + }), + }, + }), + /approve a token I haven't confirmed/, + ], + ])('refuses %s for the reason it should', (_label, payload, reason) => { + expect(() => venue.assertSafe(payload, ACCOUNT)).toThrow(reason) + }) + + it('refuses the wrong account', () => { + expect(() => venue.assertSafe(stakePayload(), STRANGER)).toThrow(ChainActionRefused) + expect(() => venue.assertSafe(unstakePayload(), STRANGER)).toThrow(ChainActionRefused) + }) +}) + +describe('staking', () => { + it('builds a deposit of exactly the amount, for this person, with no approval', async () => { + const { reader, call } = fakeReader() + const venue = createFastlaneVenue( + reader, + fakeClient('success', { input: deposit(), value: ONE }) + ) + const prepared = await venue.prepare(STAKE, ACCOUNT) + const signing = evm(prepared.signing) + expect(signing.to).toBe(SHMON) + expect(signing.value).toBe(ONE.toString()) + expect(signing.data).toBe(deposit()) + expect(signing).not.toHaveProperty('approval') + expect(call).toHaveBeenCalledOnce() + expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + }) + + it('shows what the preview read, and no rate', async () => { + const prepared = await venueWith().prepare(STAKE, ACCOUNT) + expect(prepared.receive).toEqual({ amount: '0.6', symbol: 'shMON', approximate: true }) + expect(prepared.apy).toBeUndefined() + }) + + it('says how the person gets out, and who can change the contract', async () => { + const { notes } = await venueWith().prepare(STAKE, ACCOUNT) + const text = notes.join(' ') + expect(text).toContain('0.005% to 1.005%') + expect(text).toContain('4 to 5 epochs') + expect(text).toContain('3-day wait') + expect(text).toContain('5 of 7') + }) + + it('warns only when the stake would leave under the 10 MON reserve', async () => { + const reserve = /10 MON reserve/ + const under = await venueWith({ coin: BigInt(10) * E18 }).prepare(STAKE, ACCOUNT) + expect(under.notes.join(' ')).toMatch(reserve) + expect(under.notes.join(' ')).toContain('this stake may fail') + expect(under.notes.join(' ')).not.toContain('this swap') + const over = await venueWith({ coin: BigInt(11) * E18 }).prepare(STAKE, ACCOUNT) + expect(over.notes.join(' ')).not.toMatch(reserve) + }) + + it.each<[string, ChainActionRequest, Chain]>([ + ['another token', { ...STAKE, tokenIn: 'USDC' }, {}], + ['shMON', { ...STAKE, tokenIn: 'shMON' }, {}], + ['a zero amount', { ...STAKE, amount: '0' }, {}], + ['too many decimals', { ...STAKE, amount: '0.0000000000000000001' }, {}], + ['a preview that can not be read', STAKE, { preview: new Error('rpc') }], + ['a preview of nothing', STAKE, { preview: BigInt(0) }], + ['a balance that can not be read', STAKE, { coin: new Error('rpc') }], + ['a balance with no gas left over', STAKE, { coin: ONE }], + ['a call that would fail', STAKE, { callFails: true }], + ])('refuses %s', async (_label, request, chain) => { + await expect(venueWith(chain).prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) + + it('refuses a malformed account', async () => { + await expect(venueWith().prepare(STAKE, 'nope')).rejects.toThrow(ChainActionRefused) + }) +}) + +describe('the race quote', () => { + it('reads the preview and tries nothing', async () => { + const { reader, call } = fakeReader() + const venue = createFastlaneVenue( + reader, + fakeClient('success', { input: deposit(), value: ONE }) + ) + await expect(venue.quote?.(STAKE, ACCOUNT)).resolves.toEqual({ + receive: { amount: '0.6', symbol: 'shMON' }, + }) + expect(call).not.toHaveBeenCalled() + }) + + it('refuses when no preview can be read, rather than guessing', async () => { + await expect(venueWith({ preview: new Error('rpc') }).quote?.(STAKE, ACCOUNT)).rejects.toThrow( + ChainActionRefused + ) + }) +}) + +describe('unstaking', () => { + it('builds the atomic exit with a minimum 1% under the preview, paying this person', async () => { + const venue = venueWith() + const prepared = await venue.prepare(UNSTAKE, ACCOUNT) + const signing = evm(prepared.signing) + const minOut = (BigInt(16) * E18 * BigInt(99)) / BigInt(1000) + expect(signing.data).toBe(redeem(ACCOUNT, ACCOUNT, minOut)) + expect(signing.value).toBe('0') + expect(signing).not.toHaveProperty('approval') + expect(prepared.pay).toEqual({ amount: '1', symbol: 'shMON' }) + expect(prepared.receive).toEqual({ amount: '1.6', symbol: 'MON', approximate: true }) + expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + }) + + it('states the fee it read, the range, and the queue it does not offer', async () => { + const { notes } = await venueWith().prepare(UNSTAKE, ACCOUNT) + const text = notes.join(' ') + expect(text).toContain('about 0.005 MON') + expect(text).toContain('0.005% to 1.005%') + expect(text).toContain('requestUnstake') + expect(text).toContain('does not offer') + }) + + it.each<[string, ChainActionRequest, Chain]>([ + ['MON, which is what comes out', { ...UNSTAKE, tokenIn: 'MON' }, {}], + ['another token', { ...UNSTAKE, tokenIn: 'USDC' }, {}], + ['more than the person holds', UNSTAKE, { shares: ONE - BigInt(1) }], + ['more than the pool can cover', UNSTAKE, { redeemable: ONE - BigInt(1) }], + ['a preview of nothing', UNSTAKE, { net: BigInt(0) }], + ['a minimum of nothing', UNSTAKE, { net: BigInt(1) }], + ['no gas', UNSTAKE, { coin: BigInt(1) }], + ['a balance that can not be read', UNSTAKE, { coin: new Error('rpc') }], + ['a call that would fail', UNSTAKE, { callFails: true }], + ])('refuses %s', async (_label, request, chain) => { + await expect(venueWith(chain).prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) + + it('names the free queue when the pool falls short', async () => { + await expect(venueWith({ redeemable: BigInt(0) }).prepare(UNSTAKE, ACCOUNT)).rejects.toThrow( + /queue with no fee/ + ) + }) + + it('has nothing to quote, since an unstake does not race', async () => { + await expect(venueWith().quote?.(UNSTAKE, ACCOUNT)).rejects.toThrow(ChainActionRefused) + }) +}) + +describe('after the wallet sends', () => { + const signed = { family: 'evm' as const, hash: HASH } + const input = { account: ACCOUNT, venueId: 'fastlane', signed } + + it('settles a landed stake', async () => { + const venue = venueWith({}, fakeClient('success', { input: deposit(), value: ONE })) + await expect(venue.submit?.(input)).resolves.toMatchObject({ ok: true, hash: HASH }) + }) + + it('names the reserve when a stake reverts', async () => { + const venue = venueWith({}, fakeClient('reverted', { input: deposit(), value: ONE })) + const result = await venue.submit?.(input) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(JSON.stringify(result)).toContain('10 MON reserve') + }) + + it('does not blame the reserve when an unstake reverts', async () => { + const venue = venueWith({}, fakeClient('reverted', { input: redeem(), value: BigInt(0) })) + const result = await venue.submit?.(input) + expect(result).toMatchObject({ ok: false }) + expect(JSON.stringify(result)).not.toContain('reserve') + }) + + it('stays pending for a transaction that pays someone else', async () => { + const venue = venueWith( + {}, + fakeClient('success', { input: redeem(STRANGER), value: BigInt(0) }) + ) + await expect(venue.submit?.(input)).resolves.toMatchObject({ ok: false, pending: true }) + }) +}) + +describe('following a swap', () => { + it('can stake MON and nothing else', async () => { + const venue = venueWith() + await expect( + venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'MON' }, ACCOUNT) + ).resolves.toBeUndefined() + await expect( + venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'USDC' }, ACCOUNT) + ).rejects.toThrow(ChainActionRefused) + await expect( + venue.assertCanFollow?.({ kind: 'unstake', tokenIn: 'MON' }, ACCOUNT) + ).rejects.toThrow(ChainActionRefused) + }) +}) + +describe('shmonPriceUsd', () => { + it('values a shMON at what the contract says it is worth in MON', async () => { + await expect(shmonPriceUsd(2, fakeReader().reader)).resolves.toBeCloseTo(3.3) + }) + + it('gives no price when either side can not be read', async () => { + await expect(shmonPriceUsd(undefined, fakeReader().reader)).resolves.toBeUndefined() + await expect( + shmonPriceUsd(2, fakeReader({ rate: new Error('rpc') }).reader) + ).resolves.toBeUndefined() + }) +}) + +describe('the shMON token', () => { + it('is the pinned contract', () => { + expect(FASTLANE_TOKENS).toEqual([{ symbol: 'shMON', id: SHMON, decimals: 18, name: 'ShMonad' }]) + }) +}) diff --git a/apps/dapp/lib/__tests__/haedal.live.test.ts b/apps/dapp/lib/__tests__/haedal.live.test.ts new file mode 100644 index 00000000..ae76899d --- /dev/null +++ b/apps/dapp/lib/__tests__/haedal.live.test.ts @@ -0,0 +1,77 @@ +import { fromBase64 } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { suiClient } from '../chains/sui/client' +import { + HAEDAL_PACKAGE, + HAEDAL_STAKING, + haedalVenue, + HASUI_TYPE, + readObjectJson, + readU64, +} from '../chains/sui/haedal' + +/** + * Pinned ids, and real unsigned transactions against mainnet, read-only. Nothing here signs or + * sends: each transaction is only built and dry-run, for public addresses whose keys nobody here + * has. When an address empties these fail on balance, so swap it for another funded one. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const SUI_HOLDER = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4' +const HASUI_HOLDER = '0x4d91d2c4fd130a651e42a2c3347217653eb84e87c4123ac4952a65292afca1fb' +/** Haedal's UpgradeCap. Its `package` field is the latest package. */ +const UPGRADE_CAP = '0xc1c6690f21937f90e6b42f7c916d8e8c942926e3fb9e5c3c5584ae1e91502f25' + +async function simulateOk(transaction: string): Promise { + const result = await suiClient().core.simulateTransaction({ + transaction: fromBase64(transaction), + include: { effects: true }, + }) + return result.Transaction?.effects?.status.success === true +} + +describe.skipIf(SKIP)('Haedal against mainnet', () => { + it('builds a stake that passes its check and dry-runs clean', async () => { + const prepared = await haedalVenue.prepare( + { kind: 'stake', amount: '1', tokenIn: 'SUI' }, + SUI_HOLDER + ) + expect(prepared.receive?.symbol).toBe('haSUI') + expect(Number(prepared.receive?.amount)).toBeGreaterThan(0.5) + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + expect(() => haedalVenue.assertSafe(prepared.signing, SUI_HOLDER)).not.toThrow() + expect(await simulateOk(prepared.signing.transaction)).toBe(true) + }, 60_000) + + it('builds an instant unstake that pays the value it read, less only the fee it read', async () => { + const prepared = await haedalVenue.prepare( + { kind: 'unstake', amount: '1.5', tokenIn: 'haSUI' }, + HASUI_HOLDER + ) + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + expect(() => haedalVenue.assertSafe(prepared.signing, HASUI_HOLDER)).not.toThrow() + expect(await simulateOk(prepared.signing.transaction)).toBe(true) + + const value = await readU64( + HASUI_HOLDER, + `${HAEDAL_PACKAGE}::staking::get_sui_by_stsui`, + (tx) => [tx.object(HAEDAL_STAKING), tx.pure.u64(BigInt(1_500_000_000))] + ) + const staking = await readObjectJson(HAEDAL_STAKING) + const config = staking?.['config'] as { service_fee: string } + const expected = (Number(value) * (1 - Number(config.service_fee) / 10_000_000)) / 1e9 + expect(prepared.receive?.symbol).toBe('SUI') + expect(Math.abs(Number(prepared.receive?.amount) - expected)).toBeLessThan(0.00001) + }, 60_000) + + it("still matches Haedal's upgrade cap and objects, or the ids need a fresh look", async () => { + const cap = await readObjectJson(UPGRADE_CAP) + expect(cap?.['package']).toBe(HAEDAL_PACKAGE) + const staking = await readObjectJson(HAEDAL_STAKING) + expect(staking?.['id']).toBe(HAEDAL_STAKING) + const { coinMetadata } = await suiClient().core.getCoinMetadata({ coinType: HASUI_TYPE }) + expect(coinMetadata?.symbol).toBe('haSUI') + expect(coinMetadata?.decimals).toBe(9) + }, 30_000) +}) diff --git a/apps/dapp/lib/__tests__/haedal.test.ts b/apps/dapp/lib/__tests__/haedal.test.ts new file mode 100644 index 00000000..3f8692a3 --- /dev/null +++ b/apps/dapp/lib/__tests__/haedal.test.ts @@ -0,0 +1,403 @@ +import { bcs } from '@mysten/sui/bcs' +import { Inputs, Transaction } from '@mysten/sui/transactions' +import { toBase64 } from '@mysten/sui/utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + assertPinnedObjects, + creditedAmount, + HAEDAL_PACKAGE, + HAEDAL_STAKING, + HAEDAL_TOKENS, + haedalVenue, + HASUI_TYPE, + percentText, + returnedCoinAmount, +} from '../chains/sui/haedal' +import { ChainActionRefused } from '../chains/types' + +const ME = '0x1111111111111111111111111111111111111111111111111111111111111111' +const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead' +const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01' +const DIGEST = '11111111111111111111111111111111' + +const mocks = vi.hoisted(() => ({ + simulate: vi.fn(), + getObjects: vi.fn(), + built: [] as Transaction[], + bytes: '', +})) + +vi.mock('../chains/sui/client', async (original) => ({ + ...(await original()), + suiClient: () => ({ + core: { simulateTransaction: mocks.simulate, getObjects: mocks.getObjects }, + }), + buildUnsigned: async (tx: Transaction) => { + mocks.built.push(tx) + return mocks.bytes + }, +})) + +function shared(tx: Transaction, objectId: string) { + return tx.object(Inputs.SharedObjectRef({ objectId, initialSharedVersion: 1, mutable: true })) +} + +function ownedCoin(tx: Transaction, objectId = FOREIGN) { + return tx.object(Inputs.ObjectRef({ objectId, version: 1, digest: DIGEST })) +} + +async function built( + edit: (tx: Transaction) => void, + sender: string = ME, + gasOwner: string = sender +): Promise { + const tx = new Transaction() + tx.setSender(sender) + tx.setGasOwner(gasOwner) + tx.setGasPrice(1000) + tx.setGasBudget(10_000_000) + tx.setGasPayment([{ objectId: FOREIGN, version: 1, digest: DIGEST }]) + edit(tx) + return toBase64(await tx.build()) +} + +const STAKE = `${HAEDAL_PACKAGE}::staking::request_stake_coin` +const UNSTAKE = `${HAEDAL_PACKAGE}::staking::request_unstake_instant_coin` + +function goodStake(tx: Transaction, validator = '0x0', staking = HAEDAL_STAKING, to = ME): void { + const [sui] = tx.splitCoins(tx.gas, [BigInt(1_000_000_000)]) + const [minted] = tx.moveCall({ + target: STAKE, + arguments: [shared(tx, '0x5'), shared(tx, staking), sui as never, tx.pure.address(validator)], + }) + tx.transferObjects([minted as never], to) +} + +function goodUnstake(tx: Transaction, to = ME): void { + const [sui] = tx.moveCall({ + target: UNSTAKE, + arguments: [shared(tx, '0x5'), shared(tx, HAEDAL_STAKING), ownedCoin(tx, THIEF)], + }) + tx.transferObjects([sui as never], to) +} + +function check(transaction: string, account: string = ME): void { + haedalVenue.assertSafe({ family: 'sui', transaction }, account) +} + +beforeEach(() => { + mocks.simulate.mockReset() + mocks.getObjects.mockReset() + mocks.built.length = 0 + mocks.getObjects.mockResolvedValue({ + objects: [{ json: { config: { service_fee: '2000', reward_fee: '600000' } } }], + }) +}) + +describe('Haedal safety on what the wallet would sign', () => { + it('passes the stake and the unstake it builds', async () => { + await expect(built(goodStake).then(check)).resolves.toBeUndefined() + await expect(built(goodUnstake).then(check)).resolves.toBeUndefined() + }) + + it('refuses the wrong account', async () => { + const transaction = await built(goodStake) + expect(() => check(transaction, THIEF)).toThrow(/different account/) + }) + + it('refuses gas paid by someone else', async () => { + const transaction = await built(goodStake, ME, THIEF) + expect(() => check(transaction)).toThrow(/network fee/) + }) + + it('refuses an unknown contract', async () => { + const transaction = await built((tx) => { + goodStake(tx) + tx.moveCall({ target: `${FOREIGN}::drain::everything` }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + }) + + it.each([ + 'request_unstake_delay', + 'request_unstake_instant_v2', + 'claim_coin_v2', + 'get_exchange_rate', + ])('refuses %s, which this flow never uses', async (fn) => { + const transaction = await built((tx) => { + tx.moveCall({ target: `${HAEDAL_PACKAGE}::staking::${fn}` }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + }) + + it('refuses anything in the vault and manage modules', async () => { + for (const target of ['vault::deposit', 'manage::collect_service_fee']) { + const transaction = await built((tx) => { + tx.moveCall({ target: `${HAEDAL_PACKAGE}::${target}` }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + } + }) + + it('refuses extra steps', async () => { + const transaction = await built((tx) => { + goodStake(tx) + tx.publish({ modules: ['AA=='], dependencies: ['0x1', '0x2'] }) + }) + expect(() => check(transaction)).toThrow(/Publish/) + }) + + it('refuses the framework transfer, which would hide a payment', async () => { + const transaction = await built((tx) => { + goodStake(tx) + const [coin] = tx.splitCoins(tx.gas, [BigInt(1)]) + tx.moveCall({ + target: '0x2::transfer::public_transfer', + typeArguments: ['0x2::coin::Coin<0x2::sui::SUI>'], + arguments: [coin as never, tx.pure.address(THIEF)], + }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + }) + + it('refuses haSUI paid to someone else', async () => { + const transaction = await built((tx) => goodStake(tx, '0x0', HAEDAL_STAKING, THIEF)) + expect(() => check(transaction)).toThrow(/another address/) + }) + + it('refuses SUI from an unstake paid to someone else', async () => { + const transaction = await built((tx) => goodUnstake(tx, THIEF)) + expect(() => check(transaction)).toThrow(/another address/) + }) + + it('refuses a stake object other than Haedal’s', async () => { + const transaction = await built((tx) => goodStake(tx, '0x0', FOREIGN)) + expect(() => check(transaction)).toThrow(/object/) + }) + + it('refuses a validator it did not choose', async () => { + const transaction = await built((tx) => goodStake(tx, THIEF)) + expect(() => check(transaction)).toThrow(/validator/) + }) + + it('refuses a call that names a token', async () => { + const transaction = await built((tx) => { + tx.moveCall({ + target: UNSTAKE, + typeArguments: ['0x2::sui::SUI'], + arguments: [shared(tx, '0x5'), shared(tx, HAEDAL_STAKING), ownedCoin(tx)], + }) + }) + expect(() => check(transaction)).toThrow(/token/) + }) + + it('refuses a payload that is not a Sui transaction', () => { + expect(() => haedalVenue.assertSafe({ family: 'solana', transaction: 'AA==' }, ME)).toThrow( + ChainActionRefused + ) + expect(() => check('not base64 at all!')).toThrow(ChainActionRefused) + }) + + it('refuses a call with the wrong number of arguments', async () => { + const transaction = await built((tx) => { + tx.moveCall({ target: UNSTAKE, arguments: [shared(tx, '0x5')] }) + }) + expect(() => check(transaction)).toThrow(/in a way/) + }) + + it('leaves an unpinned call alone in assertPinnedObjects', async () => { + const transaction = await built(goodStake) + expect(() => + assertPinnedObjects({ family: 'sui', transaction }, new Map(), 'Haedal') + ).not.toThrow() + }) +}) + +describe('building a Haedal stake', () => { + it('refuses a token that is not SUI', async () => { + await expect( + haedalVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'USDC' }, ME) + ).rejects.toThrow(/stakes SUI/) + expect(mocks.built).toHaveLength(0) + }) + + it('refuses under the contract’s minimum', async () => { + await expect( + haedalVenue.prepare({ kind: 'stake', amount: '0.5', tokenIn: 'SUI' }, ME) + ).rejects.toThrow(/at least 1 SUI/) + }) + + it('refuses a bad account before anything else', async () => { + await expect( + haedalVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, 'nope') + ).rejects.toThrow(/Sui address/) + }) + + it('reads the minted haSUI from the dry run and shows the fee it read', async () => { + mocks.bytes = await built(goodStake) + mocks.simulate.mockResolvedValue({ + Transaction: { + balanceChanges: [ + { coinType: '0x2::sui::SUI', address: ME, amount: '-1000000000' }, + { coinType: HASUI_TYPE, address: ME, amount: '924000000' }, + ], + }, + }) + const prepared = await haedalVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME) + expect(prepared.receive).toEqual({ amount: '0.924', symbol: 'haSUI', approximate: true }) + const notes = prepared.notes.join(' ') + expect(notes).toMatch(/keeps 6%/) + expect(notes).toMatch(/fee of 0.02%/) + expect(notes).toMatch(/\$915K in June 2026/) + expect(notes).toMatch(/could not confirm/) + const data = mocks.built[0]?.getData() + const targets = data?.commands.flatMap((c) => + c.$kind === 'MoveCall' ? [c.MoveCall.function] : [] + ) + expect(targets).toEqual(['request_stake_coin']) + expect(prepared.apy).toBeUndefined() + }) + + it('refuses when the dry run credits no haSUI to this account', async () => { + mocks.bytes = await built(goodStake) + mocks.simulate.mockResolvedValue({ + Transaction: { balanceChanges: [{ coinType: HASUI_TYPE, address: THIEF, amount: '5' }] }, + }) + await expect( + haedalVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME) + ).rejects.toThrow(/couldn't confirm/) + }) + + it('quotes the same reading, with no rate', async () => { + mocks.bytes = await built(goodStake) + mocks.simulate.mockResolvedValue({ + Transaction: { balanceChanges: [{ coinType: HASUI_TYPE, address: ME, amount: '924000000' }] }, + }) + const quote = await haedalVenue.quote?.({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME) + expect(quote).toEqual({ receive: { amount: '0.924', symbol: 'haSUI' } }) + }) +}) + +describe('building a Haedal unstake', () => { + const u64 = (n: string) => bcs.u64().serialize(n).toBytes() + const coin = (n: string) => + bcs + .struct('Coin', { id: bcs.Address, value: bcs.u64() }) + .serialize({ id: FOREIGN, value: n }) + .toBytes() + + function answers(value: string, payout: string | undefined): void { + mocks.simulate.mockImplementation(async ({ transaction }: { transaction: unknown }) => + transaction instanceof Transaction + ? { Transaction: {}, commandResults: [{ returnValues: [{ bcs: u64(value) }] }] } + : { + Transaction: {}, + commandResults: [{ returnValues: payout === undefined ? [] : [{ bcs: coin(payout) }] }], + } + ) + } + + it('refuses a token that is not haSUI', async () => { + await expect( + haedalVenue.prepare({ kind: 'unstake', amount: '1', tokenIn: 'afSUI' }, ME) + ).rejects.toThrow(/unstakes haSUI/) + }) + + it('refuses under 1 SUI of value, as the contract would', async () => { + answers('999999999', '999799999') + await expect( + haedalVenue.prepare({ kind: 'unstake', amount: '0.9', tokenIn: 'haSUI' }, ME) + ).rejects.toThrow(/at least 1 SUI worth/) + expect(mocks.built).toHaveLength(0) + }) + + it('refuses when the value cannot be read', async () => { + mocks.simulate.mockResolvedValue({ Transaction: {}, commandResults: [] }) + await expect( + haedalVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'haSUI' }, ME) + ).rejects.toThrow(/couldn't read/) + }) + + it('reads the SUI from the coin the call returned, not the wallet’s balance change', async () => { + mocks.bytes = await built(goodUnstake) + answers('2162000000', '2161567600') + const prepared = await haedalVenue.prepare( + { kind: 'unstake', amount: '2', tokenIn: 'haSUI' }, + ME + ) + expect(prepared.kind).toBe('unstake') + expect(prepared.pay).toEqual({ amount: '2', symbol: 'haSUI' }) + expect(prepared.receive).toEqual({ amount: '2.161567', symbol: 'SUI', approximate: true }) + expect(prepared.notes.join(' ')).toMatch(/instant unstake/) + expect(prepared.notes.join(' ')).toMatch(/0.02%/) + expect(prepared.notes.join(' ')).toMatch(/separate claim/) + const targets = mocks.built[0] + ?.getData() + .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall.function] : [])) + expect(targets).toEqual(['request_unstake_instant_coin']) + }) + + it('refuses when the dry run shows no payout', async () => { + mocks.bytes = await built(goodUnstake) + answers('2162000000', undefined) + await expect( + haedalVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'haSUI' }, ME) + ).rejects.toThrow(/couldn't confirm/) + }) + + it('refuses when the fee cannot be read, rather than leaving it off the card', async () => { + mocks.bytes = await built(goodUnstake) + answers('2162000000', '2161567600') + mocks.getObjects.mockResolvedValue({ objects: [{ json: { config: {} } }] }) + await expect( + haedalVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'haSUI' }, ME) + ).rejects.toThrow(/fees/) + }) +}) + +describe('after a swap', () => { + it('stakes SUI only, and never unstakes', async () => { + await expect( + haedalVenue.assertCanFollow?.({ kind: 'stake', tokenIn: 'SUI' }, ME) + ).resolves.toBeUndefined() + await expect( + haedalVenue.assertCanFollow?.({ kind: 'stake', tokenIn: 'USDC' }, ME) + ).rejects.toThrow(/takes SUI/) + await expect( + haedalVenue.assertCanFollow?.({ kind: 'unstake', tokenIn: 'SUI' }, ME) + ).rejects.toThrow(/only stake/) + }) +}) + +describe('helpers', () => { + it('finds the coin a call returned by its place in the transaction', async () => { + const transaction = await built(goodUnstake) + const coinBytes = bcs + .struct('Coin', { id: bcs.Address, value: bcs.u64() }) + .serialize({ id: FOREIGN, value: '7' }) + .toBytes() + expect(returnedCoinAmount([{ returnValues: [{ bcs: coinBytes }] }], transaction, UNSTAKE)).toBe( + '7' + ) + expect(returnedCoinAmount([], transaction, UNSTAKE)).toBeUndefined() + expect(returnedCoinAmount(undefined, transaction, STAKE)).toBeUndefined() + }) + + it('never reads a debit as a credit', () => { + expect( + creditedAmount([{ coinType: HASUI_TYPE, address: ME, amount: '-5' }], HASUI_TYPE, ME) + ).toBeUndefined() + }) + + it('writes small rates plainly', () => { + expect(percentText(0.02)).toBe('0.02') + expect(percentText(0.3365)).toBe('0.34') + expect(percentText(6)).toBe('6') + expect(percentText(0.001)).toBe('under 0.01') + }) + + it('lists haSUI with the decimals its metadata gives', () => { + expect(HAEDAL_TOKENS).toEqual([expect.objectContaining({ symbol: 'haSUI', decimals: 9 })]) + }) +}) diff --git a/apps/dapp/lib/__tests__/jito.test.ts b/apps/dapp/lib/__tests__/jito.test.ts new file mode 100644 index 00000000..34dc5152 --- /dev/null +++ b/apps/dapp/lib/__tests__/jito.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from 'vitest' + +import { associatedTokenAccount } from '../chains/solana/assert-safe' +import { createJitoVenue, JITO_STAKE_POOL, JITOSOL_MINT } from '../chains/solana/jito' +import { jitoListing } from '../chains/solana/jito-listing' +import { + computeBudget, + decodeStakePool, + readU64, + STAKE_POOL_PROGRAM, +} from '../chains/solana/stake-pool' +import { decodeSolanaTransaction } from '../chains/solana/assert-safe' +import type { PreparedAction } from '../chains/types' +import { + budget, + encodeStakePool, + fakeRpc, + ME, + payload, + rebuilt, + SOMEONE_ELSE, + transferOf, + UNKNOWN_PROGRAM, + unlimitedApproval, + withAccount, + withExtraData, + withOtherFunction, + type PoolFixture, +} from './solana-stake-fixtures' + +const RESERVE = 'BgKUXdS29YcHCFrPm5M8oLHiTzZaMDjsebggjoaQ6KFL' +const EPOCH = 1077 + +const BASE: PoolFixture = { + poolMint: JITOSOL_MINT, + reserveStake: RESERVE, + managerFeeAccount: '8yoigZfzZ1nNaadumY9uPVD118225UYHTDpmjpr2nrSa', + validatorList: '3R3nGZpQs2aZo5FDQvd2MUQ6R7KhAPainds6uT6uE2mn', + totalLamports: BigInt('10351794116510279'), + poolTokenSupply: BigInt('7903028498840376'), + lastUpdateEpoch: BigInt(EPOCH), + solWithdrawalFee: [BigInt(1), BigInt(1000)], +} + +function venueWith( + overrides: Partial = {}, + reserveLamports = BigInt('40298249972443') +) { + const rpc = fakeRpc({ + epoch: EPOCH, + accounts: { + [JITO_STAKE_POOL]: { + owner: STAKE_POOL_PROGRAM, + lamports: BigInt(1), + data: encodeStakePool({ ...BASE, ...overrides }), + }, + [RESERVE]: { + owner: 'Stake11111111111111111111111111111111111111', + lamports: reserveLamports, + data: new Uint8Array(0), + }, + }, + }) + return { venue: createJitoVenue({ rpc }), rpc } +} + +const stake = { kind: 'stake', amount: '1', tokenIn: 'SOL' } as const +const unstake = { kind: 'unstake', amount: '1', tokenIn: 'JitoSOL' } as const + +async function preparedStake(): Promise { + return venueWith().venue.prepare(stake, ME) +} + +async function preparedUnstake(): Promise { + return venueWith().venue.prepare(unstake, ME) +} + +function refuses(action: () => unknown, pattern: RegExp): void { + expect(action).toThrow(pattern) +} + +describe('jito stake', () => { + it('reads the amount out from the pool, with no rate invented', async () => { + const prepared = await preparedStake() + // 1 SOL * supply / total, at the figures above. + expect(prepared.receive).toEqual({ + amount: '0.763445293', + symbol: 'JitoSOL', + approximate: true, + }) + expect(prepared.apy).toBeUndefined() + expect(prepared.pay).toEqual({ amount: '1', symbol: 'SOL' }) + expect(prepared.notes.join(' ')).toMatch(/not showing a yearly rate/) + }) + + it('binds the deposit to the amount, a floor within 1%, and the person', async () => { + const prepared = await preparedStake() + if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.') + const decoded = decodeSolanaTransaction(prepared.signing.transaction) + expect(decoded.feePayer).toBe(ME) + expect(decoded.signerCount).toBe(1) + expect(decoded.feePayerSignature).toBeNull() + const deposit = decoded.instructions.at(-1)! + expect(decoded.staticAccounts[deposit.programIndex]).toBe(STAKE_POOL_PROGRAM) + expect(deposit.data[0]).toBe(25) + expect(readU64(deposit.data, 1)).toBe(BigInt('1000000000')) + const floor = readU64(deposit.data, 9) + expect(floor).toBeGreaterThanOrEqual((BigInt('763445293') * BigInt(99)) / BigInt(100)) + expect(floor).toBeLessThan(BigInt('763445293')) + const own = associatedTokenAccount(ME, JITOSOL_MINT) + expect(decoded.staticAccounts[deposit.accounts[3]!]).toBe(ME) + expect(decoded.staticAccounts[deposit.accounts[4]!]).toBe(own) + expect(decoded.staticAccounts[deposit.accounts[6]!]).toBe(own) + }) + + it('passes its own check, and a quote builds nothing', async () => { + const { venue, rpc } = venueWith() + const prepared = await venue.prepare(stake, ME) + expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow() + + const quoting = venueWith() + await expect(quoting.venue.quote?.(stake, ME)).resolves.toEqual({ + receive: { amount: '0.763445293', symbol: 'JitoSOL' }, + }) + expect(quoting.rpc.calls).not.toContain('getLatestBlockhash') + expect(rpc.calls).toContain('getLatestBlockhash') + }) + + it('refuses another token, an account that is no address, and a request it does not do', async () => { + const { venue } = venueWith() + await expect(venue.prepare({ ...stake, tokenIn: 'USDC' }, ME)).rejects.toThrow(/takes SOL/) + await expect(venue.prepare(stake, 'not-an-address')).rejects.toThrow(/not a Solana wallet/) + await expect(venue.prepare({ ...stake, kind: 'supply' }, ME)).rejects.toThrow( + /stake and unstake/ + ) + await expect(venue.prepare({ ...stake, amount: '0' }, ME)).rejects.toThrow(/more than zero/) + }) + + it('refuses a pool that wants another signature, is behind the epoch, or has another mint', async () => { + await expect( + venueWith({ solDepositAuthority: SOMEONE_ELSE }).venue.prepare(stake, ME) + ).rejects.toThrow(/another signature/) + await expect( + venueWith({ lastUpdateEpoch: BigInt(EPOCH - 1) }).venue.prepare(stake, ME) + ).rejects.toThrow(/still updating/) + await expect( + venueWith({ poolMint: 'So11111111111111111111111111111111111111112' }).venue.prepare( + stake, + ME + ) + ).rejects.toThrow(/doesn’t look as expected/) + }) + + it('takes only SOL after a swap', async () => { + const { venue } = venueWith() + await expect( + venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'SOL' }, ME) + ).resolves.toBeUndefined() + await expect(venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'USDC' }, ME)).rejects.toThrow() + }) +}) + +describe('jito unstake', () => { + it('pays the person from the reserve, after the fee read on chain', async () => { + const prepared = await preparedUnstake() + // 0.999 JitoSOL burned at total / supply: the fee rounds up, in the pool's favour. + expect(prepared.receive).toEqual({ amount: '1.308541696', symbol: 'SOL', approximate: true }) + expect(prepared.pay).toEqual({ amount: '1', symbol: 'JitoSOL' }) + expect(prepared.notes.join(' ')).toMatch(/0\.1%/) + if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.') + const decoded = decodeSolanaTransaction(prepared.signing.transaction) + const withdraw = decoded.instructions.at(-1)! + expect(withdraw.data[0]).toBe(26) + expect(readU64(withdraw.data, 1)).toBe(BigInt('1000000000')) + expect(decoded.staticAccounts[withdraw.accounts[5]!]).toBe(ME) + }) + + it('refuses when the reserve is too thin, and says what else can be done', async () => { + await expect(venueWith({}, BigInt(1_000_000_000)).venue.prepare(unstake, ME)).rejects.toThrow( + /swap JitoSOL for SOL on Jupiter/ + ) + }) + + it('refuses an unstake named in SOL rather than JitoSOL', async () => { + await expect(venueWith().venue.prepare({ ...unstake, tokenIn: 'SOL' }, ME)).rejects.toThrow( + /takes JitoSOL/ + ) + }) + + it('refuses a pool that wants another signature to withdraw', async () => { + await expect( + venueWith({ solWithdrawAuthority: SOMEONE_ELSE }).venue.prepare(unstake, ME) + ).rejects.toThrow(/another signature/) + }) +}) + +describe('jito assertSafe', () => { + const venue = venueWith().venue + + it('refuses the wrong signer', async () => { + const prepared = await preparedStake() + refuses(() => venue.assertSafe(prepared.signing, SOMEONE_ELSE), /network fee/) + refuses(() => venue.assertSafe({ family: 'sui', transaction: 'AA==' }, ME), /not a Solana/) + }) + + it('refuses a transaction someone else pays for or must also sign', async () => { + const prepared = await preparedStake() + const rest = rebuilt(prepared, ME) + refuses(() => venue.assertSafe(payload(SOMEONE_ELSE, rest), ME), /network fee/) + }) + + it('refuses an unknown program', async () => { + const prepared = await preparedStake() + const rest = rebuilt(prepared, ME) + const last = rest.at(-1)! + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest.slice(0, -1), + { ...last, programAddress: UNKNOWN_PROGRAM as never }, + ]), + ME + ), + /should not need/ + ) + refuses( + () => + venue.assertSafe( + payload(ME, [...rest, { ...last, programAddress: UNKNOWN_PROGRAM as never }]), + ME + ), + /should not need/ + ) + }) + + it('refuses an unknown function of a known program', async () => { + const prepared = await preparedStake() + const rest = rebuilt(prepared, ME) + refuses( + () => + venue.assertSafe(payload(ME, [...rest.slice(0, -1), withOtherFunction(rest.at(-1)!)]), ME), + /didn’t expect/ + ) + }) + + it('refuses extra calldata', async () => { + const prepared = await preparedStake() + const rest = rebuilt(prepared, ME) + refuses( + () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), withExtraData(rest.at(-1)!)]), ME), + /didn’t expect/ + ) + }) + + it('refuses minted tokens going to someone else, on either token account', async () => { + const prepared = await preparedStake() + const rest = rebuilt(prepared, ME) + const theirs = associatedTokenAccount(SOMEONE_ELSE, JITOSOL_MINT) + for (const position of [4, 6]) { + refuses( + () => + venue.assertSafe( + payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, position, theirs)]), + ME + ), + /isn’t what Jito should get/ + ) + } + }) + + it('refuses unstaked SOL going to someone else', async () => { + const prepared = await preparedUnstake() + const rest = rebuilt(prepared, ME) + refuses( + () => + venue.assertSafe( + payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 5, SOMEONE_ELSE)]), + ME + ), + /isn’t what Jito should get/ + ) + }) + + it('refuses a SOL transfer added to the transaction', async () => { + const prepared = await preparedStake() + refuses( + () => + venue.assertSafe( + payload(ME, [...rebuilt(prepared, ME), transferOf(ME, SOMEONE_ELSE, BigInt(1))]), + ME + ), + /SOL transfer/ + ) + }) + + it('refuses an approval, even of the person’s own token', async () => { + const prepared = await preparedUnstake() + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rebuilt(prepared, ME), + unlimitedApproval(associatedTokenAccount(ME, JITOSOL_MINT), SOMEONE_ELSE, ME), + ]), + ME + ), + /moves or approves tokens/ + ) + }) + + it('refuses the wrong token and the wrong pool', async () => { + const prepared = await preparedStake() + const rest = rebuilt(prepared, ME) + const usdc = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + refuses( + () => + venue.assertSafe( + payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 7, usdc)]), + ME + ), + /isn’t what Jito should get/ + ) + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest.slice(0, -1), + withAccount(rest.at(-1)!, 0, 'po1osKDWYF9oiVEGmzKA4eTs8eMveFRMox3bUKazGN2'), + ]), + ME + ), + /isn’t what Jito should get/ + ) + }) + + it('refuses a floor of zero, which would leave no price protection', async () => { + const prepared = await preparedStake() + const rest = rebuilt(prepared, ME) + const last = rest.at(-1)! + const data = Uint8Array.from(last.data ?? []) + data.fill(0, 9) + refuses( + () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), { ...last, data }]), ME), + /isn’t what Jito should get/ + ) + }) + + it('refuses a huge priority fee and a missing budget', async () => { + const prepared = await preparedStake() + const rest = rebuilt(prepared, ME).slice(2) + refuses(() => venue.assertSafe(payload(ME, rest), ME), /compute budget/) + const price = new Uint8Array(9) + price[0] = 3 + price.fill(255, 1) + refuses( + () => + venue.assertSafe( + payload(ME, [ + computeBudget(200_000)[0]!, + { programAddress: budget[1]!.programAddress, data: price }, + ...rest, + ]), + ME + ), + /priority fee/ + ) + }) + + it('refuses unreadable bytes', () => { + refuses( + () => venue.assertSafe({ family: 'solana', transaction: '%%%' }, ME), + /could not be read/ + ) + }) +}) + +describe('jito pins and listing', () => { + it('decodes the pool layout it encodes, reading fees apart', () => { + const state = decodeStakePool(encodeStakePool(BASE)) + expect(state?.poolMint).toBe(JITOSOL_MINT) + expect(state?.solWithdrawalFee).toEqual({ numerator: BigInt(1), denominator: BigInt(1000) }) + expect(state?.epochFee).toEqual({ numerator: BigInt(4), denominator: BigInt(100) }) + expect(decodeStakePool(Uint8Array.of(2))).toBeUndefined() + }) + + it('lists as executing on Solana', () => { + expect(jitoListing).toMatchObject({ id: 'jito', family: 'solana', integration: 'executes' }) + expect(jitoListing.capability).toMatch(/No yearly rate/) + }) +}) diff --git a/apps/dapp/lib/__tests__/lend-cap.test.ts b/apps/dapp/lib/__tests__/lend-cap.test.ts deleted file mode 100644 index 997ce161..00000000 --- a/apps/dapp/lib/__tests__/lend-cap.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { assertReserveWithinCap } from '../lend/cap' -import { blendXlm } from '../lend/reserves' -import { TradeCapExceeded } from '../server/trade-cap' -import type { MarketPrice } from '../swap/price-types' - -/** - * Blend names a reserve by contract id; the cap prices by symbol. XLM is the - * one reserve this app can price, so it is the one that can be capped, and a - * reserve with no price is refused on mainnet rather than estimated. - */ - -const asOf = '2026-10-01T00:00:00.000Z' -const table: Record = { - XLM: { symbol: 'XLM', usd: 0.2, source: 'reflector', asOf }, -} -const prices = () => Promise.resolve(table) - -describe('capping a Blend amount', () => { - it('passes an XLM amount under the cap and refuses one over it', async () => { - // 200 XLM = $40; 300 XLM = $60 against a $50 cap. Base units: 7 decimals. - await expect( - assertReserveWithinCap(blendXlm(), '2000000000', { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - }) - ).resolves.toBeUndefined() - await expect( - assertReserveWithinCap(blendXlm(), '3000000000', { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - }) - ).rejects.toThrow(TradeCapExceeded) - }) - - it('refuses a reserve it cannot price', async () => { - await expect( - assertReserveWithinCap('CNOTAPRICEDRESERVE', '10000000', { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - }) - ).rejects.toThrow(/could not be valued/) - }) - - it('reads no prices on testnet', async () => { - await expect( - assertReserveWithinCap(blendXlm(), '99999999999999', { - network: 'testnet', - prices: () => Promise.reject(new Error('must not be asked')), - }) - ).resolves.toBeUndefined() - }) -}) diff --git a/apps/dapp/lib/__tests__/marinade.test.ts b/apps/dapp/lib/__tests__/marinade.test.ts new file mode 100644 index 00000000..2e8f9032 --- /dev/null +++ b/apps/dapp/lib/__tests__/marinade.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from 'vitest' + +import { associatedTokenAccount, decodeSolanaTransaction } from '../chains/solana/assert-safe' +import { + createMarinadeVenue, + decodeMarinadeState, + liquidUnstake, + MARINADE_PROGRAM, + MARINADE_STATE, + MARINADE_TOKENS, + MSOL_MINT, +} from '../chains/solana/marinade' +import { marinadeListing } from '../chains/solana/marinade-listing' +import { readU64 } from '../chains/solana/stake-pool' +import type { PreparedAction } from '../chains/types' +import { + addressBytes, + fakeRpc, + ME, + payload, + rebuilt, + SOMEONE_ELSE, + transferOf, + u32Bytes, + u64Bytes, + UNKNOWN_PROGRAM, + unlimitedApproval, + withAccount, + withExtraData, + withOtherFunction, +} from './solana-stake-fixtures' + +const SOL_LEG = 'UefNb6z6yvArqe4cJHTXCqStRsKmWhGxnZzuHbikP5Q' +const MSOL_LEG = '7GgPYjS5Dza89wV6FpZ23kUJRG5vbQ1GM25ezspYFSoE' +const TREASURY = 'B1aLzaNMeFVAyQ6f3XbbUyKcH2YPHu2fqiEagmiF23VR' + +interface StateFixture { + mint?: string + msolLeg?: string + active?: bigint + reserve?: bigint + tickets?: bigint + supply?: bigint + paused?: boolean + maxFee?: number + minFee?: number + target?: bigint + minDeposit?: bigint + minWithdraw?: bigint + cap?: bigint +} + +/** Marinade's `State` at its Borsh offsets, with zeros where this code reads nothing. */ +function encodeState(f: StateFixture = {}): Uint8Array { + const data = new Uint8Array(647) + const put = (at: number, bytes: number[]): void => data.set(bytes, at) + put(8, addressBytes(f.mint ?? MSOL_MINT)) + put(104, addressBytes(TREASURY)) + put(138, u64Bytes(BigInt(2039280))) + put(376, u64Bytes(f.active ?? BigInt('2233647963872566'))) + put(420, addressBytes(f.msolLeg ?? MSOL_LEG)) + put(452, u64Bytes(f.target ?? BigInt('3500000000000'))) + put(460, u32Bytes(f.maxFee ?? 150)) + put(464, u32Bytes(f.minFee ?? 3)) + put(496, u64Bytes(f.reserve ?? BigInt('23619572140625'))) + put(504, u64Bytes(f.supply ?? BigInt('1593220460250312'))) + put(528, u64Bytes(f.tickets ?? BigInt('1050140472887'))) + put(544, u64Bytes(f.minDeposit ?? BigInt(1))) + put(552, u64Bytes(f.minWithdraw ?? BigInt(1))) + put(560, u64Bytes(f.cap ?? BigInt('18446744073709551615'))) + data[608] = f.paused === true ? 1 : 0 + put(609, u32Bytes(2000)) + data[638] = 2 + return data +} + +function venueWith(state: StateFixture = {}, solLeg = BigInt('116426934088')) { + const rpc = fakeRpc({ + epoch: 1077, + accounts: { + [MARINADE_STATE]: { owner: MARINADE_PROGRAM, lamports: BigInt(1), data: encodeState(state) }, + [SOL_LEG]: { + owner: '11111111111111111111111111111111', + lamports: solLeg, + data: new Uint8Array(0), + }, + }, + }) + return { venue: createMarinadeVenue({ rpc }), rpc } +} + +const stake = { kind: 'stake', amount: '1', tokenIn: 'SOL' } as const +const unstake = { kind: 'unstake', amount: '1', tokenIn: 'mSOL' } as const + +const preparedStake = (): Promise => venueWith().venue.prepare(stake, ME) +const preparedUnstake = (): Promise => venueWith().venue.prepare(unstake, ME) + +function refuses(action: () => unknown, pattern: RegExp): void { + expect(action).toThrow(pattern) +} + +describe('marinade stake', () => { + it('reads the amount from the state, with no rate invented', async () => { + const prepared = await preparedStake() + expect(prepared.receive?.symbol).toBe('mSOL') + expect(Number(prepared.receive?.amount)).toBeCloseTo(0.70615, 4) + expect(prepared.apy).toBeUndefined() + expect(prepared.notes.join(' ')).toMatch(/not showing a yearly rate/) + expect(prepared.notes.join(' ')).toMatch(/6-of-13/) + expect(prepared.notes.join(' ')).toMatch(/3-of-5/) + expect(prepared.notes.join(' ')).toMatch(/Neodyme, Ackee and Kudelski/) + }) + + it('deposits the amount asked, from the person, to their own mSOL account', async () => { + const prepared = await preparedStake() + if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.') + const decoded = decodeSolanaTransaction(prepared.signing.transaction) + expect(decoded.feePayer).toBe(ME) + expect(decoded.signerCount).toBe(1) + const deposit = decoded.instructions.at(-1)! + expect(decoded.staticAccounts[deposit.programIndex]).toBe(MARINADE_PROGRAM) + expect(readU64(deposit.data, 8)).toBe(BigInt('1000000000')) + expect(decoded.staticAccounts[deposit.accounts[6]!]).toBe(ME) + expect(decoded.staticAccounts[deposit.accounts[7]!]).toBe(associatedTokenAccount(ME, MSOL_MINT)) + }) + + it('passes its own check, and a quote builds nothing', async () => { + const { venue, rpc } = venueWith() + const prepared = await venue.prepare(stake, ME) + expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow() + const quoting = venueWith() + const quoted = await quoting.venue.quote?.(stake, ME) + expect(quoted?.receive?.symbol).toBe('mSOL') + expect(quoted?.apyPercent).toBeUndefined() + expect(quoting.rpc.calls).not.toContain('getLatestBlockhash') + expect(rpc.calls).toContain('getLatestBlockhash') + }) + + it('refuses another token, when paused, over the cap, under the minimum', async () => { + await expect(venueWith().venue.prepare({ ...stake, tokenIn: 'USDC' }, ME)).rejects.toThrow( + /takes SOL/ + ) + await expect(venueWith({ paused: true }).venue.prepare(stake, ME)).rejects.toThrow(/paused/) + await expect(venueWith({ cap: BigInt(1) }).venue.prepare(stake, ME)).rejects.toThrow( + /cap is full/ + ) + await expect( + venueWith({ minDeposit: BigInt('2000000000') }).venue.prepare(stake, ME) + ).rejects.toThrow(/at least 2 SOL/) + }) + + it('refuses a state with another mint or another pool leg', async () => { + await expect( + venueWith({ mint: 'So11111111111111111111111111111111111111112' }).venue.prepare(stake, ME) + ).rejects.toThrow(/doesn’t look as expected/) + await expect(venueWith({ msolLeg: SOMEONE_ELSE }).venue.prepare(stake, ME)).rejects.toThrow( + /doesn’t look as expected/ + ) + }) +}) + +describe('marinade unstake', () => { + it('calls it a sale into the pool, and names the fee and its ceiling', async () => { + const prepared = await preparedUnstake() + expect(prepared.kind).toBe('unstake') + expect(prepared.pay).toEqual({ amount: '1', symbol: 'mSOL' }) + expect(prepared.receive?.symbol).toBe('SOL') + const notes = prepared.notes.join(' ') + expect(notes).toMatch(/sale into Marinade’s SOL pool/) + expect(notes).toMatch(/takes no minimum/) + expect(notes).toMatch(/1\.5% maximum/) + expect(notes).toMatch(/delayed unstake charges 0\.2%/) + }) + + it('computes the fee as the program does, falling as the pool fills', () => { + const state = decodeMarinadeState(encodeState())! + const msol = BigInt('1000000000') + const thin = liquidUnstake(state, msol, BigInt('116426934088')) + expect(thin.feeBp).toBe(BigInt(146)) + const full = liquidUnstake(state, msol, BigInt('5000000000000')) + expect(full.feeBp).toBe(BigInt(3)) + const drained = liquidUnstake(state, msol, BigInt(2039280)) + expect(drained.feeBp).toBe(BigInt(150)) + expect(full.lamports).toBeGreaterThan(thin.lamports) + }) + + it('refuses more than the pool holds, and a fee over the ceiling', async () => { + await expect( + venueWith({}, BigInt(1_000_000_000)).venue.prepare({ ...unstake, amount: '5' }, ME) + ).rejects.toThrow(/doesn’t hold enough SOL/) + await expect(venueWith({ maxFee: 500 }).venue.prepare(unstake, ME)).rejects.toThrow(/too high/) + }) + + it('refuses a token that is not mSOL', async () => { + await expect(venueWith().venue.prepare({ ...unstake, tokenIn: 'SOL' }, ME)).rejects.toThrow( + /takes mSOL/ + ) + }) +}) + +describe('marinade assertSafe', () => { + const venue = venueWith().venue + + it('refuses the wrong signer', async () => { + const prepared = await preparedStake() + refuses(() => venue.assertSafe(prepared.signing, SOMEONE_ELSE), /network fee/) + refuses(() => venue.assertSafe(payload(SOMEONE_ELSE, rebuilt(prepared, ME)), ME), /network fee/) + }) + + it('refuses an unknown program', async () => { + const rest = rebuilt(await preparedStake(), ME) + const last = rest.at(-1)! + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest.slice(0, -1), + { ...last, programAddress: UNKNOWN_PROGRAM as never }, + ]), + ME + ), + /should not need/ + ) + }) + + it('refuses an unknown function', async () => { + const rest = rebuilt(await preparedStake(), ME) + refuses( + () => + venue.assertSafe(payload(ME, [...rest.slice(0, -1), withOtherFunction(rest.at(-1)!)]), ME), + /didn’t expect/ + ) + }) + + it('refuses extra calldata', async () => { + const rest = rebuilt(await preparedUnstake(), ME) + refuses( + () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), withExtraData(rest.at(-1)!)]), ME), + /didn’t expect/ + ) + }) + + it('refuses mSOL going to someone else', async () => { + const rest = rebuilt(await preparedStake(), ME) + const theirs = associatedTokenAccount(SOMEONE_ELSE, MSOL_MINT) + refuses( + () => + venue.assertSafe( + payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 7, theirs)]), + ME + ), + /isn’t what Marinade should get/ + ) + }) + + it('refuses unstaked SOL going to someone else', async () => { + const rest = rebuilt(await preparedUnstake(), ME) + refuses( + () => + venue.assertSafe( + payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 7, SOMEONE_ELSE)]), + ME + ), + /isn’t what Marinade should get/ + ) + }) + + it('refuses a transfer or an unlimited approval added to it', async () => { + const rest = rebuilt(await preparedUnstake(), ME) + refuses( + () => venue.assertSafe(payload(ME, [...rest, transferOf(ME, SOMEONE_ELSE, BigInt(1))]), ME), + /SOL transfer/ + ) + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest, + unlimitedApproval(associatedTokenAccount(ME, MSOL_MINT), SOMEONE_ELSE, ME), + ]), + ME + ), + /moves or approves tokens/ + ) + }) + + it('refuses the wrong token and the wrong state', async () => { + const rest = rebuilt(await preparedStake(), ME) + const last = rest.at(-1)! + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest.slice(0, -1), + withAccount(last, 1, 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + ]), + ME + ), + /isn’t what Marinade should get/ + ) + refuses( + () => + venue.assertSafe( + payload(ME, [...rest.slice(0, -1), withAccount(last, 0, SOMEONE_ELSE)]), + ME + ), + /isn’t what Marinade should get/ + ) + }) + + it('refuses an amount of zero', async () => { + const rest = rebuilt(await preparedStake(), ME) + const last = rest.at(-1)! + const data = Uint8Array.from(last.data ?? []) + data.fill(0, 8) + refuses( + () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), { ...last, data }]), ME), + /isn’t what Marinade should get/ + ) + }) +}) + +describe('marinade tokens and listing', () => { + it('names mSOL with its mint and decimals', () => { + expect(MARINADE_TOKENS).toEqual([ + { symbol: 'mSOL', id: MSOL_MINT, decimals: 9, name: 'Marinade staked SOL' }, + ]) + }) + + it('lists as executing on Solana', () => { + expect(marinadeListing).toMatchObject({ + id: 'marinade', + family: 'solana', + integration: 'executes', + }) + expect(marinadeListing.capability).toMatch(/Native staking and delayed unstake aren’t wired/) + }) +}) diff --git a/apps/dapp/lib/__tests__/market-context-evm.test.ts b/apps/dapp/lib/__tests__/market-context-evm.test.ts index 0baa6b4b..463849b2 100644 --- a/apps/dapp/lib/__tests__/market-context-evm.test.ts +++ b/apps/dapp/lib/__tests__/market-context-evm.test.ts @@ -26,7 +26,11 @@ describe('the market on EVM chains that share a family', () => { it('gives each chain only the venues that name it', () => { const ids = (chain: string): string[] => buildMarketContext(chain, {}, 'mainnet').venues.map((v) => v.id) - expect(ids('avalanche')).toEqual(['on-avalanche']) - expect(ids('monad')).toEqual(['on-monad']) + expect(ids('avalanche')).toContain('on-avalanche') + expect(ids('avalanche')).not.toContain('on-monad') + expect(ids('avalanche')).not.toContain('aave-monad') + expect(ids('monad')).toContain('on-monad') + expect(ids('monad')).not.toContain('on-avalanche') + expect(ids('monad')).not.toContain('aave-avalanche') }) }) diff --git a/apps/dapp/lib/__tests__/momentum.live.test.ts b/apps/dapp/lib/__tests__/momentum.live.test.ts new file mode 100644 index 00000000..d8411727 --- /dev/null +++ b/apps/dapp/lib/__tests__/momentum.live.test.ts @@ -0,0 +1,89 @@ +import { fromBase64, normalizeStructTag } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { suiClient } from '../chains/sui/client' +import { tokenType } from '../chains/sui/direct-swap' +import { + MOMENTUM_PACKAGE, + MOMENTUM_POOLS, + MOMENTUM_VERSION, + momentumVenue, +} from '../chains/sui/momentum' + +/** + * Read-only against mainnet: everything is built and dry-run, for a public address holding USDC + * and SUI whose key nobody here has. When that address empties the swaps fail on balance, so swap + * it for another funded one. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const ACCOUNT = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4' +const UPGRADE_CAP = '0x59d16188948e7b89791271c7af78cb877092b8858db44c9166b8af9dee55dcb4' +const ORIGINAL_PACKAGE = '0x70285592c97965e811e0c6f98dccc3a9c2b4ad854b3594faab9597ada267b860' +const USDC = tokenType('USDC') + +describe.skipIf(SKIP)('Momentum against mainnet', () => { + it('still matches its upgrade cap, or the package needs a fresh look', async () => { + const { objects } = await suiClient().core.getObjects({ + objectIds: [UPGRADE_CAP], + include: { json: true }, + }) + const [cap] = objects as unknown as [{ json?: { package?: string } }] + expect(cap.json?.package).toBe(MOMENTUM_PACKAGE) + }, 30_000) + + it('still has the version object it pins', async () => { + const { objects } = await suiClient().core.getObjects({ objectIds: [MOMENTUM_VERSION] }) + const [version] = objects as unknown as [{ type?: string }] + expect(version.type).toBe(`${ORIGINAL_PACKAGE}::version::Version`) + }, 30_000) + + it.each(MOMENTUM_POOLS.map((p) => [`${p.x}/${p.y}`, p] as const))( + '%s is still a pool of those coins', + async (_name, pool) => { + const { objects } = await suiClient().core.getObjects({ objectIds: [pool.id] }) + const [object] = objects as unknown as [ + { type?: string; owner?: { Shared?: { initialSharedVersion: string } } }, + ] + expect(object.type).toBe( + `${ORIGINAL_PACKAGE}::pool::Pool<${tokenType(pool.x)},${tokenType(pool.y)}>` + ) + expect(object.owner?.Shared?.initialSharedVersion).toBe(String(pool.initialSharedVersion)) + }, + 30_000 + ) + + it('quotes USDC for SUI, builds it, passes the check, and the dry run pays near the quote', async () => { + const request = { kind: 'swap', amount: '5', tokenIn: 'SUI', tokenOut: 'USDC' } as const + const quoted = await momentumVenue.quote?.(request, ACCOUNT) + const prepared = await momentumVenue.prepare(request, ACCOUNT) + expect(() => momentumVenue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + + const result = await suiClient().core.simulateTransaction({ + transaction: fromBase64(prepared.signing.transaction), + include: { balanceChanges: true, effects: true }, + }) + expect(result.Transaction?.effects?.status.success).toBe(true) + const paid = result.Transaction?.balanceChanges?.find( + (c) => normalizeStructTag(c.coinType) === USDC && c.address === ACCOUNT + ) + const got = Number(paid?.amount) / 1e6 + expect(got).toBeGreaterThan(Number(quoted?.receive?.amount) * 0.995) + expect(got).toBeLessThan(Number(quoted?.receive?.amount) * 1.005) + }, 60_000) + + it('builds SUI for USDC the other way, and the dry run passes', async () => { + const prepared = await momentumVenue.prepare( + { kind: 'swap', amount: '5', tokenIn: 'USDC', tokenOut: 'SUI' }, + ACCOUNT + ) + expect(() => momentumVenue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + const result = await suiClient().core.simulateTransaction({ + transaction: fromBase64(prepared.signing.transaction), + include: { effects: true }, + }) + expect(result.Transaction?.effects?.status.success).toBe(true) + }, 60_000) +}) diff --git a/apps/dapp/lib/__tests__/momentum.test.ts b/apps/dapp/lib/__tests__/momentum.test.ts new file mode 100644 index 00000000..d9432fb1 --- /dev/null +++ b/apps/dapp/lib/__tests__/momentum.test.ts @@ -0,0 +1,412 @@ +import { bcs } from '@mysten/sui/bcs' +import { Transaction } from '@mysten/sui/transactions' +import { fromBase64, toBase64 } from '@mysten/sui/utils' +import { beforeAll, describe, expect, it } from 'vitest' + +import type { SwapIo } from '../chains/sui/direct-swap' +import { + createMomentumVenue, + MOMENTUM_PACKAGE, + MOMENTUM_POOLS, + MOMENTUM_VERSION, +} from '../chains/sui/momentum' +import { momentumListing } from '../chains/sui/momentum-listing' +import { ChainActionRefused, type ChainActionRequest } from '../chains/types' + +const ME = '0x00000000000000000000000000000000000000000000000000000000000000a1' +const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead' +const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01' +const COIN = '0x00000000000000000000000000000000000000000000000000000000000000c1' +const GAS = '0x00000000000000000000000000000000000000000000000000000000000000c2' +const DIGEST = '11111111111111111111111111111111' +const SCAM = '0x778e2ae6096389cfad3e0b1c6e8080a9287f8792a08d3df1f9025251d820be93::usdt::USDT' + +const u64 = (n: string): Uint8Array => bcs.u64().serialize(n).toBytes() +const u128 = (n: string): Uint8Array => bcs.u128().serialize(n).toBytes() + +interface PoolReading { + out?: string + left?: string + after?: string + before?: string +} + +/** 10 SUI into 9.3 USDC at a 0.175% fee, moving the price a hair. */ +function fakeIo(reading: PoolReading = {}, prices: Record = {}) { + const views: Transaction[] = [] + const built: Transaction[] = [] + const io: SwapIo = { + view: async (tx) => { + views.push(tx) + return [ + [], + [u64(reading.out ?? '9300000')], + [u64(reading.left ?? '0')], + [u128(reading.after ?? '563200040872796795')], + [u128(reading.before ?? '563203070563371839')], + [u64('1750')], + ] + }, + build: async (tx) => { + built.push(tx) + tx.setGasPrice(1000) + tx.setGasBudget(50_000_000) + tx.setGasPayment([{ objectId: GAS, version: '1', digest: DIGEST }]) + return toBase64(await tx.build()) + }, + price: async (token) => + token.symbol in prices ? prices[token.symbol] : token.symbol === 'SUI' ? 0.932 : 1, + spend: (tx) => tx.objectRef({ objectId: COIN, version: '1', digest: DIGEST }), + } + return { io, views, built } +} + +const SELL: ChainActionRequest = { kind: 'swap', amount: '10', tokenIn: 'SUI', tokenOut: 'USDC' } +const BUY: ChainActionRequest = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'SUI' } + +async function preparedBytes( + request: ChainActionRequest = SELL, + reading: PoolReading = {} +): Promise { + const prepared = await createMomentumVenue(fakeIo(reading).io).prepare(request, ME) + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + return prepared.signing.transaction +} + +const venue = createMomentumVenue(fakeIo().io) +const check = (transaction: string, account = ME) => + venue.assertSafe({ family: 'sui', transaction }, account) + +let SOLD = '' + +beforeAll(async () => { + SOLD = await preparedBytes(SELL) +}) + +async function edited(base: string, edit: (tx: Transaction) => void): Promise { + const tx = Transaction.from(base) + edit(tx) + return toBase64(await tx.build()) +} + +interface JsonArg { + Input?: number + Result?: number + NestedResult?: [number, number] +} +interface JsonCall { + package: string + module: string + function: string + typeArguments: string[] + arguments: JsonArg[] +} +interface JsonTx { + inputs: { Pure?: { bytes: string }; Object?: { SharedObject?: { objectId: string } } }[] + commands: { MoveCall?: JsonCall }[] +} + +async function rewritten(base: string, edit: (data: JsonTx) => void): Promise { + const data = JSON.parse(await Transaction.from(base).toJSON()) as JsonTx + edit(data) + return toBase64(await Transaction.from(JSON.stringify(data)).build()) +} + +function named(data: JsonTx, fn: string): JsonCall { + const call = data.commands.find((c) => c.MoveCall?.function === fn)?.MoveCall + if (call === undefined) throw new Error(`no ${fn} in this transaction`) + return call +} + +function moveCalls(transaction: string) { + return Transaction.from(transaction) + .getData() + .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall] : [])) +} + +describe('the Momentum quote', () => { + it('reads what the pool gives, and builds nothing', async () => { + const { io, views, built } = fakeIo() + const quoted = await createMomentumVenue(io).quote?.(SELL, ME) + expect(quoted?.receive).toEqual({ amount: '9.3', symbol: 'USDC' }) + expect(built).toHaveLength(0) + expect(views[0]?.getData().commands.map((c) => c.MoveCall?.function)).toEqual([ + 'compute_swap_result', + 'get_state_amount_calculated', + 'get_state_amount_specified', + 'get_state_sqrt_price', + 'sqrt_price', + 'swap_fee_rate', + ]) + }) + + it('refuses a pair with no pinned pool', async () => { + await expect(venue.quote?.({ ...SELL, tokenOut: 'USDsui' }, ME)).rejects.toThrow( + /no direct pool/i + ) + }) + + it('refuses a token outside the list', async () => { + await expect(venue.quote?.({ ...SELL, tokenOut: 'FDUSD' }, ME)).rejects.toThrow( + ChainActionRefused + ) + }) + + it('refuses when the read fails, rather than guessing', async () => { + const { io } = fakeIo() + io.view = () => Promise.reject(new Error('fullnode down')) + await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/couldn't read/i) + }) + + it('refuses a pool that runs out before the amount is spent', async () => { + const { io } = fakeIo({ left: '5000000000', out: '4650000' }) + await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/part of this amount/i) + }) + + it('refuses a swap that moves the price more than the limit', async () => { + const { io } = fakeIo({ after: '540000000000000000' }) + await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/move the price/) + }) + + it('refuses a fill worth far less than the market', async () => { + const { io } = fakeIo({}, { SUI: 2 }) + await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/worse than the market/) + }) + + it('refuses when no outside price can be read', async () => { + const { io } = fakeIo({}, { USDC: undefined }) + await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/live price/) + }) + + it('refuses an account that is not a Sui address', async () => { + await expect(venue.quote?.(SELL, 'not-an-address')).rejects.toThrow(/Sui address/) + }) +}) + +describe('what Momentum prepare builds', () => { + it('runs the flash swap in full, with a minimum 0.5% under the quote', () => { + const calls = moveCalls(SOLD) + expect(calls.map((c) => `${c.module}::${c.function}`)).toEqual([ + 'trade::flash_swap', + 'balance::destroy_zero', + 'trade::swap_receipt_debts', + 'coin::into_balance', + 'balance::zero', + 'trade::repay_flash_swap', + 'coin::from_balance', + 'coin::split', + ]) + expect(calls[0]?.package).toBe(MOMENTUM_PACKAGE) + const data = Transaction.from(SOLD).getData() + const minimum = calls[7]?.arguments[1] + if (minimum?.$kind !== 'Input') throw new Error('minimum is not stated') + const bytes = data.inputs[minimum.Input]?.Pure?.bytes ?? '' + expect(bcs.u64().parse(fromBase64(bytes))).toBe('9253500') + expect(() => check(SOLD)).not.toThrow() + }) + + it('swaps the other way through the same pool, and passes its own check', async () => { + const transaction = await preparedBytes(BUY, { out: '10700000000' }) + const data = Transaction.from(transaction).getData() + const direction = moveCalls(transaction)[0]?.arguments[1] + if (direction?.$kind !== 'Input') throw new Error('direction is not stated') + const bytes = data.inputs[direction.Input]?.Pure?.bytes ?? '' + expect(bcs.bool().parse(fromBase64(bytes))).toBe(false) + expect(() => check(transaction)).not.toThrow() + }) + + it('sends what is left of the input and all the output to the person', () => { + const data = Transaction.from(SOLD).getData() + const transfer = data.commands.find((c) => c.$kind === 'TransferObjects')?.TransferObjects + expect(transfer?.objects).toHaveLength(2) + const to = transfer?.address + if (to?.$kind !== 'Input') throw new Error('recipient is not stated') + expect(bcs.Address.parse(fromBase64(data.inputs[to.Input]?.Pure?.bytes ?? ''))).toBe(ME) + }) + + it('tells the person the price protection, the fee it read and the risks', async () => { + const prepared = await venue.prepare(SELL, ME) + const notes = prepared.notes.join(' ') + expect(notes).toMatch(/more than 0\.5%, the swap cancels/) + expect(notes).toMatch(/fee is 0\.175%/) + expect(notes).toMatch(/upgrade/) + expect(notes).toMatch(/pause/) + expect(prepared.receive).toEqual({ amount: '9.3', symbol: 'USDC', approximate: true }) + expect(prepared.venueId).toBe('momentum') + }) +}) + +describe('the Momentum safety check refuses', () => { + it('a different account', () => { + expect(() => check(SOLD, THIEF)).toThrow(/different account/) + }) + + it('a transaction sent by someone else', async () => { + const tampered = await edited(SOLD, (tx) => tx.setSender(THIEF)) + expect(() => check(tampered)).toThrow(/different account/) + }) + + it('an unknown contract', async () => { + const tampered = await edited(SOLD, (tx) => { + tx.moveCall({ target: `${FOREIGN}::drain::everything` }) + }) + expect(() => check(tampered)).toThrow(/confirmed/) + }) + + it('the original package id, which is not the one to call', async () => { + const tampered = await rewritten(SOLD, (data) => { + named(data, 'flash_swap').package = + '0x70285592c97965e811e0c6f98dccc3a9c2b4ad854b3594faab9597ada267b860' + }) + expect(() => check(tampered)).toThrow(/confirmed/) + }) + + it('a Momentum function this flow never needs', async () => { + const tampered = await rewritten(SOLD, (data) => { + named(data, 'flash_swap').function = 'flash_loan' + }) + expect(() => check(tampered)).toThrow(/confirmed/) + }) + + it('an extra argument', async () => { + const tampered = await rewritten(SOLD, (data) => { + const call = named(data, 'flash_swap') + call.arguments.push(call.arguments[3] as JsonArg) + }) + expect(() => check(tampered)).toThrow(/does not match/) + }) + + it('payment to someone else', async () => { + const tampered = await edited(SOLD, (tx) => { + const [coin] = tx.splitCoins(tx.gas, [BigInt(1)]) + tx.transferObjects([coin as never], THIEF) + }) + expect(() => check(tampered)).toThrow(/another address/) + }) + + it('a framework transfer that hides the recipient', async () => { + const tampered = await edited(SOLD, (tx) => { + const [coin] = tx.splitCoins(tx.gas, [BigInt(1)]) + tx.moveCall({ + target: '0x2::transfer::public_transfer', + typeArguments: ['0x2::coin::Coin<0x2::sui::SUI>'], + arguments: [coin as never, tx.pure.address(THIEF)], + }) + }) + expect(() => check(tampered)).toThrow(/confirmed/) + }) + + it('no minimum, which is what an unlimited approval would be here', async () => { + const tampered = await rewritten(SOLD, (data) => { + const at = named(data, 'split').arguments[1]?.Input + const input = at === undefined ? undefined : data.inputs[at] + if (input?.Pure === undefined) throw new Error('no minimum') + input.Pure.bytes = toBase64(u64('0')) + }) + expect(() => check(tampered)).toThrow(/minimum/) + }) + + it('a minimum computed elsewhere in the transaction', async () => { + const tampered = await rewritten(SOLD, (data) => { + named(data, 'split').arguments[1] = { NestedResult: [2, 0] } + }) + expect(() => check(tampered)).toThrow(/minimum/) + }) + + it('a minimum taken from a coin other than the output', async () => { + const tampered = await rewritten(SOLD, (data) => { + named(data, 'split').arguments[0] = { NestedResult: [0, 0] } + }) + expect(() => check(tampered)).toThrow(/minimum/) + }) + + it('a swap with no minimum at all', async () => { + const tampered = await rewritten(SOLD, (data) => { + data.commands = data.commands.filter((c) => c.MoveCall?.function !== 'split') + }) + expect(() => check(tampered)).toThrow(/minimum/) + }) + + it('a token outside the pool', async () => { + const tampered = await rewritten(SOLD, (data) => { + const call = named(data, 'flash_swap') + call.typeArguments[1] = SCAM + }) + expect(() => check(tampered)).toThrow(/pool/) + }) + + it('a pool that is not pinned', async () => { + const tampered = await rewritten(SOLD, (data) => { + const at = named(data, 'flash_swap').arguments[0]?.Input + const pool = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject + if (pool === undefined) throw new Error('no pool') + pool.objectId = FOREIGN + }) + expect(() => check(tampered)).toThrow(/pool/) + }) + + it("one pool's id with another pair's types", async () => { + const other = MOMENTUM_POOLS.find((p) => p.x === 'USDT') + if (other === undefined) throw new Error('no USDT pool') + const tampered = await rewritten(SOLD, (data) => { + const at = named(data, 'flash_swap').arguments[0]?.Input + const pool = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject + if (pool === undefined) throw new Error('no pool') + pool.objectId = other.id + }) + expect(() => check(tampered)).toThrow(/pool/) + }) + + it('an exact-out swap, which the minimum was never worked out for', async () => { + const tampered = await rewritten(SOLD, (data) => { + const at = named(data, 'flash_swap').arguments[2]?.Input + const input = at === undefined ? undefined : data.inputs[at] + if (input?.Pure === undefined) throw new Error('no flag') + input.Pure.bytes = toBase64(bcs.bool().serialize(false).toBytes()) + }) + expect(() => check(tampered)).toThrow(/does not match/) + }) + + it('a version object that is not the pinned one', async () => { + const tampered = await rewritten(SOLD, (data) => { + const at = named(data, 'flash_swap').arguments[6]?.Input + const version = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject + if (version === undefined) throw new Error('no version') + version.objectId = FOREIGN + }) + expect(() => check(tampered)).toThrow(/does not match/) + }) + + it('two swaps in one transaction', async () => { + const tampered = await rewritten(SOLD, (data) => { + const flash = data.commands.find((c) => c.MoveCall?.function === 'flash_swap') + if (flash === undefined) throw new Error('no swap') + data.commands.push(JSON.parse(JSON.stringify(flash)) as JsonTx['commands'][number]) + }) + expect(() => check(tampered)).toThrow(/one swap/) + }) + + it('a payload for another chain', () => { + expect(() => venue.assertSafe({ family: 'solana', transaction: SOLD }, ME)).toThrow( + ChainActionRefused + ) + }) + + it('bytes that are not a transaction', () => { + expect(() => check('not base64 at all!')).toThrow(ChainActionRefused) + }) +}) + +describe('the Momentum pins and listing', () => { + it('pins only the pools with real depth for tokens in the table', () => { + expect(MOMENTUM_POOLS.map((p) => `${p.x}/${p.y}`).sort()).toEqual(['SUI/USDC', 'USDT/USDC']) + expect(MOMENTUM_VERSION).toMatch(/^0x[0-9a-f]{64}$/) + }) + + it('lists as executing on Sui', () => { + expect(momentumListing.id).toBe('momentum') + expect(momentumListing.family).toBe('sui') + expect(momentumListing.integration).toBe('executes') + expect(momentumListing.capability).toMatch(/SUI, USDC and USDT/) + }) +}) diff --git a/apps/dapp/lib/__tests__/morpho.live.test.ts b/apps/dapp/lib/__tests__/morpho.live.test.ts new file mode 100644 index 00000000..1f644d93 --- /dev/null +++ b/apps/dapp/lib/__tests__/morpho.live.test.ts @@ -0,0 +1,184 @@ +import { + encodeAbiParameters, + encodeFunctionData, + http, + keccak256, + pad, + parseAbi, + toHex, + type Address, + type Hex, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import { evmReader } from '../chains/evm/client' +import { + createMorphoVenue, + MORPHO_ADAPTER, + MORPHO_BLUE_MONAD, + MORPHO_REVIEWED_MARKETS, + MORPHO_VAULT, + readMorphoApy, + readMorphoLiquidity, + VAULT_V2_FACTORY_MONAD, +} from '../chains/evm/morpho' +import { sameAddress } from '../chains/evm/submit' + +/** + * Whether what's pinned still holds on Monad, read-only. Nothing here signs or sends: a deposit is + * tried with `eth_call` and overridden balances, and a withdrawal from a real holder's address, + * whose key nobody here has. If that holder leaves, swap it for another. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const RPC = process.env['MONAD_RPC_URL']?.trim() || 'https://rpc.monad.xyz' +const HOLDER = '0xB936bCd526069e44bB1F3A9dc155d0C86A8e2fD1' +const NOBODY = '0x000000000000000000000000000000000000dEaD' +const SLOW_MS = 60_000 +/** Where Circle's USDC keeps balances and allowances, found by probing with an override. */ +const USDC_BALANCE_SLOT = BigInt(9) +const USDC_ALLOWANCE_SLOT = BigInt(10) + +const ABI = parseAbi([ + 'function asset() view returns (address)', + 'function symbol() view returns (string)', + 'function decimals() view returns (uint8)', + 'function curator() view returns (address)', + 'function isVaultV2(address) view returns (bool)', + 'function adaptersLength() view returns (uint256)', + 'function adapters(uint256) view returns (address)', + 'function receiveAssetsGate() view returns (address)', + 'function sendAssetsGate() view returns (address)', + 'function receiveSharesGate() view returns (address)', + 'function sendSharesGate() view returns (address)', + 'function marketIdsLength() view returns (uint256)', + 'function marketIds(uint256) view returns (bytes32)', + 'function idToMarketParams(bytes32) view returns (address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)', + 'function deposit(uint256, address) returns (uint256)', +]) + +const STREAM_TOKENS = ['XUSD', 'DEUSD', 'SDEUSD'] + +const slotOf = (key: string, slot: bigint): Hex => + keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [key as Address, slot])) +const allowanceSlot = (owner: string, spender: string): Hex => + keccak256( + encodeAbiParameters( + [{ type: 'address' }, { type: 'bytes32' }], + [spender as Address, slotOf(owner, USDC_ALLOWANCE_SLOT)] + ) + ) + +describe.skipIf(SKIP)('Morpho on Monad, live', () => { + const reader = evmReader('monad', http(RPC, { timeout: 30_000, retryCount: 2 })) + const vault = MORPHO_VAULT.address as Address + const read = (address: string, functionName: string, args: unknown[] = []) => + reader.readContract({ + address: address as Address, + abi: ABI, + functionName, + args, + } as never) as Promise + + it('finds code at every pinned address', { timeout: SLOW_MS }, async () => { + for (const address of [MORPHO_BLUE_MONAD, VAULT_V2_FACTORY_MONAD, vault, MORPHO_ADAPTER]) { + const code = await reader.getCode({ address: address as Address }) + expect(code?.length ?? 0).toBeGreaterThan(2) + } + }) + + it('is still the reviewed vault', { timeout: SLOW_MS }, async () => { + expect(await read(VAULT_V2_FACTORY_MONAD, 'isVaultV2', [vault])).toBe(true) + expect(sameAddress((await read(vault, 'asset')) as string, MORPHO_VAULT.asset.id)).toBe(true) + expect(await read(vault, 'symbol')).toBe(MORPHO_VAULT.shareSymbol) + expect(await read(vault, 'decimals')).toBe(MORPHO_VAULT.shareDecimals) + // Named Steakhouse Financial in Morpho's curator registry. + expect( + sameAddress( + (await read(vault, 'curator')) as string, + '0x827e86072B06674a077f592A531dcE4590aDeCdB' + ) + ).toBe(true) + expect(await read(vault, 'adaptersLength')).toBe(BigInt(1)) + expect( + sameAddress((await read(vault, 'adapters', [BigInt(0)])) as string, MORPHO_ADAPTER) + ).toBe(true) + }) + + it('has no gate that could block a deposit or a withdrawal', { timeout: SLOW_MS }, async () => { + for (const gate of [ + 'receiveAssetsGate', + 'sendAssetsGate', + 'receiveSharesGate', + 'sendSharesGate', + ]) { + expect(sameAddress((await read(vault, gate)) as string, `0x${'0'.repeat(40)}`)).toBe(true) + } + }) + + it( + 'lends only USDC against reviewed collateral, with no Stream token', + { timeout: SLOW_MS }, + async () => { + const count = (await read(MORPHO_ADAPTER, 'marketIdsLength')) as bigint + const collateral: string[] = [] + for (let index = BigInt(0); index < count; index += BigInt(1)) { + const id = (await read(MORPHO_ADAPTER, 'marketIds', [index])) as string + expect(MORPHO_REVIEWED_MARKETS).toContain(id) + const params = (await read(MORPHO_BLUE_MONAD, 'idToMarketParams', [id])) as readonly [ + string, + string, + ] + expect(sameAddress(params[0], MORPHO_VAULT.asset.id)).toBe(true) + collateral.push(((await read(params[1], 'symbol')) as string).toUpperCase()) + } + expect(collateral.filter((symbol) => STREAM_TOKENS.includes(symbol))).toEqual([]) + } + ) + + it('reads a free amount and a believable rate', { timeout: SLOW_MS }, async () => { + expect(await readMorphoLiquidity(reader)).toBeGreaterThan(BigInt(0)) + const apy = await readMorphoApy(reader) + if (apy !== undefined) { + expect(apy.basePercent).toBeGreaterThan(0) + expect(apy.basePercent).toBeLessThan(50) + } + }) + + it('prepares a deposit for an address that holds USDC', { timeout: SLOW_MS }, async () => { + const venue = createMorphoVenue({ reader }) + // The vault's own cash stands in for a person with USDC. Nothing is signed. + const prepared = await venue.prepare({ kind: 'supply', amount: '5', tokenIn: 'USDC' }, vault) + expect(() => venue.assertSafe(prepared.signing, vault)).not.toThrow() + }) + + it( + 'accepts the deposit this code builds, with funds overridden', + { timeout: SLOW_MS }, + async () => { + const units = BigInt(5_000_000) + await expect( + reader.call({ + account: NOBODY, + to: vault, + data: encodeFunctionData({ abi: ABI, functionName: 'deposit', args: [units, NOBODY] }), + stateOverride: [ + { + address: MORPHO_VAULT.asset.id as Address, + stateDiff: [ + { slot: slotOf(NOBODY, USDC_BALANCE_SLOT), value: pad(toHex(units)) }, + { slot: allowanceSlot(NOBODY, vault), value: pad(toHex(units)) }, + ], + }, + ], + }) + ).resolves.toBeDefined() + } + ) + + it('prepares and tries a withdrawal for a real holder', { timeout: SLOW_MS }, async () => { + const venue = createMorphoVenue({ reader }) + const prepared = await venue.prepare({ kind: 'withdraw', amount: '5', tokenIn: 'USDC' }, HOLDER) + expect(() => venue.assertSafe(prepared.signing, HOLDER)).not.toThrow() + }) +}) diff --git a/apps/dapp/lib/__tests__/morpho.test.ts b/apps/dapp/lib/__tests__/morpho.test.ts new file mode 100644 index 00000000..20d75f7e --- /dev/null +++ b/apps/dapp/lib/__tests__/morpho.test.ts @@ -0,0 +1,289 @@ +import { decodeFunctionData, encodeAbiParameters, erc20Abi, keccak256, type Address } from 'viem' +import { describe, expect, it } from 'vitest' + +import type { EvmReaderClient } from '../chains/evm/call' +import { + createMorphoVenue, + MORPHO_ADAPTER, + MORPHO_REVIEWED_MARKETS, + MORPHO_VAULT, + readMorphoLiquidity, +} from '../chains/evm/morpho' +import { morphoListing } from '../chains/evm/morpho-listing' +import { MONAD_TOKENS } from '../chains/evm/tokens' +import { ChainActionRefused, type ChainActionRequest } from '../chains/types' +import { ACCOUNT, describeVaultRefusals, STRANGER } from './erc4626-refusals' + +const MONAD_ID = 143 +const SUPPLY: ChainActionRequest = { kind: 'supply', amount: '100', tokenIn: 'USDC' } +const WITHDRAW: ChainActionRequest = { kind: 'withdraw', amount: '100', tokenIn: 'USDC' } + +const units = (usdc: number) => BigInt(usdc) * BigInt(1_000_000) +const PARAMS = [ + { type: 'address' }, + { type: 'address' }, + { type: 'address' }, + { type: 'address' }, + { type: 'uint256' }, +] as const +const LIQUIDITY_DATA = encodeAbiParameters(PARAMS, [ + MORPHO_VAULT.asset.id as Address, + '0x01bFF41798a0BcF287b996046Ca68b395DbC1071', + '0xE0b4E42B082F639bc95f4EDf95deEBE7b5Ea94aa', + '0x09475a3D6eA8c314c592b1a3799bDE044E2F400F', + BigInt('770000000000000000'), +]) + +interface Scene { + held?: bigint + shares?: bigint + allowance?: bigint + idle?: bigint + marketFree?: bigint + priceNow?: bigint + priceWas?: bigint + historyFails?: boolean + markets?: readonly string[] + adapters?: bigint + liquidityFails?: boolean + simulationFails?: boolean +} + +const WEEK = 604_800 +const NOW_BLOCK = BigInt(100_000_000) +const NOW_TIME = BigInt(1_800_000_000) +const PRICE = BigInt('1000000000000') + +function fakeReader(scene: Scene = {}) { + const calls: { to?: string; data?: string }[] = [] + const reader = { + getBlock: async (input?: { blockNumber?: bigint }) => { + if (input?.blockNumber !== undefined && scene.historyFails) throw new Error('no history') + return input?.blockNumber === undefined + ? { number: NOW_BLOCK, timestamp: NOW_TIME } + : { number: input.blockNumber, timestamp: NOW_TIME - BigInt(WEEK) } + }, + call: async (input: { to?: string; data?: string }) => { + calls.push(input) + if (scene.simulationFails) throw new Error('reverted') + return {} + }, + readContract: async ({ + address, + functionName, + args, + blockNumber, + }: { + address: string + functionName: string + args?: readonly unknown[] + blockNumber?: bigint + }) => { + const isVault = address.toLowerCase() === MORPHO_VAULT.address.toLowerCase() + switch (functionName) { + case 'balanceOf': + if (isVault) return scene.shares ?? BigInt(0) + return (args?.[0] as string).toLowerCase() === MORPHO_VAULT.address.toLowerCase() + ? (scene.idle ?? units(50_000)) + : (scene.held ?? units(1_000)) + case 'allowance': + return scene.allowance ?? BigInt(0) + case 'totalAssets': + return units(200_000) + case 'previewDeposit': + return BigInt('97000000000000000000') + case 'convertToAssets': + if (blockNumber === undefined) return args?.[0] as bigint + if (blockNumber !== NOW_BLOCK && scene.historyFails) throw new Error('no history') + return blockNumber === NOW_BLOCK + ? (scene.priceNow ?? (PRICE * BigInt(1001)) / BigInt(1000)) + : (scene.priceWas ?? PRICE) + case 'adaptersLength': + return scene.adapters ?? BigInt(1) + case 'adapters': + return MORPHO_ADAPTER + case 'marketIdsLength': + return BigInt((scene.markets ?? MORPHO_REVIEWED_MARKETS).length) + case 'marketIds': + return (scene.markets ?? MORPHO_REVIEWED_MARKETS)[Number(args?.[0])] + case 'liquidityAdapter': + if (scene.liquidityFails) throw new Error('no reading') + return MORPHO_ADAPTER + case 'liquidityData': + return LIQUIDITY_DATA + case 'market': + return [ + units(100_000), + units(100_000), + units(100_000) - (scene.marketFree ?? units(10_000)), + BigInt(0), + BigInt(0), + BigInt(0), + ] + case 'position': + return [units(30_000), BigInt(0), BigInt(0)] + default: + throw new Error(`Unexpected read: ${functionName}`) + } + }, + } + return { reader: reader as unknown as EvmReaderClient, calls } +} + +function venueWith(scene: Scene = {}) { + const { reader, calls } = fakeReader(scene) + return { venue: createMorphoVenue({ reader }), calls } +} + +describeVaultRefusals(createMorphoVenue({ reader: fakeReader().reader }), MORPHO_VAULT, MONAD_ID, [ + // Hyperithm USDC Apex, SharpByte USDC High Yield and August USDC V2: reviewed and left out. + '0x78999cc96d2Ba0341588C60CcB0E91c6C33CF371', + '0xf57E9898575fab80b0f2aCDe5443276BeC8C542D', + '0x80017bF0f793EBbE9679Cd61ff0e395B62CAbB59', + // The older Steakhouse vault of the same name. + '0x802c91d807A8DaCA257c4708ab264B6520964e44', +]) + +describe('Morpho on Monad', () => { + it('pins the USDC in the chain table', () => { + expect(MONAD_TOKENS.find((t) => t.symbol === 'USDC')).toEqual(MORPHO_VAULT.asset) + }) + + it('lists as executing on Monad only', () => { + expect(morphoListing).toMatchObject({ + id: 'morpho', + family: 'evm', + chains: ['Monad'], + integration: 'executes', + }) + }) + + it('builds a deposit crediting the person, with an exact approval', async () => { + const { venue } = venueWith() + const prepared = await venue.prepare(SUPPLY, ACCOUNT) + expect(prepared.venueId).toBe('morpho') + expect(prepared.receive).toEqual({ amount: '97', symbol: 'bbqUSDC', approximate: true }) + if (prepared.signing.family !== 'evm') throw new Error('Expected an EVM payload.') + expect(prepared.signing.to).toBe(MORPHO_VAULT.address) + const approval = decodeFunctionData({ + abi: erc20Abi, + data: prepared.signing.approval?.data as `0x${string}`, + }) + expect(approval.functionName).toBe('approve') + expect(approval.args).toEqual([MORPHO_VAULT.address, units(100)]) + expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + }) + + it('names the curator and the risk on the card', async () => { + const { venue } = venueWith() + const { summary, notes } = await venue.prepare(SUPPLY, ACCOUNT) + expect(summary).toContain('Steakhouse Financial') + expect(notes.join(' ')).toMatch(/XAUt0, WMON, WETH and WBTC/) + expect(notes.join(' ')).toMatch(/\$250,000/) + expect(notes.join(' ')).toMatch(/About 60,000 of the 200,000 USDC/) + }) + + it('tries the call when the allowance is already there', async () => { + const { venue, calls } = venueWith({ allowance: units(100) }) + const prepared = await venue.prepare(SUPPLY, ACCOUNT) + expect(calls).toHaveLength(1) + expect(prepared.signing).not.toHaveProperty('approval') + }) + + it('reads the rate from a week of share price, with no reward part', async () => { + const { venue } = venueWith({ priceNow: BigInt('1000500000000') }) + const { apy } = await venue.prepare(SUPPLY, ACCOUNT) + expect(apy?.basePercent).toBeCloseTo((Math.pow(1.0005, 365 / 7) - 1) * 100, 6) + expect(apy?.rewardPercent).toBeUndefined() + await expect(venue.quote?.(SUPPLY, ACCOUNT)).resolves.toEqual({ + apyPercent: apy?.basePercent, + }) + }) + + it('shows no rate when the chain has no history, and still builds', async () => { + const { venue } = venueWith({ historyFails: true }) + const prepared = await venue.prepare(SUPPLY, ACCOUNT) + expect(prepared).not.toHaveProperty('apy') + await expect(venue.quote?.(SUPPLY, ACCOUNT)).resolves.toEqual({}) + }) + + it('shows no rate when the price has not moved', async () => { + const { venue } = venueWith({ priceNow: PRICE }) + expect(await venue.prepare(SUPPLY, ACCOUNT)).not.toHaveProperty('apy') + }) + + it('refuses a deposit into a vault that lost value this week', async () => { + const { venue } = venueWith({ priceNow: PRICE - BigInt(1) }) + await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/lost value/) + await expect(venue.quote?.(SUPPLY, ACCOUNT)).rejects.toThrow(/lost value/) + }) + + it('refuses a vault lending to a market that was not reviewed', async () => { + const { venue } = venueWith({ markets: [...MORPHO_REVIEWED_MARKETS, keccak256('0x01')] }) + await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/added a market/) + }) + + it('refuses a vault with another adapter', async () => { + const { venue } = venueWith({ adapters: BigInt(2) }) + await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/how it lends/) + }) + + it('refuses a deposit into a vault with almost nothing free', async () => { + const { venue } = venueWith({ idle: units(1_000), marketFree: units(1_000) }) + await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/won't add to it/) + }) + + it('refuses a deposit when the vault cannot be read', async () => { + const { venue } = venueWith({ liquidityFails: true }) + await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/couldn't read/) + }) + + it('refuses more than the wallet holds', async () => { + const { venue } = venueWith({ held: units(50) }) + await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/You hold 50 USDC/) + }) + + it('counts cash plus what the liquidity market can give, up to the position', async () => { + const { reader } = fakeReader({ idle: units(5), marketFree: units(40_000) }) + await expect(readMorphoLiquidity(reader)).resolves.toBe(units(30_005)) + }) + + it('builds a withdrawal to the person from the person, with no approval', async () => { + const { venue, calls } = venueWith({ shares: units(500) }) + const prepared = await venue.prepare(WITHDRAW, ACCOUNT) + expect(prepared.kind).toBe('withdraw') + expect(prepared.pay).toEqual({ amount: '100', symbol: 'USDC' }) + expect(calls).toHaveLength(1) + expect(prepared.signing).not.toHaveProperty('approval') + expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + expect(() => venue.assertSafe(prepared.signing, STRANGER)).toThrow(ChainActionRefused) + }) + + it('refuses a withdrawal with no position, or more than the position', async () => { + await expect(venueWith().venue.prepare(WITHDRAW, ACCOUNT)).rejects.toThrow(/You have no USDC/) + await expect(venueWith({ shares: units(50) }).venue.prepare(WITHDRAW, ACCOUNT)).rejects.toThrow( + /about 50 USDC/ + ) + }) + + it('says plainly when the vault cannot pay out that much now', async () => { + const { venue } = venueWith({ + shares: units(5_000), + idle: units(10), + marketFree: units(20), + }) + const request = { ...WITHDRAW, amount: '1000' } + await expect(venue.prepare(request, ACCOUNT)).rejects.toThrow(/can pay out about 30 USDC/) + }) + + it('leaves the way out open when liquidity cannot be read, and lets the chain decide', async () => { + const { venue, calls } = venueWith({ shares: units(500), liquidityFails: true }) + await expect(venue.prepare(WITHDRAW, ACCOUNT)).resolves.toMatchObject({ kind: 'withdraw' }) + expect(calls).toHaveLength(1) + }) + + it('refuses a withdrawal that fails on the chain', async () => { + const { venue } = venueWith({ shares: units(500), simulationFails: true }) + await expect(venue.prepare(WITHDRAW, ACCOUNT)).rejects.toThrow(/would fail on the chain/) + }) +}) diff --git a/apps/dapp/lib/__tests__/navi-withdraw.live.test.ts b/apps/dapp/lib/__tests__/navi-withdraw.live.test.ts new file mode 100644 index 00000000..95d5a2ce --- /dev/null +++ b/apps/dapp/lib/__tests__/navi-withdraw.live.test.ts @@ -0,0 +1,89 @@ +import { getConfig, getPools } from '@naviprotocol/lending' +import { describe, expect, it } from 'vitest' + +import { suiClient } from '../chains/sui/client' +import { dryRunChange } from '../chains/sui/lending-calls' +import { naviVenue } from '../chains/sui/navi' +import { + NAVI_INCENTIVE_V2, + NAVI_INCENTIVE_V3, + NAVI_ORACLE_CONFIG, + NAVI_ORACLE_PACKAGE, + NAVI_PRICE_ORACLE, + NAVI_STORAGE, + NAVI_SUPRA_HOLDER, + NAVI_SWITCHBOARD, + NAVI_USDC_ASSET_ID, + NAVI_USDC_FEED, + NAVI_USDC_POOL, + NAVI_USDC_PYTH_OBJECT, + NAVI_USER_INFO, + readNaviHoldings, +} from '../chains/sui/navi-withdraw' +import { SUI_TOKENS } from '../chains/sui/tokens' + +/** + * Real transactions built and dry-run against mainnet, for public addresses whose keys + * nobody here has. Nothing signs or sends. If the holder moves its funds these fail, so + * swap it for another wallet whose only NAVI position is USDC. + */ +const SKIP = process.env['SKIP_LIVE'] === '1' +const USDC_ONLY = '0xb26c1e93bed3707be8bb037aa36e9d2d4f640dbd4b470a214ab3e1b59983a40a' +const NEVER_USED = '0x0000000000000000000000000000000000000000000000000000000000001234' +const USDC = SUI_TOKENS.find((t) => t.symbol === 'USDC')?.id ?? '' + +describe.skipIf(SKIP)('NAVI withdrawals against mainnet', () => { + it("still matches NAVI's published config, or the pins need a fresh look", async () => { + const config = await getConfig({ env: 'prod' }) + expect(config.storage).toBe(NAVI_STORAGE) + expect(config.incentiveV2).toBe(NAVI_INCENTIVE_V2) + expect(config.incentiveV3).toBe(NAVI_INCENTIVE_V3) + expect(config.priceOracle).toBe(NAVI_PRICE_ORACLE) + expect(config.oracle.packageId).toBe(NAVI_ORACLE_PACKAGE) + expect(config.oracle.oracleConfig).toBe(NAVI_ORACLE_CONFIG) + expect(config.oracle.supraOracleHolder).toBe(NAVI_SUPRA_HOLDER) + expect(config.oracle.switchboardAggregator).toBe(NAVI_SWITCHBOARD) + expect(config.oracle.updateFunction).toBe('update_single_price_v3') + const feed = config.oracle.feeds.find((f) => f.oracleId === NAVI_USDC_ASSET_ID) + expect(feed?.feedId).toBe(NAVI_USDC_FEED) + expect(feed?.pythPriceInfoObject).toBe(NAVI_USDC_PYTH_OBJECT) + }, 30_000) + + it("still has the pinned USDC pool in NAVI's list", async () => { + const pools = await getPools({ env: 'prod' }) + const pool = pools.find((p) => p.id === NAVI_USDC_ASSET_ID) + expect(pool?.contract.pool).toBe(NAVI_USDC_POOL) + expect(pool?.suiCoinType).toBe(USDC) + }, 30_000) + + it('has the user table on the pinned storage object', async () => { + const { object } = await suiClient().core.getObject({ + objectId: NAVI_STORAGE, + include: { json: true }, + }) + const json = object.json as { user_info: { id: string } } + expect(json.user_info.id).toBe(NAVI_USER_INFO) + }, 30_000) + + it('reads holdings from the chain, and finds none for an unused wallet', async () => { + expect((await readNaviHoldings(USDC_ONLY))?.collaterals).toEqual([NAVI_USDC_ASSET_ID]) + expect(await readNaviHoldings(NEVER_USED)).toBeUndefined() + }, 30_000) + + it('builds a withdrawal from pinned ids, passes its check and pays what was asked', async () => { + const prepared = await naviVenue.prepare( + { kind: 'withdraw', amount: '0.001', tokenIn: 'USDC' }, + USDC_ONLY + ) + expect(() => naviVenue.assertSafe(prepared.signing, USDC_ONLY)).not.toThrow() + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + const received = await dryRunChange(prepared.signing.transaction, USDC, USDC_ONLY, 'Dry run') + expect(Number(received)).toBe(1000) + }, 60_000) + + it('says what the wallet holds when asked for more', async () => { + await expect( + naviVenue.prepare({ kind: 'withdraw', amount: '1000', tokenIn: 'USDC' }, USDC_ONLY) + ).rejects.toThrow(/You have about .* USDC in NAVI/) + }, 60_000) +}) diff --git a/apps/dapp/lib/__tests__/navi-withdraw.test.ts b/apps/dapp/lib/__tests__/navi-withdraw.test.ts new file mode 100644 index 00000000..9ae9528b --- /dev/null +++ b/apps/dapp/lib/__tests__/navi-withdraw.test.ts @@ -0,0 +1,202 @@ +import { Transaction } from '@mysten/sui/transactions' +import { toBase64 } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { CAPTURED_NAVI_DEPOSIT, CAPTURED_SENDER } from '../chains/sui/captured-transactions' +import { naviVenue } from '../chains/sui/navi' +import { + assertWithdrawableHoldings, + NAVI_USDC_ASSET_ID, + prepareNaviWithdraw, +} from '../chains/sui/navi-withdraw' +import { ChainActionRefused, type ChainActionRequest } from '../chains/types' +import { + callNamed, + inputOf, + NAVI_WITHDRAW, + NAVI_WITHDRAW_SENDER, + OTHER_ADDRESS, + OTHER_OBJECT, + pointAt, + tampered, +} from './sui-lending-captured' + +const USDT = '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT' +const ME = '0x0000000000000000000000000000000000000000000000000000000000000abc' +const OTHER_ASSET = 3 + +function check(transaction: string, account = NAVI_WITHDRAW_SENDER): void { + naviVenue.assertSafe({ family: 'sui', transaction }, account) +} + +function withdraw(amount = '1'): ChainActionRequest { + return { kind: 'withdraw', amount, tokenIn: 'USDC' } +} + +describe('what the venue really built passes', () => { + it('a withdrawal', () => { + expect(() => check(NAVI_WITHDRAW)).not.toThrow() + }) + + it('a deposit, unchanged by the new checks', () => { + expect(() => + naviVenue.assertSafe({ family: 'sui', transaction: CAPTURED_NAVI_DEPOSIT }, CAPTURED_SENDER) + ).not.toThrow() + }) +}) + +describe('NAVI refuses a withdrawal transaction that', () => { + it('another account would sign', () => { + expect(() => check(NAVI_WITHDRAW, OTHER_ADDRESS)).toThrow(/different account/) + }) + + it('calls a contract that is not confirmed', async () => { + const tx = await tampered(NAVI_WITHDRAW, (t) => { + callNamed(t, 'entry_withdraw').package = OTHER_OBJECT + }) + expect(() => check(tx)).toThrow(/haven't confirmed for NAVI/) + }) + + it.each(['withdraw_v2', 'entry_borrow_v2', 'borrow_v2'])( + 'calls %s, which this venue never uses', + async (name) => { + const tx = await tampered(NAVI_WITHDRAW, (t) => { + callNamed(t, 'entry_withdraw').function = name + }) + expect(() => check(tx)).toThrow(/haven't confirmed for NAVI/) + } + ) + + it('passes an extra argument', async () => { + const tx = await tampered(NAVI_WITHDRAW, (t) => { + const call = callNamed(t, 'entry_withdraw') + const extra = call.arguments[0] + if (extra === undefined) throw new Error('no argument') + call.arguments.push(extra) + }) + expect(() => check(tx)).toThrow(/wrong arguments/) + }) + + it('takes an extra step of a kind it never needs', async () => { + const base = Transaction.from(NAVI_WITHDRAW) + base.makeMoveVec({ type: 'u64', elements: [] }) + const tx = toBase64(await base.build()) + expect(() => check(tx)).toThrow(/does more than NAVI needs/) + }) + + it('pays anything to someone else through the framework', async () => { + const base = Transaction.from(NAVI_WITHDRAW) + base.moveCall({ + target: '0x2::transfer::public_transfer', + typeArguments: ['0x2::sui::SUI'], + arguments: [base.gas, base.pure.address(OTHER_ADDRESS)], + }) + const tx = toBase64(await base.build()) + expect(() => check(tx)).toThrow(ChainActionRefused) + }) + + it('pays anything to someone else with a transfer', async () => { + const base = Transaction.from(NAVI_WITHDRAW) + base.transferObjects([base.gas], OTHER_ADDRESS) + const tx = toBase64(await base.build()) + expect(() => check(tx)).toThrow(/another address/) + }) + + it('uses a token other than USDC', async () => { + const tx = await tampered(NAVI_WITHDRAW, (t) => { + callNamed(t, 'entry_withdraw').typeArguments = [USDT] + }) + expect(() => check(tx)).toThrow(/token NAVI isn't confirmed for/) + }) + + it.each([ + ['price oracle', 1], + ['storage', 2], + ['pool', 3], + ['first incentive', 6], + ['second incentive', 7], + ])('points at another %s', async (_name, index) => { + const tx = await tampered(NAVI_WITHDRAW, (t) => { + pointAt(inputOf(t, callNamed(t, 'entry_withdraw'), index), OTHER_OBJECT) + }) + expect(() => check(tx)).toThrow(/object NAVI doesn't use/) + }) + + it('names another asset', async () => { + const tx = await tampered(NAVI_WITHDRAW, (t) => { + const input = inputOf(t, callNamed(t, 'entry_withdraw'), 4) + if (input.Pure === undefined) throw new Error('not a literal') + input.Pure.bytes = 'AQ==' + }) + expect(() => check(tx)).toThrow(/value NAVI doesn't use/) + }) + + it('updates a price from another feed or object', async () => { + const feed = await tampered(NAVI_WITHDRAW, (t) => { + const input = inputOf(t, callNamed(t, 'update_single_price_v3'), 6) + if (input.Pure === undefined) throw new Error('not a literal') + input.Pure.bytes = toBase64(new Uint8Array(32)) + }) + expect(() => check(feed)).toThrow(/value NAVI doesn't use/) + const pyth = await tampered(NAVI_WITHDRAW, (t) => { + pointAt(inputOf(t, callNamed(t, 'update_single_price_v3'), 4), OTHER_OBJECT) + }) + expect(() => check(pyth)).toThrow(/object NAVI doesn't use/) + }) +}) + +describe('assertWithdrawableHoldings', () => { + it('passes a USDC-only position', () => { + expect(() => + assertWithdrawableHoldings({ collaterals: [NAVI_USDC_ASSET_ID], loans: [] }) + ).not.toThrow() + }) + + it('refuses a wallet with no USDC in NAVI', () => { + expect(() => assertWithdrawableHoldings(undefined)).toThrow(/no USDC in NAVI/) + expect(() => assertWithdrawableHoldings({ collaterals: [OTHER_ASSET], loans: [] })).toThrow( + /no USDC in NAVI/ + ) + }) + + it('sends a position with more in it to NAVI, rather than build what would abort', () => { + const others = { collaterals: [NAVI_USDC_ASSET_ID, OTHER_ASSET], loans: [] } + const loan = { collaterals: [NAVI_USDC_ASSET_ID], loans: [OTHER_ASSET] } + expect(() => assertWithdrawableHoldings(others)).toThrow(/app\.naviprotocol\.io/) + expect(() => assertWithdrawableHoldings(loan)).toThrow(/app\.naviprotocol\.io/) + }) +}) + +describe('prepareNaviWithdraw refuses before anything is built', () => { + const holdsUsdc = async () => ({ collaterals: [NAVI_USDC_ASSET_ID], loans: [] }) + + it('a token other than USDC', async () => { + await expect( + prepareNaviWithdraw({ ...withdraw(), tokenIn: 'SUI' }, ME, holdsUsdc) + ).rejects.toThrow(/takes USDC here, not SUI/) + }) + + it('a malformed account', async () => { + await expect(prepareNaviWithdraw(withdraw(), 'nope', holdsUsdc)).rejects.toThrow( + /not a Sui address/ + ) + }) + + it('more decimals than USDC has', async () => { + await expect(prepareNaviWithdraw(withdraw('0.0000001'), ME, holdsUsdc)).rejects.toThrow( + /decimal places/ + ) + }) + + it('a wallet with nothing in NAVI', async () => { + await expect(prepareNaviWithdraw(withdraw(), ME, async () => undefined)).rejects.toThrow( + /no USDC in NAVI/ + ) + }) +}) + +describe('the venue', () => { + it('offers supply and withdraw', () => { + expect(naviVenue.kinds).toEqual(['supply', 'withdraw']) + }) +}) diff --git a/apps/dapp/lib/__tests__/near-stake.test.ts b/apps/dapp/lib/__tests__/near-stake.test.ts index 74d8049d..469941b4 100644 --- a/apps/dapp/lib/__tests__/near-stake.test.ts +++ b/apps/dapp/lib/__tests__/near-stake.test.ts @@ -198,26 +198,21 @@ describe('what both stake venues refuse', () => { describe('the module', () => { it('stakes with Meta Pool by default, and with rNEAR when named', async () => { - const env = { MAINNET_MAX_TRADE_USD: '500' } - await expect(prepareChainAction(nearModule, stake(), ME, env)).resolves.toMatchObject({ + await expect(prepareChainAction(nearModule, stake(), ME)).resolves.toMatchObject({ venueId: 'metapool', }) await expect( - prepareChainAction(nearModule, stake({ venue: 'rNEAR' }), ME, env) + prepareChainAction(nearModule, stake({ venue: 'rNEAR' }), ME) ).resolves.toMatchObject({ venueId: 'rnear' }) await expect( - prepareChainAction(nearModule, stake({ venue: 'meta pool' }), ME, env) + prepareChainAction(nearModule, stake({ venue: 'meta pool' }), ME) ).resolves.toMatchObject({ venueId: 'metapool' }) }) - it('refuses a stake over the cap, and a venue that cannot stake', async () => { - const env = { MAINNET_MAX_TRADE_USD: '500' } - await expect(prepareChainAction(nearModule, stake({ amount: '126' }), ME, env)).rejects.toThrow( - /caps each trade/ + it('refuses a venue that cannot stake', async () => { + await expect(prepareChainAction(nearModule, stake({ venue: '1click' }), ME)).rejects.toThrow( + /can't stake here/ ) - await expect( - prepareChainAction(nearModule, stake({ venue: '1click' }), ME, env) - ).rejects.toThrow(/can't stake here/) }) it('has exactly the allowlisted tokens at the confirmed decimals', () => { diff --git a/apps/dapp/lib/__tests__/near-swap.test.ts b/apps/dapp/lib/__tests__/near-swap.test.ts index f6692ad3..e6f6853c 100644 --- a/apps/dapp/lib/__tests__/near-swap.test.ts +++ b/apps/dapp/lib/__tests__/near-swap.test.ts @@ -490,35 +490,18 @@ describe('what 1Click can refuse', () => { }) describe('the whole prepare path for NEAR', () => { - const noCap = { MAINNET_MAX_TRADE_USD: '500' } - it('defaults swaps to 1Click', async () => { - const prepared = await prepareChainAction(nearModule, swap(), ME, noCap) + const prepared = await prepareChainAction(nearModule, swap(), ME) expect(prepared.venueId).toBe('1click') }) - it('refuses over the cap before asking for any quote', async () => { - await expect( - prepareChainAction(nearModule, swap({ amount: '501' }), ME, noCap) - ).rejects.toThrow(/caps each trade/) - await expect( - prepareChainAction( - nearModule, - swap({ tokenIn: 'NEAR', tokenOut: 'USDC', amount: '126' }), - ME, - noCap - ) - ).rejects.toThrow(/caps each trade/) - expect(sim.quotes).toHaveLength(0) - }) - it('refuses an unknown token and a venue that cannot do this', async () => { - await expect( - prepareChainAction(nearModule, swap({ tokenIn: 'DOGE' }), ME, noCap) - ).rejects.toThrow(/verified token/) - await expect( - prepareChainAction(nearModule, swap({ venue: 'rnear' }), ME, noCap) - ).rejects.toThrow(/can't swap here/) + await expect(prepareChainAction(nearModule, swap({ tokenIn: 'DOGE' }), ME)).rejects.toThrow( + /verified token/ + ) + await expect(prepareChainAction(nearModule, swap({ venue: 'rnear' }), ME)).rejects.toThrow( + /can't swap here/ + ) }) it('reads the price of the exact asset, not another with the same symbol', async () => { @@ -534,13 +517,10 @@ describe('the whole prepare path for NEAR', () => { ).resolves.toBeUndefined() }) - it('has no price when the token list fails, so the trade is refused', async () => { + it('has no price when the token list fails', async () => { vi.mocked(fetch).mockImplementation(async () => new Response('nope', { status: 500 })) const usdc = nearModule.tokens.find((t) => t.symbol === 'USDC') await expect(nearModule.priceUsd(usdc as NonNullable)).resolves.toBeUndefined() - await expect(prepareChainAction(nearModule, swap(), ME, noCap)).rejects.toThrow( - /could not be valued/ - ) }) }) diff --git a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts index a567b763..12687ca9 100644 --- a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts +++ b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts @@ -24,7 +24,7 @@ describe('capabilitySheet per chain', () => { it('says agents compete on Solana and Sui now their venues can be quoted', () => { expect(capabilitySheet('solana')).toContain('Agents compete') expect(capabilitySheet('sui')).toContain('Agents compete') - expect(capabilitySheet('solana')).not.toContain('Stake:') + expect(capabilitySheet('solana')).toContain('Stake:') }) it('leaves a chain with no module as it was', () => { @@ -48,21 +48,22 @@ describe('capabilitySheet reads what the chain can run on this server', () => { return (await import('../orchestrate/capabilities')).capabilitySheet } - it('promises nothing on Avalanche and Monad with no secret to issue a deposit', async () => { + it('promises only the onchain venues on Avalanche and Monad with no secret', async () => { const sheet = await sheetWith('') for (const chain of ['avalanche', 'monad']) { - expect(sheet(chain)).toBe( - '- Nothing can be swapped, supplied or staked on this chain right now.' - ) + expect(sheet(chain)).toContain('Swap:') + expect(sheet(chain)).toContain('Supply:') + expect(sheet(chain)).not.toContain('Send:') } - }) + }, 60_000) it('promises the race on them once the secret is set', async () => { const sheet = await sheetWith('x'.repeat(48)) for (const chain of ['avalanche', 'monad']) { expect(sheet(chain)).toContain('Swap:') expect(sheet(chain)).toContain('Agents compete') - expect(sheet(chain)).not.toMatch(/Supply:|Stake:/) + expect(sheet(chain)).toContain('Supply:') + expect(sheet(chain)).toContain('Borrow and repay:') } }, 60_000) @@ -123,12 +124,13 @@ describe('capabilitySheet multi-step forms', () => { expect(near).toMatch(/stake after a swap is not available on NEAR/i) }, 60_000) - it('says Monad runs swap and send, and not cash-out or deposit', async () => { + it('says Monad runs swap then deposit or send, and not cash-out', async () => { const sheet = await sheetWith('k') const monad = sheet('monad') expect(monad).toMatch(/Swap then send:/) - expect(monad).not.toMatch(/Swap then (cash out|deposit):/) - expect(monad).toMatch(/Cash out and deposit after a swap do not run on Monad/) + expect(monad).toMatch(/Swap then deposit:/) + expect(monad).not.toMatch(/Swap then cash out:/) + expect(monad).toMatch(/Cash out after a swap does not run on Monad/) }, 60_000) it('says which native coins cannot be sent yet', async () => { @@ -179,9 +181,9 @@ describe('the orchestrator prompt', () => { it('is byte for byte what it was on Stellar', async () => { vi.stubEnv('WEAVE_SECRET_KEY', '') const system = await systemFor('stellar') - expect(system.length).toBe(6270) + expect(system.length).toBe(6227) expect(createHash('sha256').update(system).digest('hex')).toBe( - '06762643ae6b219a91581fc48e65b67563bb70062f2761e60a8a5d3aeae9be1b' + '6cead16754955621290de2860fcd3356f1456c9e7a70472f09972e5c748fbe69' ) }) diff --git a/apps/dapp/lib/__tests__/pharaoh.live.test.ts b/apps/dapp/lib/__tests__/pharaoh.live.test.ts new file mode 100644 index 00000000..01d83754 --- /dev/null +++ b/apps/dapp/lib/__tests__/pharaoh.live.test.ts @@ -0,0 +1,156 @@ +import { formatUnits, parseAbi, parseUnits, type Address } from 'viem' +import { describe, expect, it } from 'vitest' + +import { evmReader } from '../chains/evm/client' +import { createPharaohVenue, pharaohRouter } from '../chains/evm/pharaoh' +import { ChainActionRefused } from '../chains/types' +import { WAVAX } from '../chains/evm/v3-swap' +import { fakeClient, fundedReader, wethHolding } from './v3-swap-suite' + +/** + * The real contracts, read-only. Nothing signs or sends: the swaps below are `eth_call`s from an + * account that only exists inside the call, given a balance by a state override. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const ME = '0x00000000000000000000000000000000000feed1' +const MUCH = BigInt('1000000000000000000000000') +const IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' +const DEPLOYER = '0x6a4113ed0915bCf5E48e758e8f4cEBFFC07C66f9' +const FACTORY = '0xAE6E5c62328ade73ceefD42228528b70c8157D0d' + +const IMMUTABLES = parseAbi([ + 'function deployer() view returns (address)', + 'function WETH9() view returns (address)', +]) +const FACTORY_ABI = parseAbi([ + 'function ramsesV3PoolDeployer() view returns (address)', + 'function tickSpacingInitialFee(int24) view returns (uint24)', +]) + +describe.skipIf(SKIP)('Pharaoh, live', () => { + const router = pharaohRouter() + const reader = evmReader('avalanche') + + it.each([ + ['router', router.router], + ['quoter', router.quoter], + ])( + 'the %s answers with the docs’ deployer and wrapped coin', + async (_label, address) => { + const [deployer, weth] = await Promise.all([ + reader.readContract({ + address: address as Address, + abi: IMMUTABLES, + functionName: 'deployer', + }), + reader.readContract({ + address: address as Address, + abi: IMMUTABLES, + functionName: 'WETH9', + }), + ]) + expect(deployer.toLowerCase()).toBe(DEPLOYER.toLowerCase()) + expect(weth.toLowerCase()).toBe(WAVAX.id.toLowerCase()) + }, + 30_000 + ) + + it('the factory names that deployer', async () => { + const deployer = await reader.readContract({ + address: FACTORY, + abi: FACTORY_ABI, + functionName: 'ramsesV3PoolDeployer', + }) + expect(deployer.toLowerCase()).toBe(DEPLOYER.toLowerCase()) + }, 30_000) + + it('the router holds the functions this venue calls, and is no proxy', async () => { + const [code, implementation] = await Promise.all([ + reader.getCode({ address: router.router as Address }), + reader.getStorageAt({ address: router.router as Address, slot: IMPLEMENTATION_SLOT }), + ]) + expect(code).toContain('ac9650d8') + expect(code).toContain('12210e8a') + expect(BigInt(implementation ?? '0x0')).toBe(BigInt(0)) + }, 30_000) + + it('every tick spacing is switched on in the factory', async () => { + for (const tier of router.tiers) { + const fee = await reader.readContract({ + address: FACTORY, + abi: FACTORY_ABI, + functionName: 'tickSpacingInitialFee', + args: [tier], + }) + expect(fee).toBeGreaterThan(0) + } + }, 30_000) + + it('quotes a real price', async () => { + const quoted = await createPharaohVenue().quote?.( + { kind: 'swap', amount: '20', tokenIn: 'USDC', tokenOut: 'WAVAX' }, + ME + ) + expect(Number(quoted?.receive?.amount)).toBeGreaterThan(0) + }, 30_000) + + it('swaps AVAX in, wrapped by the router, in a call from a funded account', async () => { + const venue = createPharaohVenue({ + reader: () => fundedReader(reader, [{ address: ME as Address, balance: MUCH }], BigInt(0)), + client: () => fakeClient(), + }) + // `prepare` runs the call against the chain and refuses if it would fail. + const prepared = await venue.prepare( + { kind: 'swap', amount: '1', tokenIn: 'AVAX', tokenOut: 'USDC' }, + ME + ) + expect(Number(prepared.receive?.amount)).toBeGreaterThan(0) + }, 60_000) + + it('swaps WAVAX in, pulled under an exact allowance, in a call from a funded account', async () => { + const units = parseUnits('2', WAVAX.decimals) + const venue = createPharaohVenue({ + reader: () => + fundedReader( + reader, + [ + { address: ME as Address, balance: MUCH }, + { address: WAVAX.id as Address, stateDiff: wethHolding(ME, router.router, units) }, + ], + units + ), + client: () => fakeClient(), + }) + const prepared = await venue.prepare( + { + kind: 'swap', + amount: formatUnits(units, WAVAX.decimals), + tokenIn: 'WAVAX', + tokenOut: 'USDC', + }, + ME + ) + expect(prepared.signing).not.toHaveProperty('approval') + expect(Number(prepared.receive?.amount)).toBeGreaterThan(0) + }, 60_000) + + it('refuses that same swap from an account that holds none, so the two above prove something', async () => { + const units = parseUnits('2', WAVAX.decimals) + const venue = createPharaohVenue({ + reader: () => fundedReader(reader, [{ address: ME as Address, balance: MUCH }], units), + client: () => fakeClient(), + }) + await expect( + venue.prepare( + { + kind: 'swap', + amount: formatUnits(units, WAVAX.decimals), + tokenIn: 'WAVAX', + tokenOut: 'USDC', + }, + ME + ) + ).rejects.toThrow(ChainActionRefused) + }, 60_000) +}) diff --git a/apps/dapp/lib/__tests__/pharaoh.test.ts b/apps/dapp/lib/__tests__/pharaoh.test.ts new file mode 100644 index 00000000..89499aaa --- /dev/null +++ b/apps/dapp/lib/__tests__/pharaoh.test.ts @@ -0,0 +1,113 @@ +import { + decodeFunctionData, + encodeFunctionData, + parseAbi, + toFunctionSelector, + type Address, + type Hex, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import { createPharaohVenue } from '../chains/evm/pharaoh' +import { + ACCOUNT, + describeV3SwapVenue, + fakeClient, + fakeReader, + type RouterFixture, + type SwapFields, +} from './v3-swap-suite' + +// Written out by hand from the source Routescan verifies at this router, apart from the code under test. +const ROUTER_ABI = parseAbi([ + 'struct Params { address tokenIn; address tokenOut; int24 tickSpacing; address recipient; uint256 deadline; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; }', + 'function exactInputSingle(Params params) payable returns (uint256 amountOut)', + 'function multicall(bytes[] data) payable returns (bytes[] results)', +]) + +function encodeSwap(fields: SwapFields): Hex { + return encodeFunctionData({ + abi: ROUTER_ABI, + functionName: 'exactInputSingle', + args: [ + { + tokenIn: fields.tokenIn as Address, + tokenOut: fields.tokenOut as Address, + tickSpacing: fields.tier, + recipient: fields.recipient as Address, + deadline: fields.deadline, + amountIn: fields.amountIn, + amountOutMinimum: fields.amountOutMinimum, + sqrtPriceLimitX96: fields.sqrtPriceLimitX96, + }, + ], + }) +} + +function decodeSwap(data: Hex): SwapFields & { outer: Hex[] } { + const outer = decodeFunctionData({ abi: ROUTER_ABI, data }) + if (outer.functionName !== 'multicall') throw new Error('not a multicall') + const [calls] = outer.args + const inner = decodeFunctionData({ abi: ROUTER_ABI, data: calls[0] as Hex }) + if (inner.functionName !== 'exactInputSingle') throw new Error('not a swap') + const [params] = inner.args + return { + tokenIn: params.tokenIn, + tokenOut: params.tokenOut, + tier: params.tickSpacing, + recipient: params.recipient, + amountIn: params.amountIn, + amountOutMinimum: params.amountOutMinimum, + sqrtPriceLimitX96: params.sqrtPriceLimitX96, + deadline: params.deadline, + outer: [...calls], + } +} + +const PHARAOH: RouterFixture = { + label: 'Pharaoh', + venue: (reader, client) => createPharaohVenue({ reader: () => reader, client: () => client }), + chainId: 43114, + otherChainId: 143, + router: '0xc8B8fCbDb5C019D7802fFb0b39603395D7d3915c', + quoter: '0xB7297301b7CC659BB96D51754643A0Df6eEA2138', + nativeSymbol: 'AVAX', + wrapped: { symbol: 'WAVAX', id: '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7' }, + stable: { symbol: 'USDC', id: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E' }, + stable2: { symbol: 'USDT', id: '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7' }, + bestTier: 10, + worseTier: 1, + unverifiedTier: 500, + tierOf: (params) => params['tickSpacing'] as number, + encodeSwap, + encodeOuter: (calls) => + encodeFunctionData({ abi: ROUTER_ABI, functionName: 'multicall', args: [calls] }), + deadlineIn: 'swap', + decodeSwap, +} + +describeV3SwapVenue(PHARAOH) + +describe('Pharaoh encoding', () => { + it('uses the multicall selector in the deployed bytecode', () => { + expect(toFunctionSelector(ROUTER_ABI[1])).toBe('0xac9650d8') + }) + + it('only swaps, under its one id', () => { + expect(createPharaohVenue().id).toBe('pharaoh') + expect(createPharaohVenue().kinds).toEqual(['swap']) + }) + + it('says on the card that its audits are not of its own deployment', async () => { + const { reader } = fakeReader(PHARAOH, { rate: { 10: BigInt(900_000) } }) + const venue = createPharaohVenue({ reader: () => reader, client: () => fakeClient() }) + const prepared = await venue.prepare( + { kind: 'swap', amount: '5', tokenIn: 'USDC', tokenOut: 'USDT' }, + ACCOUNT + ) + const notes = prepared.notes.join(' ') + expect(notes).toContain('not a review of Pharaoh’s deployment') + expect(notes).toContain('about $93,000') + expect(notes).toContain('change a pool’s fee') + }) +}) diff --git a/apps/dapp/lib/__tests__/recipient-names.live.test.ts b/apps/dapp/lib/__tests__/recipient-names.live.test.ts new file mode 100644 index 00000000..c1f0410a --- /dev/null +++ b/apps/dapp/lib/__tests__/recipient-names.live.test.ts @@ -0,0 +1,232 @@ +import { bcs } from '@mysten/sui/bcs' +import { createPublicClient, http, parseAbi } from 'viem' +import { describe, expect, it } from 'vitest' + +import { + ANS_MONAD_REGISTRY, + ANS_MONAD_RESOLVER, + ANS_MONAD_ROOT, +} from '../chains/names/alldomains-monad' +import { ansAccounts } from '../chains/names/alldomains-solana' +import { resolveName } from '../chains/names/resolve' +import { snsAccounts, solAccounts } from '../chains/names/sns' +import { + addressAt, + soleHolder, + solanaNameRpc, + TOKEN_2022_PROGRAM, +} from '../chains/names/solana-rpc' +import { SUINS_REGISTRY_TABLE } from '../chains/names/suins' +import { NAME_TLDS, recipientName } from '../chains/names/tlds' +import { suiClient } from '../chains/sui/client' +import { ChainActionRefused, type ChainWithActions } from '../chains/types' + +/** + * Read-only: that the ids pinned for names still hold on chain, and that real names read as the + * resolver says. Signs nothing and sends nothing. Solana's public endpoint rate-limits hard, so + * these run one after another with a pause, and a name that changes hands fails its test, which + * says to look rather than that the code is wrong. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const SLOW_MS = 90_000 +const SOLANA_URL = process.env['SOLANA_RPC_URL']?.trim() || 'https://api.mainnet-beta.solana.com' +const MONAD_URL = 'https://rpc.monad.xyz' + +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +async function reads(chain: ChainWithActions, text: string): Promise { + await wait(1_500) + const typed = recipientName(text) + if (typed === undefined) throw new Error(`${text} is not a name`) + return (await resolveName(chain, typed)).address +} + +async function refusal(chain: ChainWithActions, text: string): Promise { + try { + await reads(chain, text) + } catch (e) { + if (e instanceof ChainActionRefused) return e.message + throw e + } + throw new Error(`${text} read without a refusal`) +} + +async function account(address: string): Promise<{ owner: string; executable: boolean } | null> { + await wait(1_500) + const reply = await fetch(SOLANA_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getAccountInfo', + params: [address, { encoding: 'base64', dataSlice: { offset: 0, length: 0 } }], + }), + }) + const body = (await reply.json()) as { + result: { value: { owner: string; executable: boolean } | null } + } + return body.result.value +} + +describe.skipIf(SKIP).sequential('names on chain', () => { + it( + "SNS and .sol programs are still programs, and the root is the name program's", + async () => { + const programs = [ + 'namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX', + 'nftD3vbNkNqfj2Sd3HZwbpw4BxxKWr4AjGb9X38JeZk', + 'srsWjm76StJucL7atFyPSdXFaVLNPFqEt1uFEDPrZsn', + 'ALTNSZ46uaAUU7XUV6awvdorLGqAsPwa9shm7h4uP2FK', + 'NH3uX6FtVE2fNREAioP7hm5RaozotZxeL6khU1EHx51', + ] + for (const program of programs) { + expect((await account(program))?.executable).toBe(true) + } + expect((await account('58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx'))?.owner).toBe( + 'namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX' + ) + }, + SLOW_MS + ) + + it( + 'a .sol name both registries agree on reads to that wallet, and .sns to the same', + async () => { + const { record } = await solAccounts('toly') + const { domain } = await snsAccounts('toly') + const [srs, sns] = await solanaNameRpc().accounts([record, domain]) + expect(srs).not.toBeNull() + expect(sns).not.toBeNull() + const owner = addressAt(sns?.data as Uint8Array, 32) + expect(addressAt(srs?.data as Uint8Array, 34)).toBe(owner) + expect(await reads('solana', 'toly.sol')).toBe(owner) + expect(await reads('solana', 'toly.sns')).toBe(owner) + }, + SLOW_MS + ) + + it( + 'a .sol name the registries disagree on is refused, with both wallets named', + async () => { + const words = await refusal('solana', 'mert.sol') + expect(words).toContain('two different wallets') + expect(words).toContain('Nothing was signed or sent.') + }, + SLOW_MS + ) + + it( + 'V2 and V1 records as they really are on chain', + async () => { + expect(await reads('solana', 'pumpfunian.sns')).toBe( + '25XoARksNA8wGUpTpqeczHc3yhkJgE2sCjHRvqqLi4Bb' + ) + expect(await reads('solana', 'erizzo.sns')).toBe( + 'GRQfjSEYfYEjnWbU7S8fzSmjWcex5Gn6XyfxbXdRmUVm' + ) + }, + SLOW_MS + ) + + it( + 'a tokenized name reads to one holder, and a holder that is a program is refused', + async () => { + const { nft } = await snsAccounts('chiba') + expect((await account(nft))?.owner).toBe('nftD3vbNkNqfj2Sd3HZwbpw4BxxKWr4AjGb9X38JeZk') + expect(await refusal('solana', 'chiba.sns')).toContain('held by a program') + // chiba is listed for sale, so Magic Eden's escrow holds it, as most sampled tokenized names were. + expect(await reads('solana', 'robansk2025.sns')).toBe( + 'BZxiMxyWkx9vLkidkMBGbph47vxEYxhe9we9g7mc93bH' + ) + }, + SLOW_MS + ) + + it( + 'a tokenized .sol record has one Token-2022 holder at its canonical mint', + async () => { + await wait(1_500) + expect( + await soleHolder( + solanaNameRpc(), + '22egh7pqeQZ9gCm5wfB5g6EGBgRwqdzPDS2p1oy7BMte', + TOKEN_2022_PROGRAM + ) + ).toBe('2q7yVFkqX1RfcEPBV94Wmw2QntXPVsAR8tqT5cw65npp') + }, + SLOW_MS + ) + + it( + 'an unregistered name is refused', + async () => { + const words = await refusal('solana', 'no-such-name-here-20261105.sns') + expect(words).toContain("isn't registered") + }, + SLOW_MS + ) + + it( + 'AllDomains on Solana: the origin derives, and a known name reads', + async () => { + const { parent } = await ansAccounts('miester', 'poor') + expect(parent).toBe('8err4ThuTiZo9LbozHAvMrzXUmyPWj9urnMo38vC6FdQ') + expect(await reads('solana', 'miester.poor')).toBe( + '2EGGxj2qbNAJNgLCPKca8sxZYetyTjnoRspTPjzN2D67' + ) + }, + SLOW_MS + ) + + it( + 'AllDomains on Monad: contracts hold code, the root names its registry and every ending', + async () => { + const client = createPublicClient({ transport: http(MONAD_URL, { timeout: 15_000 }) }) + for (const address of [ANS_MONAD_ROOT, ANS_MONAD_REGISTRY, ANS_MONAD_RESOLVER]) { + expect(((await client.getCode({ address })) ?? '0x').length).toBeGreaterThan(2) + } + const root = parseAbi([ + 'function registry() view returns (address)', + 'struct T { address a; address b; string tld; string d; string e; bool f; bytes32 g; bytes32 h; }', + 'function listTlds() view returns (T[])', + ]) + expect( + await client.readContract({ address: ANS_MONAD_ROOT, abi: root, functionName: 'registry' }) + ).toBe(ANS_MONAD_REGISTRY) + const tlds = await client.readContract({ + address: ANS_MONAD_ROOT, + abi: root, + functionName: 'listTlds', + }) + expect(tlds.map((t) => t.tld.slice(1)).sort()).toEqual([...NAME_TLDS.monad].sort()) + expect(await reads('monad', 'miester.mon')).toBe('0x94Bfb92da83B27B39370550CA038Af96d182462f') + }, + SLOW_MS + ) + + it( + 'SuiNS: the registry table is the one the SuiNS object holds, and names read', + async () => { + const V1 = '0xd22b24490e0bae52676651b4f56660a5ff8022a2576e0089f79b3c88d44e08f0' + const SUINS = '0x6e0ddefc0ad98889c04bab9639e512c21766c5e6366f89e696956d9be6952871' + const { dynamicField } = await suiClient().core.getDynamicField({ + parentId: SUINS, + name: { + type: `${V1}::suins::RegistryKey<${V1}::registry::Registry>`, + bcs: bcs.bool().serialize(false).toBytes(), + }, + }) + expect(dynamicField.value.type).toBe(`${V1}::registry::Registry`) + const table = `0x${Buffer.from(dynamicField.value.bcs.subarray(0, 32)).toString('hex')}` + expect(table).toBe(SUINS_REGISTRY_TABLE) + expect(await reads('sui', 'adeniyi.sui')).toBe( + '0x1eb7c57e3f2bd0fc6cb9dcffd143ea957e4d98f805c358733f76dee0667fe0b1' + ) + expect(await refusal('sui', 'ada.sui')).toContain('no address set') + expect(await refusal('sui', 'no-such-name-here-20261105.sui')).toContain("isn't registered") + }, + SLOW_MS + ) +}) diff --git a/apps/dapp/lib/__tests__/recipient-names.test.ts b/apps/dapp/lib/__tests__/recipient-names.test.ts new file mode 100644 index 00000000..7dc317de --- /dev/null +++ b/apps/dapp/lib/__tests__/recipient-names.test.ts @@ -0,0 +1,1213 @@ +import { generateKeyPair, getAddressFromPublicKey, signBytes, type Address } from '@solana/kit' +import { getAddress, keccak256, toBytes, zeroAddress } from 'viem' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ansAccounts, resolveAllDomainsSolana } from '../chains/names/alldomains-solana' +import { + ANS_MONAD_REGISTRY, + ANS_MONAD_RESOLVER, + ANS_MONAD_ROOT, + ansNode, + resolveAllDomainsMonad, + type MonadNameReader, +} from '../chains/names/alldomains-monad' +import { resolveName, resolveRecipient, withResolvedName } from '../chains/names/resolve' +import { snsAccounts, resolveSnsName, solAccounts, resolveSolName } from '../chains/names/sns' +import type { NameAccount, SolanaNameRpc } from '../chains/names/solana-rpc' +import { + NameRecord, + SUINS_NAME_RECORD_TYPE, + SUINS_REGISTRY_TABLE, + resolveSuiName, + type SuiNameReader, +} from '../chains/names/suins' +import { recipientName } from '../chains/names/tlds' +import { parseChainAction } from '../chains/parse-action' +import { prepareChainAction } from '../chains/prepare' +import { isValidRecipient, sendRefusal } from '../chains/recipient' +import { createSendVenue } from '../chains/send' +import { SOLANA_TOKENS } from '../chains/solana/tokens' +import { + ChainActionRefused, + type ChainActionKind, + type ChainActionRequest, + type ChainModule, + type ChainVenue, + type ChainWithActions, + type PreparedAction, +} from '../chains/types' + +vi.hoisted(() => { + process.env['AUTH_SECRET'] = 'x'.repeat(48) +}) + +const NOTHING = 'Nothing was signed or sent.' +const NAME_PROGRAM = 'namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX' +const SNS_ROOT = '58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx' +const TOKENIZER = 'nftD3vbNkNqfj2Sd3HZwbpw4BxxKWr4AjGb9X38JeZk' +const RECORDS_CENTRAL_STATE = '2pMnqHvei2N5oDcVGCRdZx48gqti199wr5CsyTTafsbo' +const SRS_PROGRAM = 'srsWjm76StJucL7atFyPSdXFaVLNPFqEt1uFEDPrZsn' +const SRS_CLASS = 'Dqc9TrYg1AZLEa6UkZUqCDrr9zsHQveo1szGTE1BTW5' +const ANS_PROGRAM = 'ALTNSZ46uaAUU7XUV6awvdorLGqAsPwa9shm7h4uP2FK' +const NAME_HOUSE = 'NH3uX6FtVE2fNREAioP7hm5RaozotZxeL6khU1EHx51' +const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +const TOKEN_2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' +const ZERO = '11111111111111111111111111111111' + +const { getAddressEncoder } = await import('@solana/kit') +const encode = (address: string): Uint8Array => + new Uint8Array(getAddressEncoder().encode(address as Address)) + +function bytes(...parts: (Uint8Array | number[])[]): Uint8Array { + return new Uint8Array(parts.flatMap((part) => [...part])) +} + +function u64(value: number | bigint): Uint8Array { + const out = new Uint8Array(8) + new DataView(out.buffer).setBigUint64(0, BigInt(value), true) + return out +} + +function i64(value: number | bigint): Uint8Array { + const out = new Uint8Array(8) + new DataView(out.buffer).setBigInt64(0, BigInt(value), true) + return out +} + +function registry(parent: string, owner: string, cls = ZERO, extra = 0): Uint8Array { + return bytes(encode(parent), encode(owner), encode(cls), new Uint8Array(extra)) +} + +function v1Data(domain: string, owner: string, target: string, signature: Uint8Array): Uint8Array { + return bytes(registry(domain, owner), encode(target), signature) +} + +function hexMessage(target: string, record: string): Uint8Array { + const raw = bytes(encode(target), encode(record)) + const hex = [...raw].map((b) => b.toString(16).padStart(2, '0')).join('') + return new TextEncoder().encode(hex) +} + +function v2Data( + domain: string, + stale: string, + roa: string, + content: Uint8Array, + { staleness = 1, right = 1 }: { staleness?: number; right?: number } = {} +): Uint8Array { + const header = new Uint8Array(8) + const view = new DataView(header.buffer) + view.setUint16(0, staleness, true) + view.setUint16(2, right, true) + view.setUint32(4, content.length, true) + return bytes( + registry(domain, RECORDS_CENTRAL_STATE, RECORDS_CENTRAL_STATE), + header, + encode(stale), + encode(roa), + content + ) +} + +function nftRecord(tag: number, nameAccount: string, mint: string): Uint8Array { + return bytes([tag, 255], encode(nameAccount), encode(ZERO), encode(mint)) +} + +function mintData({ supply = 1, decimals = 0, init = 1 } = {}): Uint8Array { + return bytes(new Uint8Array(36), u64(supply), [decimals, init], new Uint8Array(36)) +} + +function tokenData(mint: string, owner: string, amount = 1, state = 1): Uint8Array { + return bytes( + encode(mint), + encode(owner), + u64(amount), + new Uint8Array(36), + [state], + new Uint8Array(56) + ) +} + +function srsData( + cls: string, + ownerType: number, + owner: string, + { + frozen = 0, + expiry = 0, + discriminator = 2, + }: { frozen?: number; expiry?: number; discriminator?: number } = {} +): Uint8Array { + return bytes( + [discriminator], + encode(cls), + [ownerType], + encode(owner), + [frozen], + i64(expiry), + new Uint8Array(40) + ) +} + +class World implements SolanaNameRpc { + accountsByAddress = new Map() + holders = new Map() + reads = 0 + broken = false + + set(address: string, program: string, data: Uint8Array): void { + this.accountsByAddress.set(address, { program, data }) + } + + async accounts(addresses: readonly string[]): Promise<(NameAccount | null)[]> { + this.reads += 1 + if (this.broken) throw new Error('rpc down') + return addresses.map((address) => this.accountsByAddress.get(address) ?? null) + } + + async tokenAccounts(mint: string): Promise<{ address: string; data: Uint8Array }[]> { + this.reads += 1 + if (this.broken) throw new Error('rpc down') + return this.holders.get(mint) ?? [] + } +} + +let owner: { address: string; keys: CryptoKeyPair } +let oldOwner: { address: string; keys: CryptoKeyPair } +let wallet: string +let holder: string +let me: string + +async function keyed(): Promise<{ address: string; keys: CryptoKeyPair }> { + const keys = await generateKeyPair() + return { address: await getAddressFromPublicKey(keys.publicKey), keys } +} + +beforeAll(async () => { + owner = await keyed() + oldOwner = await keyed() + wallet = (await keyed()).address + holder = (await keyed()).address + me = (await keyed()).address +}) + +async function snsWorld(label = 'ada', domainOwner = owner.address): Promise { + const world = new World() + const { domain } = await snsAccounts(label) + world.set(domain, NAME_PROGRAM, registry(SNS_ROOT, domainOwner)) + return world +} + +async function setHolder( + world: World, + label = 'ada', + { tag = 2, supply = 1, holders = 1 }: { tag?: number; supply?: number; holders?: number } = {} +): Promise { + const { domain, nft, mint } = await snsAccounts(label) + world.set(domain, NAME_PROGRAM, registry(SNS_ROOT, nft)) + world.set(nft, TOKENIZER, nftRecord(tag, domain, mint)) + world.set(mint, TOKEN_PROGRAM, mintData({ supply })) + world.holders.set( + mint, + Array.from({ length: holders }, (_, i) => ({ + address: `token${i}`, + data: tokenData(mint, holder), + })) + ) +} + +async function setV2( + world: World, + target: string, + stale = owner.address, + roa = target +): Promise { + const { domain, v2 } = await snsAccounts('ada') + world.set(v2, NAME_PROGRAM, v2Data(domain, stale, roa, encode(target))) +} + +async function setV1(world: World, target: string, signer = owner.keys): Promise { + const { domain, v1 } = await snsAccounts('ada') + const signature = await signBytes(signer.privateKey, hexMessage(target, v1)) + world.set(v1, NAME_PROGRAM, v1Data(domain, owner.address, target, new Uint8Array(signature))) +} + +describe('what counts as a name', () => { + it('reads the shapes a person types, through every kind that names someone', () => { + expect(parseChainAction('send 50 USDC to ada.sol')).toMatchObject({ + kind: 'send', + recipient: 'ada.sol', + }) + expect(parseChainAction('send 50 USDC to ada.sol.')).toMatchObject({ recipient: 'ada.sol' }) + expect(parseChainAction('stream 100 USDC to bob.mon over 30 days')).toMatchObject({ + kind: 'stream', + recipient: 'bob.mon', + }) + expect(parseChainAction('invoice ada.skr for 100 USDC due 2027-01-01')).toMatchObject({ + kind: 'invoice', + recipient: 'ada.skr', + }) + }) + + it('lower-cases a name and leaves anything else alone', () => { + expect(recipientName('ADA.Sol')).toMatchObject({ name: 'ada.sol', label: 'ada', tld: 'sol' }) + expect(recipientName('pay.ada.sol')).toMatchObject({ tld: 'sol', sub: true }) + expect(recipientName('alice.near')).toBeUndefined() + expect(recipientName(SNS_ROOT)).toBeUndefined() + expect(recipientName(zeroAddress)).toBeUndefined() + expect(recipientName('ada.eth')).toBeUndefined() + }) + + it('never lets a name stand as an address, even where it fits the shape', () => { + expect(isValidRecipient('near', 'ada.sol')).toBe(false) + expect(isValidRecipient('near', 'ada.sui')).toBe(false) + expect(isValidRecipient('near', 'alice.near')).toBe(true) + }) +}) + +describe('a name on the wrong chain', () => { + const ON_SUI = `ada.sol is a Solana name, and this is on Sui, so it can't be read here. Switch to Solana, or use a Sui address. ${NOTHING}` + + it('says so in plain words', () => { + expect(sendRefusal('sui', 'USDC', 'ada.sol')).toBe(ON_SUI) + expect(sendRefusal('near', 'USDC', 'ada.sol')).toContain('ada.sol is a Solana name') + expect(sendRefusal('avalanche', 'USDC', 'ada.sol')).toContain('this is on Avalanche') + expect(sendRefusal('monad', 'USDC', 'ada.sns')).toContain('this is on Monad') + expect(sendRefusal('solana', 'USDC', 'bob.mon')).toContain('bob.mon is a Monad name') + expect(sendRefusal('solana', 'USDC', 'bob.sui')).toContain('bob.sui is a Sui name') + expect(sendRefusal('monad', 'USDC', 'bob.skr')).toContain('bob.skr is a Solana name') + }) + + it('refuses subnames and characters it will not read, on the right chain', () => { + expect(sendRefusal('solana', 'USDC', 'pay.ada.sol')).toContain('pay.ada.sol') + expect(sendRefusal('solana', 'USDC', 'pay.ada.sol')).toContain(NOTHING) + expect(sendRefusal('solana', 'USDC', 'a_b.sol')).toContain("can't be read as a name") + }) + + it('passes a name on its own chain, and still checks the token', () => { + expect(sendRefusal('solana', 'USDC', 'ada.sol')).toBeUndefined() + expect(sendRefusal('solana', 'USDC', 'ada.poor')).toBeUndefined() + expect(sendRefusal('sui', 'USDC', 'ada.sui')).toBeUndefined() + expect(sendRefusal('monad', 'USDC', 'ada.mon')).toBeUndefined() + expect(sendRefusal('solana', 'DOGE', 'ada.sol')).toContain('Sending DOGE') + }) + + it('refuses in the same words through the money path, with no read at all', async () => { + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + await expect( + prepareChainAction( + moduleWith('sui', [sendVenueStub('send')]), + { kind: 'send', amount: '1', tokenIn: 'USDC', recipient: 'ada.sol' }, + '0x' + '1'.repeat(64) + ) + ).rejects.toThrow(ON_SUI) + expect(fetchSpy).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) +}) + +describe('SNS: the order, on ada.sns', () => { + it('step 4: pays the owner when nothing else is set', async () => { + const world = await snsWorld() + await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(owner.address) + }) + + it('step 3: a signed V1 record beats the owner', async () => { + const world = await snsWorld() + await setV1(world, wallet) + await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(wallet) + }) + + it('step 2: a verified V2 record beats V1 and the owner', async () => { + const world = await snsWorld() + await setV1(world, holder) + await setV2(world, wallet) + await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(wallet) + }) + + it('step 1: the token holder beats every record', async () => { + const world = await snsWorld() + await setV2(world, wallet) + await setHolder(world) + await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(holder) + }) + + it('an inactive token record is passed over', async () => { + const world = await snsWorld() + const { domain, nft, mint } = await snsAccounts('ada') + world.set(nft, TOKENIZER, nftRecord(3, domain, mint)) + await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(owner.address) + }) +}) + +describe('SNS: refusals', () => { + const refused = async (world: SolanaNameRpc, match: RegExp | string): Promise => { + const attempt = resolveSnsName(world, 'ada.sns', 'ada') + await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused) + await expect(attempt).rejects.toThrow(match) + await expect(attempt).rejects.toThrow(NOTHING) + } + + it('an unregistered name', async () => { + await refused(new World(), "isn't registered") + }) + + it("a domain account not under the SNS root, or not the program's", async () => { + const world = new World() + const { domain } = await snsAccounts('ada') + world.set(domain, NAME_PROGRAM, registry(wallet, owner.address)) + await refused(world, 'could not be confirmed') + world.set(domain, wallet, registry(SNS_ROOT, owner.address)) + await refused(world, 'could not be confirmed') + }) + + it('a V1 record signed by nobody', async () => { + const world = await snsWorld() + const { domain, v1 } = await snsAccounts('ada') + world.set(v1, NAME_PROGRAM, v1Data(domain, owner.address, wallet, new Uint8Array(64))) + await refused(world, 'not signed') + }) + + it('a V1 record signed by a previous owner', async () => { + const world = await snsWorld() + await setV1(world, wallet, oldOwner.keys) + await refused(world, 'not signed') + }) + + it('a V1 record whose address was changed after signing', async () => { + const world = await snsWorld() + await setV1(world, wallet) + const { domain, v1 } = await snsAccounts('ada') + const signed = world.accountsByAddress.get(v1) as NameAccount + world.set(v1, NAME_PROGRAM, v1Data(domain, owner.address, holder, signed.data.slice(128))) + await refused(world, 'not signed') + }) + + it('a V1 record signed over the raw bytes, not the hex SNS signs', async () => { + const world = await snsWorld() + const { domain, v1 } = await snsAccounts('ada') + const signature = await signBytes(owner.keys.privateKey, bytes(encode(wallet), encode(v1))) + world.set(v1, NAME_PROGRAM, v1Data(domain, owner.address, wallet, new Uint8Array(signature))) + await refused(world, 'not signed') + }) + + it('a V1 record cut short', async () => { + const world = await snsWorld() + const { domain, v1 } = await snsAccounts('ada') + world.set(v1, NAME_PROGRAM, bytes(registry(domain, owner.address), encode(wallet))) + await refused(world, 'not signed') + }) + + it('a stale V2 record, set by a previous owner, even with a good V1 under it', async () => { + const world = await snsWorld() + await setV1(world, holder) + await setV2(world, wallet, oldOwner.address) + await refused(world, 'previous owner') + }) + + it('a V2 record whose right of association does not match its content', async () => { + const world = await snsWorld() + await setV2(world, wallet, owner.address, holder) + await refused(world, "doesn't prove") + }) + + it('a V2 record with validation other than Solana', async () => { + const world = await snsWorld() + const { domain, v2 } = await snsAccounts('ada') + world.set(v2, NAME_PROGRAM, v2Data(domain, owner.address, wallet, encode(wallet), { right: 3 })) + await refused(world, "doesn't prove") + }) + + it('a V2 record whose content is not one address', async () => { + const world = await snsWorld() + const { domain, v2 } = await snsAccounts('ada') + world.set(v2, NAME_PROGRAM, v2Data(domain, owner.address, wallet, new Uint8Array(20))) + await refused(world, "doesn't prove") + }) + + it('a record account the name program does not own', async () => { + const world = await snsWorld() + const { domain, v2 } = await snsAccounts('ada') + world.set(v2, wallet, v2Data(domain, owner.address, wallet, encode(wallet))) + await refused(world, "doesn't prove") + }) + + it('a token record with no sole holder', async () => { + let world = await snsWorld() + await setHolder(world, 'ada', { holders: 0 }) + await refused(world, 'single holder') + world = await snsWorld() + await setHolder(world, 'ada', { holders: 2 }) + await refused(world, 'single holder') + world = await snsWorld() + await setHolder(world, 'ada', { supply: 2 }) + await refused(world, 'single holder') + }) + + it('a token record pointing at another mint', async () => { + const world = await snsWorld() + await setHolder(world) + const { domain, nft } = await snsAccounts('ada') + world.set(nft, TOKENIZER, nftRecord(2, domain, wallet)) + await refused(world, 'single holder') + }) + + it('a token record in a state it does not know', async () => { + const world = await snsWorld() + const { domain, nft, mint } = await snsAccounts('ada') + world.set(nft, TOKENIZER, nftRecord(1, domain, mint)) + await refused(world, 'could not be confirmed') + }) +}) + +describe('.sol: the .sol registry and SNS must agree', () => { + async function solWorld(srsOwner = owner.address, options = {}): Promise { + const world = await snsWorld() + const { record } = await solAccounts('ada') + world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 0, srsOwner, options)) + return world + } + + it('pays when both say the same wallet', async () => { + await expect(resolveSolName(await solWorld(), 'ada.sol', 'ada')).resolves.toBe(owner.address) + }) + + it('refuses when they name different wallets', async () => { + const attempt = resolveSolName(await solWorld(wallet), 'ada.sol', 'ada') + await expect(attempt).rejects.toThrow('two different wallets') + await expect(attempt).rejects.toThrow(wallet) + await expect(attempt).rejects.toThrow(owner.address) + }) + + it('refuses a V2 record that sends elsewhere than the .sol owner', async () => { + const world = await solWorld() + await setV2(world, wallet) + await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('two different wallets') + }) + + it('refuses a name only one of them knows', async () => { + const onlySns = await snsWorld() + await expect(resolveSolName(onlySns, 'ada.sol', 'ada')).rejects.toThrow(ChainActionRefused) + const onlySrs = new World() + const { record } = await solAccounts('ada') + onlySrs.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 0, owner.address)) + await expect(resolveSolName(onlySrs, 'ada.sol', 'ada')).rejects.toThrow(ChainActionRefused) + }) + + it('refuses an expired record, and passes the no-expiry and future ones', async () => { + const now = Math.floor(Date.now() / 1000) + await expect( + resolveSolName(await solWorld(owner.address, { expiry: now - 1 }), 'ada.sol', 'ada') + ).rejects.toThrow('expired') + await expect( + resolveSolName(await solWorld(owner.address, { expiry: now + 86_400 }), 'ada.sol', 'ada') + ).resolves.toBe(owner.address) + }) + + it('refuses a frozen, misclassed, wrongly owned or malformed record', async () => { + await expect( + resolveSolName(await solWorld(owner.address, { frozen: 1 }), 'ada.sol', 'ada') + ).rejects.toThrow('could not be confirmed') + await expect( + resolveSolName(await solWorld(owner.address, { discriminator: 1 }), 'ada.sol', 'ada') + ).rejects.toThrow('could not be confirmed') + const world = await snsWorld() + const { record } = await solAccounts('ada') + world.set(record, SRS_PROGRAM, srsData(wallet, 0, owner.address)) + await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('could not be confirmed') + world.set(record, wallet, srsData(SRS_CLASS, 0, owner.address)) + await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('could not be confirmed') + world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 2, owner.address)) + await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('could not be confirmed') + }) + + it('reads a tokenized .sol record through its canonical Token-2022 mint', async () => { + const world = await snsWorld('ada', holder) + const { record, mint } = await solAccounts('ada') + world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 1, mint)) + world.set(mint, TOKEN_2022, mintData()) + world.holders.set(mint, [{ address: 't', data: tokenData(mint, holder) }]) + await expect(resolveSolName(world, 'ada.sol', 'ada')).resolves.toBe(holder) + + world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 1, wallet)) + await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('single holder') + }) + + it('refuses a mint run by the wrong token program', async () => { + const world = await snsWorld('ada', holder) + const { record, mint } = await solAccounts('ada') + world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 1, mint)) + world.set(mint, TOKEN_PROGRAM, mintData()) + world.holders.set(mint, [{ address: 't', data: tokenData(mint, holder) }]) + await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('single holder') + }) +}) + +describe('AllDomains on Solana', () => { + const ALT_DISCRIMINATOR = [0x44, 0x48, 0x58, 0x2c, 0x0f, 0xa7, 0x67, 0xf3] + const NFT_DISCRIMINATOR = [174, 190, 114, 100, 177, 14, 90, 254] + + function ansRecord( + parent: string, + recordOwner: string, + expiry = 0, + discriminator = ALT_DISCRIMINATOR + ): Uint8Array { + return bytes( + discriminator, + encode(parent), + encode(recordOwner), + encode(ZERO), + u64(expiry), + u64(0), + new Uint8Array(80) + ) + } + + it('pays the owner of an unwrapped name', async () => { + const world = new World() + const { parent, name } = await ansAccounts('ada', 'poor') + world.set(name, ANS_PROGRAM, ansRecord(parent, owner.address)) + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).resolves.toBe( + owner.address + ) + }) + + it('pays the holder of a wrapped name', async () => { + const world = new World() + const { parent, name, nftRecord: nft } = await ansAccounts('ada', 'poor') + world.set(name, ANS_PROGRAM, ansRecord(parent, nft)) + world.set( + nft, + NAME_HOUSE, + bytes(NFT_DISCRIMINATOR, [1, 255], encode(name), encode(ZERO), encode(wallet), encode(ZERO)) + ) + world.set(wallet, TOKEN_PROGRAM, mintData()) + world.holders.set(wallet, [{ address: 't', data: tokenData(wallet, holder) }]) + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).resolves.toBe(holder) + }) + + it('refuses a wrapped name whose record is inactive or for another name', async () => { + const world = new World() + const { parent, name, nftRecord: nft } = await ansAccounts('ada', 'poor') + world.set(name, ANS_PROGRAM, ansRecord(parent, nft)) + world.set( + nft, + NAME_HOUSE, + bytes(NFT_DISCRIMINATOR, [2, 255], encode(name), encode(ZERO), encode(wallet), encode(ZERO)) + ) + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow( + 'could not be confirmed' + ) + world.set( + nft, + NAME_HOUSE, + bytes(NFT_DISCRIMINATOR, [1, 255], encode(wallet), encode(ZERO), encode(wallet), encode(ZERO)) + ) + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow( + 'could not be confirmed' + ) + }) + + it('refuses an expired name, and passes one with time left', async () => { + const now = Math.floor(Date.now() / 1000) + const world = new World() + const { parent, name } = await ansAccounts('ada', 'poor') + world.set(name, ANS_PROGRAM, ansRecord(parent, owner.address, now - 1)) + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow( + 'expired' + ) + world.set(name, ANS_PROGRAM, ansRecord(parent, owner.address, now + 86_400)) + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).resolves.toBe( + owner.address + ) + }) + + it('refuses a missing name, a wrong parent, a wrong program, a wrong discriminator', async () => { + const world = new World() + const { parent, name } = await ansAccounts('ada', 'poor') + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow( + "isn't registered" + ) + world.set(name, ANS_PROGRAM, ansRecord(wallet, owner.address)) + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow( + 'could not be confirmed' + ) + world.set(name, wallet, ansRecord(parent, owner.address)) + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow( + 'could not be confirmed' + ) + world.set(name, ANS_PROGRAM, ansRecord(parent, owner.address, 0, [1, 2, 3, 4, 5, 6, 7, 8])) + await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow( + 'could not be confirmed' + ) + }) +}) + +describe('AllDomains on Monad', () => { + const HOLDER = getAddress('0x94bfb92da83b27b39370550ca038af96d182462f') + const OTHER = getAddress('0x1111111111111111111111111111111111111111') + const REGISTRAR = getAddress('0xbc811d4c4a17a645e024e8b9eefa72d4ed30cdd0') + + interface Chain { + tld?: string + registrar?: string + owner?: string + resolver?: string + name?: string + expiry?: bigint + nftOwner?: string + addr?: string + fail?: boolean + } + + function reader(chain: Chain = {}): MonadNameReader & { calls: string[] } { + const calls: string[] = [] + return { + calls, + read: async ({ address, functionName, args }) => { + calls.push(`${address}:${functionName}`) + if (chain.fail === true) throw new Error('rpc down') + const node = ansNode('ada', 'mon') + switch (functionName) { + case 'getTld': + expect(address).toBe(ANS_MONAD_ROOT) + expect(args?.[0]).toBe(keccak256(toBytes('.mon'))) + return { + tld: chain.tld ?? '.mon', + registrar: chain.registrar ?? REGISTRAR, + } + case 'owner': + expect(address).toBe(ANS_MONAD_REGISTRY) + expect(args?.[0]).toBe(node) + return chain.owner ?? HOLDER + case 'resolver': + expect(address).toBe(ANS_MONAD_REGISTRY) + return chain.resolver ?? ANS_MONAD_RESOLVER + case 'nameData': + expect(address).toBe(chain.registrar ?? REGISTRAR) + expect(args?.[0]).toBe(BigInt(keccak256(toBytes('ada')))) + return { + name: chain.name ?? 'ada', + expiry: chain.expiry ?? BigInt(Math.floor(Date.now() / 1000) + 86_400), + owner: chain.nftOwner ?? HOLDER, + } + case 'addr': + expect(address).toBe(ANS_MONAD_RESOLVER) + return chain.addr ?? zeroAddress + default: + throw new Error(`unexpected ${functionName}`) + } + }, + } + } + + const resolve = (chain?: Chain): Promise => + resolveAllDomainsMonad(reader(chain), 'ada.mon', 'ada', 'mon') + + it('computes the node as the protocol does, with the dot in the ending', () => { + // The .mon node as root.listTlds() returns it on chain, then the label over it. + const tldNode = '0xcbc339c8da5e6733357b69f6b396a8319afd0a9624eb6cc5ef65e35b54a77421' + expect(ansNode('miester', 'mon')).toBe( + keccak256(`0x${tldNode.slice(2)}${keccak256(toBytes('miester')).slice(2)}`) + ) + }) + + it('pays the owner when the registry, the registrar and the resolver agree', async () => { + await expect(resolve()).resolves.toBe(HOLDER) + await expect(resolve({ addr: HOLDER })).resolves.toBe(HOLDER) + await expect(resolve({ resolver: zeroAddress })).resolves.toBe(HOLDER) + }) + + it('refuses an unregistered name', async () => { + await expect(resolve({ owner: zeroAddress, nftOwner: zeroAddress, name: '' })).rejects.toThrow( + "isn't registered" + ) + }) + + it('refuses an expired name', async () => { + await expect(resolve({ expiry: BigInt(Math.floor(Date.now() / 1000) - 1) })).rejects.toThrow( + 'expired' + ) + }) + + it('refuses when the registrar and the registry disagree', async () => { + await expect(resolve({ nftOwner: OTHER })).rejects.toThrow('two different wallets') + }) + + it('refuses an address record set elsewhere than the owner', async () => { + await expect(resolve({ addr: OTHER })).rejects.toThrow('two different wallets') + }) + + it('refuses a resolver this app does not know', async () => { + await expect(resolve({ resolver: OTHER })).rejects.toThrow('could not be confirmed') + }) + + it('refuses a registrar record for another name or ending', async () => { + await expect(resolve({ name: 'bob' })).rejects.toThrow('could not be confirmed') + await expect(resolve({ tld: '.nad' })).rejects.toThrow('could not be confirmed') + await expect(resolve({ registrar: zeroAddress })).rejects.toThrow('could not be confirmed') + }) + + it('never reads a registrar this root did not name, and a failed read is a refusal', async () => { + const seen = reader() + await resolveAllDomainsMonad(seen, 'ada.mon', 'ada', 'mon') + expect(seen.calls[0]).toBe(`${ANS_MONAD_ROOT}:getTld`) + await expect( + resolveName('monad', recipientName('ada.mon')!, { monad: () => reader({ fail: true }) }) + ).rejects.toThrow('could not be read') + }) +}) + +describe('SuiNS', () => { + const TARGET = '0x' + 'a'.repeat(64) + const future = (): bigint => BigInt(Date.now() + 86_400_000) + const encodeNameRecord = ({ + expiresMs, + target, + }: { + expiresMs: bigint + target: string | null + }): Uint8Array => + NameRecord.serialize({ + nft_id: '0x' + 'b'.repeat(64), + expiration_timestamp_ms: expiresMs, + target_address: target, + data: { contents: [{ key: 'avatar', value: 'x' }] }, + }).toBytes() + + function reader( + record: { value: Uint8Array; type: string } | null | 'broken' + ): SuiNameReader & { labels: string[][] } { + const labels: string[][] = [] + return { + labels, + nameRecord: async (parts) => { + labels.push([...parts]) + if (record === 'broken') throw new Error('rpc down') + return record + }, + } + } + + it('pays the target, reading the labels in the order the registry keys them', async () => { + const seen = reader({ + value: encodeNameRecord({ expiresMs: future(), target: TARGET }), + type: SUINS_NAME_RECORD_TYPE, + }) + await expect(resolveSuiName(seen, 'ada.sui', 'ada')).resolves.toBe(TARGET) + expect(seen.labels).toEqual([['sui', 'ada']]) + expect(SUINS_REGISTRY_TABLE).toMatch(/^0x[0-9a-f]{64}$/) + }) + + it('refuses a name with no target set', async () => { + await expect( + resolveSuiName( + reader({ + value: encodeNameRecord({ expiresMs: future(), target: null }), + type: SUINS_NAME_RECORD_TYPE, + }), + 'ada.sui', + 'ada' + ) + ).rejects.toThrow('no address set') + }) + + it('refuses an expired name', async () => { + await expect( + resolveSuiName( + reader({ + value: encodeNameRecord({ expiresMs: BigInt(Date.now() - 1), target: TARGET }), + type: SUINS_NAME_RECORD_TYPE, + }), + 'ada.sui', + 'ada' + ) + ).rejects.toThrow('expired') + }) + + it('refuses an unregistered name', async () => { + await expect(resolveSuiName(reader(null), 'ada.sui', 'ada')).rejects.toThrow("isn't registered") + }) + + it('refuses a value of any other type, and bytes that do not decode', async () => { + await expect( + resolveSuiName( + reader({ + value: encodeNameRecord({ expiresMs: future(), target: TARGET }), + type: '0x2::other::Thing', + }), + 'ada.sui', + 'ada' + ) + ).rejects.toThrow('could not be confirmed') + await expect( + resolveName('sui', recipientName('ada.sui')!, { + sui: () => reader({ value: new Uint8Array([1, 2]), type: SUINS_NAME_RECORD_TYPE }), + }) + ).rejects.toThrow('could not be read') + }) + + it('a failed read is a refusal, and the card warns a sold name keeps its target', async () => { + await expect( + resolveName('sui', recipientName('ada.sui')!, { sui: () => reader('broken') }) + ).rejects.toThrow('could not be read') + const resolved = await resolveName('sui', recipientName('ada.sui')!, { + sui: () => + reader({ + value: encodeNameRecord({ expiresMs: future(), target: TARGET }), + type: SUINS_NAME_RECORD_TYPE, + }), + }) + expect(resolved).toMatchObject({ name: 'ada.sui', address: TARGET, source: 'SuiNS' }) + expect(resolved.caution).toContain('sold') + }) +}) + +describe('resolveName', () => { + it('routes .sol and .sns to SNS, with the .sol registry only behind .sol', async () => { + const world = await snsWorld() + await expect( + resolveName('solana', recipientName('ada.sns')!, { solana: () => world }) + ).resolves.toMatchObject({ address: owner.address, source: 'the Solana Name Service' }) + await expect( + resolveName('solana', recipientName('ada.sol')!, { solana: () => world }) + ).rejects.toThrow(ChainActionRefused) + }) + + it('a failed read is a refusal, never a guess', async () => { + const world = await snsWorld() + world.broken = true + await expect( + resolveName('solana', recipientName('ada.sns')!, { solana: () => world }) + ).rejects.toThrow( + `ada.sns could not be read from the chain just now, so I won't guess where it points. Try again, or use the wallet address. ${NOTHING}` + ) + }) + + it('refuses anything a program holds rather than a wallet', async () => { + const { domain } = await snsAccounts('ada') + const world = await snsWorld('ada', domain) + await expect( + resolveName('solana', recipientName('ada.sns')!, { solana: () => world }) + ).rejects.toThrow('held by a program') + }) + + it('will not run a name on the wrong chain even if asked directly', async () => { + await expect(resolveName('sui', recipientName('ada.sol')!, {})).rejects.toThrow( + 'is a Solana name' + ) + }) +}) + +describe('a resolved address gets every check a typed address gets', () => { + it('refuses a name that points at a token contract, the zero address or your own wallet', async () => { + const mint = SOLANA_TOKENS.find((t) => t.symbol === 'USDC')?.id as string + for (const [target, words] of [ + [mint, 'not an address that can receive'], + [ZERO, 'not an address that can receive'], + [me, 'your own wallet'], + ] as const) { + await expect( + resolveRecipient('solana', 'USDC', 'ada.sns', me, { + solana: () => ({ + accounts: async (addresses: readonly string[]) => + addresses.map((_, i) => + i === 0 ? { program: NAME_PROGRAM, data: registry(SNS_ROOT, target) } : null + ), + tokenAccounts: async () => [], + }), + }) + ).rejects.toThrow(words) + } + }) + + it('says which name led there', async () => { + const world = await snsWorld('ada', me) + await expect( + resolveRecipient('solana', 'USDC', 'ada.sns', me, { solana: () => world }) + ).rejects.toThrow(`ada.sns points to ${me}.`) + }) + + it('leaves a typed address alone and does not read anything', async () => { + const world = await snsWorld() + await expect( + resolveRecipient('solana', 'USDC', wallet, me, { solana: () => world }) + ).resolves.toBeUndefined() + expect(world.reads).toBe(0) + }) +}) + +describe('the card', () => { + const prepared: PreparedAction = { + venueId: 'send', + venueName: 'Wallet transfer', + kind: 'send', + summary: 'Send 1 USDC to WALLET.', + pay: { amount: '1', symbol: 'USDC' }, + notes: ['Existing note.'], + signing: { family: 'solana', transaction: 'x' }, + context: { recipient: 'WALLET' }, + } + const named = { name: 'ada.sol', address: 'WALLET', source: 'the Solana Name Service' } + + it('names the address and where it came from', () => { + const card = withResolvedName(prepared, named) + expect(card.summary).toBe('Send 1 USDC to ada.sol (WALLET).') + expect(card.notes[0]).toContain('ada.sol points to WALLET') + expect(card.notes[0]).toContain('the Solana Name Service') + expect(card.notes).toContain('Existing note.') + }) + + it('adds the address where a summary leaves it out', () => { + const card = withResolvedName({ ...prepared, summary: 'Stream 1 USDC for 30 days.' }, named) + expect(card.summary).toBe('Stream 1 USDC for 30 days. ada.sol is WALLET.') + }) + + it('refuses a venue that issued anything but the address the name read to', () => { + expect(() => + withResolvedName({ ...prepared, context: { recipient: 'SOMEONE_ELSE' } }, named) + ).toThrow(ChainActionRefused) + }) +}) + +/** A module whose venues are given, reading nothing else. */ +function moduleWith(chain: ChainWithActions, venues: ChainVenue[]): ChainModule { + return { + chain, + tokens: SOLANA_TOKENS, + venues, + defaultVenue: { send: venues[0]?.id ?? 'send' }, + priceUsd: async () => 1, + submit: async () => ({ ok: false, error: 'unused' }), + balances: async () => ({ tokens: [], complete: false }), + } +} + +function sendVenueStub(id: string, kinds: ChainActionKind[] = ['send', 'stream', 'invoice']) { + const seen: ChainActionRequest[] = [] + const venue: ChainVenue & { seen: ChainActionRequest[] } = { + id, + name: id, + kinds, + seen, + prepare: async (request) => { + seen.push(request) + return { + venueId: id, + venueName: id, + kind: request.kind, + summary: `${request.kind} over time.`, + pay: { amount: request.amount, symbol: request.tokenIn }, + notes: [], + signing: { family: 'solana', transaction: 'x' }, + context: { recipient: request.recipient as string }, + } + }, + assertSafe: () => undefined, + } + return venue +} + +describe('through prepareChainAction', () => { + let world: World + let methods: string[] + let executable: boolean + + beforeEach(async () => { + world = await snsWorld() + methods = [] + executable = false + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: string | URL, init?: RequestInit) => { + const { method, params } = JSON.parse(init?.body as string) as { + method: string + params: unknown[] + } + methods.push(method) + const b64 = (data: Uint8Array): string => Buffer.from(data).toString('base64') + const reply = (result: unknown): Response => + new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result })) + if (method === 'getMultipleAccounts') { + const found = await world.accounts(params[0] as string[]) + return reply({ + context: { slot: 1 }, + value: found.map((account) => + account === null + ? null + : { owner: account.program, data: [b64(account.data), 'base64'] } + ), + }) + } + if (method === 'getAccountInfo') { + return reply({ + context: { slot: 1 }, + value: executable ? { owner: 'x', executable } : null, + }) + } + if (method === 'getLatestBlockhash') { + return reply({ + context: { slot: 1 }, + value: { + blockhash: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG', + lastValidBlockHeight: 9, + }, + }) + } + throw new Error(`unexpected ${method}`) + }) + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + const request: ChainActionRequest = { + kind: 'send', + amount: '50', + tokenIn: 'USDC', + recipient: 'ada.sns', + } + + it('builds to the resolved address, binds the proof to it, and shows it', async () => { + const venue = createSendVenue('solana') + const prepared = await prepareChainAction(moduleWith('solana', [venue]), request, me) + + expect(prepared.context?.['recipient']).toBe(owner.address) + expect(prepared.context?.['proof']).toBeDefined() + expect(prepared.summary).toBe(`Send 50 USDC to ada.sns (${owner.address}).`) + expect(prepared.notes[0]).toContain(`ada.sns points to ${owner.address}`) + expect(prepared.notes[0]).toContain('the Solana Name Service') + + expect(() => venue.assertSafe(prepared.signing, me, prepared.context)).not.toThrow() + expect(() => + venue.assertSafe(prepared.signing, me, { ...prepared.context, recipient: 'ada.sns' }) + ).toThrow(ChainActionRefused) + expect(() => + venue.assertSafe(prepared.signing, me, { ...prepared.context, recipient: wallet }) + ).toThrow(ChainActionRefused) + }) + + it('reads the name again each time, so a moved name is what is shown', async () => { + const venue = createSendVenue('solana') + const api = moduleWith('solana', [venue]) + const first = await prepareChainAction(api, request, me) + const { domain } = await snsAccounts('ada') + world.set(domain, NAME_PROGRAM, registry(SNS_ROOT, wallet)) + const second = await prepareChainAction(api, request, me) + expect(first.context?.['recipient']).toBe(owner.address) + expect(second.context?.['recipient']).toBe(wallet) + expect(() => venue.assertSafe(second.signing, me, first.context)).toThrow(ChainActionRefused) + }) + + it('still stops at the receiver check the build makes', async () => { + executable = true + await expect( + prepareChainAction(moduleWith('solana', [createSendVenue('solana')]), request, me) + ).rejects.toThrow('That address is a program') + }) + + it('builds nothing when the connection drops', async () => { + world.broken = true + await expect( + prepareChainAction(moduleWith('solana', [createSendVenue('solana')]), request, me) + ).rejects.toThrow("Solana didn't answer in time. Nothing was signed or sent") + expect(methods).not.toContain('getLatestBlockhash') + }) + + it.each([ + ['an RPC error', () => new Response('{"jsonrpc":"2.0","id":1,"error":{"code":-32005}}')], + ['a 500', () => new Response('oops', { status: 500 })], + ['an answer of the wrong shape', () => new Response('{"result":{"value":[{"owner":1}]}}')], + ['too few accounts', () => new Response('{"result":{"value":[null]}}')], + [ + 'data in another encoding', + () => + new Response('{"result":{"value":[{"owner":"x","data":["00","base58"]},null,null,null]}}'), + ], + ])('builds nothing on %s', async (_what, answer) => { + const earlier = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation(async (input, init) => { + const { method } = JSON.parse(init?.body as string) as { method: string } + methods.push(method) + if (method === 'getMultipleAccounts') return answer() + return (earlier as typeof fetch)(input, init) + }) + await expect( + prepareChainAction(moduleWith('solana', [createSendVenue('solana')]), request, me) + ).rejects.toThrow('ada.sns could not be read from the chain just now') + expect(methods).not.toContain('getLatestBlockhash') + }) + + it('builds nothing on a tampered record', async () => { + await setV2(world, wallet, oldOwner.address) + await expect( + prepareChainAction(moduleWith('solana', [createSendVenue('solana')]), request, me) + ).rejects.toThrow('previous owner') + expect(methods).not.toContain('getLatestBlockhash') + }) + + it('streams and invoices go the same way', async () => { + const venue = sendVenueStub('fake') + const api: ChainModule = { + ...moduleWith('solana', [venue]), + defaultVenue: { stream: 'fake', invoice: 'fake' }, + } + for (const kind of ['stream', 'invoice'] as const) { + const prepared = await prepareChainAction(api, { ...request, kind }, me) + expect(venue.seen.at(-1)?.recipient).toBe(owner.address) + expect(prepared.summary).toContain(`ada.sns is ${owner.address}.`) + expect(prepared.notes[0]).toContain(`ada.sns points to ${owner.address}`) + } + }) + + it('refuses a venue that kept the typed name or changed the address', async () => { + const venue: ChainVenue = { + ...sendVenueStub('fake'), + prepare: async (r) => ({ + venueId: 'fake', + venueName: 'fake', + kind: r.kind, + summary: 'x', + pay: { amount: r.amount, symbol: r.tokenIn }, + notes: [], + signing: { family: 'solana', transaction: 'x' }, + context: { recipient: wallet }, + }), + } + await expect(prepareChainAction(moduleWith('solana', [venue]), request, me)).rejects.toThrow( + ChainActionRefused + ) + }) + + it('a kind that names no one still refuses a name', async () => { + await expect( + prepareChainAction( + { ...moduleWith('solana', [sendVenueStub('fake', ['swap'])]) }, + { ...request, kind: 'supply' }, + me + ) + ).rejects.toThrow("doesn't take an address") + }) + + it('the send venue will not take a name that was not read first', async () => { + const venue = createSendVenue('solana') + await expect(venue.prepare(request, me)).rejects.toThrow(ChainActionRefused) + expect(methods).toEqual([]) + }) + + it('a follow-on reads the name before the swap, and refuses what it finds', async () => { + const venue = createSendVenue('solana') + await expect( + venue.assertCanFollow?.({ kind: 'send', tokenIn: 'USDC', recipient: 'ada.sns' }, me) + ).resolves.toBeUndefined() + expect(methods).toContain('getMultipleAccounts') + await setV2(world, wallet, oldOwner.address) + await expect( + venue.assertCanFollow?.({ kind: 'send', tokenIn: 'USDC', recipient: 'ada.sns' }, me) + ).rejects.toThrow('previous owner') + }) +}) diff --git a/apps/dapp/lib/__tests__/route-cap.test.ts b/apps/dapp/lib/__tests__/route-cap.test.ts deleted file mode 100644 index dfbb046c..00000000 --- a/apps/dapp/lib/__tests__/route-cap.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { Keypair } from '@stellar/stellar-sdk' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type { ResolvedRecipient } from '../names/resolve' -import type { MarketPrice } from '../swap/price-types' - -/** - * The mainnet trade cap at the routes, where a client's figures arrive. - * - * The cap is checked against what the deployment will actually spend — the - * re-quoted amount, or the ceiling a fixed-output swap is widened to — and - * never against the number the browser sent. A quote that claims to spend - * "1" while asking for a large delivery is the case that matters: the - * client's send figure is a statement of intent, and the cap must read the - * fresh quote instead. - */ - -const asOf = '2026-09-24T00:00:00.000Z' -const table: Record = { - XLM: { symbol: 'XLM', usd: 0.2, source: 'reflector', asOf }, - USDC: { symbol: 'USDC', usd: 1, source: 'stellar-mainnet', asOf }, -} - -vi.mock('../swap/prices', async (importOriginal) => ({ - ...(await importOriginal()), - fetchMarketPrices: () => Promise.resolve(table), -})) - -const resolve = vi.fn<(input: string) => Promise>() -vi.mock('../names/resolve', () => ({ - resolveRecipient: (input: string) => resolve(input), -})) - -const ME = Keypair.random().publicKey() -const THEM = Keypair.random().publicKey() -const USDC = { kind: 'classic' as const, code: 'USDC', issuer: Keypair.random().publicKey() } -const XLM = { kind: 'classic' as const, code: 'XLM' } - -/** Horizon that prices every path at 5 XLM per USDC and knows every account. */ -function horizon(calls: string[]): typeof fetch { - return ((url: string) => { - calls.push(url) - const u = new URL(url) - if (u.pathname.endsWith('/paths/strict-send')) { - const usdc = u.searchParams.get('source_amount') ?? '0' - const xlm = (Number(usdc) * 5).toFixed(7) - return json({ - _embedded: { records: [{ source_amount: usdc, destination_amount: xlm, path: [] }] }, - }) - } - if (u.pathname.endsWith('/paths/strict-receive')) { - const xlm = u.searchParams.get('destination_amount') ?? '0' - const usdc = (Number(xlm) / 5).toFixed(7) - return json({ - _embedded: { records: [{ source_amount: usdc, destination_amount: xlm, path: [] }] }, - }) - } - if (u.pathname.includes('/accounts/')) { - return json({ id: ME, sequence: '4578890000000001', balances: [] }) - } - return Promise.resolve(new Response('{}', { status: 500 })) - }) as unknown as typeof fetch -} - -function json(body: unknown): Promise { - return Promise.resolve( - new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } }) - ) -} - -function post(path: string, body: unknown): Request { - return new Request(`http://localhost${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -let calls: string[] - -beforeEach(() => { - calls = [] - vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', 'mainnet') - vi.stubEnv('MAINNET_MAX_TRADE_USD', '50') - vi.stubGlobal('fetch', horizon(calls)) - resolve.mockReset() -}) - -afterEach(() => { - vi.unstubAllEnvs() - vi.unstubAllGlobals() -}) - -describe('POST /api/swap/build on mainnet', () => { - it('refuses a fixed-input swap over the cap, on the re-quoted amount', async () => { - const { POST } = await import('../../app/api/swap/build/route') - const res = await POST( - post('/api/swap/build', { - account: ME, - quote: { - source: 'horizon', - kind: 'strict_send', - from: USDC, - to: XLM, - sendAmount: '600000000', - destAmount: '3000000000', - path: [], - quotedAt: new Date().toISOString(), - }, - }) - ) - expect(res.status).toBe(400) - const body = (await res.json()) as { error: string } - expect(body.error).toMatch(/caps each trade at \$50/) - expect(body.error).toMatch(/\$60\.00/) - }) - - it('refuses a fixed-output swap by its ceiling, not by the send figure the client claims', async () => { - const { POST } = await import('../../app/api/swap/build/route') - const res = await POST( - post('/api/swap/build', { - account: ME, - quote: { - source: 'horizon', - kind: 'strict_receive', - from: USDC, - to: XLM, - // "I will spend 1 stroop" — for 300 XLM, which costs 60 USDC. - sendAmount: '1', - destAmount: '3000000000', - path: [], - quotedAt: new Date().toISOString(), - }, - }) - ) - expect(res.status).toBe(400) - const body = (await res.json()) as { error: string } - expect(body.error).toMatch(/caps each trade at \$50/) - // The ceiling: 60 USDC widened by the default tolerance. - expect(body.error).toMatch(/\$60\.3/) - // Refused before any account was loaded to build against. - expect(calls.some((u) => u.includes('/accounts/'))).toBe(false) - }) -}) - -describe('POST /api/plan/build on mainnet', () => { - it('refuses a plan with a step over the cap', async () => { - const { POST } = await import('../../app/api/plan/build/route') - const res = await POST( - post('/api/plan/build', { - account: ME, - actions: [{ kind: 'swap', from: USDC, to: XLM, sendAmount: '600000000', minReceive: '1' }], - }) - ) - expect(res.status).toBe(400) - expect(((await res.json()) as { error: string }).error).toMatch(/caps each trade at \$50/) - }) - - it('builds a plan whose steps are under the cap', async () => { - const { POST } = await import('../../app/api/plan/build/route') - const res = await POST( - post('/api/plan/build', { - account: ME, - actions: [{ kind: 'swap', from: USDC, to: XLM, sendAmount: '100000000', minReceive: '1' }], - }) - ) - expect(res.status).toBe(200) - expect(((await res.json()) as { xdr?: string }).xdr).toBeTypeOf('string') - }) -}) - -describe('POST /api/send/build on mainnet', () => { - it('values the asset by its canonical code, however the client spelled it', async () => { - resolve.mockResolvedValue({ - input: 'deon.xlm', - kind: 'soroban-domain', - address: THEM, - resolvedOn: 'stellar-mainnet', - }) - const { POST } = await import('../../app/api/send/build/route') - const res = await POST( - post('/api/send/build', { account: ME, asset: 'xlm', amount: '1000', recipient: 'deon.xlm' }) - ) - expect(res.status).toBe(400) - const body = (await res.json()) as { error: string } - expect(body.error).toMatch(/caps each trade at \$50/) - expect(body.error).not.toMatch(/could not be valued/) - }) -}) diff --git a/apps/dapp/lib/__tests__/sablier.live.test.ts b/apps/dapp/lib/__tests__/sablier.live.test.ts new file mode 100644 index 00000000..da0956d6 --- /dev/null +++ b/apps/dapp/lib/__tests__/sablier.live.test.ts @@ -0,0 +1,248 @@ +import { + decodeFunctionResult, + encodeAbiParameters, + erc20Abi, + getAddress, + http, + keccak256, + parseAbi, + toFunctionSelector, + type Address, + type Hex, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import { evmReader } from '../chains/evm/client' +import { + createSablierVenue, + SABLIER_COMPTROLLER, + SABLIER_LOCKUP, + SABLIER_LOCKUP_ABI, + SABLIER_STREAMABLE, +} from '../chains/evm/sablier' +import { EVM_CHAINS, type EvmChain } from '../chains/evm/tokens' +import type { SigningPayload } from '../chains/types' + +/** + * The real contracts, read-only: nothing signs or sends. The stream is tried with `eth_call`, with + * the sender's balance and allowance overridden for that one call, so the real contract runs it. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const SENDER = getAddress('0x1111111111111111111111111111111111111111') +const RECIPIENT = getAddress('0x2222222222222222222222222222222222222222') +const CHAINS: EvmChain[] = ['avalanche', 'monad'] + +// Slots in Circle's FiatToken: balanceAndBlacklistStates is 9, allowed is 10. +const USDC_BALANCE_SLOT = BigInt(9) +const USDC_ALLOWANCE_SLOT = BigInt(10) + +const VIEWS = parseAbi([ + 'function name() view returns (string)', + 'function nextStreamId() view returns (uint256)', + 'function comptroller() view returns (address)', + 'function getSender(uint256 streamId) view returns (address)', + 'function calculateMinFeeWei(uint256 streamId) view returns (uint256)', +]) + +// The public RPCs rate-limit a burst, so these reads go one at a time with room to retry. +function patientReader(chain: EvmChain) { + const url = process.env[EVM_CHAINS[chain].rpcEnv]?.trim() || undefined + return evmReader(chain, http(url, { timeout: 15_000, retryCount: 6, retryDelay: 2_000 })) +} + +const FEES = parseAbi([ + 'function VERSION() view returns (string)', + 'function MAX_FEE_USD() view returns (uint256)', + 'function admin() view returns (address)', + 'function oracle() view returns (address)', + 'function getMinFeeUSD(uint8 protocol) view returns (uint256)', + 'function getMinFeeUSDFor(uint8 protocol, address user) view returns (uint256)', + 'function calculateMinFeeWeiFor(uint8 protocol, address user) view returns (uint256)', +]) + +const EIP1967_IMPLEMENTATION = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' + +function mappingSlot(key: Address | Hex, slot: bigint): Hex { + const type = key.length === 42 ? 'address' : 'bytes32' + return keccak256(encodeAbiParameters([{ type }, { type: 'uint256' }], [key, slot] as never)) +} + +function usdcOf(chain: EvmChain): Address { + const token = EVM_CHAINS[chain].tokens.find((t) => t.symbol === 'USDC') + return token?.id as Address +} + +describe.skipIf(SKIP)('Sablier Lockup against mainnet', () => { + describe.each(CHAINS)('%s', (chain) => { + const reader = patientReader(chain) + const lockup = SABLIER_LOCKUP[chain] as Address + + it('has the v4.0 contract at the pinned address', async () => { + const code = await reader.getCode({ address: lockup }) + expect((code ?? '0x').length).toBeGreaterThan(2) + expect(code).toContain(toFunctionSelector(SABLIER_LOCKUP_ABI[0]).slice(2)) + const name = await reader.readContract({ address: lockup, abi: VIEWS, functionName: 'name' }) + expect(name).toBe('Sablier Lockup NFT') + }, 60_000) + + it('uses the pinned comptroller, which reads a capped fee', async () => { + const comptroller = await reader.readContract({ + address: lockup, + abi: VIEWS, + functionName: 'comptroller', + }) + expect(getAddress(comptroller)).toBe(SABLIER_COMPTROLLER) + const address = SABLIER_COMPTROLLER as Address + const version = await reader.readContract({ address, abi: FEES, functionName: 'VERSION' }) + const max = await reader.readContract({ address, abi: FEES, functionName: 'MAX_FEE_USD' }) + const admin = await reader.readContract({ address, abi: FEES, functionName: 'admin' }) + const oracle = await reader.readContract({ address, abi: FEES, functionName: 'oracle' }) + const usd = await reader.readContract({ + address, + abi: FEES, + functionName: 'getMinFeeUSDFor', + args: [2, SENDER], + }) + const wei = await reader.readContract({ + address, + abi: FEES, + functionName: 'calculateMinFeeWeiFor', + args: [2, SENDER], + }) + const implementation = await reader.getStorageAt({ address, slot: EIP1967_IMPLEMENTATION }) + const adminCode = await reader.getCode({ address: admin }) + console.info(chain, { + version, + max, + admin, + adminCodeBytes: ((adminCode ?? '0x').length - 2) / 2, + oracle, + usd, + wei, + implementation, + }) + expect(max).toBe(BigInt(10_000_000_000)) + expect(usd).toBeLessThanOrEqual(max) + // The card says a single key holds this. If that changes, the card should too. + expect((adminCode ?? '0x').length).toBe(2) + const defaults = [] + for (const protocol of [0, 1, 2, 3, 4]) { + defaults.push( + await reader.readContract({ + address, + abi: FEES, + functionName: 'getMinFeeUSD', + args: [protocol], + }) + ) + } + console.info(chain, { defaultFeesUsdByProtocol: defaults }) + }, 120_000) + + it('reads the fee under the same protocol number Lockup itself asks with', async () => { + const next = await reader.readContract({ + address: lockup, + abi: VIEWS, + functionName: 'nextStreamId', + }) + console.info(chain, { nextStreamId: next }) + if (next <= BigInt(1)) return + const streamId = next - BigInt(1) + const sender = await reader.readContract({ + address: lockup, + abi: VIEWS, + functionName: 'getSender', + args: [streamId], + }) + const fromLockup = await reader.readContract({ + address: lockup, + abi: VIEWS, + functionName: 'calculateMinFeeWei', + args: [streamId], + }) + const fromComptroller = await reader.readContract({ + address: SABLIER_COMPTROLLER as Address, + abi: FEES, + functionName: 'calculateMinFeeWeiFor', + args: [2, sender], + }) + console.info(chain, { streamId, sender, fromLockup, fromComptroller }) + expect(fromComptroller).toBe(fromLockup) + }, 120_000) + + it.each(SABLIER_STREAMABLE[chain])( + '%s is a real token with six decimals', + async (symbol) => { + const token = EVM_CHAINS[chain].tokens.find((t) => t.symbol === symbol) + const address = token?.id as Address + const decimals = await reader.readContract({ + address, + abi: erc20Abi, + functionName: 'decimals', + }) + expect(decimals).toBe(token?.decimals) + expect(decimals).toBe(6) + }, + 60_000 + ) + + it('runs the built stream on the real contract', async () => { + const venue = createSablierVenue(chain, reader) + const prepared = await venue.prepare( + { + kind: 'stream', + amount: '5', + tokenIn: 'USDC', + recipient: RECIPIENT, + durationDays: '30', + }, + SENDER + ) + const signing = prepared.signing as Extract + expect(signing.approval).toBeDefined() + expect(prepared.notes.join('\n')).toContain('bug bounty ended on May 1, 2026') + console.info(chain, prepared.notes) + + const usdc = usdcOf(chain) + const balanceSlot = mappingSlot(SENDER, USDC_BALANCE_SLOT) + const allowanceSlot = mappingSlot( + lockup, + keccak256( + encodeAbiParameters( + [{ type: 'address' }, { type: 'uint256' }], + [SENDER, USDC_ALLOWANCE_SLOT] + ) + ) as never + ) + const lots = `0x${BigInt(1_000_000_000).toString(16).padStart(64, '0')}` as Hex + const stateOverride = [ + { + address: usdc, + stateDiff: [ + { slot: balanceSlot, value: lots }, + { slot: allowanceSlot, value: lots }, + ], + }, + ] + + const result = await reader.call({ + account: SENDER, + to: signing.to as Address, + data: signing.data as Hex, + stateOverride, + }) + const streamId = decodeFunctionResult({ + abi: SABLIER_LOCKUP_ABI, + functionName: 'createWithDurationsLL', + data: result.data as Hex, + }) + const next = await reader.readContract({ + address: lockup, + abi: VIEWS, + functionName: 'nextStreamId', + }) + expect(streamId).toBe(next) + }, 180_000) + }) +}) diff --git a/apps/dapp/lib/__tests__/sablier.test.ts b/apps/dapp/lib/__tests__/sablier.test.ts new file mode 100644 index 00000000..0fcb838f --- /dev/null +++ b/apps/dapp/lib/__tests__/sablier.test.ts @@ -0,0 +1,602 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +import { + encodeFunctionData, + erc20Abi, + getAddress, + maxUint256, + parseAbi, + toFunctionSelector, + zeroAddress, + type Address, + type Hex, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import type { EvmReaderClient } from '../chains/evm/call' +import type { EvmClient, EvmReceipt } from '../chains/evm/client' +import { + assertStreamAsAsked, + createSablierVenue, + SABLIER_COMPTROLLER, + SABLIER_LOCKUP, + SABLIER_LOCKUP_ABI, + sablierAvalancheVenue, + sablierMonadVenue, +} from '../chains/evm/sablier' +import { sablierVenues } from '../chains/evm/sablier-listing' +import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types' + +const ME = getAddress('0x1111111111111111111111111111111111111111') +const THEM = getAddress('0x2222222222222222222222222222222222222222') +const STRANGER = getAddress('0x3333333333333333333333333333333333333333') +const LOCKUP = SABLIER_LOCKUP.avalanche as Address +const USDC = '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E' as Address +const USDT = '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7' as Address +const MONAD_USDC = '0x754704Bc059F8C67012fEd69BC8A327a5aafb603' as Address +const AVALANCHE_ID = 43114 +const MONAD_ID = 143 +const DAY = 86_400 +const FIVE_HUNDRED = BigInt(500_000_000) + +const REQUEST: ChainActionRequest = { + kind: 'stream', + amount: '500', + tokenIn: 'USDC', + recipient: THEM, + durationDays: '30', +} + +interface StreamArgs { + sender: Address + recipient: Address + depositAmount: bigint + token: Address + cancelable: boolean + transferable: boolean + shape: string + start: bigint + cliffUnlock: bigint + granularity: number + cliff: number + total: number +} + +const GOOD: StreamArgs = { + sender: ME, + recipient: THEM, + depositAmount: FIVE_HUNDRED, + token: USDC, + cancelable: true, + transferable: false, + shape: 'linear', + start: BigInt(0), + cliffUnlock: BigInt(0), + granularity: 1, + cliff: 0, + total: 30 * DAY, +} + +function create(overrides: Partial = {}): Hex { + const a = { ...GOOD, ...overrides } + return encodeFunctionData({ + abi: SABLIER_LOCKUP_ABI, + functionName: 'createWithDurationsLL', + args: [ + { + sender: a.sender, + recipient: a.recipient, + depositAmount: a.depositAmount, + token: a.token, + cancelable: a.cancelable, + transferable: a.transferable, + shape: a.shape, + }, + { start: a.start, cliff: a.cliffUnlock }, + a.granularity, + { cliff: a.cliff, total: a.total }, + ], + }) +} + +function approve(spender: Address = LOCKUP, units: bigint = FIVE_HUNDRED): Hex { + return encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [spender, units] }) +} + +function payload( + overrides: Partial> = {} +): Extract { + return { + family: 'evm', + chainId: AVALANCHE_ID, + to: LOCKUP, + data: create(), + value: '0', + ...overrides, + } +} + +interface FakeOptions { + allowance?: bigint + callFails?: boolean + feeFails?: boolean + feeWei?: bigint + comptroller?: string +} + +function fakeReader(options: FakeOptions = {}) { + const calls: { to: string; data: string }[] = [] + const reader = { + readContract: async ({ functionName }: { functionName: string }) => { + switch (functionName) { + case 'allowance': + return options.allowance ?? BigInt(0) + case 'comptroller': + if (options.feeFails === true) throw new Error('rpc down') + return options.comptroller ?? SABLIER_COMPTROLLER + case 'calculateMinFeeWeiFor': + if (options.feeFails === true) throw new Error('rpc down') + return options.feeWei ?? BigInt('98770000000000000') + case 'getMinFeeUSDFor': + return BigInt(99_000_000) + case 'MAX_FEE_USD': + return BigInt(10_000_000_000) + default: + throw new Error(`unexpected read ${functionName}`) + } + }, + call: async (args: { to: string; data: string }) => { + calls.push(args) + if (options.callFails === true) throw new Error('reverted') + return { data: '0x' } + }, + } as unknown as EvmReaderClient + return { reader, calls } +} + +function venueWith(options: FakeOptions = {}) { + const fake = fakeReader(options) + return { venue: createSablierVenue('avalanche', fake.reader), calls: fake.calls } +} + +function refusal(run: () => unknown): string { + try { + run() + } catch (e) { + expect(e).toBeInstanceOf(ChainActionRefused) + return (e as Error).message + } + throw new Error('expected a refusal') +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run() + } catch (e) { + expect(e).toBeInstanceOf(ChainActionRefused) + return (e as Error).message + } + throw new Error('expected a refusal') +} + +describe('the pinned call', () => { + it('encodes the selector of Lockup v4.0 createWithDurationsLL', () => { + expect(create().slice(0, 10)).toBe('0x7b4dae9e') + }) + + it('has no broker argument anywhere, since v3.0 removed it', () => { + expect(JSON.stringify(SABLIER_LOCKUP_ABI)).not.toMatch(/broker/i) + }) + + it('funds the stream from the two chains’ own Lockup contracts', () => { + expect(SABLIER_LOCKUP.avalanche).toBe('0xB891b41533776Ec20f7738c647a11506AA44b8A8') + expect(SABLIER_LOCKUP.monad).toBe('0x82723C1ffEc9D43dE5FA80b25Da8df99AfD470ba') + }) +}) + +describe('assertSafe', () => { + const venue = sablierAvalancheVenue + + it('passes the stream as this venue builds it', () => { + expect(() => venue.assertSafe(payload(), ME)).not.toThrow() + }) + + it('passes it with an exact approval to the Lockup contract', () => { + const signing = payload({ approval: { to: USDC, data: approve() } }) + expect(() => venue.assertSafe(signing, ME)).not.toThrow() + }) + + it('passes a USDT stream with its own approval', () => { + const signing = payload({ + data: create({ token: USDT }), + approval: { to: USDT, data: approve() }, + }) + expect(() => venue.assertSafe(signing, ME)).not.toThrow() + }) + + it.each<[string, SigningPayload]>([ + ['another family', { family: 'sui', transaction: 'AA==' }], + ['another chain', payload({ chainId: MONAD_ID })], + ['an unknown contract', payload({ to: STRANGER })], + ['the other chain’s Lockup', payload({ to: SABLIER_LOCKUP.monad as string })], + ['native coin as value', payload({ value: '1' })], + ['a value that is not a number', payload({ value: 'abc' })], + ['extra calldata', payload({ data: `${create()}00` })], + ['a stream from someone else as sender', payload({ data: create({ sender: STRANGER }) })], + ['the person streaming to themselves', payload({ data: create({ recipient: ME }) })], + ['the zero address as recipient', payload({ data: create({ recipient: zeroAddress }) })], + ['the Lockup contract as recipient', payload({ data: create({ recipient: LOCKUP }) })], + ['a token contract as recipient', payload({ data: create({ recipient: USDC }) })], + ['a token this venue does not stream', payload({ data: create({ token: STRANGER }) })], + ['a Monad token on Avalanche', payload({ data: create({ token: MONAD_USDC }) })], + ['a stream that cannot be canceled', payload({ data: create({ cancelable: false }) })], + ['a stream that can be moved', payload({ data: create({ transferable: true }) })], + ['a stream paying out at its start', payload({ data: create({ start: BigInt(1) }) })], + ['a stream paying out at a cliff', payload({ data: create({ cliffUnlock: BigInt(1) }) })], + ['a cliff', payload({ data: create({ cliff: DAY }) })], + ['a coarser unlock step', payload({ data: create({ granularity: DAY }) })], + ['another shape', payload({ data: create({ shape: 'x'.repeat(8) }) })], + ['a length of zero', payload({ data: create({ total: 0 }) })], + ['a length that is not whole days', payload({ data: create({ total: DAY + 1 }) })], + ['a length past what can be typed', payload({ data: create({ total: 100_000 * DAY }) })], + ])('refuses %s', (_name, signing) => { + expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/Nothing was signed or sent\.$/) + }) + + it('refuses the wrong signer', () => { + expect(refusal(() => venue.assertSafe(payload(), STRANGER))).toMatch(/your wallet/) + }) + + it.each([ + [ + 'cancel', + encodeFunctionData({ + abi: parseAbi(['function cancel(uint256 streamId)']), + functionName: 'cancel', + args: [BigInt(1)], + }), + ], + [ + 'withdraw', + encodeFunctionData({ + abi: parseAbi(['function withdraw(uint256 streamId, address to, uint128 amount)']), + functionName: 'withdraw', + args: [BigInt(1), STRANGER, BigInt(1)], + }), + ], + [ + 'createWithTimestampsLL', + encodeFunctionData({ + abi: parseAbi([ + 'struct T { uint40 start; uint40 end; }', + 'struct P { address a; address b; uint128 c; address d; bool e; bool f; T g; string h; }', + 'function createWithTimestampsLL(P p, (uint128,uint128) u, uint40 g, uint40 c)', + ]), + functionName: 'createWithTimestampsLL', + args: [ + { + a: ME, + b: THEM, + c: FIVE_HUNDRED, + d: USDC, + e: true, + f: false, + g: { start: 1, end: 2 }, + h: 'linear', + }, + [BigInt(0), BigInt(0)], + 1, + 0, + ], + }), + ], + [ + 'the v2 form that paid a broker', + encodeFunctionData({ + abi: parseAbi([ + 'struct B { address account; uint256 fee; }', + 'struct P { address a; address b; uint128 c; address d; bool e; bool f; B broker; }', + 'function createWithDurationsLL(P p, (uint128,uint128) u, (uint40,uint40) d)', + ]), + functionName: 'createWithDurationsLL', + args: [ + { + a: ME, + b: THEM, + c: FIVE_HUNDRED, + d: USDC, + e: true, + f: false, + broker: { account: STRANGER, fee: BigInt(1) }, + }, + [BigInt(0), BigInt(0)], + [0, DAY], + ], + }), + ], + ])('refuses the function %s', (_name, data) => { + expect(toFunctionSelector(SABLIER_LOCKUP_ABI[0])).not.toBe(data.slice(0, 10)) + expect(refusal(() => venue.assertSafe(payload({ data }), ME))).toMatch(/haven't confirmed/) + }) + + it('refuses an approval to someone else', () => { + const signing = payload({ approval: { to: USDC, data: approve(STRANGER) } }) + expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/isn't confirmed/) + }) + + it('refuses an unlimited approval', () => { + const signing = payload({ approval: { to: USDC, data: approve(LOCKUP, maxUint256) } }) + expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/unlimited/) + }) + + it('refuses an approval larger than the amount', () => { + const signing = payload({ + approval: { to: USDC, data: approve(LOCKUP, FIVE_HUNDRED + BigInt(1)) }, + }) + expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/exactly/) + }) + + it('refuses an approval smaller than the amount', () => { + const signing = payload({ + approval: { to: USDC, data: approve(LOCKUP, FIVE_HUNDRED - BigInt(1)) }, + }) + expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/exactly/) + }) + + it('refuses an approval of a different token than the stream’s', () => { + const signing = payload({ approval: { to: USDT, data: approve() } }) + expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/different token/) + }) + + it('refuses an approval of a token it does not know', () => { + const signing = payload({ approval: { to: STRANGER, data: approve() } }) + expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/haven't confirmed/) + }) +}) + +describe('assertStreamAsAsked', () => { + const asked = (signing: SigningPayload, request: ChainActionRequest = REQUEST) => + assertStreamAsAsked(signing, request, ME, 'avalanche') + + it('passes a stream that is what was typed', () => { + expect(() => asked(payload())).not.toThrow() + }) + + it('reads a typed address in any case as the same address', () => { + const request = { ...REQUEST, recipient: THEM.toLowerCase() } + expect(() => asked(payload(), request)).not.toThrow() + }) + + it.each<[string, Partial, RegExp]>([ + ['another recipient', { recipient: STRANGER }, /address you typed/], + ['a shorter length', { total: 29 * DAY }, /days/], + ['another amount', { depositAmount: FIVE_HUNDRED - BigInt(1) }, /amount/], + ['another token', { token: USDT }, /token/], + ['another sender', { sender: STRANGER }, /your wallet/], + ])('refuses %s', (_name, overrides, reason) => { + const message = refusal(() => asked(payload({ data: create(overrides) }))) + expect(message).toMatch(reason) + expect(message).toMatch(/Nothing was signed or sent\.$/) + }) + + it('refuses something that is not an EVM call', () => { + expect(refusal(() => asked({ family: 'sui', transaction: 'AA==' }))).toMatch(/not/) + }) +}) + +describe('prepare', () => { + it('builds exactly the stream that was typed, approving first when it must', async () => { + const { venue } = venueWith() + const prepared = await venue.prepare(REQUEST, ME) + expect(prepared.venueId).toBe('sablier-avalanche') + expect(prepared.kind).toBe('stream') + expect(prepared.pay).toEqual({ amount: '500', symbol: 'USDC' }) + expect(prepared.summary).toContain(THEM) + expect(prepared.summary).toContain('30 days') + expect(prepared.signing).toEqual({ + family: 'evm', + chainId: AVALANCHE_ID, + to: LOCKUP, + data: create(), + value: '0', + approval: { to: USDC, data: approve() }, + }) + expect(prepared.notes.some((note) => note.includes('asks twice'))).toBe(true) + }) + + it('tries the call when no approval is needed, and asks only once', async () => { + const { venue, calls } = venueWith({ allowance: FIVE_HUNDRED }) + const prepared = await venue.prepare(REQUEST, ME) + const signing = prepared.signing as Extract + expect(signing.approval).toBeUndefined() + expect(calls).toHaveLength(1) + expect(prepared.notes.some((note) => note.includes('asks twice'))).toBe(false) + }) + + it('refuses a stream that would fail on the chain', async () => { + const { venue } = venueWith({ allowance: FIVE_HUNDRED, callFails: true }) + expect(await refusalOf(() => venue.prepare(REQUEST, ME))).toMatch(/would fail/) + }) + + it('passes its own assertSafe', async () => { + const { venue } = venueWith() + const prepared = await venue.prepare(REQUEST, ME) + expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow() + }) + + it('builds for Monad against Monad’s contract and token', async () => { + const { reader } = fakeReader() + const prepared = await createSablierVenue('monad', reader).prepare(REQUEST, ME) + const signing = prepared.signing as Extract + expect(prepared.venueId).toBe('sablier-monad') + expect(signing.chainId).toBe(MONAD_ID) + expect(signing.to).toBe(SABLIER_LOCKUP.monad) + expect(signing.approval?.to).toBe(MONAD_USDC) + expect(prepared.notes.join(' ')).toContain('MON') + }) + + it.each<[string, Partial, RegExp]>([ + ['another kind', { kind: 'send' }, /only streams/], + ['the native coin', { tokenIn: 'AVAX' }, /USDC or USDT/], + ['a token it does not stream', { tokenIn: 'DOGE' }, /USDC or USDT/], + ['a token named with another chain’s symbol', { tokenIn: 'USDT0' }, /USDC or USDT/], + ['no recipient', { recipient: undefined }, /address/], + ['a bad checksum', { recipient: '0xb891b41533776Ec20f7738c647a11506AA44b8A8' }, /address/], + ['the zero address', { recipient: zeroAddress }, /address/], + ['a token as the recipient', { recipient: USDC }, /address/], + ['the Lockup as the recipient', { recipient: LOCKUP }, /Sablier/], + ['the fee contract as the recipient', { recipient: SABLIER_COMPTROLLER }, /Sablier/], + ['the person themselves', { recipient: ME }, /your own/], + ['no length', { durationDays: undefined }, /days/], + ['a length of zero', { durationDays: '0' }, /days/], + ['a part of a day', { durationDays: '1.5' }, /days/], + ['too long a length', { durationDays: '100000' }, /days/], + ['an amount of zero', { amount: '0' }, /more than zero/], + ['too many decimals', { amount: '1.0000001' }, /decimal/], + ['an amount past what a stream holds', { amount: '9'.repeat(39) }, /too large/], + ])('refuses %s', async (_name, overrides, reason) => { + const { venue, calls } = venueWith() + const message = await refusalOf(() => venue.prepare({ ...REQUEST, ...overrides }, ME)) + expect(message).toMatch(reason) + expect(calls).toHaveLength(0) + }) + + it('refuses a malformed account', async () => { + const { venue } = venueWith() + expect(await refusalOf(() => venue.prepare(REQUEST, 'not-an-address'))).toMatch(/wallet/) + }) + + it('refuses when Lockup points at a fee contract nobody checked', async () => { + const { venue } = venueWith({ comptroller: STRANGER }) + expect(await refusalOf(() => venue.prepare(REQUEST, ME))).toMatch(/fee contract/) + }) +}) + +describe('what the card says', () => { + async function notes(options: FakeOptions = {}): Promise { + const { venue } = venueWith(options) + return (await venue.prepare(REQUEST, ME)).notes.join('\n') + } + + it('says the stream is cancelable, what that does, and how', async () => { + const text = await notes() + expect(text).toMatch(/You can cancel/) + expect(text).toContain('not yet streamed comes back') + expect(text).toContain('cancel') + expect(text).toContain(LOCKUP) + expect(text).toContain('stream’s id') + }) + + it('says the recipient must withdraw what has streamed', async () => { + expect(await notes()).toMatch(new RegExp(`${THEM} has to withdraw`)) + }) + + it('shows the withdrawal fee read from the chain', async () => { + const text = await notes() + expect(text).toContain('0.09877 AVAX') + expect(text).toContain('0.99 dollars') + expect(text).toContain('100 dollars') + }) + + it('says so plainly when the fee reads as zero', async () => { + const text = await notes({ feeWei: BigInt(0) }) + expect(text).toContain('reads as zero right now') + expect(text).toContain('its own site charges 0.99 dollars') + }) + + it('does not count the site’s charge on top of a fee the contract asks for', async () => { + expect(await notes()).not.toContain('its own site charges') + }) + + it('shows no figure when the fee cannot be read', async () => { + const text = await notes({ feeFails: true }) + expect(text).toContain("couldn't read Sablier's withdrawal fee") + expect(text).not.toMatch(/\d AVAX/) + }) + + it('says creating it pays no one else', async () => { + expect(await notes()).toMatch(/no fee to create/) + }) + + it('names the ended bug bounty', async () => { + expect(await notes()).toContain('bug bounty ended on May 1, 2026') + }) + + it('names who can change what', async () => { + const text = await notes() + expect(text).toContain("can't be upgraded") + expect(text).toContain('no waiting period') + expect(text).toContain('A single key') + }) + + it('has no em dash', async () => { + expect(await notes()).not.toContain('—') + }) +}) + +describe('submit', () => { + const HASH = `0x${'a'.repeat(64)}` + + function client(receipt: Partial, data: string = create()): EvmClient { + return { + tokenBalance: async () => BigInt(0), + nativeBalance: async () => BigInt(0), + waitForReceipt: async () => + ({ status: 'success', from: ME, to: LOCKUP, logs: [], ...receipt }) as EvmReceipt, + transaction: async () => ({ value: BigInt(0), input: data }), + } + } + + function submit(fake: EvmClient) { + const { reader } = fakeReader() + return createSablierVenue('avalanche', reader, fake).submit?.({ + account: ME, + venueId: 'sablier-avalanche', + signed: { family: 'evm', hash: HASH }, + }) + } + + it('confirms what the chain says was sent, with no stream id needed', async () => { + const result = await submit(client({})) + expect(result).toMatchObject({ ok: true, hash: HASH }) + }) + + it('reports a revert of the real transaction as nothing moved', async () => { + const result = await submit(client({ status: 'reverted' })) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + }) + + it('does not vouch for a transaction that streams from someone else', async () => { + const result = await submit(client({}, create({ sender: STRANGER }))) + expect(result).toMatchObject({ ok: false, pending: true }) + }) + + it('does not vouch for a transaction from another wallet', async () => { + const result = await submit(client({ from: STRANGER })) + expect(result).toMatchObject({ ok: false, pending: true }) + }) +}) + +describe('the venues and their listing', () => { + it('offers only streams, and joins no race', () => { + for (const venue of [sablierAvalancheVenue, sablierMonadVenue]) { + expect(venue.kinds).toEqual(['stream']) + expect(venue.quote).toBeUndefined() + } + }) + + it('lists one executing entry per chain, each with its own logo', () => { + expect(sablierVenues.map((v) => [v.id, v.family, v.chains, v.integration])).toEqual([ + ['sablier-avalanche', 'evm', ['Avalanche'], 'executes'], + ['sablier-monad', 'evm', ['Monad'], 'executes'], + ]) + for (const venue of sablierVenues) { + expect(venue.capability).toBeTruthy() + expect( + existsSync(join(process.cwd(), 'public', 'images', 'venues', `${venue.id}.webp`)) + ).toBe(true) + } + }) +}) diff --git a/apps/dapp/lib/__tests__/scallop.live.test.ts b/apps/dapp/lib/__tests__/scallop.live.test.ts new file mode 100644 index 00000000..013949ff --- /dev/null +++ b/apps/dapp/lib/__tests__/scallop.live.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' + +import { suiClient } from '../chains/sui/client' +import { dryRunChange } from '../chains/sui/lending-calls' +import { + SCALLOP_MARKET, + SCALLOP_ORIGINAL_PACKAGE, + SCALLOP_PACKAGE, + SCALLOP_RECEIPT_TYPE, + SCALLOP_UPGRADE_CAP, + SCALLOP_VERSION, + scallopVenue, +} from '../chains/sui/scallop' +import { SUI_TOKENS } from '../chains/sui/tokens' + +/** + * Real transactions built and dry-run against mainnet, for public addresses whose keys + * nobody here has. Nothing signs or sends. If a holder moves its funds these fail on + * balance, so swap it for another that holds USDC receipts. + */ +const SKIP = process.env['SKIP_LIVE'] === '1' +const USDC_HOLDER = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4' +const RECEIPT_HOLDER = '0xbe3fd04e98d2a2603122a3d2f7459e5ecef01768f1dc40cded8203995305203f' +const USDC = SUI_TOKENS.find((t) => t.symbol === 'USDC')?.id ?? '' + +describe.skipIf(SKIP)('Scallop against mainnet', () => { + it('still has the pinned package as its latest, or the allowlist needs a fresh look', async () => { + const { object } = await suiClient().core.getObject({ + objectId: SCALLOP_UPGRADE_CAP, + include: { json: true }, + }) + expect((object.json as { package?: string }).package).toBe(SCALLOP_PACKAGE) + }, 30_000) + + it('still has the pinned market and version objects', async () => { + const { objects } = await suiClient().core.getObjects({ + objectIds: [SCALLOP_MARKET, SCALLOP_VERSION], + }) + const [market, version] = objects as unknown as [{ type?: string }, { type?: string }] + expect(market.type).toBe(`${SCALLOP_ORIGINAL_PACKAGE}::market::Market`) + expect(version.type).toBe(`${SCALLOP_ORIGINAL_PACKAGE}::version::Version`) + }, 30_000) + + it('builds a supply with a rate, passes its check and dry-runs clean', async () => { + const prepared = await scallopVenue.prepare( + { kind: 'supply', amount: '1', tokenIn: 'USDC' }, + USDC_HOLDER + ) + expect(prepared.apy?.basePercent).toBeGreaterThan(0) + expect(Number(prepared.receive?.amount)).toBeGreaterThan(0) + expect(prepared.notes.join(' ')).toMatch(/\$142K/) + expect(() => scallopVenue.assertSafe(prepared.signing, USDC_HOLDER)).not.toThrow() + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + const minted = await dryRunChange( + prepared.signing.transaction, + SCALLOP_RECEIPT_TYPE, + USDC_HOLDER, + 'Dry run' + ) + expect(Number(minted)).toBeGreaterThan(0) + }, 60_000) + + it('builds a withdrawal and pays what was asked', async () => { + const prepared = await scallopVenue.prepare( + { kind: 'withdraw', amount: '1', tokenIn: 'USDC' }, + RECEIPT_HOLDER + ) + expect(() => scallopVenue.assertSafe(prepared.signing, RECEIPT_HOLDER)).not.toThrow() + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + const received = await dryRunChange( + prepared.signing.transaction, + USDC, + RECEIPT_HOLDER, + 'Dry run' + ) + expect(Number(received)).toBeGreaterThanOrEqual(999_900) + }, 60_000) + + it('refuses a wallet with no receipts', async () => { + await expect( + scallopVenue.prepare({ kind: 'withdraw', amount: '1', tokenIn: 'USDC' }, USDC_HOLDER) + ).rejects.toThrow(/no Scallop USDC receipts/) + }, 30_000) +}) diff --git a/apps/dapp/lib/__tests__/scallop.test.ts b/apps/dapp/lib/__tests__/scallop.test.ts new file mode 100644 index 00000000..35c5df52 --- /dev/null +++ b/apps/dapp/lib/__tests__/scallop.test.ts @@ -0,0 +1,236 @@ +import { Transaction } from '@mysten/sui/transactions' +import { toBase64 } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { + amountForReceipts, + createScallopVenue, + receiptsForAmount, + scallopSupplyApr, + scallopVenue, + type ScallopPool, + type ScallopReader, +} from '../chains/sui/scallop' +import { scallopListing } from '../chains/sui/scallop-listing' +import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types' +import { + callNamed, + inputOf, + OTHER_ADDRESS, + OTHER_OBJECT, + payTo, + pointAt, + SCALLOP_SUPPLY, + SCALLOP_SUPPLY_SENDER, + SCALLOP_WITHDRAW, + SCALLOP_WITHDRAW_SENDER, + tampered, +} from './sui-lending-captured' + +const USDT = '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT' +const ME = '0x0000000000000000000000000000000000000000000000000000000000000abc' + +function checkSupply(transaction: string, account = SCALLOP_SUPPLY_SENDER): void { + scallopVenue.assertSafe({ family: 'sui', transaction }, account) +} + +function checkWithdraw(transaction: string, account = SCALLOP_WITHDRAW_SENDER): void { + scallopVenue.assertSafe({ family: 'sui', transaction }, account) +} + +/** The USDC pool as read on 2026-11-05. */ +const POOL: ScallopPool = { + cash: BigInt('408594658931'), + debt: BigInt('1148010447929'), + revenue: BigInt('3217384834'), + receiptSupply: BigInt('1358202340967'), + borrowRate: BigInt('123747030'), + borrowRateScale: BigInt('10000000'), + revenueFactor: BigInt('858993459'), +} + +function reader(receipts: bigint, pool: ScallopPool = POOL): ScallopReader { + return { pool: async () => pool, receipts: async () => receipts } +} + +function withdraw(amount = '1'): ChainActionRequest { + return { kind: 'withdraw', amount, tokenIn: 'USDC' } +} + +describe('what the venue really built passes', () => { + it('a supply and a withdrawal', () => { + expect(() => checkSupply(SCALLOP_SUPPLY)).not.toThrow() + expect(() => checkWithdraw(SCALLOP_WITHDRAW)).not.toThrow() + }) +}) + +describe('Scallop refuses a transaction that', () => { + it('another account would sign', () => { + expect(() => checkSupply(SCALLOP_SUPPLY, OTHER_ADDRESS)).toThrow(/different account/) + expect(() => checkWithdraw(SCALLOP_WITHDRAW, OTHER_ADDRESS)).toThrow(ChainActionRefused) + }) + + it('calls a contract that is not confirmed', async () => { + const tx = await tampered(SCALLOP_WITHDRAW, (t) => { + callNamed(t, 'redeem').package = OTHER_OBJECT + }) + expect(() => checkWithdraw(tx)).toThrow(/haven't confirmed for Scallop/) + }) + + it('calls the older package the docs still name', async () => { + const tx = await tampered(SCALLOP_WITHDRAW, (t) => { + callNamed(t, 'redeem').package = + '0xa45b8ffca59e5b44ec7c04481a04cb620b0e07b2b183527bca4e5f32372c5f1a' + }) + expect(() => checkWithdraw(tx)).toThrow(/haven't confirmed for Scallop/) + }) + + it.each(['redeem_entry', 'flash_loan', 'borrow'])( + 'calls %s, which this venue never uses', + async (name) => { + const tx = await tampered(SCALLOP_WITHDRAW, (t) => { + callNamed(t, 'redeem').function = name + }) + expect(() => checkWithdraw(tx)).toThrow(/haven't confirmed for Scallop/) + } + ) + + it('passes an extra argument', async () => { + const tx = await tampered(SCALLOP_WITHDRAW, (t) => { + const call = callNamed(t, 'redeem') + const extra = call.arguments[0] + if (extra === undefined) throw new Error('no argument') + call.arguments.push(extra) + }) + expect(() => checkWithdraw(tx)).toThrow(/wrong arguments/) + }) + + it('takes an extra step of a kind it never needs', async () => { + const base = Transaction.from(SCALLOP_SUPPLY) + base.makeMoveVec({ type: 'u64', elements: [] }) + const tx = toBase64(await base.build()) + expect(() => checkSupply(tx)).toThrow(/does more than Scallop needs/) + }) + + it('moves anything through the framework', async () => { + const base = Transaction.from(SCALLOP_WITHDRAW) + base.moveCall({ + target: '0x2::transfer::public_transfer', + typeArguments: ['0x2::sui::SUI'], + arguments: [base.gas, base.pure.address(OTHER_ADDRESS)], + }) + const tx = toBase64(await base.build()) + expect(() => checkWithdraw(tx)).toThrow(ChainActionRefused) + }) + + it('pays the withdrawal to someone else', async () => { + const tx = await tampered(SCALLOP_WITHDRAW, (t) => payTo(t, OTHER_ADDRESS)) + expect(() => checkWithdraw(tx)).toThrow(/another address/) + }) + + it('sends the receipts to someone else', async () => { + const tx = await tampered(SCALLOP_SUPPLY, (t) => payTo(t, OTHER_ADDRESS)) + expect(() => checkSupply(tx)).toThrow(/another address/) + }) + + it('uses a token other than USDC', async () => { + const tx = await tampered(SCALLOP_SUPPLY, (t) => { + callNamed(t, 'mint').typeArguments = [USDT] + }) + expect(() => checkSupply(tx)).toThrow(/token Scallop isn't confirmed for/) + }) + + it('points at another market or version object', async () => { + const market = await tampered(SCALLOP_WITHDRAW, (t) => { + pointAt(inputOf(t, callNamed(t, 'redeem'), 1), OTHER_OBJECT) + }) + expect(() => checkWithdraw(market)).toThrow(/object Scallop doesn't use/) + const version = await tampered(SCALLOP_WITHDRAW, (t) => { + pointAt(inputOf(t, callNamed(t, 'redeem'), 0), OTHER_OBJECT) + }) + expect(() => checkWithdraw(version)).toThrow(/object Scallop doesn't use/) + }) + + it('is not a Sui transaction, or not readable', () => { + const near: SigningPayload = { family: 'near', transactions: [] } + expect(() => scallopVenue.assertSafe(near, SCALLOP_SUPPLY_SENDER)).toThrow(/not a Sui/) + expect(() => checkSupply('not base64!')).toThrow(ChainActionRefused) + }) +}) + +describe('amounts', () => { + it('matches what a dry run redeemed: 100000 receipts for about 114370', () => { + const value = amountForReceipts(BigInt(100_000), POOL) + expect(Number(value)).toBeGreaterThan(114_300) + expect(Number(value)).toBeLessThan(114_400) + }) + + it('rounds receipts up, and a whole holding converts back within itself', () => { + const held = BigInt('874346') + const value = amountForReceipts(held, POOL) + expect(receiptsForAmount(value, POOL) <= held).toBe(true) + expect(receiptsForAmount(BigInt(1_000_000), POOL) * BigInt(1) > BigInt(874_000)).toBe(true) + }) + + it('reads the rate from the contract, with Scallop’s share taken off', () => { + expect(scallopSupplyApr(POOL)).toBeCloseTo(5.37, 1) + }) + + it('shows no rate when it cannot be worked out', () => { + expect(scallopSupplyApr({ ...POOL, borrowRateScale: BigInt(0) })).toBeUndefined() + expect(scallopSupplyApr({ ...POOL, debt: BigInt(0), cash: BigInt(0) })).toBeUndefined() + }) + + it('refuses a pool it cannot value rather than guess', () => { + const empty = { ...POOL, receiptSupply: BigInt(0) } + expect(() => receiptsForAmount(BigInt(1), empty)).toThrow(/couldn't read Scallop/) + expect(() => amountForReceipts(BigInt(1), empty)).toThrow(ChainActionRefused) + }) +}) + +describe('prepare refuses before anything is built', () => { + it('a token other than USDC', async () => { + await expect( + createScallopVenue(reader(BigInt(0))).prepare( + { kind: 'supply', amount: '1', tokenIn: 'SUI' }, + ME + ) + ).rejects.toThrow(/takes USDC here, not SUI/) + }) + + it('a malformed account', async () => { + await expect(createScallopVenue(reader(BigInt(1))).prepare(withdraw(), 'nope')).rejects.toThrow( + /not a Sui address/ + ) + }) + + it('a withdrawal with no receipts, and says where such a deposit can be', async () => { + await expect(createScallopVenue(reader(BigInt(0))).prepare(withdraw(), ME)).rejects.toThrow( + /no Scallop USDC receipts.*scallop\.io/ + ) + }) + + it('more than the receipts are worth, and names what they are', async () => { + await expect( + createScallopVenue(reader(BigInt(1_000_000))).prepare(withdraw('5'), ME) + ).rejects.toThrow(/worth about 1\.14\d+ USDC, which is less than 5/) + }) +}) + +describe('the venue', () => { + it('offers only supply and withdraw', () => { + expect(scallopVenue.kinds).toEqual(['supply', 'withdraw']) + }) + + it('quotes a base rate from the reader alone', async () => { + const quote = await createScallopVenue(reader(BigInt(0))).quote?.( + { kind: 'supply', amount: '1', tokenIn: 'USDC' }, + ME + ) + expect(quote?.apyPercent).toBeCloseTo(5.37, 1) + }) + + it('has a listing that says it executes', () => { + expect(scallopListing).toMatchObject({ id: 'scallop', family: 'sui', integration: 'executes' }) + }) +}) diff --git a/apps/dapp/lib/__tests__/solana-stake-fixtures.ts b/apps/dapp/lib/__tests__/solana-stake-fixtures.ts new file mode 100644 index 00000000..0f7bcceb --- /dev/null +++ b/apps/dapp/lib/__tests__/solana-stake-fixtures.ts @@ -0,0 +1,258 @@ +import { + appendTransactionMessageInstructions, + compileTransaction, + createTransactionMessage, + getAddressEncoder, + getBase64EncodedWireTransaction, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + type Address, + type Blockhash, + type Instruction, +} from '@solana/kit' + +import { + ASSOCIATED_TOKEN_PROGRAM, + decodeSolanaTransaction, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, +} from '../chains/solana/assert-safe' +import { + accountRoles, + computeBudget, + STAKE_PROGRAM, + type SolanaRpc, +} from '../chains/solana/stake-pool' +import type { PreparedAction, SigningPayload } from '../chains/types' + +/** Test inputs only. Nothing here signs or sends, and the transport never touches a network. */ + +export const ME = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS' +export const SOMEONE_ELSE = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' +export const UNKNOWN_PROGRAM = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4' +export const BLOCKHASH = 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' + +const { readonly, signer, writable } = accountRoles + +export function compileUnsigned(feePayer: string, instructions: readonly Instruction[]): string { + const message = pipe( + createTransactionMessage({ version: 'legacy' }), + (m) => setTransactionMessageFeePayer(feePayer as Address, m), + (m) => + setTransactionMessageLifetimeUsingBlockhash( + { blockhash: BLOCKHASH as Blockhash, lastValidBlockHeight: BigInt(1) }, + m + ), + (m) => appendTransactionMessageInstructions(instructions, m) + ) + return getBase64EncodedWireTransaction(compileTransaction(message)) +} + +export function payload(feePayer: string, instructions: readonly Instruction[]): SigningPayload { + return { family: 'solana', transaction: compileUnsigned(feePayer, instructions) } +} + +export const budget = computeBudget(200_000) + +export function transferOf(from: string, to: string, lamports: bigint): Instruction { + const bytes = new Uint8Array(12) + bytes[0] = 2 + for (let i = 0; i < 8; i += 1) bytes[4 + i] = Number((lamports >> BigInt(8 * i)) & BigInt(255)) + return { + programAddress: SYSTEM_PROGRAM as Address, + accounts: [signer(from), writable(to)], + data: bytes, + } +} + +/** The Token Program's `approve` with the largest amount there is. */ +export function unlimitedApproval( + tokenAccount: string, + delegate: string, + owner: string +): Instruction { + return { + programAddress: TOKEN_PROGRAM as Address, + accounts: [writable(tokenAccount), readonly(delegate), signer(owner)], + data: Uint8Array.from([4, 255, 255, 255, 255, 255, 255, 255, 255]), + } +} + +/** The same instruction with extra bytes after what the program reads. */ +export function withExtraData(instruction: Instruction): Instruction { + return { ...instruction, data: Uint8Array.from([...(instruction.data ?? []), 0]) } +} + +/** The same instruction with a different discriminator or tag. */ +export function withOtherFunction(instruction: Instruction): Instruction { + const data = Uint8Array.from(instruction.data ?? []) + data[0] = (data[0] ?? 0) ^ 255 + return { ...instruction, data } +} + +export function withAccount( + instruction: Instruction, + position: number, + address: string +): Instruction { + const accounts = [...(instruction.accounts ?? [])] + accounts[position] = writable(address) + return { ...instruction, accounts } +} + +export function instructionsOf(prepared: PreparedAction): Instruction[] { + if (prepared.signing.family !== 'solana') throw new Error('Expected a Solana payload.') + const decoded = decodeSolanaTransaction(prepared.signing.transaction) + return decoded.instructions.map((instruction) => ({ + programAddress: decoded.staticAccounts[instruction.programIndex] as Address, + accounts: instruction.accounts.map((index) => ({ + address: decoded.staticAccounts[index] as Address, + role: accountRoles.readonly('11111111111111111111111111111111').role, + })), + data: instruction.data, + })) +} + +const NEVER_WRITABLE = new Set([ + SYSTEM_PROGRAM, + TOKEN_PROGRAM, + ASSOCIATED_TOKEN_PROGRAM, + STAKE_PROGRAM, + 'SysvarC1ock11111111111111111111111111111111', + 'SysvarStakeHistory1111111111111111111111111', +]) + +/** The instructions as built, with roles restored so that they compile back to one signer. */ +export function rebuilt(prepared: PreparedAction, account: string): Instruction[] { + const instructions = instructionsOf(prepared) + const programs = new Set(instructions.map((i) => i.programAddress)) + return instructions.map((instruction) => ({ + ...instruction, + accounts: (instruction.accounts ?? []).map((meta) => { + if (meta.address === account) return signer(account) + return programs.has(meta.address) || NEVER_WRITABLE.has(meta.address) + ? readonly(meta.address) + : writable(meta.address) + }), + })) +} + +export function addressBytes(address: string): number[] { + return [...getAddressEncoder().encode(address as Address)] +} + +export function u64Bytes(value: bigint): number[] { + return Array.from({ length: 8 }, (_, i) => Number((value >> BigInt(8 * i)) & BigInt(255))) +} + +export function u32Bytes(value: number): number[] { + return Array.from({ length: 4 }, (_, i) => (value >>> (8 * i)) & 255) +} + +export interface FakeAccount { + owner: string + lamports: bigint + data: Uint8Array +} + +/** A transport that answers from a table, and refuses anything that could send. */ +export function fakeRpc(options: { + accounts: Record + epoch: number + rent?: number +}): SolanaRpc & { calls: string[] } { + const calls: string[] = [] + const rpc = async (method: string, params: unknown[]): Promise => { + calls.push(method) + switch (method) { + case 'getAccountInfo': { + const account = options.accounts[params[0] as string] + return { + value: + account === undefined + ? null + : { + owner: account.owner, + lamports: Number(account.lamports), + data: [Buffer.from(account.data).toString('base64'), 'base64'], + }, + } + } + case 'getEpochInfo': + return { epoch: options.epoch } + case 'getLatestBlockhash': + return { value: { blockhash: BLOCKHASH, lastValidBlockHeight: 1 } } + case 'getMinimumBalanceForRentExemption': + return options.rent ?? 1_666_240 + case 'getStakeMinimumDelegation': + return { value: 1_000_000_000 } + default: + throw new Error(`This test must not call ${method}.`) + } + } + return Object.assign(rpc, { calls }) +} + +export interface PoolFixture { + poolMint: string + reserveStake: string + managerFeeAccount: string + validatorList: string + totalLamports: bigint + poolTokenSupply: bigint + lastUpdateEpoch: bigint + solWithdrawalFee?: [bigint, bigint] + stakeWithdrawalFee?: [bigint, bigint] + solDepositAuthority?: string + solWithdrawAuthority?: string +} + +const NO_FEE = [BigInt(0), BigInt(0)] as const + +function fee([numerator, denominator]: readonly [bigint, bigint]): number[] { + return [...u64Bytes(denominator), ...u64Bytes(numerator)] +} + +function option(address: string | undefined): number[] { + return address === undefined ? [0] : [1, ...addressBytes(address)] +} + +/** A stake pool in the program's own Borsh layout, field by field. */ +export function encodeStakePool(fixture: PoolFixture): Uint8Array { + const zero = addressBytes(SYSTEM_PROGRAM) + return Uint8Array.from([ + 1, + ...zero, + ...zero, + ...zero, + 255, + ...addressBytes(fixture.validatorList), + ...addressBytes(fixture.reserveStake), + ...addressBytes(fixture.poolMint), + ...addressBytes(fixture.managerFeeAccount), + ...addressBytes(TOKEN_PROGRAM), + ...u64Bytes(fixture.totalLamports), + ...u64Bytes(fixture.poolTokenSupply), + ...u64Bytes(fixture.lastUpdateEpoch), + ...u64Bytes(BigInt(0)), + ...u64Bytes(BigInt(0)), + ...zero, + ...fee([BigInt(4), BigInt(100)]), + 0, + 0, + 0, + ...fee(NO_FEE), + ...fee(fixture.stakeWithdrawalFee ?? NO_FEE), + 0, + 0, + ...option(fixture.solDepositAuthority), + ...fee(NO_FEE), + 0, + ...option(fixture.solWithdrawAuthority), + ...fee(fixture.solWithdrawalFee ?? NO_FEE), + 0, + ...u64Bytes(BigInt(0)), + ...u64Bytes(BigInt(0)), + ]) +} diff --git a/apps/dapp/lib/__tests__/solana-staking.live.test.ts b/apps/dapp/lib/__tests__/solana-staking.live.test.ts new file mode 100644 index 00000000..4e100096 --- /dev/null +++ b/apps/dapp/lib/__tests__/solana-staking.live.test.ts @@ -0,0 +1,279 @@ +import { z } from 'zod' +import { describe, expect, it } from 'vitest' + +import { bytesFromBase64 } from '../chains/base64' +import { associatedTokenAccount, decodeSolanaTransaction } from '../chains/solana/assert-safe' +import { createJitoVenue, JITO_STAKE_POOL, JITOSOL_MINT } from '../chains/solana/jito' +import { + createMarinadeVenue, + decodeMarinadeState, + MARINADE_PROGRAM, + MARINADE_STATE, + MSOL_MINT, +} from '../chains/solana/marinade' +import { + createSolayerVenue, + SOLAYER_LP_MINT, + SOLAYER_RESTAKING_POOL, + SOLAYER_RESTAKING_PROGRAM, + SOLAYER_STAKE_POOL, + SSOL_MINT, +} from '../chains/solana/solayer' +import { + decodeStakePool, + decodeValidatorList, + readAccount, + readAddress, + readOnlyRpc, + readU64, + STAKE_POOL_PROGRAM, +} from '../chains/solana/stake-pool' +import type { ChainVenue, PreparedAction } from '../chains/types' + +/** + * Read-only checks that the pinned ids still hold on chain, and that what the venues build runs, + * by the network's simulation. `simulateTransaction` here has signature checks off, and nothing is + * ever signed or sent. The wallets are public: a funded one, and a large holder of each token. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const SLOW_MS = 180_000 +const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +const FUNDED = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS' +const env = { + ...process.env, + SOLANA_RPC_URL: process.env['SOLANA_RPC_URL'] ?? 'https://api.mainnet-beta.solana.com', +} +const baseRpc = readOnlyRpc(env) + +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +/** The public node allows only so much, and a refused read proves nothing about what's pinned. */ +const rpc = async (method: string, params: unknown[]): Promise => { + for (let attempt = 0; ; attempt += 1) { + try { + await wait(400) + return await baseRpc(method, params) + } catch (e) { + if (attempt >= 4) throw e + await wait(5_000) + } + } +} + +const Simulated = z.object({ + value: z.object({ + err: z.unknown().nullable(), + logs: z.array(z.string()).nullable(), + unitsConsumed: z.number().optional(), + accounts: z + .array(z.object({ lamports: z.number(), data: z.tuple([z.string(), z.string()]) }).nullable()) + .nullable() + .optional(), + }), +}) + +async function simulate( + prepared: PreparedAction, + addresses: string[] = [] +): Promise['value']> { + if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.') + expect(decodeSolanaTransaction(prepared.signing.transaction).feePayerSignature).toBeNull() + const reply = await rpc('simulateTransaction', [ + prepared.signing.transaction, + { + encoding: 'base64', + sigVerify: false, + replaceRecentBlockhash: true, + commitment: 'confirmed', + ...(addresses.length === 0 ? {} : { accounts: { addresses, encoding: 'base64' } }), + }, + ]) + return Simulated.parse(reply).value +} + +function expectRan(result: z.infer['value']): void { + if (result.err !== null) console.error(JSON.stringify(result.logs?.slice(-14), null, 1)) + expect(result.err).toBeNull() +} + +/** A token account's amount as it stands now, zero if it doesn't exist yet. */ +async function tokenAmount(address: string): Promise { + const reply = TokenBalance.safeParse( + await rpc('getTokenAccountBalance', [address]).catch(() => undefined) + ) + return reply.success ? BigInt(reply.data.value.amount) : BigInt(0) +} + +const baseUnits = (text: string | undefined): bigint => + BigInt(Math.round(Number(text) * 1_000_000_000)) + +const Signatures = z.array(z.object({ signature: z.string(), err: z.unknown().nullable() })) +const TransactionBalances = z.object({ + meta: z.object({ + postTokenBalances: z.array( + z.object({ + mint: z.string(), + owner: z.string().optional(), + uiTokenAmount: z.object({ amount: z.string() }), + }) + ), + }), +}) +const Wallet = z.object({ value: z.object({ owner: z.string(), lamports: z.number() }) }) +const TokenBalance = z.object({ value: z.object({ amount: z.string() }) }) + +/** + * A plain wallet holding at least this much of a token in its own associated account, with SOL for + * the fee, found among whoever last traded it. The public node refuses the largest-holders call. + */ +async function holderOf(mint: string, atLeast: bigint): Promise { + const signatures = Signatures.parse(await rpc('getSignaturesForAddress', [mint, { limit: 120 }])) + for (const { signature, err } of signatures) { + if (err !== null) continue + const transaction = TransactionBalances.safeParse( + await rpc('getTransaction', [ + signature, + { encoding: 'json', maxSupportedTransactionVersion: 1 }, + ]) + ) + if (!transaction.success) continue + for (const { mint: held, owner, uiTokenAmount } of transaction.data.meta.postTokenBalances) { + if (held !== mint || owner === undefined || BigInt(uiTokenAmount.amount) < atLeast) continue + const wallet = Wallet.safeParse(await rpc('getAccountInfo', [owner, { encoding: 'base64' }])) + if ( + !wallet.success || + wallet.data.value.owner !== '11111111111111111111111111111111' || + wallet.data.value.lamports < 50_000_000 + ) { + continue + } + const balance = TokenBalance.safeParse( + await rpc('getTokenAccountBalance', [associatedTokenAccount(owner, mint)]).catch( + () => undefined + ) + ) + if (balance.success && BigInt(balance.data.value.amount) >= atLeast) return owner + } + } + throw new Error(`No usable holder of ${mint} among its recent traders.`) +} + +describe.skipIf(SKIP)('staking pins on chain', () => { + it( + 'marinade: the program, state, mint and pool legs are what the code pins', + async () => { + const state = await readAccount(rpc, MARINADE_STATE) + expect(state.owner).toBe(MARINADE_PROGRAM) + const decoded = decodeMarinadeState(state.data) + expect(decoded?.msolMint).toBe(MSOL_MINT) + expect(decoded?.msolSupply).toBeGreaterThan(BigInt(0)) + expect((await readAccount(rpc, MSOL_MINT)).owner).toBe(TOKEN_PROGRAM) + }, + SLOW_MS + ) + + it( + 'jito: the pool is the stake pool program’s and holds this mint', + async () => { + const pool = await readAccount(rpc, JITO_STAKE_POOL) + expect(pool.owner).toBe(STAKE_POOL_PROGRAM) + const decoded = decodeStakePool(pool.data) + expect(decoded?.poolMint).toBe(JITOSOL_MINT) + expect(decoded?.tokenProgram).toBe(TOKEN_PROGRAM) + expect(decoded?.solDepositAuthority).toBeUndefined() + expect(decoded?.solWithdrawAuthority).toBeUndefined() + }, + SLOW_MS + ) + + it( + 'solayer: the restaking pool holds these mints, and the stake pool mints the LP', + async () => { + const restaking = await readAccount(rpc, SOLAYER_RESTAKING_POOL) + expect(restaking.owner).toBe(SOLAYER_RESTAKING_PROGRAM) + expect(readAddress(restaking.data, 8)).toBe(SOLAYER_LP_MINT) + expect(readAddress(restaking.data, 40)).toBe(SSOL_MINT) + const pool = await readAccount(rpc, SOLAYER_STAKE_POOL) + expect(pool.owner).toBe(STAKE_POOL_PROGRAM) + const decoded = decodeStakePool(pool.data) + expect(decoded?.poolMint).toBe(SOLAYER_LP_MINT) + expect(decoded?.solDepositAuthority).toBeUndefined() + const list = await readAccount(rpc, decoded?.validatorList as string) + expect(decodeValidatorList(list.data)?.length).toBeGreaterThan(0) + }, + SLOW_MS + ) +}) + +describe.skipIf(SKIP)('what the staking venues build runs on chain', () => { + const cases: [string, ChainVenue, string, string][] = [ + ['marinade', createMarinadeVenue({ rpc }), 'mSOL', '0.1'], + ['jito', createJitoVenue({ rpc }), 'JitoSOL', '0.1'], + // Under Solana's minimum for a stake account, this venue refuses, so this asks for more. + ['solayer', createSolayerVenue({ rpc }), 'sSOL', '1'], + ] + const mints: Record = { mSOL: MSOL_MINT, JitoSOL: JITOSOL_MINT, sSOL: SSOL_MINT } + + it.each(cases)( + '%s stakes SOL', + async (_id, venue, symbol) => { + const prepared = await venue.prepare( + { kind: 'stake', amount: '0.05', tokenIn: 'SOL' }, + FUNDED + ) + expect(() => venue.assertSafe(prepared.signing, FUNDED)).not.toThrow() + expect(prepared.receive?.symbol).toBe(symbol) + const own = associatedTokenAccount(FUNDED, mints[symbol] as string) + const before = await tokenAmount(own) + const result = await simulate(prepared, [own]) + console.info(_id, 'stake units', result.unitsConsumed, prepared.receive?.amount) + expectRan(result) + // What the card said is what lands in the person's own account, to well inside 0.1%. + const after = readU64(bytesFromBase64(result.accounts?.[0]?.data[0] as string), 64) + const expected = baseUnits(prepared.receive?.amount) + const gained = after - before + const gap = gained > expected ? gained - expected : expected - gained + expect(gap).toBeLessThan(expected / BigInt(1000)) + }, + SLOW_MS + ) + + it.each(cases)( + '%s unstakes back to the person', + async (_id, venue, symbol, amount) => { + const atLeast = BigInt(Math.round(Number(amount) * 1_000_000_000)) + const holder = await holderOf(mints[symbol] as string, atLeast) + const prepared = await venue.prepare({ kind: 'unstake', amount, tokenIn: symbol }, holder) + expect(() => venue.assertSafe(prepared.signing, holder)).not.toThrow() + const stakeAccount = /[1-9A-HJ-NP-Za-km-z]{32,44}/.exec(prepared.notes[0] ?? '')?.[0] + const result = await simulate(prepared, [stakeAccount ?? holder]) + console.info( + _id, + 'unstake units', + result.unitsConsumed, + prepared.receive?.amount, + prepared.notes[0] + ) + expectRan(result) + const seen = result.accounts?.[0] + const expected = baseUnits(prepared.receive?.amount) + if (stakeAccount === undefined) { + // SOL comes straight back to the wallet, less a network fee this bound more than covers. + const wallet = Wallet.parse(await rpc('getAccountInfo', [holder, { encoding: 'base64' }])) + const gained = BigInt(seen?.lamports as number) - BigInt(wallet.value.lamports) + expect(gained).toBeGreaterThan(expected - BigInt(200_000)) + expect(gained).toBeLessThanOrEqual(expected + BigInt(1_000)) + } else { + // A stake account the person alone controls, holding what the card said, already cooling. + const data = bytesFromBase64(seen?.data[0] as string) + expect(readAddress(data, 12)).toBe(holder) + expect(readAddress(data, 44)).toBe(holder) + const delegated = readU64(data, 156) + expect(delegated).toBeGreaterThan(expected - expected / BigInt(1000)) + expect(readU64(data, 172)).not.toBe(BigInt('18446744073709551615')) + } + }, + SLOW_MS + ) +}) diff --git a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts index 35ee3c0d..6aefec37 100644 --- a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts +++ b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentProposalResult } from '../agents/brain' import { quoteChainRoutes, type ChainRouteQuote } from '../agents/chain-routes' +import { racesFirst } from '../chains/parse-action' import { planScore } from '../agents/plan-scoring' import { applyChainMeasure, isUnmeasuredSwap } from '../agents/settle-chain-plan' import { clearTokenCache } from '../chains/near/oneclick' @@ -426,12 +427,13 @@ describe('the race on these chains', () => { expect(buildSuiVenues(env).map((v) => v.id)).not.toContain('1click-sui') }) - it('gives every venue on these chains a quote to race with, but a wallet transfer', () => { + it('gives every venue that races a quote, and none to one with no price to race on', () => { const lulo = createLuloVenue({ apiKey: 'k' }) const env = { AUTH_SECRET: 'x'.repeat(48) } for (const venue of [...buildSolanaVenues(env), lulo, ...buildSuiVenues(env)]) { - if (venue.kinds.includes('send')) expect(venue.quote, venue.id).toBeUndefined() - else expect(venue.quote, venue.id).toBeTypeOf('function') + const races = venue.kinds.some((kind) => racesFirst({ kind })) + if (races) expect(venue.quote, venue.id).toBeTypeOf('function') + else expect(venue.quote, venue.id).toBeUndefined() } }) @@ -456,7 +458,7 @@ describe('the race on these chains', () => { it('says agents compete on Solana and Sui, and nothing these chains cannot do', () => { const solana = capabilitySheet('solana') expect(solana).toContain('Agents compete') - expect(solana).not.toMatch(/Stake:|Send:|limit order/) + expect(solana).not.toMatch(/Send:|limit order/) const onSui = capabilitySheet('sui') expect(onSui).toContain('Agents compete') expect(onSui).toContain('Stake:') diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts index c42f7f9d..98c98df4 100644 --- a/apps/dapp/lib/__tests__/solana-venues.test.ts +++ b/apps/dapp/lib/__tests__/solana-venues.test.ts @@ -75,6 +75,8 @@ describe('tokens', () => { USDT: [USDT, 6], JitoSOL: ['J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn', 9], ONyc: [ONYC, 9], + mSOL: ['mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So', 9], + sSOL: ['sSo14endRuUbvQaJS3dq36Q829a3A6BEfoeeRGJywEh', 9], }) }) @@ -83,16 +85,14 @@ describe('tokens', () => { prepareChainAction( solanaModule, { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'USDC.e' }, - CAPTURE_ACCOUNT, - {} + CAPTURE_ACCOUNT ) ).rejects.toThrow(/verified token/) await expect( prepareChainAction( solanaModule, { kind: 'swap', amount: '1', tokenIn: USDC, tokenOut: 'SOL' }, - CAPTURE_ACCOUNT, - {} + CAPTURE_ACCOUNT ) ).rejects.toThrow(/verified token/) }) @@ -670,12 +670,23 @@ describe('module', () => { '1click-solana', 'jupiter-lend', 'kamino', + 'jito', + 'marinade', + 'solayer', + 'streamflow', 'send', ]) }) - it('offers the three venues that need no key', () => { - expect(buildSolanaVenues({}).map((v) => v.id)).toEqual(['jupiter', 'jupiter-lend', 'kamino']) + it('offers the venues that need no key', () => { + expect(buildSolanaVenues({}).map((v) => v.id)).toEqual([ + 'jupiter', + 'jupiter-lend', + 'kamino', + 'jito', + 'marinade', + 'solayer', + ]) expect(solanaModule.defaultVenue).toMatchObject({ swap: 'jupiter', supply: 'jupiter-lend' }) expect(solanaModule.chain).toBe('solana') }) @@ -688,30 +699,13 @@ describe('module', () => { expect(kamino?.submit).toBeUndefined() }) - it('refuses a trade over the $500 cap before building anything', async () => { - const { calls } = stubFetch((url) => - url.includes('/price/v3') ? { body: { [USDC]: { usdPrice: 1 } } } : undefined - ) - const chainApi = { ...solanaModule, priceUsd: async () => 1 } - await expect( - prepareChainAction( - chainApi, - { kind: 'swap', amount: '501', tokenIn: 'USDC', tokenOut: 'SOL' }, - CAPTURE_ACCOUNT, - {} - ) - ).rejects.toThrow(/caps each trade/) - expect(calls).toHaveLength(0) - }) - it('refuses a named venue that cannot do this', async () => { const chainApi = { ...solanaModule, priceUsd: async () => 1 } await expect( prepareChainAction( chainApi, { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SOL', venue: 'kamino' }, - CAPTURE_ACCOUNT, - {} + CAPTURE_ACCOUNT ) ).rejects.toThrow(/can't swap here/) }) @@ -733,8 +727,7 @@ describe('module', () => { await prepareChainAction( chainApi, { kind: 'supply', amount: '1', tokenIn: 'USDC', venue }, - CAPTURE_ACCOUNT, - {} + CAPTURE_ACCOUNT ).catch(() => undefined) } expect(seen).toEqual(['kamino', 'jupiter-lend']) @@ -743,8 +736,8 @@ describe('module', () => { describe('venues page', () => { it('lists every row of the report', () => { - expect(solanaVenues).toHaveLength(15) - expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(15) + expect(solanaVenues).toHaveLength(19) + expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(19) for (const v of solanaVenues) { expect(v.family).toBe('solana') expect(v.chains).toEqual(['Solana']) @@ -780,8 +773,8 @@ describe('venues page', () => { describe('capabilities', () => { it('has one entry per wired kind, and each example parses and resolves', async () => { const kinds = solanaCapabilities.map((c) => c.kind) - expect(new Set(kinds)).toEqual(new Set(['swap', 'supply'])) - expect(kinds).toHaveLength(2) + expect(new Set(kinds)).toEqual(new Set(['swap', 'supply', 'stake', 'unstake'])) + expect(kinds).toHaveLength(4) const swap = solanaCapabilities.find((c) => c.kind === 'swap')! expect(parseChainAction(`swap 10 USDC to ${swap.symbol}`)).toMatchObject({ kind: 'swap', diff --git a/apps/dapp/lib/__tests__/solayer.test.ts b/apps/dapp/lib/__tests__/solayer.test.ts new file mode 100644 index 00000000..c3a6e665 --- /dev/null +++ b/apps/dapp/lib/__tests__/solayer.test.ts @@ -0,0 +1,455 @@ +import { describe, expect, it } from 'vitest' + +import { associatedTokenAccount, decodeSolanaTransaction } from '../chains/solana/assert-safe' +import { + chooseValidator, + createSolayerVenue, + SOLAYER_LP_MINT, + SOLAYER_RESTAKING_POOL, + SOLAYER_RESTAKING_PROGRAM, + SOLAYER_STAKE_POOL, + SOLAYER_TOKENS, + SSOL_MINT, +} from '../chains/solana/solayer' +import { solayerListing } from '../chains/solana/solayer-listing' +import { + addressWithSeed, + createStakeAccountWithSeed, + decodeValidatorList, + readU64, + STAKE_POOL_PROGRAM, + STAKE_PROGRAM, +} from '../chains/solana/stake-pool' +import type { PreparedAction } from '../chains/types' +import { + addressBytes, + encodeStakePool, + fakeRpc, + ME, + payload, + rebuilt, + SOMEONE_ELSE, + transferOf, + u32Bytes, + u64Bytes, + UNKNOWN_PROGRAM, + unlimitedApproval, + withAccount, + withExtraData, + withOtherFunction, + type PoolFixture, +} from './solana-stake-fixtures' + +const EPOCH = 1077 +const LIST = 'nk5E1Gc2rCuU2MDTRqdcQdiMfV9KnZ6JHykA1cTJQ56' +const BIG_VOTE = 'SLaYv7tCwetrFGbPCRnqpHswG5qqKino78EYpbGF7xY' +const SMALL_VOTE = 'CatzoSMUkTRidT5DwBxAC2pEtnwMBTpkCepHkFgZDiqb' +/** Hro3G6fA... is the stake account a live withdrawal used for that validator on 4 November 2026. */ +const BIG_VALIDATOR_STAKE = 'Hro3G6fALTcz85Qwke3BKZEtK6UzhMYrqXMoUxPMoXz' + +const BASE: PoolFixture = { + poolMint: SOLAYER_LP_MINT, + reserveStake: 'Brh9rB6npnjM1vDXyCXtzkXVGRnsh6KHqmBz26tVACg9', + managerFeeAccount: 'ARs3HTD79nsaUdDKqfGhgbNMVJkXVdRs2EpHAm4LNEcq', + validatorList: LIST, + totalLamports: BigInt('82390512858986'), + poolTokenSupply: BigInt('69899353088216'), + lastUpdateEpoch: BigInt(EPOCH), + stakeWithdrawalFee: [BigInt(8), BigInt(10000)], +} + +function validatorEntry(vote: string, active: bigint, status = 0): number[] { + return [ + ...u64Bytes(active), + ...u64Bytes(BigInt(0)), + ...u64Bytes(BigInt(EPOCH)), + ...u64Bytes(BigInt(0)), + ...u32Bytes(0), + ...u32Bytes(0), + status, + ...addressBytes(vote), + ] +} + +function encodeList(entries: number[][]): Uint8Array { + return Uint8Array.from([2, ...u32Bytes(100), ...u32Bytes(entries.length), ...entries.flat()]) +} + +const LIST_BYTES = encodeList([ + validatorEntry(SMALL_VOTE, BigInt('1009574703')), + validatorEntry(BIG_VOTE, BigInt('82389651605762')), +]) + +function venueWith( + overrides: Partial = {}, + options: { restakingOwner?: string; list?: Uint8Array } = {} +) { + const rpc = fakeRpc({ + epoch: EPOCH, + accounts: { + [SOLAYER_STAKE_POOL]: { + owner: STAKE_POOL_PROGRAM, + lamports: BigInt(1), + data: encodeStakePool({ ...BASE, ...overrides }), + }, + [SOLAYER_RESTAKING_POOL]: { + owner: options.restakingOwner ?? SOLAYER_RESTAKING_PROGRAM, + lamports: BigInt(1), + data: new Uint8Array(74), + }, + [LIST]: { owner: STAKE_POOL_PROGRAM, lamports: BigInt(1), data: options.list ?? LIST_BYTES }, + }, + }) + return { venue: createSolayerVenue({ rpc, now: () => 1793830450018 }), rpc } +} + +const stake = { kind: 'stake', amount: '1', tokenIn: 'SOL' } as const +const unstake = { kind: 'unstake', amount: '1', tokenIn: 'sSOL' } as const + +const preparedStake = (): Promise => venueWith().venue.prepare(stake, ME) +const preparedUnstake = (): Promise => venueWith().venue.prepare(unstake, ME) + +function refuses(action: () => unknown, pattern: RegExp): void { + expect(action).toThrow(pattern) +} + +describe('solayer stake', () => { + it('reads the amount from the pool and shows no rate', async () => { + const prepared = await preparedStake() + expect(prepared.receive?.symbol).toBe('sSOL') + expect(Number(prepared.receive?.amount)).toBeCloseTo(0.84839, 4) + expect(prepared.apy).toBeUndefined() + const notes = prepared.notes.join(' ') + expect(notes).toMatch(/not showing a yearly rate/) + expect(notes).toMatch(/3-of-5/) + expect(notes).toMatch(/\$520\.67M/) + expect(notes).toMatch(/about 82,390 SOL/) + expect(notes).toMatch(/Leaving is slow/) + expect(notes).toMatch(/at least 1 SOL worth of sSOL at once/) + }) + + it('restakes exactly the floor it asks the deposit for, with only the person signing', async () => { + const prepared = await preparedStake() + if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.') + const decoded = decodeSolanaTransaction(prepared.signing.transaction) + expect(decoded.signerCount).toBe(1) + expect(decoded.feePayer).toBe(ME) + const [deposit, restake] = decoded.instructions.slice(-2) + expect(decoded.staticAccounts[deposit!.programIndex]).toBe(STAKE_POOL_PROGRAM) + expect(readU64(deposit!.data, 1)).toBe(BigInt('1000000000')) + expect(decoded.staticAccounts[restake!.programIndex]).toBe(SOLAYER_RESTAKING_PROGRAM) + expect(readU64(restake!.data, 8)).toBe(readU64(deposit!.data, 9)) + expect(decoded.staticAccounts[restake!.accounts[3]!]).toBe( + associatedTokenAccount(ME, SSOL_MINT) + ) + }) + + it('passes its own check, and a quote builds nothing', async () => { + const { venue } = venueWith() + const prepared = await venue.prepare(stake, ME) + expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow() + const quoting = venueWith() + const quoted = await quoting.venue.quote?.(stake, ME) + expect(quoted?.receive?.symbol).toBe('sSOL') + expect(quoted?.apyPercent).toBeUndefined() + expect(quoting.rpc.calls).not.toContain('getLatestBlockhash') + }) + + it('refuses another token, a deposit that needs someone else, a stale pool, a wrong restaking owner', async () => { + await expect(venueWith().venue.prepare({ ...stake, tokenIn: 'sUSD' }, ME)).rejects.toThrow( + /takes SOL/ + ) + await expect( + venueWith({ solDepositAuthority: SOMEONE_ELSE }).venue.prepare(stake, ME) + ).rejects.toThrow(/another signature/) + await expect( + venueWith({ lastUpdateEpoch: BigInt(EPOCH - 1) }).venue.prepare(stake, ME) + ).rejects.toThrow(/still updating/) + await expect( + venueWith({}, { restakingOwner: SOMEONE_ELSE }).venue.prepare(stake, ME) + ).rejects.toThrow(/doesn’t look as expected/) + }) +}) + +describe('solayer unstake', () => { + it('says plainly that this exit is delayed and how the person finishes it', async () => { + const prepared = await preparedUnstake() + expect(prepared.kind).toBe('unstake') + expect(prepared.pay).toEqual({ amount: '1', symbol: 'sSOL' }) + expect(prepared.summary).toMatch(/cools down before you can withdraw/) + const notes = prepared.notes.join(' ') + expect(notes).toMatch(/This exit is delayed/) + expect(notes).toMatch(/does not reach your balance now/) + expect(notes).toMatch(/withdraw the SOL from that stake account yourself/) + expect(notes).toMatch(/0\.08%/) + expect(notes).toContain(addressWithSeed(ME, 'intent-1793830450018', STAKE_PROGRAM)) + }) + + it('needs only the person: the stake account comes from a seed, not a new keypair', async () => { + const prepared = await preparedUnstake() + if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.') + const decoded = decodeSolanaTransaction(prepared.signing.transaction) + expect(decoded.signerCount).toBe(1) + const stakeAccount = addressWithSeed(ME, 'intent-1793830450018', STAKE_PROGRAM) + const [unrestake, create, withdraw, deactivate] = decoded.instructions.slice(-4) + expect(readU64(unrestake!.data, 8)).toBe(BigInt('1000000000')) + expect(decoded.staticAccounts[create!.accounts[1]!]).toBe(stakeAccount) + expect(readU64(withdraw!.data, 1)).toBe(BigInt('1000000000')) + expect(decoded.staticAccounts[withdraw!.accounts[3]!]).toBe(BIG_VALIDATOR_STAKE) + expect(decoded.staticAccounts[withdraw!.accounts[4]!]).toBe(stakeAccount) + expect(decoded.staticAccounts[withdraw!.accounts[5]!]).toBe(ME) + expect(decoded.staticAccounts[withdraw!.accounts[6]!]).toBe(ME) + expect(decoded.staticAccounts[deactivate!.accounts[0]!]).toBe(stakeAccount) + expect(decoded.staticAccounts[deactivate!.accounts[2]!]).toBe(ME) + expect(() => venueWith().venue.assertSafe(prepared.signing, ME)).not.toThrow() + }) + + it('derives the address the chain did for a seed in a live transaction', () => { + // 4krfXaL9... on 4 November 2026 created this account from this base and seed. + expect( + addressWithSeed( + 'HH2mfuVsuVpLBT7poqvSbzXXUXtMxUB5HKmUa5F7e1bG', + 'solstk1793830450018', + STAKE_PROGRAM + ) + ).toBe('DGTBCx2YJvGQEzFvT144ttKHoXkLAeh8FnpuZCxXDbjp') + }) + + it('picks the validator holding the most, unless the pool prefers one, and skips any not active', () => { + const validators = decodeValidatorList( + encodeList([ + validatorEntry(SMALL_VOTE, BigInt(5)), + validatorEntry(BIG_VOTE, BigInt(9)), + validatorEntry(SOMEONE_ELSE, BigInt(100), 1), + ]) + )! + expect(validators).toHaveLength(2) + expect(chooseValidator({ preferredWithdrawValidator: undefined }, validators)?.vote).toBe( + BIG_VOTE + ) + expect(chooseValidator({ preferredWithdrawValidator: SMALL_VOTE }, validators)?.vote).toBe( + SMALL_VOTE + ) + expect( + chooseValidator({ preferredWithdrawValidator: SOMEONE_ELSE }, validators) + ).toBeUndefined() + }) + + it('refuses less than Solana lets a stake account hold, and names the market instead', async () => { + await expect(venueWith().venue.prepare({ ...unstake, amount: '0.5' }, ME)).rejects.toThrow( + /under the 1 SOL Solana requires.*sell sSOL on the market/ + ) + }) + + it('refuses another token, more than the validator holds, and a pool with no validator', async () => { + await expect(venueWith().venue.prepare({ ...unstake, tokenIn: 'SOL' }, ME)).rejects.toThrow( + /takes sSOL/ + ) + await expect(venueWith().venue.prepare({ ...unstake, tokenIn: 'sUSD' }, ME)).rejects.toThrow( + /takes sSOL/ + ) + await expect( + venueWith({}, { list: encodeList([validatorEntry(BIG_VOTE, BigInt(10))]) }).venue.prepare( + unstake, + ME + ) + ).rejects.toThrow(/can’t be withdrawn/) + await expect( + venueWith({}, { list: encodeList([]) }).venue.prepare(unstake, ME) + ).rejects.toThrow(/no validator/) + }) +}) + +describe('solayer assertSafe', () => { + const venue = venueWith().venue + + it('refuses the wrong signer', async () => { + const prepared = await preparedStake() + refuses(() => venue.assertSafe(prepared.signing, SOMEONE_ELSE), /network fee/) + refuses(() => venue.assertSafe(payload(SOMEONE_ELSE, rebuilt(prepared, ME)), ME), /network fee/) + }) + + it('refuses an unknown program', async () => { + const rest = rebuilt(await preparedStake(), ME) + const last = rest.at(-1)! + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest.slice(0, -1), + { ...last, programAddress: UNKNOWN_PROGRAM as never }, + ]), + ME + ), + /should not need/ + ) + }) + + it('refuses an unknown function', async () => { + const rest = rebuilt(await preparedStake(), ME) + refuses( + () => + venue.assertSafe(payload(ME, [...rest.slice(0, -1), withOtherFunction(rest.at(-1)!)]), ME), + /didn’t expect/ + ) + }) + + it('refuses extra calldata', async () => { + const rest = rebuilt(await preparedStake(), ME) + refuses( + () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), withExtraData(rest.at(-1)!)]), ME), + /didn’t expect/ + ) + }) + + it('refuses sSOL or LP tokens going to someone else', async () => { + const rest = rebuilt(await preparedStake(), ME) + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest.slice(0, -1), + withAccount(rest.at(-1)!, 3, associatedTokenAccount(SOMEONE_ELSE, SSOL_MINT)), + ]), + ME + ), + /isn’t what Solayer should get/ + ) + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest.slice(0, -2), + withAccount(rest.at(-2)!, 4, associatedTokenAccount(SOMEONE_ELSE, SOLAYER_LP_MINT)), + rest.at(-1)!, + ]), + ME + ), + /isn’t what Solayer should get/ + ) + }) + + it('refuses a restake that does not match its deposit', async () => { + const rest = rebuilt(await preparedStake(), ME) + const last = rest.at(-1)! + const data = Uint8Array.from(last.data ?? []) + data[8] = (data[8] ?? 0) ^ 1 + refuses( + () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), { ...last, data }]), ME), + /doesn’t match its deposit/ + ) + }) + + it('refuses withdrawn stake going under someone else’s authority', async () => { + const rest = rebuilt(await preparedUnstake(), ME) + const withdraw = rest.at(-2)! + refuses( + () => + venue.assertSafe( + payload(ME, [...rest.slice(0, -2), withAccount(withdraw, 5, SOMEONE_ELSE), rest.at(-1)!]), + ME + ), + /isn’t what Solayer should get/ + ) + }) + + it('refuses a stake account whose address the seed does not give, or that takes too much SOL', async () => { + const rest = rebuilt(await preparedUnstake(), ME) + const create = rest.at(-3)! + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest.slice(0, -3), + withAccount(create, 1, SOMEONE_ELSE), + ...rest.slice(-2), + ]), + ME + ), + /isn’t what Solayer should get/ + ) + const costly = createStakeAccountWithSeed(ME, 'intent-1793830450018', BigInt('5000000001')) + refuses( + () => venue.assertSafe(payload(ME, [...rest.slice(0, -3), costly, ...rest.slice(-2)]), ME), + /isn’t what Solayer should get/ + ) + }) + + it('refuses an exit that withdraws to a different stake account than it creates', async () => { + const rest = rebuilt(await preparedUnstake(), ME) + const other = addressWithSeed(ME, 'intent-2', STAKE_PROGRAM) + refuses( + () => + venue.assertSafe( + payload(ME, [...rest.slice(0, -2), withAccount(rest.at(-2)!, 4, other), rest.at(-1)!]), + ME + ), + /doesn’t fit together/ + ) + }) + + it('refuses a transfer or an unlimited approval added to it', async () => { + const rest = rebuilt(await preparedUnstake(), ME) + refuses( + () => venue.assertSafe(payload(ME, [...rest, transferOf(ME, SOMEONE_ELSE, BigInt(1))]), ME), + /SOL transfer/ + ) + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest, + unlimitedApproval(associatedTokenAccount(ME, SOLAYER_LP_MINT), SOMEONE_ELSE, ME), + ]), + ME + ), + /moves or approves tokens/ + ) + }) + + it('refuses a plain transfer where the stake account creation belongs', async () => { + const rest = rebuilt(await preparedUnstake(), ME) + refuses( + () => + venue.assertSafe( + payload(ME, [ + ...rest.slice(0, -3), + transferOf(ME, SOMEONE_ELSE, BigInt(1)), + ...rest.slice(-2), + ]), + ME + ), + /didn’t expect/ + ) + }) + + it('refuses the wrong token', async () => { + const rest = rebuilt(await preparedStake(), ME) + const usdc = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + refuses( + () => + venue.assertSafe( + payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 4, usdc)]), + ME + ), + /isn’t what Solayer should get/ + ) + }) +}) + +describe('solayer tokens and listing', () => { + it('offers sSOL only', () => { + expect(SOLAYER_TOKENS).toEqual([ + { symbol: 'sSOL', id: SSOL_MINT, decimals: 9, name: 'Solayer restaked SOL' }, + ]) + }) + + it('lists as executing on Solana and says what it leaves out', () => { + expect(solayerListing).toMatchObject({ + id: 'solayer', + family: 'solana', + integration: 'executes', + }) + expect(solayerListing.capability).toMatch(/not sUSD, Pay, the card or the chain/) + }) +}) diff --git a/apps/dapp/lib/__tests__/springsui-unstake.live.test.ts b/apps/dapp/lib/__tests__/springsui-unstake.live.test.ts new file mode 100644 index 00000000..ed400e96 --- /dev/null +++ b/apps/dapp/lib/__tests__/springsui-unstake.live.test.ts @@ -0,0 +1,39 @@ +import { fromBase64 } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { suiClient } from '../chains/sui/client' +import { SSUI_TYPE } from '../chains/sui/policies' +import { springsuiVenue } from '../chains/sui/springsui' + +/** + * A real unsigned transaction against mainnet, read-only. Nothing here signs or sends: it is only + * built and dry-run, for a public address whose key nobody here has. When that address empties + * this fails on balance, so swap it for another funded one. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const SSUI_HOLDER = '0xe4b732379e532578abd81ca8bbc40f213eed36941ab280205654059c8bfab56d' + +describe.skipIf(SKIP)('SpringSui unstake against mainnet', () => { + it('builds an unstake that passes its check, pays out, and dry-runs clean', async () => { + const prepared = await springsuiVenue.prepare( + { kind: 'unstake', amount: '2', tokenIn: 'sSUI' }, + SSUI_HOLDER + ) + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + expect(() => springsuiVenue.assertSafe(prepared.signing, SSUI_HOLDER)).not.toThrow() + const result = await suiClient().core.simulateTransaction({ + transaction: fromBase64(prepared.signing.transaction), + include: { effects: true }, + }) + expect(result.Transaction?.effects?.status.success).toBe(true) + expect(prepared.receive?.symbol).toBe('SUI') + expect(Number(prepared.receive?.amount)).toBeGreaterThan(2) + }, 60_000) + + it('sSUI still has the decimals in its token entry', async () => { + const { coinMetadata } = await suiClient().core.getCoinMetadata({ coinType: SSUI_TYPE }) + expect(coinMetadata?.symbol).toBe('sSUI') + expect(coinMetadata?.decimals).toBe(9) + }, 30_000) +}) diff --git a/apps/dapp/lib/__tests__/springsui-unstake.test.ts b/apps/dapp/lib/__tests__/springsui-unstake.test.ts new file mode 100644 index 00000000..eec1344f --- /dev/null +++ b/apps/dapp/lib/__tests__/springsui-unstake.test.ts @@ -0,0 +1,261 @@ +import { bcs } from '@mysten/sui/bcs' +import { Inputs, Transaction } from '@mysten/sui/transactions' +import { toBase64 } from '@mysten/sui/utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { CAPTURED_SENDER, CAPTURED_SPRINGSUI_STAKE } from '../chains/sui/captured-transactions' +import { SPRINGSUI_PACKAGE, SSUI_INFO, SSUI_TYPE, SSUI_WEIGHT_HOOK } from '../chains/sui/policies' +import { springsuiVenue } from '../chains/sui/springsui' +import { SPRINGSUI_TOKENS } from '../chains/sui/springsui-unstake' +import { ChainActionRefused } from '../chains/types' + +const ME = '0x1111111111111111111111111111111111111111111111111111111111111111' +const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead' +const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01' +const DIGEST = '11111111111111111111111111111111' +const OTHER_LST = '0x460c669acd3f294dc4247a6877ec2532340ffde76162ab201e72fe95355830e7::asui::ASUI' + +const mocks = vi.hoisted(() => ({ + simulate: vi.fn(), + getObjects: vi.fn(), + built: [] as Transaction[], + bytes: '', +})) + +vi.mock('../chains/sui/client', async (original) => ({ + ...(await original()), + suiClient: () => ({ + core: { simulateTransaction: mocks.simulate, getObjects: mocks.getObjects }, + }), + buildUnsigned: async (tx: Transaction) => { + mocks.built.push(tx) + return mocks.bytes + }, +})) + +function shared(tx: Transaction, objectId: string) { + return tx.object(Inputs.SharedObjectRef({ objectId, initialSharedVersion: 1, mutable: true })) +} + +async function built(edit: (tx: Transaction) => void, sender: string = ME): Promise { + const tx = new Transaction() + tx.setSender(sender) + tx.setGasOwner(sender) + tx.setGasPrice(1000) + tx.setGasBudget(10_000_000) + tx.setGasPayment([{ objectId: FOREIGN, version: 1, digest: DIGEST }]) + edit(tx) + return toBase64(await tx.build()) +} + +interface Options { + coinType?: string + info?: string + to?: string +} + +function goodRedeem(tx: Transaction, options: Options = {}): void { + const coinType = options.coinType ?? SSUI_TYPE + const info = options.info ?? SSUI_INFO + const [sui] = tx.moveCall({ + target: `${SPRINGSUI_PACKAGE}::liquid_staking::redeem`, + typeArguments: [coinType], + arguments: [ + shared(tx, info), + tx.object(Inputs.ObjectRef({ objectId: THIEF, version: 1, digest: DIGEST })), + shared(tx, '0x5'), + ], + }) + tx.moveCall({ + target: `${SPRINGSUI_PACKAGE}::weight::rebalance`, + typeArguments: [coinType], + arguments: [shared(tx, SSUI_WEIGHT_HOOK), shared(tx, '0x5'), shared(tx, info)], + }) + tx.transferObjects([sui as never], options.to ?? ME) +} + +function check(transaction: string, account: string = ME): void { + springsuiVenue.assertSafe({ family: 'sui', transaction }, account) +} + +const u64Coin = (n: string) => + bcs + .struct('Coin', { id: bcs.Address, value: bcs.u64() }) + .serialize({ id: FOREIGN, value: n }) + .toBytes() + +beforeEach(() => { + mocks.simulate.mockReset() + mocks.getObjects.mockReset() + mocks.built.length = 0 + mocks.getObjects.mockResolvedValue({ + objects: [{ json: { fee_config: { element: { redeem_fee_bps: '2' } } } }], + }) +}) + +describe('SpringSui unstake safety on what the wallet would sign', () => { + it('passes the redeem it builds', async () => { + await expect(built(goodRedeem).then(check)).resolves.toBeUndefined() + }) + + it('still passes the stake it built before', () => { + expect(() => + springsuiVenue.assertSafe( + { family: 'sui', transaction: CAPTURED_SPRINGSUI_STAKE }, + CAPTURED_SENDER + ) + ).not.toThrow() + }) + + it('refuses a redeem and a mint in one transaction', async () => { + const transaction = await built((tx) => { + goodRedeem(tx) + tx.moveCall({ + target: `${SPRINGSUI_PACKAGE}::liquid_staking::mint`, + typeArguments: [SSUI_TYPE], + }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + }) + + it('refuses the wrong account', async () => { + const transaction = await built(goodRedeem) + expect(() => check(transaction, THIEF)).toThrow(/different account/) + }) + + it('refuses an unknown contract', async () => { + const transaction = await built((tx) => { + goodRedeem(tx) + tx.moveCall({ target: `${FOREIGN}::drain::everything` }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + }) + + it.each(['custom_redeem', 'custom_redeem_request', 'collect_fees', 'update_fees'])( + 'refuses %s, which this flow never uses', + async (fn) => { + const transaction = await built((tx) => { + goodRedeem(tx) + tx.moveCall({ + target: `${SPRINGSUI_PACKAGE}::liquid_staking::${fn}`, + typeArguments: [SSUI_TYPE], + }) + }) + expect(() => check(transaction)).toThrow(/confirmed/) + } + ) + + it('refuses extra steps', async () => { + const transaction = await built((tx) => { + goodRedeem(tx) + tx.publish({ modules: ['AA=='], dependencies: ['0x1', '0x2'] }) + }) + expect(() => check(transaction)).toThrow(/Publish/) + }) + + it('refuses the SUI paid to someone else', async () => { + const transaction = await built((tx) => goodRedeem(tx, { to: THIEF })) + expect(() => check(transaction)).toThrow(/another address/) + }) + + it('refuses a token other than sSUI', async () => { + const transaction = await built((tx) => goodRedeem(tx, { coinType: OTHER_LST })) + expect(() => check(transaction)).toThrow(/sSUI/) + }) + + it('refuses a pool other than sSUI’s', async () => { + const transaction = await built((tx) => goodRedeem(tx, { info: FOREIGN })) + expect(() => check(transaction)).toThrow(/object/) + }) + + it('refuses a rebalance against another hook', async () => { + const transaction = await built((tx) => { + goodRedeem(tx) + tx.moveCall({ + target: `${SPRINGSUI_PACKAGE}::weight::rebalance`, + typeArguments: [SSUI_TYPE], + arguments: [shared(tx, FOREIGN), shared(tx, '0x5'), shared(tx, SSUI_INFO)], + }) + }) + expect(() => check(transaction)).toThrow(/object/) + }) + + it('refuses a payload that is not a Sui transaction', () => { + expect(() => springsuiVenue.assertSafe({ family: 'solana', transaction: 'AA==' }, ME)).toThrow( + ChainActionRefused + ) + expect(() => check('not base64 at all!')).toThrow(ChainActionRefused) + }) +}) + +describe('building a SpringSui unstake', () => { + it('refuses a token that is not sSUI, and says how to name the right app', async () => { + await expect( + springsuiVenue.prepare({ kind: 'unstake', amount: '1', tokenIn: 'haSUI' }, ME) + ).rejects.toThrow(/name it/) + expect(mocks.built).toHaveLength(0) + }) + + it('refuses a bad account', async () => { + await expect( + springsuiVenue.prepare({ kind: 'unstake', amount: '1', tokenIn: 'sSUI' }, 'nope') + ).rejects.toThrow(/Sui address/) + }) + + it('reads the SUI from the coin returned, and the fee it read', async () => { + mocks.bytes = await built(goodRedeem) + mocks.simulate.mockResolvedValue({ + Transaction: { balanceChanges: [] }, + commandResults: [{ returnValues: [{ bcs: u64Coin('2075784416') }] }], + }) + const prepared = await springsuiVenue.prepare( + { kind: 'unstake', amount: '2', tokenIn: 'sSUI' }, + ME + ) + expect(prepared.kind).toBe('unstake') + expect(prepared.pay).toEqual({ amount: '2', symbol: 'sSUI' }) + expect(prepared.receive).toEqual({ amount: '2.075784', symbol: 'SUI', approximate: true }) + expect(prepared.notes.join(' ')).toMatch(/no wait/) + expect(prepared.notes.join(' ')).toMatch(/0.02%/) + const targets = mocks.built[0] + ?.getData() + .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall.function] : [])) + expect(targets).toEqual(['redeem', 'rebalance']) + }) + + it('refuses when the dry run shows no payout', async () => { + mocks.bytes = await built(goodRedeem) + mocks.simulate.mockResolvedValue({ Transaction: {}, commandResults: [] }) + await expect( + springsuiVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'sSUI' }, ME) + ).rejects.toThrow(/couldn't confirm/) + }) + + it('refuses when the fee cannot be read', async () => { + mocks.bytes = await built(goodRedeem) + mocks.simulate.mockResolvedValue({ + Transaction: {}, + commandResults: [{ returnValues: [{ bcs: u64Coin('2075784416') }] }], + }) + mocks.getObjects.mockResolvedValue({ objects: [{ json: {} }] }) + await expect( + springsuiVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'sSUI' }, ME) + ).rejects.toThrow(/fee/) + }) +}) + +describe('the venue as a whole', () => { + it('does both, and follows a swap only to stake', async () => { + expect(springsuiVenue.kinds).toEqual(['stake', 'unstake']) + await expect( + springsuiVenue.assertCanFollow?.({ kind: 'stake', tokenIn: 'SUI' }, ME) + ).resolves.toBeUndefined() + await expect( + springsuiVenue.assertCanFollow?.({ kind: 'unstake', tokenIn: 'sSUI' }, ME) + ).rejects.toThrow(/only stake/) + }) + + it('lists sSUI with the decimals its metadata gives', () => { + expect(SPRINGSUI_TOKENS).toEqual([expect.objectContaining({ symbol: 'sSUI', decimals: 9 })]) + }) +}) diff --git a/apps/dapp/lib/__tests__/streamflow.live.test.ts b/apps/dapp/lib/__tests__/streamflow.live.test.ts new file mode 100644 index 00000000..28d51cca --- /dev/null +++ b/apps/dapp/lib/__tests__/streamflow.live.test.ts @@ -0,0 +1,129 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +import { decodeSolanaTransaction, TOKEN_PROGRAM } from '../chains/solana/assert-safe' +import { + createStreamflowVenue, + STREAMFLOW_FEE_ORACLE, + STREAMFLOW_PROGRAM, + STREAMFLOW_TREASURY, + STREAMFLOW_WITHDRAWOR, +} from '../chains/solana/streamflow' +import { SOLANA_TOKENS } from '../chains/solana/tokens' + +/** + * Read-only: nothing here signs or sends. Checks that each pinned id still holds on chain, and + * that a real build passes the program in a dry run with no signature. The account is the public + * exchange wallet the other Solana live tests use, which holds USDC and SOL. + * + * A failure on the upgrade pins means Streamflow changed its program or who can change it, so the + * card's note and this venue need another look before anything else ships. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const RPC = process.env['SOLANA_RPC_URL']?.trim() || 'https://solana-rpc.publicnode.com' +const FUNDED = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS' +const RECIPIENT = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' +const SLOW_MS = 60_000 + +const PROGRAM_DATA = '796tZf2QrH1ebZTQC1mDHYP6weDqSKuqbeyaXd64Prs' +/** 21 September 2026, the date the card's note names. */ +const LAST_DEPLOYED_SLOT = 449063805 +const UPGRADE_AUTHORITY = '5u7o2WGgHckh18opTfPsqKb8E3nhDKcReBrbzUeXg2n7' +const PARTNER_ORACLE_PROGRAM = 'pardpVtPjC8nLj1Dwncew62mUzfChdCX1EaoZe8oCAa' +const SYSTEM_PROGRAM = '11111111111111111111111111111111' + +interface Account { + owner: string + executable: boolean + data: { parsed?: { info?: Record } } | unknown +} + +async function rpc(method: string, params: unknown[]): Promise { + const response = await fetch(RPC, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }) + return ((await response.json()) as { result: unknown }).result +} + +async function account(address: string): Promise { + const { value } = (await rpc('getAccountInfo', [address, { encoding: 'jsonParsed' }])) as { + value: Account + } + return value +} + +function parsedInfo(value: Account): Record { + return (value.data as { parsed: { info: Record } }).parsed.info +} + +describe.skipIf(SKIP)('Streamflow on chain', () => { + beforeAll(() => { + vi.stubEnv('AUTH_SECRET', 'a-secret-long-enough-to-sign-with-here') + }) + + afterAll(() => { + vi.unstubAllEnvs() + }) + + it( + 'the program still lives where the docs say, with the same code and upgrade key', + async () => { + const program = await account(STREAMFLOW_PROGRAM) + expect(program.executable).toBe(true) + expect(parsedInfo(program)['programData']).toBe(PROGRAM_DATA) + + const data = parsedInfo(await account(PROGRAM_DATA)) + expect(data['slot']).toBe(LAST_DEPLOYED_SLOT) + expect(data['authority']).toBe(UPGRADE_AUTHORITY) + }, + SLOW_MS + ) + + it( + 'the fee accounts are what the SDK says', + async () => { + expect((await account(STREAMFLOW_TREASURY)).owner).toBe(SYSTEM_PROGRAM) + expect((await account(STREAMFLOW_WITHDRAWOR)).owner).toBe(SYSTEM_PROGRAM) + expect((await account(STREAMFLOW_FEE_ORACLE)).owner).toBe(PARTNER_ORACLE_PROGRAM) + }, + SLOW_MS + ) + + it( + 'USDC and USDT are on the Token Program this builds for', + async () => { + for (const symbol of ['USDC', 'USDT']) { + const mint = SOLANA_TOKENS.find((t) => t.symbol === symbol)?.id as string + expect((await account(mint)).owner).toBe(TOKEN_PROGRAM) + } + }, + SLOW_MS + ) + + it( + 'a real build passes the program in a dry run, and reads its fees', + async () => { + const venue = createStreamflowVenue() + const action = await venue.prepare( + { + kind: 'stream', + amount: '10', + tokenIn: 'USDC', + recipient: RECIPIENT, + durationDays: '30', + }, + FUNDED + ) + if (action.signing.family !== 'solana') throw new Error('Expected a Solana payload.') + const decoded = decodeSolanaTransaction(action.signing.transaction) + expect(decoded.feePayer).toBe(FUNDED) + expect(decoded.feePayerSignature).toBeNull() + expect(decoded.signerCount).toBe(1) + expect(() => venue.assertSafe(action.signing, FUNDED, action.context)).not.toThrow() + expect(action.notes[1]).toMatch(/takes [\d.]+ USDC and [\d.]+ SOL/) + }, + SLOW_MS + ) +}) diff --git a/apps/dapp/lib/__tests__/streamflow.test.ts b/apps/dapp/lib/__tests__/streamflow.test.ts new file mode 100644 index 00000000..e9bd59a1 --- /dev/null +++ b/apps/dapp/lib/__tests__/streamflow.test.ts @@ -0,0 +1,734 @@ +import { + getAddressDecoder, + getAddressEncoder, + getCompiledTransactionMessageDecoder, + getCompiledTransactionMessageEncoder, + getTransactionDecoder, + type Address, +} from '@solana/kit' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + ASSOCIATED_TOKEN_PROGRAM, + associatedTokenAccount, + decodeSolanaTransaction, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, +} from '../chains/solana/assert-safe' +import { + createStreamflowVenue, + STREAMFLOW_FEE_ORACLE, + STREAMFLOW_PROGRAM, + STREAMFLOW_TREASURY, + STREAMFLOW_WITHDRAWOR, + type SolanaRpc, +} from '../chains/solana/streamflow' +import { streamflowVenue } from '../chains/solana/streamflow-listing' +import { SOLANA_TOKENS } from '../chains/solana/tokens' +import { + ChainActionRefused, + type ChainActionRequest, + type PreparedAction, + type SigningPayload, +} from '../chains/types' + +const ACCOUNT = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS' +const RECIPIENT = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' +const ATTACKER = '9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin' +const USDC = SOLANA_TOKENS.find((t) => t.symbol === 'USDC')?.id as string +const USDT = SOLANA_TOKENS.find((t) => t.symbol === 'USDT')?.id as string +const COMPUTE_BUDGET = 'ComputeBudget111111111111111111111111111111' +const BLOCKHASH = '4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn' +const SECRET = 'a-secret-long-enough-to-sign-with-here' + +const ASK: ChainActionRequest = { + kind: 'stream', + amount: '100', + tokenIn: 'USDC', + recipient: RECIPIENT, + durationDays: '30', +} + +interface Message { + version: number | 'legacy' + header: { + numSignerAccounts: number + numReadonlySignerAccounts: number + numReadonlyNonSignerAccounts: number + } + staticAccounts: readonly Address[] + lifetimeToken: string + instructions: readonly { + programAddressIndex: number + accountIndices?: readonly number[] + data?: Uint8Array + }[] + addressTableLookups?: readonly unknown[] +} + +function open(base64: string): Message { + const { messageBytes } = getTransactionDecoder().decode(Buffer.from(base64, 'base64')) + return getCompiledTransactionMessageDecoder().decode(messageBytes) as unknown as Message +} + +function close(message: Message, signatureCount = message.header.numSignerAccounts): string { + const bytes = getCompiledTransactionMessageEncoder().encode( + message as unknown as Parameters< + ReturnType['encode'] + >[0] + ) + return Buffer.concat([ + Buffer.from([signatureCount]), + Buffer.alloc(64 * signatureCount), + Buffer.from(bytes), + ]).toString('base64') +} + +function address(bytes: Uint8Array, offset: number): string { + return getAddressDecoder().decode(bytes.subarray(offset, offset + 32)) +} + +/** What the program writes to a stream's account, at the offsets of the SDK's `streamLayout`. */ +function metadataFor( + overrides: Partial<{ + sender: string + recipient: string + treasury: string + partner: string + mint: string + net: bigint + partnerFee: bigint + }> = {} +): string { + const buffer = Buffer.alloc(1104) + const put = (at: number, value: string): void => { + buffer.set(getAddressEncoder().encode(value as Address), at) + } + put(49, overrides.sender ?? ACCOUNT) + put(113, overrides.recipient ?? RECIPIENT) + put(177, overrides.mint ?? USDC) + put(241, overrides.treasury ?? STREAMFLOW_TREASURY) + buffer.writeBigUInt64LE(BigInt(190_000), 305) + put(325, overrides.partner ?? overrides.sender ?? ACCOUNT) + buffer.writeBigUInt64LE(overrides.partnerFee ?? BigInt(0), 389) + buffer.writeBigUInt64LE(overrides.net ?? BigInt(100_000_000), 417) + buffer.writeUInt32LE(160_000_000, 704) + return buffer.toString('base64') +} + +interface FakeChain { + recipientAccount?: { owner: string; executable: boolean } | null + simulationError?: unknown + metadata?: string + unreadable?: readonly string[] +} + +function fakeRpc(chain: FakeChain = {}): SolanaRpc { + return async (method) => { + if (chain.unreadable?.includes(method) === true) return undefined + switch (method) { + case 'getAccountInfo': + return { result: { value: chain.recipientAccount ?? null } } + case 'getLatestBlockhash': + return { result: { value: { blockhash: BLOCKHASH, lastValidBlockHeight: 400 } } } + case 'simulateTransaction': + return { + result: { + value: { + err: chain.simulationError ?? null, + accounts: [{ data: [chain.metadata ?? metadataFor(), 'base64'] }], + }, + }, + } + default: + throw new Error(`Unexpected ${method}`) + } + } +} + +async function prepared(chain?: FakeChain, ask: ChainActionRequest = ASK): Promise { + return createStreamflowVenue({ rpc: fakeRpc(chain) }).prepare(ask, ACCOUNT) +} + +const venue = createStreamflowVenue({ rpc: fakeRpc() }) + +function solana(transaction: string): SigningPayload { + return { family: 'solana', transaction } +} + +function transactionOf(action: PreparedAction): string { + if (action.signing.family !== 'solana') throw new Error('Expected a Solana payload.') + return action.signing.transaction +} + +/** Runs the check on a changed copy of what was built, with what the server issued for it. */ +async function checkTampered(change: (message: Message) => Message): Promise { + const action = await prepared() + venue.assertSafe(solana(close(change(open(transactionOf(action))))), ACCOUNT, action.context) +} + +function swapAccount(message: Message, from: string, to: string): Message { + expect(message.staticAccounts).toContain(from) + return { + ...message, + staticAccounts: message.staticAccounts.map((a) => (a === from ? (to as Address) : a)), + } +} + +function lastData(message: Message): Uint8Array { + return message.instructions[message.instructions.length - 1]?.data as Uint8Array +} + +function withLastData(message: Message, data: Uint8Array): Message { + return { + ...message, + instructions: message.instructions.map((ix, i) => + i === message.instructions.length - 1 ? { ...ix, data } : ix + ), + } +} + +function changedByte(message: Message, offset: number, value: number): Message { + const data = Uint8Array.from(lastData(message)) + data[offset] = value + return withLastData(message, data) +} + +function withStatic(message: Message, ...addresses: string[]): Message { + return { + ...message, + header: { + ...message.header, + numReadonlyNonSignerAccounts: message.header.numReadonlyNonSignerAccounts + addresses.length, + }, + staticAccounts: [...message.staticAccounts, ...(addresses as Address[])], + } +} + +/** Puts an instruction first, calling a program by address, with accounts by address. */ +function withExtra(message: Message, program: string, accounts: string[], data: number[]): Message { + const missing = [program, ...accounts].filter( + (a) => !message.staticAccounts.includes(a as Address) + ) + const grown = withStatic(message, ...new Set(missing)) + const index = (a: string): number => grown.staticAccounts.indexOf(a as Address) + return { + ...grown, + instructions: [ + { + programAddressIndex: index(program), + accountIndices: accounts.map(index), + data: Uint8Array.from(data), + }, + ...grown.instructions, + ], + } +} + +function le(value: bigint, bytes: number): number[] { + return Array.from({ length: bytes }, (_, i) => Number((value >> BigInt(8 * i)) & BigInt(255))) +} + +beforeEach(() => { + vi.stubEnv('AUTH_SECRET', SECRET) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('Streamflow stream', () => { + it('builds one create_v2 from the person to the address they typed', async () => { + const action = await prepared() + const decoded = decodeSolanaTransaction(transactionOf(action)) + const message = open(transactionOf(action)) + expect(decoded.feePayer).toBe(ACCOUNT) + expect(decoded.feePayerSignature).toBeNull() + expect(decoded.signerCount).toBe(1) + expect(decoded.readonlySignerCount).toBe(0) + expect(message.version).toBe('legacy') + + const programs = message.instructions.map( + (ix) => message.staticAccounts[ix.programAddressIndex] + ) + expect(programs).toEqual([COMPUTE_BUDGET, COMPUTE_BUDGET, STREAMFLOW_PROGRAM]) + + const call = message.instructions[2] + const accounts = (call?.accountIndices ?? []).map((i) => message.staticAccounts[i]) + expect(accounts).toHaveLength(18) + expect(accounts[0]).toBe(ACCOUNT) + expect(accounts[1]).toBe(associatedTokenAccount(ACCOUNT, USDC)) + expect(accounts[2]).toBe(RECIPIENT) + expect(accounts[5]).toBe(associatedTokenAccount(RECIPIENT, USDC)) + expect(accounts[6]).toBe(STREAMFLOW_TREASURY) + expect(accounts[7]).toBe(associatedTokenAccount(STREAMFLOW_TREASURY, USDC)) + expect(accounts[8]).toBe(STREAMFLOW_WITHDRAWOR) + expect(accounts[9]).toBe(ACCOUNT) + expect(accounts[10]).toBe(associatedTokenAccount(ACCOUNT, USDC)) + expect(accounts[11]).toBe(USDC) + expect(accounts[12]).toBe(STREAMFLOW_FEE_ORACLE) + expect(accounts[14]).toBe(STREAMFLOW_PROGRAM) + expect(accounts[15]).toBe(TOKEN_PROGRAM) + expect(accounts[16]).toBe(ASSOCIATED_TOKEN_PROGRAM) + expect(accounts[17]).toBe(SYSTEM_PROGRAM) + }) + + it('writes the schedule the person asked for, hour by hour, with every power off but cancel', async () => { + const data = Buffer.from(lastData(open(transactionOf(await prepared())))) + expect(data).toHaveLength(150) + expect(data.subarray(0, 8).toString('hex')).toBe('d6904cec5f8b31b4') + expect(data.readBigUInt64LE(8)).toBe(BigInt(0)) + expect(data.readBigUInt64LE(16)).toBe(BigInt(100_000_000)) + expect(data.readBigUInt64LE(24)).toBe(BigInt(3600)) + // 720 hours of 138,888 units, and the 640 units division leaves at the start. + expect(data.readBigUInt64LE(32)).toBe(BigInt(138_888)) + expect(data.readBigUInt64LE(40)).toBe(BigInt(0)) + expect(data.readBigUInt64LE(48)).toBe(BigInt(640)) + expect(BigInt(138_888) * BigInt(720) + BigInt(640)).toBe(BigInt(100_000_000)) + expect([...data.subarray(56, 62)]).toEqual([1, 0, 0, 0, 0, 0]) + expect(data.readBigUInt64LE(126)).toBe(BigInt(3600)) + expect([...data.subarray(134, 136)]).toEqual([0, 0]) + expect([...data.subarray(140)]).toEqual(Array.from({ length: 10 }, () => 0)) + }) + + it('gets a different stream address each time, so two streams do not collide', async () => { + const one = lastData(open(transactionOf(await prepared()))) + const two = lastData(open(transactionOf(await prepared()))) + expect(Buffer.from(one.subarray(136, 140))).not.toEqual(Buffer.from(two.subarray(136, 140))) + }) + + it('says what it does, and the fees it read', async () => { + const action = await prepared() + expect(action.venueId).toBe('streamflow') + expect(action.kind).toBe('stream') + expect(action.pay).toEqual({ amount: '100', symbol: 'USDC' }) + expect(action.summary).toContain(RECIPIENT) + expect(action.summary).toContain('30 days') + const notes = action.notes.join(' ') + expect(notes).toContain('0.19 USDC') + expect(notes).toContain('0.16 SOL') + expect(notes).toMatch(/withdraw/i) + expect(notes).toMatch(/cancel/i) + expect(notes).toMatch(/upgrade/i) + expect(notes).toContain('Neodyme') + expect(notes).not.toContain('—') + }) + + it('passes its own check, unsigned and with a signature in the slot', async () => { + const action = await prepared() + const transaction = transactionOf(action) + expect(() => venue.assertSafe(solana(transaction), ACCOUNT, action.context)).not.toThrow() + const signed = Buffer.from(transaction, 'base64') + signed.fill(7, 1, 65) + expect(() => + venue.assertSafe(solana(signed.toString('base64')), ACCOUNT, action.context) + ).not.toThrow() + }) + + it('takes USDT too', async () => { + const metadata = metadataFor({ mint: USDT }) + const action = await prepared({ metadata }, { ...ASK, tokenIn: 'USDT' }) + expect(action.pay.symbol).toBe('USDT') + const message = open(transactionOf(action)) + expect(message.staticAccounts).toContain(USDT) + expect(() => venue.assertSafe(action.signing, ACCOUNT, action.context)).not.toThrow() + }) +}) + +describe('Streamflow refuses to build', () => { + it.each<[string, Partial, RegExp]>([ + ['another kind', { kind: 'send' }, /stream/i], + ['SOL', { tokenIn: 'SOL' }, /USDC or USDT/], + ['a token with its own risks', { tokenIn: 'JitoSOL' }, /USDC or USDT/], + ['an unknown token', { tokenIn: 'NOPE' }, /USDC or USDT/], + ['no recipient', { recipient: undefined }, /address/], + ['a malformed recipient', { recipient: 'not-an-address' }, /address/], + ['the person as recipient', { recipient: ACCOUNT }, /your own wallet/], + ['a token mint as recipient', { recipient: USDC }, /address/], + ['an address with no key', { recipient: associatedTokenAccount(ACCOUNT, USDC) }, /sign/], + ['no length', { durationDays: undefined }, /days/], + ['a length of zero', { durationDays: '0' }, /days/], + ['a part of a day', { durationDays: '1.5' }, /days/], + ['too long a length', { durationDays: '3651' }, /days/], + ['an amount of nothing', { amount: '0' }, /more than zero/], + ['more than the token has places for', { amount: '1.0000001' }, /decimal/], + ['more than a u64 holds', { amount: '99999999999999999999' }, /too large/], + ['an amount too small to spread evenly', { amount: '0.001', durationDays: '365' }, /too small/], + [ + 'an amount that would mostly unlock at the start', + { amount: '0.000025', durationDays: '1' }, + /too small/, + ], + ])('%s', async (_name, change, wanted) => { + await expect(prepared({}, { ...ASK, ...change })).rejects.toThrow(wanted) + await expect(prepared({}, { ...ASK, ...change })).rejects.toBeInstanceOf(ChainActionRefused) + }) + + it('a wallet that is not a Solana address', async () => { + await expect(venue.prepare(ASK, '0x1234')).rejects.toThrow(/Solana wallet/) + }) + + it('a recipient that is a program', async () => { + await expect( + prepared({ + recipientAccount: { + owner: 'BPFLoaderUpgradeab1e11111111111111111111111', + executable: true, + }, + }) + ).rejects.toThrow(/plain wallet/) + }) + + it('a recipient that is a token account', async () => { + await expect( + prepared({ recipientAccount: { owner: TOKEN_PROGRAM, executable: false } }) + ).rejects.toThrow(/plain wallet/) + }) + + it('a wallet that already holds SOL is fine as a recipient', async () => { + await expect( + prepared({ recipientAccount: { owner: SYSTEM_PROGRAM, executable: false } }) + ).resolves.toBeDefined() + }) + + it('when the recipient cannot be read', async () => { + await expect(prepared({ unreadable: ['getAccountInfo'] })).rejects.toThrow(/could not be read/) + }) + + it('when no recent blockhash can be read', async () => { + await expect(prepared({ unreadable: ['getLatestBlockhash'] })).rejects.toThrow( + /could not be read/ + ) + }) + + it('when it can not be tried on the chain', async () => { + await expect(prepared({ unreadable: ['simulateTransaction'] })).rejects.toThrow( + /could not be read/ + ) + }) + + it('what would fail on the chain', async () => { + await expect(prepared({ simulationError: { InstructionError: [2, 'x'] } })).rejects.toThrow( + /would fail/ + ) + }) + + it('without a secret to prove what it issued', async () => { + vi.stubEnv('AUTH_SECRET', '') + await expect(prepared()).rejects.toThrow(/isn.t available/) + }) + + it.each<[string, Parameters[0]]>([ + ['pays another recipient', { recipient: ATTACKER }], + ['pays another treasury', { treasury: ATTACKER }], + ['names another partner', { partner: ATTACKER }], + ['holds another token', { mint: USDT }], + ['holds another amount', { net: BigInt(1) }], + ['pays a partner fee', { partnerFee: BigInt(1) }], + ])('a stream the program would write that %s', async (_name, overrides) => { + await expect(prepared({ metadata: metadataFor(overrides) })).rejects.toThrow(/not the stream/) + }) + + it('when the program would write something unreadable', async () => { + await expect(prepared({ metadata: 'AAAA' })).rejects.toThrow(/could not be read/) + }) +}) + +describe('Streamflow assertSafe refuses', () => { + it('another wallet', async () => { + const action = await prepared() + expect(() => venue.assertSafe(action.signing, RECIPIENT, action.context)).toThrow( + ChainActionRefused + ) + }) + + it('a wrong fee payer, even with a true proof for the person', async () => { + const other = await createStreamflowVenue({ + rpc: fakeRpc({ metadata: metadataFor({ sender: RECIPIENT, recipient: ATTACKER }) }), + }).prepare({ ...ASK, recipient: ATTACKER }, RECIPIENT) + const mine = await prepared() + expect(() => venue.assertSafe(other.signing, ACCOUNT, mine.context)).toThrow(/your wallet/) + }) + + it('anything that is not a Solana transaction', async () => { + const action = await prepared() + expect(() => + venue.assertSafe({ family: 'sui', transaction: 'AAAA' }, ACCOUNT, action.context) + ).toThrow(ChainActionRefused) + }) + + it('no proof, or a proof for other terms', async () => { + const action = await prepared() + const context = action.context as Record + const check = (changed: Record | undefined): void => + venue.assertSafe(action.signing, ACCOUNT, changed) + expect(() => check(undefined)).toThrow(/not what this app issued/) + expect(() => check({ ...context, recipient: ATTACKER })).toThrow(/not what this app issued/) + expect(() => check({ ...context, token: USDT })).toThrow(/not what this app issued/) + expect(() => check({ ...context, amount: '1' })).toThrow(/not what this app issued/) + expect(() => check({ ...context, days: '1' })).toThrow(/not what this app issued/) + expect(() => check({ ...context, proof: 'forged' })).toThrow(/not what this app issued/) + }) + + it('a proof from another secret', async () => { + const action = await prepared() + vi.stubEnv('AUTH_SECRET', 'another-secret-long-enough-to-sign-with') + expect(() => venue.assertSafe(action.signing, ACCOUNT, action.context)).toThrow( + /not what this app issued/ + ) + }) + + it('an unknown program', async () => { + await expect( + checkTampered((m) => + swapAccount(m, STREAMFLOW_PROGRAM, 'strmdbLr6w7QNmsiEXyFwWg3VSfg1GiELgU27P8aCGw') + ) + ).rejects.toThrow(/should not need/) + }) + + it('another of the program’s instructions', async () => { + await expect( + checkTampered((m) => { + const data = Uint8Array.from(lastData(m)) + // Anchor's discriminator for `create`, whose stream address needs a second signer. + data.set(Buffer.from('181ec828051c0777', 'hex'), 0) + return withLastData(m, data) + }) + ).rejects.toThrow(/didn.t expect/) + }) + + it('extra instruction data', async () => { + await expect( + checkTampered((m) => withLastData(m, Uint8Array.from([...lastData(m), 1]))) + ).rejects.toThrow(/not the stream/) + }) + + it('short instruction data', async () => { + await expect( + checkTampered((m) => withLastData(m, lastData(m).subarray(0, 140))) + ).rejects.toThrow(/not the stream/) + }) + + it('an extra account on the call', async () => { + await expect( + checkTampered((m) => { + const grown = withStatic(m, ATTACKER) + const last = grown.instructions.length - 1 + return { + ...grown, + instructions: grown.instructions.map((ix, i) => + i === last + ? { + ...ix, + accountIndices: [...(ix.accountIndices ?? []), grown.staticAccounts.length - 1], + } + : ix + ), + } + }) + ).rejects.toThrow(/not the stream/) + }) + + it.each<[string, string]>([ + ['the recipient', RECIPIENT], + ['the treasury', STREAMFLOW_TREASURY], + ['the withdrawor', STREAMFLOW_WITHDRAWOR], + ['the fee oracle', STREAMFLOW_FEE_ORACLE], + ['the mint', USDC], + ['the token program', TOKEN_PROGRAM], + ])('%s swapped for another address', async (_name, from) => { + await expect(checkTampered((m) => swapAccount(m, from, ATTACKER))).rejects.toThrow( + /not the stream/ + ) + }) + + it.each<[string, () => string]>([ + ['the person’s token account', () => associatedTokenAccount(ACCOUNT, USDC)], + ['the recipient’s token account', () => associatedTokenAccount(RECIPIENT, USDC)], + ['the treasury’s token account', () => associatedTokenAccount(STREAMFLOW_TREASURY, USDC)], + ])('%s swapped for an attacker’s', async (_name, from) => { + await expect( + checkTampered((m) => swapAccount(m, from(), associatedTokenAccount(ATTACKER, USDC))) + ).rejects.toThrow(/not the stream/) + }) + + it('a partner who is not the person', async () => { + await expect( + checkTampered((m) => { + const grown = withStatic(m, ATTACKER) + const attacker = grown.staticAccounts.length - 1 + const last = grown.instructions.length - 1 + return { + ...grown, + instructions: grown.instructions.map((ix, i) => + i === last + ? { + ...ix, + accountIndices: (ix.accountIndices ?? []).map((a, p) => (p === 9 ? attacker : a)), + } + : ix + ), + } + }) + ).rejects.toThrow(/not the stream/) + }) + + it.each<[string, number, number]>([ + ['a start time', 8, 9], + ['another amount', 16, 1], + ['another period', 24, 1], + ['another rate', 32, 1], + ['a cliff time', 40, 1], + ['another amount at the start', 48, 1], + ['no cancel for the person', 56, 0], + ['a cancel for the recipient', 57, 1], + ['automatic withdrawals, which charge a fee', 58, 1], + ['a transfer by the sender', 59, 1], + ['a transfer by the recipient', 60, 1], + ['a top up', 61, 1], + ['another name', 62, 65], + ['another withdrawal frequency', 126, 1], + ['a pause', 134, 1], + ['a rate change', 135, 1], + ['bytes after the nonce', 140, 1], + ])('%s', async (_name, offset, value) => { + await expect(checkTampered((m) => changedByte(m, offset, value))).rejects.toThrow( + /not the stream/ + ) + }) + + it('a stream address that is not the one its nonce makes', async () => { + await expect( + checkTampered((m) => { + const data = Uint8Array.from(lastData(m)) + data[136] = ((data[136] ?? 0) + 1) % 256 + return withLastData(m, data) + }) + ).rejects.toThrow(/not the stream/) + }) + + it('a second signer', async () => { + await expect( + checkTampered((m) => ({ + ...m, + header: { ...m.header, numSignerAccounts: 2 }, + })) + ).rejects.toThrow(/someone other than you/) + }) + + it('an extra SOL transfer', async () => { + await expect( + checkTampered((m) => + withExtra(m, SYSTEM_PROGRAM, [ACCOUNT, ATTACKER], [2, 0, 0, 0, ...le(BigInt(5000), 8)]) + ) + ).rejects.toThrow(/SOL transfer/) + }) + + it('an extra token transfer', async () => { + await expect( + checkTampered((m) => + withExtra( + m, + TOKEN_PROGRAM, + [associatedTokenAccount(ACCOUNT, USDC), associatedTokenAccount(ATTACKER, USDC), ACCOUNT], + [3, ...le(BigInt(1), 8)] + ) + ) + ).rejects.toThrow(/moves tokens/) + }) + + it('an unlimited approval', async () => { + await expect( + checkTampered((m) => + withExtra( + m, + TOKEN_PROGRAM, + [associatedTokenAccount(ACCOUNT, USDC), ATTACKER, ACCOUNT], + [4, ...le(BigInt('18446744073709551615'), 8)] + ) + ) + ).rejects.toThrow(/moves tokens/) + }) + + it('an extra token account, even one of the person’s own', async () => { + await expect( + checkTampered((m) => + withExtra( + m, + ASSOCIATED_TOKEN_PROGRAM, + [ + ACCOUNT, + associatedTokenAccount(ACCOUNT, USDT), + ACCOUNT, + USDT, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, + ], + [1] + ) + ) + ).rejects.toThrow(/not the stream/) + }) + + it('a second stream in the same transaction', async () => { + await expect( + checkTampered((m) => { + const call = m.instructions[m.instructions.length - 1] + return { ...m, instructions: [...m.instructions, call as Message['instructions'][number]] } + }) + ).rejects.toThrow(/not the stream/) + }) + + it('a compute budget instruction after the call', async () => { + await expect( + checkTampered((m) => { + const [first, second, call] = m.instructions + return { + ...m, + instructions: [first, call, second] as unknown as Message['instructions'], + } + }) + ).rejects.toThrow(/not the stream/) + }) + + it('a huge priority fee', async () => { + await expect( + checkTampered((m) => ({ + ...m, + instructions: m.instructions.map((ix, i) => + i === 1 ? { ...ix, data: Uint8Array.from([3, ...le(BigInt(1_000_000_000_000), 8)]) } : ix + ), + })) + ).rejects.toThrow(/priority fee/) + }) + + it('accounts behind a lookup table', async () => { + await expect( + checkTampered((m) => ({ + ...m, + version: 0, + addressTableLookups: [ + { + lookupTableAddress: ATTACKER as Address, + writableIndexes: [0], + readonlyIndexes: [], + }, + ], + })) + ).rejects.toThrow(/lookup table/) + }) +}) + +describe('the Streamflow listing', () => { + it('makes no claim the browser can’t know, since running depends on a server secret', () => { + expect(streamflowVenue).toMatchObject({ + id: 'streamflow', + family: 'solana', + chains: ['Solana'], + category: 'payments', + }) + expect(streamflowVenue.integration).toBeUndefined() + expect(streamflowVenue.capability).toBeUndefined() + expect(streamflowVenue.bestFor).toMatch(/fee in SOL/) + }) +}) diff --git a/apps/dapp/lib/__tests__/sui-lending-captured.ts b/apps/dapp/lib/__tests__/sui-lending-captured.ts new file mode 100644 index 00000000..323e9901 --- /dev/null +++ b/apps/dapp/lib/__tests__/sui-lending-captured.ts @@ -0,0 +1,108 @@ +import { Transaction } from '@mysten/sui/transactions' +import { toBase64 } from '@mysten/sui/utils' + +/** + * Unsigned transactions the real venues built against mainnet on 2026-11-05, for public + * addresses nobody here holds a key for. Test data only: the checks are proven on what + * the venues produce, and on tampered copies of it. + */ + +export const SUILEND_SUPPLY_SENDER = + '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4' +export const SUILEND_SUPPLY = + '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' + +export const SUILEND_WITHDRAW_SENDER = + '0xd22978af0d4d404067dc611d1f63af9883b5a1786e24813290ccb2d8407b1b66' +export const SUILEND_WITHDRAW = + 'AAAIAQGEAw0m2F6qcDUISgV/LxH3Abfi5O2odVG+y8fJdQXs4XOBjQQAAAAAAQAIBwAAAAAAAAABAAAY7S10UBWNDj0GqjoIt/Mv/M5jAzYaWfo8tw6TOYQy4V2jPwAAAAAgu6KVWOxqkKFRJfXC2ZjQ77yCvOpTiOk2TfGxhPUAA7oBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAQAAAAAAAAAAAAjYlQ0AAAAAAAAIBwAAAAAAAAAACAcAAAAAAAAAACDSKXivDU1AQGfcYR0fY6+Yg7WheG4kgTKQzLLYQHsbZgUAWWcpdeFcWP/JRQ9SNraqn6JAfZS4CB5jR3iqAZV9jG8ObGVuZGluZ19tYXJrZXQQd2l0aGRyYXdfY3Rva2VucwIH+VsGFB7UoXTyOUFzI73j8gm5cvWTDYUh6jilKv86bd8Hc3VpbGVuZAlNQUlOX1BPT0wAB9ujRnLjDLBlsfk+OrVTGHaP1v72bBWULJ98uEbi+QDnBHVzZGMEVVNEQwAFAQAAAQEAAQIAAQMAAQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBm9wdGlvbgRub25lAQf5WwYUHtShdPI5QXMjvePyCbly9ZMNhSHqOKUq/zpt3w5sZW5kaW5nX21hcmtldBRSYXRlTGltaXRlckV4ZW1wdGlvbgIH+VsGFB7UoXTyOUFzI73j8gm5cvWTDYUh6jilKv86bd8Hc3VpbGVuZAlNQUlOX1BPT0wAB9ujRnLjDLBlsfk+OrVTGHaP1v72bBWULJ98uEbi+QDnBHVzZGMEVVNEQwAAAFlnKXXhXFj/yUUPUja2qp+iQH2UuAgeY0d4qgGVfYxvDmxlbmRpbmdfbWFya2V0LXJlZGVlbV9jdG9rZW5zX2FuZF93aXRoZHJhd19saXF1aWRpdHlfcmVxdWVzdAIH+VsGFB7UoXTyOUFzI73j8gm5cvWTDYUh6jilKv86bd8Hc3VpbGVuZAlNQUlOX1BPT0wAB9ujRnLjDLBlsfk+OrVTGHaP1v72bBWULJ98uEbi+QDnBHVzZGMEVVNEQwAFAQAAAQUAAQMAAwAAAAADAQAAAABZZyl14VxY/8lFD1I2tqqfokB9lLgIHmNHeKoBlX2Mbw5sZW5kaW5nX21hcmtldBlmdWxmaWxsX2xpcXVpZGl0eV9yZXF1ZXN0Agf5WwYUHtShdPI5QXMjvePyCbly9ZMNhSHqOKUq/zpt3wdzdWlsZW5kCU1BSU5fUE9PTAAH26NGcuMMsGWx+T46tVMYdo/W/vZsFZQsn3y4RuL5AOcEdXNkYwRVU0RDAAMBAAABBgADAgAAAAEBAwMAAAABBwDSKXivDU1AQGfcYR0fY6+Yg7WheG4kgTKQzLLYQHsbZgEteAyaXwhOBgfoISc1pmQE6o3WH7iGdJUiy2oPN+YQ2uFdoz8AAAAAIABG23ksVuX2vcwE5pnxLbKMvizZ910gkPjprQYUd4Nc0il4rw1NQEBn3GEdH2OvmIO1oXhuJIEykMyy2EB7G2ZkAAAAAAAAADViGwAAAAAAAA==' + +export const SCALLOP_SUPPLY_SENDER = + '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4' +export const SCALLOP_SUPPLY = + 'AAAQAQEHhxxLPIR6D2dFENSXjVz2+WBFJ5Xo/28Yn9IIij9qx1h/dgAAAAAAAAEBp1eXUlUUbcloaqgjt4OLUH8xXXBPQoy62tL06gYZOdlYf3YAAAAAAAEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAQAAAAAAAAAAACDG+kinRVv/SRJDGNUpcyBEHPrDP4PO6okMSFNy3pF99AEAW2A3bPX4HFVGjXHVh0orAyAVoZq/KGJBtyy4tu8dY8ATl34+AAAAACAy5HAuIJAxv2xX4iHoJ3Sq0gjSxuIXkaSPtJC9Ke7iiAEAY1+mUIGFQtGDJuL5IRU/M8u9dN6vctrYjGTfsNDPHXnOhn4+AAAAACBauA7megTYJkMY7NkDVqBqiPrQnELJOXNLf53u/Y+mugEA29f8bDN+8vxDmhfz1HGp4P6XQ2bK7iuq6ylBHMvd/3zZun0+AAAAACCBpm+G4mZtum65AFw/w4CRSQKROtSMdUUUHU37N34OWAEA6EvUiNP7kLz8nV/HE3ZjjHo1oYPCzdp7mU/MnwDkzvjMkn4+AAAAACDIGsGihrvGmfHor1eZf1Dl3r13ciDCDRRtRkCzoePiHAEAAAFizYE0X035ISj0iMlvgmSqeVcXWdeC172dXPL3UObMCoU+AAAAACA4ejxTzGls991JdDvAWcQQapGriSHXfjN9jziVMy2WBAEASyO3g7JTmko6Qad/GsV6ZLGjvC5Vfpd09Ek/cO94HgjGv30+AAAAACD7MPfAQcUT3UGRv5ADXlexYfwtyYVu06/6dGZ6rwVCdAEANBpmSCIOnzkWde+ShZV5bQUCEM4s8K59rwkmpK6tvvavc34+AAAAACA7P+VX1/I/WhLdO8ciKlVSKuu7hTSYRyB5lZih35NUZwEAG8NOBkNcUJBqFzxhnzFiIdCjQvNYVnCH4nclxhky6cxzyIE+AAAAACA4jv+DBtMP/XyWq8bsaLSyLAvGN3cmF2RUnE8NDNh+zgEAK8vkh1QRoKuv/qef+4ZVC2wznC4zZnf8o13+7JM1Gg8Ul34+AAAAACBfOesGhd/HpZYu4jHnMtyClwwF63e7JTmf9/xPzsXLOgEARa6kbqf3WxDxRTm0BoxCfttcXqibZLF4Ekth3KBz5c9O7n0+AAAAACABVe1wxcUb8gV/0TmNluJskViE+GzobS2XOsvAt5xuPAEA8h4s0J1vDbQpQ6D+i5IygO2+p51lQGa/kk8uV7F4Eq0Ul34+AAAAACAFVx7RSUUeFsXiCWeT/9Om8gmHGL7T2UWtaNgSuYr7/gAIQEIPAAAAAAAEAwEEAAoBBQABBgABBwABCAABCQABCgABCwABDAABDQABDgACAQQAAQEPAADeXAmtFxVEqjck3GchZmjIDnVIYPQZE2po14UE6y4oBQRtaW50BG1pbnQBB9ujRnLjDLBlsfk+OrVTGHaP1v72bBWULJ98uEbi+QDnBHVzZGMEVVNEQwAEAQAAAQEAAwEAAAABAgABAQMCAAAAAQMAxvpIp0Vb/0kSQxjVKXMgRBz6wz+DzuqJDEhTct6RffQBE2/Pyiy4OnaUtZVzBUjJjx4j1yCO443gl++NxEWIorB0yIE+AAAAACAzUspPbFn3u58/e0RVnDkrJlwQHqF2usKzWq1aQoRur8b6SKdFW/9JEkMY1SlzIEQc+sM/g87qiQxIU3LekX30ZAAAAAAAAABYhgMAAAAAAAA=' + +export const SCALLOP_WITHDRAW_SENDER = + '0xbe3fd04e98d2a2603122a3d2f7459e5ecef01768f1dc40cded8203995305203f' +export const SCALLOP_WITHDRAW = + 'AAAGAQEHhxxLPIR6D2dFENSXjVz2+WBFJ5Xo/28Yn9IIij9qx1h/dgAAAAAAAAEBp1eXUlUUbcloaqgjt4OLUH8xXXBPQoy62tL06gYZOdlYf3YAAAAAAAEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAQAAAAAAAAAAACC+P9BOmNKiYDEio9L3RZ5ezvAXaPHcQM3tggOZUwUgPwEAi3BcoSvYmOCDYd7VS8kRoTIgczNo8SXVOdWXei80Y9pl6uk7AAAAACBYtsaicGOxx7Dkym3yTb4lVmmhjwvXzOj4/g5oCVTDuwAIa1cNAAAAAAADAgEEAAEBBQAA3lwJrRcVRKo3JNxnIWZoyA51SGD0GRNqaNeFBOsuKAUGcmVkZWVtBnJlZGVlbQEH26NGcuMMsGWx+T46tVMYdo/W/vZsFZQsn3y4RuL5AOcEdXNkYwRVU0RDAAQBAAABAQADAAAAAAECAAEBAwEAAAABAwC+P9BOmNKiYDEio9L3RZ5ezvAXaPHcQM3tggOZUwUgPwExUlJqtJWnd6lnmBpPUV+ZPe/n65WhuzHwwiQm/Ej514OMzjwAAAAAIKWqANu1ZHx562oQuZMYo6D28x6mXTY75ddASAIAbqVnvj/QTpjSomAxIqPS90WeXs7wF2jx3EDN7YIDmVMFID9kAAAAAAAAAM4vGAAAAAAAAA==' + +export const NAVI_WITHDRAW_SENDER = + '0xb26c1e93bed3707be8bb037aa36e9d2d4f640dbd4b470a214ab3e1b59983a40a' +export const NAVI_WITHDRAW = + 'AAANAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgEAAAAAAAAAAAEBGv4cuDY09YFgbMc8RIfd2Mw5qUS5USg68j99adVYlHixFDgSAAAAAAEBARVohl7ZoLXsQUIg6PebPQTHesyCNY9uWuRjVoc5L/vvUyp9AAAAAAABAQGqAxXwdIwfJN2ytF95Oc/0D3qBBK9cy8Sh0y+HDAtBBS39WgAAAAAAAAEBbd/G+ZIeVZmMvC5o9466iXmB15rBf2bFJ3vDiVSio/hsse0wAAAAAAABAR+nVm9A+Tzbr9WgKaIx4GZkIZRE3rtZvuwv4/GcoIt+c10FIgAAAAAAACDhIGEUNTlfFEtLzERmoAtrJteicxj5bhSGSIUqndazHAEBu04vS2IFwuKi20eutPgweW7HwAX4hTfud1mGY5vEQv5cKn0AAAAAAAEBAaNYIJe0xXYwBGwMSaiL/GsgKj7AqdtVl8MXZfdWN1WoGR8zFgAAAAABAAEKAAjoAwAAAAAAAAEB+HqKy4uB0UMHiU0SWVVBpz8Zkz+I4TJtW+NJx6b3VZyeYEcCAAAAAAEBAWKYLa0n+xC7MUszhNXejSrC1yqy2+rl2AHb2576gWyAMkeRHQAAAAABAgBIN66UQlEHVUyIR3Ic+ZVMGtjhBSBDO5433BHFBxSL6gpvcmFjbGVfcHJvFnVwZGF0ZV9zaW5nbGVfcHJpY2VfdjMABwEAAAEBAAECAAEDAAEEAAEFAAEGAABRLygmHBopP0lBbYiFuNXTK95t1oqZoL42/kKySOaDOgxpbmNlbnRpdmVfdjMOZW50cnlfd2l0aGRyYXcBB9ujRnLjDLBlsfk+OrVTGHaP1v72bBWULJ98uEbi+QDnBHVzZGMEVVNEQwAIAQAAAQIAAQcAAQgAAQkAAQoAAQsAAQwAsmwek77TcHvouwN6o26dLU9kDb1LRwohSrPhtZmDpAoB0AOAOLhYDCJrqGVWR3Gu2aGDEiyhdaY1IZdaxOnlk0DwjzA5AAAAACAkUntJoTa12Dmi4UhQBR+L65OpU4bcGQUgSKSsxgWuDLJsHpO+03B76LsDeqNunS1PZA29S0cKIUqz4bWZg6QKZAAAAAAAAADs+RcAAAAAAAA=' + +export const NAVI_SUPPLY_SENDER = + '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4' +export const NAVI_SUPPLY = + 'AAATAAhAQg8AAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYBAAAAAAAAAAABAbtOL0tiBcLiottHrrT4MHlux8AF+IU37ndZhmObxEL+XCp9AAAAAAABAQGjWCCXtMV2MARsDEmoi/xrICo+wKnbVZfDF2X3VjdVqBkfMxYAAAAAAQABCgEB+HqKy4uB0UMHiU0SWVVBpz8Zkz+I4TJtW+NJx6b3VZyeYEcCAAAAAAEBAWKYLa0n+xC7MUszhNXejSrC1yqy2+rl2AHb2576gWyAMkeRHQAAAAABAQBbYDds9fgcVUaNcdWHSisDIBWhmr8oYkG3LLi27x1jwBOXfj4AAAAAIDLkcC4gkDG/bFfiIegndKrSCNLG4heRpI+0kL0p7uKIAQBjX6ZQgYVC0YMm4vkhFT8zy7103q9y2tiMZN+w0M8dec6Gfj4AAAAAIFq4DuZ6BNgmQxjs2QNWoGqI+tCcQsk5c0t/ne79j6a6AQDb1/xsM37y/EOaF/PUcang/pdDZsruK6rrKUEcy93/fNm6fT4AAAAAIIGmb4biZm26brkAXD/DgJFJApE61Ix1RRQdTfs3fg5YAQDoS9SI0/uQvPydX8cTdmOMejWhg8LN2nuZT8yfAOTO+MySfj4AAAAAIMgawaKGu8aZ8eivV5l/UOXevXdyIMINFG1GQLOh4+IcAQAAAWLNgTRfTfkhKPSIyW+CZKp5VxdZ14LXvZ1c8vdQ5swKhT4AAAAAIDh6PFPMaWz33Ul0O8BZxBBqkauJIdd+M32POJUzLZYEAQBLI7eDslOaSjpBp38axXpksaO8LlV+l3T0ST9w73geCMa/fT4AAAAAIPsw98BBxRPdQZG/kANeV7Fh/C3JhW7Tr/p0ZnqvBUJ0AQA0GmZIIg6fORZ175KFlXltBQIQzizwrn2vCSakrq2+9q9zfj4AAAAAIDs/5VfX8j9aEt07xyIqVVIq67uFNJhHIHmVmKHfk1RnAQAbw04GQ1xQkGoXPGGfMWIh0KNC81hWcIfidyXGGTLpzHPIgT4AAAAAIDiO/4MG0w/9fJarxuxotLIsC8Y3dyYXZFScTw0M2H7OAQAry+SHVBGgq6/+p5/7hlULbDOcLjNmd/yjXf7skzUaDxSXfj4AAAAAIF856waF38elli7iMecy3IKXDAXrd7slOZ/3/E/Oxcs6AQBFrqRup/dbEPFFObQGjEJ+21xeqJtksXgSS2HcoHPlz07ufT4AAAAAIAFV7XDFxRvyBX/ROY2W4myRWIT4bOhtLZc6y8C3nG48AQDyHizQnW8NtClDoP6LkjKA7b6nnWVAZr+STy5XsXgSrRSXfj4AAAAAIAVXHtFJRR4WxeIJZ5P/06byCYcYvtPZRa1o2BK5ivv+AAhAQg8AAAAAAAMDAQcACgEIAAEJAAEKAAELAAEMAAENAAEOAAEPAAEQAAERAAIBBwABARIAAFEvKCYcGik/SUFtiIW41dMr3m3Wipmgvjb+QrJI5oM6DGluY2VudGl2ZV92Mw1lbnRyeV9kZXBvc2l0AQfbo0Zy4wywZbH5Pjq1Uxh2j9b+9mwVlCyffLhG4vkA5wR1c2RjBFVTREMACAEBAAECAAEDAAEEAAMBAAAAAQAAAQUAAQYAxvpIp0Vb/0kSQxjVKXMgRBz6wz+DzuqJDEhTct6RffQBE2/Pyiy4OnaUtZVzBUjJjx4j1yCO443gl++NxEWIorB0yIE+AAAAACAzUspPbFn3u58/e0RVnDkrJlwQHqF2usKzWq1aQoRur8b6SKdFW/9JEkMY1SlzIEQc+sM/g87qiQxIU3LekX30ZAAAAAAAAAAA6AMAAAAAAAA=' + +export interface JsonInput { + Pure?: { bytes: string } + Object?: { + SharedObject?: { objectId: string } + ImmOrOwnedObject?: { objectId: string } + } + UnresolvedObject?: { objectId: string } +} +export interface JsonMoveCall { + package: string + module: string + function: string + typeArguments: string[] + arguments: { Input?: number }[] +} +export interface JsonCommand { + MoveCall?: JsonMoveCall + TransferObjects?: { address: { Input?: number } } +} +export interface JsonTx { + inputs: JsonInput[] + commands: JsonCommand[] +} + +export const OTHER_ADDRESS = '0x000000000000000000000000000000000000000000000000000000000000dead' +export const OTHER_OBJECT = '0x00000000000000000000000000000000000000000000000000000000000bad01' + +/** Rewrites a captured transaction the way a tampered browser could, then returns its bytes. */ +export async function tampered(base: string, edit: (tx: JsonTx) => void): Promise { + const data = JSON.parse(await Transaction.from(base).toJSON()) as JsonTx + edit(data) + return toBase64(await Transaction.from(JSON.stringify(data)).build()) +} + +export function callNamed(tx: JsonTx, name: string): JsonMoveCall { + const call = tx.commands.find((c) => c.MoveCall?.function === name)?.MoveCall + if (call === undefined) throw new Error(`no ${name} in this transaction`) + return call +} + +/** The input an argument of a call refers to. */ +export function inputOf(tx: JsonTx, call: JsonMoveCall, index: number): JsonInput { + const position = call.arguments[index]?.Input + const input = position === undefined ? undefined : tx.inputs[position] + if (input === undefined) throw new Error(`argument ${index} is not an input`) + return input +} + +export function pointAt(input: JsonInput, objectId: string): void { + const object = input.Object?.SharedObject ?? input.Object?.ImmOrOwnedObject + if (object === undefined) throw new Error('not an object input') + object.objectId = objectId +} + +/** Points a transaction's transfer at another address. */ +export function payTo(tx: JsonTx, address: string): void { + const transfer = tx.commands.find((c) => c.TransferObjects !== undefined)?.TransferObjects + const position = transfer?.address.Input + const input = position === undefined ? undefined : tx.inputs[position] + if (input?.Pure === undefined) throw new Error('no transfer to rewrite') + input.Pure.bytes = toBase64( + Uint8Array.from( + address + .replace('0x', '') + .match(/../g) + ?.map((b) => parseInt(b, 16)) ?? [] + ) + ) +} diff --git a/apps/dapp/lib/__tests__/sui-module.test.ts b/apps/dapp/lib/__tests__/sui-module.test.ts index 01863a6e..1d5778f5 100644 --- a/apps/dapp/lib/__tests__/sui-module.test.ts +++ b/apps/dapp/lib/__tests__/sui-module.test.ts @@ -37,6 +37,17 @@ describe('the token list', () => { 6, ], ['USDT', '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT', 6], + ['WAL', '0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL', 9], + [ + 'sSUI', + '0x83556891f4a0f233ce7b05cfe7f957d4020492a34f5405b2cb9377d060bef4bf::spring_sui::SPRING_SUI', + 9, + ], + [ + 'afSUI', + '0xf325ce1300e8dac124071d3152c5c5ee6174914f8bc2161e88329cf579246efc::afsui::AFSUI', + 9, + ], ]) }) @@ -286,23 +297,37 @@ describe('the module', () => { it('adds 1Click once the secret that protects its deposits is there', () => { expect(buildSuiVenues({ AUTH_SECRET: 'x'.repeat(48) }).map((v) => v.id)).toEqual([ 'cetus', + 'deepbook', + 'momentum', '1click-sui', 'navi', + 'suilend', + 'scallop', 'springsui', + 'aftermath', + 'walrus', 'send', ]) }) - it('wires only swap, supply and stake, to Cetus, NAVI and SpringSui', () => { + it('wires each venue to what it does, and a default for each kind', () => { expect(buildSuiVenues({}).map((v) => [v.id, v.kinds])).toEqual([ ['cetus', ['swap']], - ['navi', ['supply']], - ['springsui', ['stake']], + ['deepbook', ['swap']], + ['momentum', ['swap']], + ['navi', ['supply', 'withdraw']], + ['suilend', ['supply', 'withdraw']], + ['scallop', ['supply', 'withdraw']], + ['springsui', ['stake', 'unstake']], + ['aftermath', ['stake', 'unstake']], + ['walrus', ['stake', 'unstake']], ]) expect(suiModule.defaultVenue).toEqual({ swap: 'cetus', supply: 'navi', + withdraw: 'navi', stake: 'springsui', + unstake: 'springsui', send: 'send', }) }) @@ -311,15 +336,17 @@ describe('the module', () => { expect(suiCapabilities).toEqual([ { kind: 'swap', venue: 'Cetus', symbol: 'SUI', then: ['cashout', 'supply', 'send'] }, { kind: 'supply', venue: 'NAVI', symbol: 'USDC' }, + { kind: 'withdraw', venue: 'NAVI', symbol: 'USDC' }, { kind: 'stake', venue: 'SpringSui', symbol: 'SUI' }, + { kind: 'unstake', venue: 'SpringSui', symbol: 'sSUI' }, ]) }) }) describe('the venue list', () => { it('has every row of the report', () => { - expect(suiVenues).toHaveLength(15) - expect(new Set(suiVenues.map((v) => v.id)).size).toBe(15) + expect(suiVenues).toHaveLength(16) + expect(new Set(suiVenues.map((v) => v.id)).size).toBe(16) }) it('is all Sui, with a real link and an honest reason', () => { diff --git a/apps/dapp/lib/__tests__/sui-route.live.test.ts b/apps/dapp/lib/__tests__/sui-route.live.test.ts index fabf8e3d..38cd7c1d 100644 --- a/apps/dapp/lib/__tests__/sui-route.live.test.ts +++ b/apps/dapp/lib/__tests__/sui-route.live.test.ts @@ -61,11 +61,6 @@ describe.skipIf(SKIP)('the Sui prepare route', () => { /verified/, ], ['a token in that is unknown', { kind: 'supply', amount: '1', tokenIn: 'SCAM' }, /verified/], - [ - 'over the cap', - { kind: 'swap', amount: '501', tokenIn: 'USDC', tokenOut: 'SUI' }, - /\$?500|limit|cap/i, - ], [ 'a named venue that cannot do this', { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SUI', venue: 'navi' }, @@ -79,7 +74,7 @@ describe.skipIf(SKIP)('the Sui prepare route', () => { ['staking anything but SUI', { kind: 'stake', amount: '1', tokenIn: 'USDC' }, /stakes SUI/], [ 'a listed staker that is not wired', - { kind: 'stake', amount: '1', tokenIn: 'SUI', venue: 'haedal' }, + { kind: 'stake', amount: '1', tokenIn: 'SUI', venue: 'aftermath' }, /can't stake/, ], [ diff --git a/apps/dapp/lib/__tests__/sui-venues.live.test.ts b/apps/dapp/lib/__tests__/sui-venues.live.test.ts index 6d673e7c..0b26bdb1 100644 --- a/apps/dapp/lib/__tests__/sui-venues.live.test.ts +++ b/apps/dapp/lib/__tests__/sui-venues.live.test.ts @@ -22,7 +22,7 @@ const EMPTY = '0x000000000000000000000000000000000000000000000000000000000000123 const ENV = {} function prepare(request: ChainActionRequest, account = ACCOUNT) { - return prepareChainAction(suiModule, request, account, ENV) + return prepareChainAction(suiModule, request, account) } async function dryRun(transaction: string): Promise { @@ -138,12 +138,6 @@ describe.skipIf(SKIP)('Sui venues against mainnet', () => { ).rejects.toThrow(ChainActionRefused) }, 30_000) - it('refuses over the cap', async () => { - await expect( - prepare({ kind: 'swap', amount: '501', tokenIn: 'USDC', tokenOut: 'SUI' }) - ).rejects.toThrow(ChainActionRefused) - }, 30_000) - it('refuses a venue that cannot do this', async () => { await expect( prepare({ kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SUI', venue: 'navi' }) diff --git a/apps/dapp/lib/__tests__/suilend.live.test.ts b/apps/dapp/lib/__tests__/suilend.live.test.ts new file mode 100644 index 00000000..ca105b40 --- /dev/null +++ b/apps/dapp/lib/__tests__/suilend.live.test.ts @@ -0,0 +1,77 @@ +import { Transaction } from '@mysten/sui/transactions' +import { describe, expect, it } from 'vitest' + +import { suiClient } from '../chains/sui/client' +import { dryRunChange } from '../chains/sui/lending-calls' +import { + SUILEND_MARKET, + SUILEND_ORIGINAL_PACKAGE, + SUILEND_PACKAGE, + SUILEND_UPGRADE_CAP, + SUILEND_USDC_RESERVE, + suilendVenue, +} from '../chains/sui/suilend' +import { SUI_TOKENS } from '../chains/sui/tokens' + +/** + * Real transactions built and dry-run against mainnet, for public addresses whose keys + * nobody here has. Nothing signs or sends. If a holder moves its funds these fail on + * balance, so swap it for another with a loan-free USDC position. + */ +const SKIP = process.env['SKIP_LIVE'] === '1' +const USDC_HOLDER = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4' +const SUPPLIER = '0xd22978af0d4d404067dc611d1f63af9883b5a1786e24813290ccb2d8407b1b66' +const USDC = SUI_TOKENS.find((t) => t.symbol === 'USDC')?.id ?? '' + +describe.skipIf(SKIP)('Suilend against mainnet', () => { + it('still has the pinned package as its latest, or the allowlist needs a fresh look', async () => { + const { object } = await suiClient().core.getObject({ + objectId: SUILEND_UPGRADE_CAP, + include: { json: true }, + }) + expect((object.json as { package?: string }).package).toBe(SUILEND_PACKAGE) + }, 30_000) + + it('still has USDC at the pinned reserve of the pinned market, at version 7', async () => { + const { object } = await suiClient().core.getObject({ + objectId: SUILEND_MARKET, + include: { json: true }, + }) + expect(object.type).toContain(`${SUILEND_ORIGINAL_PACKAGE}::lending_market::LendingMarket<`) + const json = object.json as { version: string; reserves: { coin_type: string }[] } + expect(json.version).toBe('7') + expect(USDC).toContain(json.reserves[SUILEND_USDC_RESERVE]?.coin_type) + }, 30_000) + + it('builds a first supply with a rate, passes its check and dry-runs clean', async () => { + const prepared = await suilendVenue.prepare( + { kind: 'supply', amount: '1', tokenIn: 'USDC' }, + USDC_HOLDER + ) + expect(prepared.apy?.basePercent).toBeGreaterThan(0) + expect(prepared.apy?.rewardPercent).toBeUndefined() + expect(() => suilendVenue.assertSafe(prepared.signing, USDC_HOLDER)).not.toThrow() + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + expect(Transaction.from(prepared.signing.transaction).getData().sender).toBe(USDC_HOLDER) + expect(await dryRunChange(prepared.signing.transaction, USDC, USDC_HOLDER, 'Dry run')).toBe( + BigInt(-1_000_000) + ) + }, 60_000) + + it('builds a withdrawal with no price update and no server, and pays what was asked', async () => { + const prepared = await suilendVenue.prepare( + { kind: 'withdraw', amount: '1', tokenIn: 'USDC' }, + SUPPLIER + ) + expect(() => suilendVenue.assertSafe(prepared.signing, SUPPLIER)).not.toThrow() + if (prepared.signing.family !== 'sui') throw new Error('wrong family') + const received = await dryRunChange(prepared.signing.transaction, USDC, SUPPLIER, 'Dry run') + expect(Number(received)).toBeGreaterThanOrEqual(999_900) + }, 60_000) + + it('refuses a wallet with no position', async () => { + await expect( + suilendVenue.prepare({ kind: 'withdraw', amount: '1', tokenIn: 'USDC' }, USDC_HOLDER) + ).rejects.toThrow(/no USDC in Suilend/) + }, 30_000) +}) diff --git a/apps/dapp/lib/__tests__/suilend.test.ts b/apps/dapp/lib/__tests__/suilend.test.ts new file mode 100644 index 00000000..ac03d797 --- /dev/null +++ b/apps/dapp/lib/__tests__/suilend.test.ts @@ -0,0 +1,325 @@ +import { Transaction } from '@mysten/sui/transactions' +import { toBase64 } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { + amountForCtokens, + createSuilendVenue, + ctokensForAmount, + parseSuilendReserve, + positionForSupply, + SUILEND_USDC_RESERVE, + suilendSupplyApr, + suilendVenue, + type SuilendPosition, + type SuilendReader, + type SuilendReserve, +} from '../chains/sui/suilend' +import { suilendListing } from '../chains/sui/suilend-listing' +import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types' +import { + callNamed, + inputOf, + OTHER_ADDRESS, + OTHER_OBJECT, + payTo, + pointAt, + SUILEND_SUPPLY, + SUILEND_SUPPLY_SENDER, + SUILEND_WITHDRAW, + SUILEND_WITHDRAW_SENDER, + tampered, +} from './sui-lending-captured' + +const USDT = '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT' +const ME = '0x0000000000000000000000000000000000000000000000000000000000000abc' +const WAD = BigInt('1000000000000000000') + +function sui(transaction: string): SigningPayload { + return { family: 'sui', transaction } +} + +function checkSupply(transaction: string, account = SUILEND_SUPPLY_SENDER): void { + suilendVenue.assertSafe(sui(transaction), account) +} + +function checkWithdraw(transaction: string, account = SUILEND_WITHDRAW_SENDER): void { + suilendVenue.assertSafe(sui(transaction), account) +} + +/** The reserve as read on 2026-11-05, rounded. */ +const RESERVE: SuilendReserve = { + available: BigInt('5627000000000'), + ctokenSupply: BigInt('25263000000000'), + borrowedWad: BigInt('22749000000000') * WAD, + spreadFeesWad: BigInt('1856000000') * WAD, + depositLimit: BigInt('110000000000000'), + utilsPercent: [0, 93, 97, 100], + aprsBps: [400, 700, 1500, 15000], + spreadFeeBps: 2000, +} + +function position(over: Partial = {}): SuilendPosition { + return { + capId: '0x0000000000000000000000000000000000000000000000000000000000000c01', + obligationId: '0x0000000000000000000000000000000000000000000000000000000000000b01', + ctokens: BigInt('10000000'), + hasBorrows: false, + ...over, + } +} + +function reader(positions: SuilendPosition[], reserve: SuilendReserve = RESERVE): SuilendReader { + return { reserve: async () => reserve, positions: async () => positions } +} + +function withdraw(amount = '1'): ChainActionRequest { + return { kind: 'withdraw', amount, tokenIn: 'USDC' } +} + +describe('what the venue really built passes', () => { + it('a first supply and a withdrawal', () => { + expect(() => checkSupply(SUILEND_SUPPLY)).not.toThrow() + expect(() => checkWithdraw(SUILEND_WITHDRAW)).not.toThrow() + }) +}) + +describe('Suilend refuses a transaction that', () => { + it('another account would sign', () => { + expect(() => checkSupply(SUILEND_SUPPLY, OTHER_ADDRESS)).toThrow(/different account/) + expect(() => checkWithdraw(SUILEND_WITHDRAW, OTHER_ADDRESS)).toThrow(ChainActionRefused) + }) + + it('calls a contract that is not confirmed', async () => { + const tx = await tampered(SUILEND_WITHDRAW, (t) => { + callNamed(t, 'withdraw_ctokens').package = OTHER_OBJECT + }) + expect(() => checkWithdraw(tx)).toThrow(/haven't confirmed for Suilend/) + }) + + it.each(['borrow_request', 'repay', 'forgive', 'liquidate'])( + 'calls %s, which this venue never uses', + async (name) => { + const tx = await tampered(SUILEND_WITHDRAW, (t) => { + callNamed(t, 'withdraw_ctokens').function = name + }) + expect(() => checkWithdraw(tx)).toThrow(/haven't confirmed for Suilend/) + } + ) + + it('passes an extra argument', async () => { + const tx = await tampered(SUILEND_WITHDRAW, (t) => { + const call = callNamed(t, 'fulfill_liquidity_request') + const extra = call.arguments[0] + if (extra === undefined) throw new Error('no argument') + call.arguments.push(extra) + }) + expect(() => checkWithdraw(tx)).toThrow(/wrong arguments/) + }) + + it('takes an extra step of a kind it never needs', async () => { + const base = Transaction.from(SUILEND_WITHDRAW) + base.makeMoveVec({ type: 'u64', elements: [] }) + const tx = toBase64(await base.build()) + expect(() => checkWithdraw(tx)).toThrow(/does more than Suilend needs/) + }) + + it('moves anything through the framework', async () => { + const base = Transaction.from(SUILEND_WITHDRAW) + base.moveCall({ + target: '0x2::transfer::public_transfer', + typeArguments: ['0x2::sui::SUI'], + arguments: [base.gas, base.pure.address(OTHER_ADDRESS)], + }) + const tx = toBase64(await base.build()) + expect(() => checkWithdraw(tx)).toThrow(ChainActionRefused) + }) + + it('pays someone else', async () => { + const tx = await tampered(SUILEND_WITHDRAW, (t) => payTo(t, OTHER_ADDRESS)) + expect(() => checkWithdraw(tx)).toThrow(/another address/) + }) + + it("sends a new position's cap to someone else", async () => { + const tx = await tampered(SUILEND_SUPPLY, (t) => payTo(t, OTHER_ADDRESS)) + expect(() => checkSupply(tx)).toThrow(/another address/) + }) + + it('uses a token other than USDC', async () => { + const tx = await tampered(SUILEND_WITHDRAW, (t) => { + const call = callNamed(t, 'withdraw_ctokens') + call.typeArguments = [call.typeArguments[0] ?? '', USDT] + }) + expect(() => checkWithdraw(tx)).toThrow(/token Suilend isn't confirmed for/) + }) + + it('uses another pool type', async () => { + const tx = await tampered(SUILEND_SUPPLY, (t) => { + callNamed(t, 'create_obligation').typeArguments = [USDT] + }) + expect(() => checkSupply(tx)).toThrow(/token Suilend isn't confirmed for/) + }) + + it('points at another market', async () => { + const tx = await tampered(SUILEND_WITHDRAW, (t) => { + pointAt(inputOf(t, callNamed(t, 'withdraw_ctokens'), 0), OTHER_OBJECT) + }) + expect(() => checkWithdraw(tx)).toThrow(/object Suilend doesn't use/) + }) + + it('names another reserve', async () => { + const tx = await tampered(SUILEND_SUPPLY, (t) => { + const input = inputOf(t, callNamed(t, 'deposit_liquidity_and_mint_ctokens'), 1) + if (input.Pure === undefined) throw new Error('not a literal') + input.Pure.bytes = 'AQAAAAAAAAA=' + }) + expect(() => checkSupply(tx)).toThrow(/value Suilend doesn't use/) + }) + + it('is not a Sui transaction, or not readable', () => { + const near: SigningPayload = { family: 'near', transactions: [] } + expect(() => suilendVenue.assertSafe(near, SUILEND_SUPPLY_SENDER)).toThrow(/not a Sui/) + expect(() => checkSupply('not base64!')).toThrow(ChainActionRefused) + }) +}) + +describe('amounts', () => { + it('rounds cTokens up, and a whole position converts back within what it holds', () => { + const asked = BigInt('10000000') + const ctokens = ctokensForAmount(asked, RESERVE) + expect(amountForCtokens(ctokens, RESERVE) >= asked - BigInt(1)).toBe(true) + + const held = BigInt('79254808') + const value = amountForCtokens(held, RESERVE) + expect(ctokensForAmount(value, RESERVE) <= held).toBe(true) + }) + + it('reads the rate off the contract curve, without rewards', () => { + expect(suilendSupplyApr(RESERVE)).toBeCloseTo(4.22, 1) + }) + + it('is zero with nothing borrowed, and shows none when the curve does not read', () => { + expect(suilendSupplyApr({ ...RESERVE, borrowedWad: BigInt(0) })).toBe(0) + expect(suilendSupplyApr({ ...RESERVE, utilsPercent: [] })).toBeUndefined() + expect( + suilendSupplyApr({ ...RESERVE, borrowedWad: BigInt(0), available: BigInt(0) }) + ).toBeUndefined() + }) +}) + +describe('reading the market', () => { + const usdc = { + coin_type: 'dba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC', + available_amount: '5', + ctoken_supply: '5', + borrowed_amount: { value: '0' }, + unclaimed_spread_fees: { value: '0' }, + config: { + element: { + deposit_limit: '5', + interest_rate_utils: 'AF1hZA==', + interest_rate_aprs: ['400', '700', '1500', '15000'], + spread_fee_bps: '2000', + }, + }, + } + const market = (entry: unknown) => { + const reserves: unknown[] = new Array(SUILEND_USDC_RESERVE + 1).fill({}) + reserves[SUILEND_USDC_RESERVE] = entry + return { reserves } + } + + it('takes the curve from the bytes the chain returns', () => { + expect(parseSuilendReserve(market(usdc)).utilsPercent).toEqual([0, 93, 97, 100]) + }) + + it('refuses when the pinned reserve is not USDC, or fields are missing', () => { + expect(() => parseSuilendReserve(market({ ...usdc, coin_type: 'x::y::Z' }))).toThrow( + /couldn't read Suilend/ + ) + expect(() => parseSuilendReserve(market({ ...usdc, ctoken_supply: undefined }))).toThrow( + ChainActionRefused + ) + expect(() => parseSuilendReserve(undefined)).toThrow(ChainActionRefused) + }) +}) + +describe('positionForSupply', () => { + it('prefers a loan-free position holding USDC, then any loan-free one', () => { + const empty = position({ capId: '0x1', ctokens: BigInt(0) }) + const holding = position({ capId: '0x2' }) + const loaned = position({ capId: '0x3', hasBorrows: true }) + expect(positionForSupply([loaned, empty, holding])?.capId).toBe('0x2') + expect(positionForSupply([loaned, empty])?.capId).toBe('0x1') + }) + + it('never adds to a position with a loan, so a new one opens instead', () => { + expect(positionForSupply([position({ hasBorrows: true })])).toBeUndefined() + expect(positionForSupply([])).toBeUndefined() + }) +}) + +describe('prepare refuses before anything is built', () => { + it('a token other than USDC', async () => { + const venue = createSuilendVenue(reader([])) + await expect( + venue.prepare({ kind: 'supply', amount: '1', tokenIn: 'SUI' }, ME) + ).rejects.toThrow(/takes USDC here, not SUI/) + await expect(venue.prepare({ ...withdraw(), tokenIn: 'USDT' }, ME)).rejects.toThrow( + ChainActionRefused + ) + }) + + it('a malformed account', async () => { + await expect(createSuilendVenue(reader([])).prepare(withdraw(), 'nope')).rejects.toThrow( + /not a Sui address/ + ) + }) + + it('a deposit over the market limit', async () => { + const full = { ...RESERVE, depositLimit: BigInt(1) } + await expect( + createSuilendVenue(reader([], full)).prepare( + { kind: 'supply', amount: '1', tokenIn: 'USDC' }, + ME + ) + ).rejects.toThrow(/deposit limit/) + }) + + it('a withdrawal with no position', async () => { + const empty = position({ ctokens: BigInt(0) }) + await expect(createSuilendVenue(reader([empty])).prepare(withdraw(), ME)).rejects.toThrow( + /no USDC in Suilend/ + ) + }) + + it('a withdrawal from a position that backs a loan, and says where to go', async () => { + const venue = createSuilendVenue(reader([position({ hasBorrows: true })])) + await expect(venue.prepare(withdraw(), ME)).rejects.toThrow(/backs a loan.*suilend\.fi/) + }) + + it('more than the position holds, and names what it does', async () => { + const venue = createSuilendVenue(reader([position()])) + await expect(venue.prepare(withdraw('100000'), ME)).rejects.toThrow( + /You have about 11\.\d+ USDC in Suilend, which is less than 100000/ + ) + }) +}) + +describe('the venue', () => { + it('offers only supply and withdraw', () => { + expect(suilendVenue.kinds).toEqual(['supply', 'withdraw']) + }) + + it('quotes a base rate from the reader alone', async () => { + const quote = await createSuilendVenue(reader([])).quote?.( + { kind: 'supply', amount: '1', tokenIn: 'USDC' }, + ME + ) + expect(quote?.apyPercent).toBeCloseTo(4.22, 1) + }) + + it('has a listing that says it executes', () => { + expect(suilendListing).toMatchObject({ id: 'suilend', family: 'sui', integration: 'executes' }) + }) +}) diff --git a/apps/dapp/lib/__tests__/trade-cap-copy.test.ts b/apps/dapp/lib/__tests__/trade-cap-copy.test.ts deleted file mode 100644 index 0d649db2..00000000 --- a/apps/dapp/lib/__tests__/trade-cap-copy.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { tradeCapNote } from '../trade-cap-copy' - -describe('tradeCapNote', () => { - it('says what the cap is, in dollars', () => { - expect(tradeCapNote(50, 'stellar')).toBe('Each trade is capped at $50 on mainnet.') - }) - - it('formats a larger cap with separators', () => { - expect(tradeCapNote(2500, 'stellar')).toBe('Each trade is capped at $2,500 on mainnet.') - }) - - it('says nothing when there is no cap', () => { - expect(tradeCapNote(undefined, 'stellar')).toBeUndefined() - }) - - it('says nothing on Arc, where the Stellar cap does not apply', () => { - expect(tradeCapNote(50, 'arc')).toBeUndefined() - }) -}) diff --git a/apps/dapp/lib/__tests__/trade-cap.test.ts b/apps/dapp/lib/__tests__/trade-cap.test.ts deleted file mode 100644 index bddbab6a..00000000 --- a/apps/dapp/lib/__tests__/trade-cap.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - DEFAULT_MAINNET_MAX_TRADE_USD, - TradeCapExceeded, - assertPlanWithinCap, - assertTradeWithinCap, - assertWithinCap, - tradeCapUsd, - usdValue, -} from '../server/trade-cap' -import type { MarketPrice } from '../swap/price-types' - -/** - * A small per-trade cap on mainnet, and none on testnet. - * - * Real money, new rails: until the app has been watched keeping its promises - * with real volume, no single trade may be worth more than a few tens of - * dollars. The cap is dollars rather than units so it means the same thing - * for XLM and for USDC, which is why a trade that cannot be valued in - * dollars is refused rather than waved through — a cap that cannot be - * checked is no cap. - */ - -const asOf = '2026-09-24T00:00:00.000Z' -const table: Record = { - XLM: { symbol: 'XLM', usd: 0.2, source: 'reflector', asOf }, - USDC: { symbol: 'USDC', usd: 1, source: 'stellar-mainnet', asOf }, - CETES: { symbol: 'CETES', usd: 0.05, source: 'fallback', asOf }, -} - -describe('the cap itself', () => { - it('does not exist on testnet', () => { - expect(tradeCapUsd({}, 'testnet')).toBeUndefined() - expect(tradeCapUsd({ MAINNET_MAX_TRADE_USD: '5' }, 'testnet')).toBeUndefined() - }) - - it('is five hundred dollars on mainnet unless configured', () => { - expect(DEFAULT_MAINNET_MAX_TRADE_USD).toBe(500) - expect(tradeCapUsd({}, 'mainnet')).toBe(500) - expect(tradeCapUsd({ MAINNET_MAX_TRADE_USD: '' }, 'mainnet')).toBe(500) - expect(tradeCapUsd({ MAINNET_MAX_TRADE_USD: '200' }, 'mainnet')).toBe(200) - }) - - it('refuses a cap that is not a positive number, naming the variable', () => { - expect(() => tradeCapUsd({ MAINNET_MAX_TRADE_USD: 'lots' }, 'mainnet')).toThrow( - /MAINNET_MAX_TRADE_USD/ - ) - expect(() => tradeCapUsd({ MAINNET_MAX_TRADE_USD: '0' }, 'mainnet')).toThrow( - /MAINNET_MAX_TRADE_USD/ - ) - }) -}) - -describe('checking a dollar value against it', () => { - it('is a no-op on testnet, whatever the value', () => { - expect(() => assertWithinCap(1_000_000, {}, 'testnet')).not.toThrow() - expect(() => assertWithinCap(Number.NaN, {}, 'testnet')).not.toThrow() - }) - - it('passes at or under the cap on mainnet', () => { - expect(() => assertWithinCap(499.99, {}, 'mainnet')).not.toThrow() - expect(() => assertWithinCap(500, {}, 'mainnet')).not.toThrow() - }) - - it('refuses over the cap, saying the cap and how to change it', () => { - let caught: unknown - try { - assertWithinCap(500.01, {}, 'mainnet') - } catch (e) { - caught = e - } - expect(caught).toBeInstanceOf(TradeCapExceeded) - expect((caught as Error).message).toMatch(/\$500/) - expect((caught as Error).message).toMatch(/MAINNET_MAX_TRADE_USD/) - expect((caught as Error).message).toMatch(/\$500\.01/) - }) - - it('refuses a trade it cannot value, because an unchecked cap is no cap', () => { - expect(() => assertWithinCap(Number.NaN, {}, 'mainnet')).toThrow(/could not be valued/) - expect(() => assertWithinCap(Number.NaN, {}, 'mainnet')).toThrow(TradeCapExceeded) - }) -}) - -describe('valuing a trade', () => { - it('multiplies display units by a real price', () => { - expect(usdValue('XLM', '100', table)).toBeCloseTo(20) - expect(usdValue('USDC', '12.5', table)).toBe(12.5) - }) - - it('has no value for a fallback price or an unknown asset', () => { - expect(usdValue('CETES', '100', table)).toBeNaN() - expect(usdValue('DOGE', '1', table)).toBeNaN() - }) -}) - -describe('checking a trade end to end', () => { - it('prices nothing on testnet', async () => { - await expect( - assertTradeWithinCap('XLM', '1000000', { - network: 'testnet', - prices: () => Promise.reject(new Error('must not be asked')), - }) - ).resolves.toBeUndefined() - }) - - it('passes a small mainnet trade and refuses a large one', async () => { - const prices = () => Promise.resolve(table) - await expect( - assertTradeWithinCap('XLM', '200', { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - }) - ).resolves.toBeUndefined() - await expect( - assertTradeWithinCap('XLM', '300', { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - }) - ).rejects.toThrow(/caps each trade at \$50/) - await expect( - assertTradeWithinCap('USDC', '51', { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - }) - ).rejects.toThrow(TradeCapExceeded) - }) - - it('honours a configured cap', async () => { - const prices = () => Promise.resolve(table) - await expect( - assertTradeWithinCap('USDC', '150', { - env: { MAINNET_MAX_TRADE_USD: '200' }, - network: 'mainnet', - prices, - }) - ).resolves.toBeUndefined() - }) -}) - -describe('checking a plan, step by step', () => { - const XLM = { kind: 'classic' as const, code: 'XLM' } - const USDC = { kind: 'classic' as const, code: 'USDC', issuer: 'G' } - const prices = () => Promise.resolve(table) - const pools = () => - Promise.resolve([ - { - id: 'pool-1', - assets: [ - { code: 'XLM', amount: '1000' }, - { code: 'USDC', issuer: 'G', amount: '200' }, - ], - feePct: 0.3, - totalShares: '1', - isEmpty: false, - }, - ]) - - it('passes a plan whose every step is under the cap', async () => { - await expect( - assertPlanWithinCap( - [ - { kind: 'trust', asset: USDC }, - { kind: 'swap', from: USDC, to: XLM, sendAmount: '400000000', minReceive: '1' }, - { kind: 'rest', selling: XLM, buying: USDC, amount: '100', price: { n: 1, d: 5 } }, - ], - { env: { MAINNET_MAX_TRADE_USD: '50' }, network: 'mainnet', prices, pools } - ) - ).resolves.toBeUndefined() - }) - - it('refuses the step that is over, whichever it is', async () => { - await expect( - assertPlanWithinCap( - [ - { kind: 'swap', from: USDC, to: XLM, sendAmount: '100000000', minReceive: '1' }, - { kind: 'rest', selling: XLM, buying: USDC, amount: '300', price: { n: 1, d: 5 } }, - ], - { env: { MAINNET_MAX_TRADE_USD: '50' }, network: 'mainnet', prices, pools } - ) - ).rejects.toThrow(/caps each trade at \$50/) - }) - - it('values a pool deposit as both sides together', async () => { - const deposit = (a: string, b: string) => ({ - kind: 'pool' as const, - poolId: 'pool-1', - maxAmountA: a, - maxAmountB: b, - minPrice: { n: 1, d: 10 }, - maxPrice: { n: 1, d: 1 }, - }) - await expect( - assertPlanWithinCap([deposit('100', '20')], { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - pools, - }) - ).resolves.toBeUndefined() - await expect( - assertPlanWithinCap([deposit('200', '30')], { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - pools, - }) - ).rejects.toThrow(/caps each trade at \$50/) - // A pool it cannot find cannot be valued, and is refused for that. - await expect( - assertPlanWithinCap([{ ...deposit('1', '1'), poolId: 'pool-9' }], { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - pools, - }) - ).rejects.toThrow(/could not be valued/) - }) - - it('reads neither prices nor pools on testnet', async () => { - const never = () => Promise.reject(new Error('must not be asked')) - await expect( - assertPlanWithinCap( - [{ kind: 'swap', from: USDC, to: XLM, sendAmount: '1', minReceive: '1' }], - { network: 'testnet', prices: never, pools: never } - ) - ).resolves.toBeUndefined() - }) -}) - -describe('a plan cannot move more than the cap in one asset across its steps', () => { - const XLM = { kind: 'classic' as const, code: 'XLM' } - const USDC = { kind: 'classic' as const, code: 'USDC', issuer: 'G' } - const prices = () => Promise.resolve(table) - const swap = (usdc: string) => ({ - kind: 'swap' as const, - from: USDC, - to: XLM, - sendAmount: usdc, - minReceive: '1', - }) - - it('refuses two steps that together spend over the cap of one asset', async () => { - // Ten steps of $30 would be $300 out of one account on one signature. - await expect( - assertPlanWithinCap([swap('300000000'), swap('300000000')], { - env: { MAINNET_MAX_TRADE_USD: '50' }, - network: 'mainnet', - prices, - }) - ).rejects.toThrow(/USDC across this plan/) - }) - - it('passes steps that spend different assets, each under the cap', async () => { - await expect( - assertPlanWithinCap( - [ - swap('300000000'), - { kind: 'rest', selling: XLM, buying: USDC, amount: '150', price: { n: 1, d: 5 } }, - ], - { env: { MAINNET_MAX_TRADE_USD: '50' }, network: 'mainnet', prices } - ) - ).resolves.toBeUndefined() - }) -}) diff --git a/apps/dapp/lib/__tests__/uniswap.live.test.ts b/apps/dapp/lib/__tests__/uniswap.live.test.ts new file mode 100644 index 00000000..e2d38229 --- /dev/null +++ b/apps/dapp/lib/__tests__/uniswap.live.test.ts @@ -0,0 +1,164 @@ +import { formatUnits, parseAbi, parseUnits, type Address } from 'viem' +import { describe, expect, it } from 'vitest' + +import { evmReader } from '../chains/evm/client' +import { ChainActionRefused } from '../chains/types' +import { EVM_CHAINS, type EvmChain } from '../chains/evm/tokens' +import { createUniswapVenue, uniswapRouter } from '../chains/evm/uniswap' +import { WAVAX, WMON } from '../chains/evm/v3-swap' +import { fakeClient, fundedReader, wethHolding } from './v3-swap-suite' + +/** + * The real contracts, read-only. Nothing signs or sends: the swaps below are `eth_call`s from an + * account that only exists inside the call, given a balance by a state override. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const ME = '0x00000000000000000000000000000000000feed1' +const MUCH = BigInt('1000000000000000000000000') +const IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' + +const FACTORY: Record = { + avalanche: '0x740b1c1de25031C31FF4fC9A62f554A55cdC1baD', + monad: '0x204FAca1764B154221e35c0d20aBb3c525710498', +} + +const IMMUTABLES = parseAbi([ + 'function factory() view returns (address)', + 'function WETH9() view returns (address)', +]) +const FACTORY_ABI = parseAbi(['function feeAmountTickSpacing(uint24) view returns (int24)']) + +describe.skipIf(SKIP).each<[EvmChain, string, string]>([ + ['avalanche', 'AVAX', 'USDC'], + ['monad', 'MON', 'USDC'], +])('Uniswap on %s, live', (chain, native, stable) => { + const router = uniswapRouter(chain) + const wrapped = chain === 'avalanche' ? WAVAX : WMON + const reader = evmReader(chain) + + it.each([ + ['router', router.router], + ['quoter', router.quoter], + ])( + 'the %s answers with the registry’s factory and wrapped coin', + async (_label, address) => { + const [factory, weth] = await Promise.all([ + reader.readContract({ + address: address as Address, + abi: IMMUTABLES, + functionName: 'factory', + }), + reader.readContract({ + address: address as Address, + abi: IMMUTABLES, + functionName: 'WETH9', + }), + ]) + expect(factory.toLowerCase()).toBe(FACTORY[chain].toLowerCase()) + expect(weth.toLowerCase()).toBe(wrapped.id.toLowerCase()) + }, + 30_000 + ) + + it('the router holds the functions this venue calls, and is no proxy', async () => { + const [code, implementation] = await Promise.all([ + reader.getCode({ address: router.router as Address }), + reader.getStorageAt({ address: router.router as Address, slot: IMPLEMENTATION_SLOT }), + ]) + for (const selector of ['5ae401dc', '04e45aaf', '12210e8a']) { + expect(code).toContain(selector) + } + expect(BigInt(implementation ?? '0x0')).toBe(BigInt(0)) + }, 30_000) + + it('every fee tier is switched on in the factory', async () => { + for (const tier of router.tiers) { + const spacing = await reader.readContract({ + address: FACTORY[chain] as Address, + abi: FACTORY_ABI, + functionName: 'feeAmountTickSpacing', + args: [tier], + }) + expect(spacing).toBeGreaterThan(0) + } + }, 30_000) + + it('quotes a real price', async () => { + const quoted = await createUniswapVenue(chain).quote?.( + { kind: 'swap', amount: '20', tokenIn: stable, tokenOut: wrapped.symbol }, + ME + ) + expect(Number(quoted?.receive?.amount)).toBeGreaterThan(0) + }, 30_000) + + it('swaps the native coin in, wrapped by the router, in a call from a funded account', async () => { + const venue = createUniswapVenue(chain, { + reader: () => fundedReader(reader, [{ address: ME as Address, balance: MUCH }], BigInt(0)), + client: () => fakeClient(), + }) + // `prepare` runs the call against the chain and refuses if it would fail. + const prepared = await venue.prepare( + { kind: 'swap', amount: '1', tokenIn: native, tokenOut: stable }, + ME + ) + expect(Number(prepared.receive?.amount)).toBeGreaterThan(0) + }, 60_000) + + it('swaps the wrapped coin in, pulled under an exact allowance, in a call from a funded account', async () => { + const units = parseUnits('5', wrapped.decimals) + const venue = createUniswapVenue(chain, { + reader: () => + fundedReader( + reader, + [ + { address: ME as Address, balance: MUCH }, + { + address: wrapped.id as Address, + stateDiff: wethHolding(ME, router.router, units), + }, + ], + units + ), + client: () => fakeClient(), + }) + const prepared = await venue.prepare( + { + kind: 'swap', + amount: formatUnits(units, wrapped.decimals), + tokenIn: wrapped.symbol, + tokenOut: stable, + }, + ME + ) + expect(prepared.signing).not.toHaveProperty('approval') + expect(Number(prepared.receive?.amount)).toBeGreaterThan(0) + }, 60_000) + + it('refuses that same swap from an account that holds none, so the two above prove something', async () => { + const units = parseUnits('5', wrapped.decimals) + const venue = createUniswapVenue(chain, { + reader: () => fundedReader(reader, [{ address: ME as Address, balance: MUCH }], units), + client: () => fakeClient(), + }) + await expect( + venue.prepare( + { + kind: 'swap', + amount: formatUnits(units, wrapped.decimals), + tokenIn: wrapped.symbol, + tokenOut: stable, + }, + ME + ) + ).rejects.toThrow(ChainActionRefused) + }, 60_000) + + it('uses tokens from the chain’s own table', () => { + const table = EVM_CHAINS[chain].tokens + for (const token of router.tokens) { + if (token.id === wrapped.id) continue + expect(table.find((t) => t.id === token.id)?.decimals).toBe(token.decimals) + } + }) +}) diff --git a/apps/dapp/lib/__tests__/uniswap.test.ts b/apps/dapp/lib/__tests__/uniswap.test.ts new file mode 100644 index 00000000..7e689e2d --- /dev/null +++ b/apps/dapp/lib/__tests__/uniswap.test.ts @@ -0,0 +1,163 @@ +import { + decodeFunctionData, + encodeFunctionData, + parseAbi, + toFunctionSelector, + type Address, + type Hex, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import { createUniswapVenue } from '../chains/evm/uniswap' +import { + ACCOUNT, + describeV3SwapVenue, + fakeClient, + fakeReader, + type RouterFixture, + type SwapFields, +} from './v3-swap-suite' + +// Written out by hand from Uniswap's SwapRouter02 source, apart from the code under test. +const ROUTER_ABI = parseAbi([ + 'struct Params { address tokenIn; address tokenOut; uint24 fee; address recipient; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; }', + 'function exactInputSingle(Params params) payable returns (uint256 amountOut)', + 'function multicall(uint256 deadline, bytes[] data) payable returns (bytes[] results)', +]) + +function encodeSwap(fields: SwapFields): Hex { + return encodeFunctionData({ + abi: ROUTER_ABI, + functionName: 'exactInputSingle', + args: [ + { + tokenIn: fields.tokenIn as Address, + tokenOut: fields.tokenOut as Address, + fee: fields.tier, + recipient: fields.recipient as Address, + amountIn: fields.amountIn, + amountOutMinimum: fields.amountOutMinimum, + sqrtPriceLimitX96: fields.sqrtPriceLimitX96, + }, + ], + }) +} + +function encodeOuter(calls: readonly Hex[], deadline: bigint): Hex { + return encodeFunctionData({ abi: ROUTER_ABI, functionName: 'multicall', args: [deadline, calls] }) +} + +function decodeSwap(data: Hex): SwapFields & { outer: Hex[] } { + const outer = decodeFunctionData({ abi: ROUTER_ABI, data }) + if (outer.functionName !== 'multicall') throw new Error('not a multicall') + const [deadline, calls] = outer.args + const inner = decodeFunctionData({ abi: ROUTER_ABI, data: calls[0] as Hex }) + if (inner.functionName !== 'exactInputSingle') throw new Error('not a swap') + const [params] = inner.args + return { + tokenIn: params.tokenIn, + tokenOut: params.tokenOut, + tier: params.fee, + recipient: params.recipient, + amountIn: params.amountIn, + amountOutMinimum: params.amountOutMinimum, + sqrtPriceLimitX96: params.sqrtPriceLimitX96, + deadline, + outer: [...calls], + } +} + +function fixture( + chain: 'avalanche' | 'monad', + pinned: Pick< + RouterFixture, + | 'chainId' + | 'otherChainId' + | 'router' + | 'quoter' + | 'nativeSymbol' + | 'wrapped' + | 'stable' + | 'stable2' + > +): RouterFixture { + return { + label: `Uniswap on ${chain}`, + venue: (reader, client) => + createUniswapVenue(chain, { reader: () => reader, client: () => client }), + ...pinned, + bestTier: 3000, + worseTier: 500, + unverifiedTier: 2500, + tierOf: (params) => params['fee'] as number, + encodeSwap, + encodeOuter, + deadlineIn: 'outer', + decodeSwap, + } +} + +describeV3SwapVenue( + fixture('avalanche', { + chainId: 43114, + otherChainId: 143, + router: '0xbb00FF08d01D300023C629E8fFfFcb65A5a578cE', + quoter: '0xbe0F5544EC67e9B3b2D979aaA43f18Fd87E6257F', + nativeSymbol: 'AVAX', + wrapped: { symbol: 'WAVAX', id: '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7' }, + stable: { symbol: 'USDC', id: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E' }, + stable2: { symbol: 'USDT', id: '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7' }, + }) +) + +const MONAD = fixture('monad', { + chainId: 143, + otherChainId: 43114, + router: '0xfE31F71C1b106EAc32F1A19239c9a9A72ddfb900', + quoter: '0x661E93cca42AfacB172121EF892830cA3b70F08d', + nativeSymbol: 'MON', + wrapped: { symbol: 'WMON', id: '0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A' }, + stable: { symbol: 'USDC', id: '0x754704Bc059F8C67012fEd69BC8A327a5aafb603' }, + stable2: { symbol: 'USDT0', id: '0xe7cd86e13AC4309349F30B3435a9d337750fC82D' }, +}) + +describeV3SwapVenue(MONAD) + +describe('Uniswap encoding', () => { + it('uses the selectors in the deployed bytecode', () => { + expect(toFunctionSelector(ROUTER_ABI[0])).toBe('0x04e45aaf') + expect(toFunctionSelector(ROUTER_ABI[1])).toBe('0x5ae401dc') + }) + + it('has one id for each chain', () => { + expect(createUniswapVenue('avalanche').id).toBe('uniswap-avalanche') + expect(createUniswapVenue('monad').id).toBe('uniswap-monad') + }) + + it('only swaps', () => { + expect(createUniswapVenue('avalanche').kinds).toEqual(['swap']) + }) + + it('warns of Monad’s reserve when a swap would dip under it', async () => { + const { reader } = fakeReader(MONAD, { rate: { 3000: BigInt(900_000) } }) + const venue = createUniswapVenue('monad', { + reader: () => reader, + client: () => fakeClient({ native: BigInt('11000000000000000000') }), + }) + const prepared = await venue.prepare( + { kind: 'swap', amount: '5', tokenIn: 'MON', tokenOut: 'USDC' }, + ACCOUNT + ) + expect(prepared.notes.join(' ')).toContain('10 MON reserve') + }) + + it('states the risks on the card', async () => { + const { reader } = fakeReader(MONAD, { rate: { 3000: BigInt(900_000) } }) + const venue = createUniswapVenue('monad', { reader: () => reader, client: () => fakeClient() }) + const prepared = await venue.prepare( + { kind: 'swap', amount: '5', tokenIn: 'USDC', tokenOut: 'USDT0' }, + ACCOUNT + ) + expect(prepared.notes.join(' ')).toContain('can’t be upgraded or paused') + }) +}) diff --git a/apps/dapp/lib/__tests__/usd-value.test.ts b/apps/dapp/lib/__tests__/usd-value.test.ts new file mode 100644 index 00000000..b2827b1f --- /dev/null +++ b/apps/dapp/lib/__tests__/usd-value.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { usdValue } from '../server/usd-value' +import type { MarketPrice } from '../swap/price-types' + +const table = { + XLM: { usd: 0.2, source: 'reflector' }, + USDC: { usd: 1, source: 'reflector' }, + CETES: { usd: 0.05, source: 'fallback' }, +} as unknown as Record + +describe('valuing an amount', () => { + it('multiplies display units by a real price', () => { + expect(usdValue('XLM', '100', table)).toBeCloseTo(20) + expect(usdValue('USDC', '12.5', table)).toBe(12.5) + }) + + it('has no value for a fallback price or an unknown asset', () => { + expect(usdValue('CETES', '100', table)).toBeNaN() + expect(usdValue('DOGE', '1', table)).toBeNaN() + }) +}) diff --git a/apps/dapp/lib/__tests__/v3-swap-suite.ts b/apps/dapp/lib/__tests__/v3-swap-suite.ts new file mode 100644 index 00000000..895487b9 --- /dev/null +++ b/apps/dapp/lib/__tests__/v3-swap-suite.ts @@ -0,0 +1,610 @@ +import { + encodeAbiParameters, + encodeFunctionData, + erc20Abi, + keccak256, + maxUint256, + pad, + parseAbi, + parseAbiParameters, + toHex, + type Address, + type Hex, + type StateOverride, +} from 'viem' +import { describe, expect, it } from 'vitest' + +import type { EvmReaderClient } from '../chains/evm/call' +import type { EvmClient, EvmReceipt } from '../chains/evm/client' +import { ChainActionRefused, type ChainVenue, type SigningPayload } from '../chains/types' + +/** + * The refusals every v3-style swap venue owes, run against each router's own encoding. Each + * venue's test supplies that encoding by hand, so nothing here is built by the code it checks. + */ + +export const ACCOUNT = '0x1111111111111111111111111111111111111111' +export const STRANGER = '0x2222222222222222222222222222222222222222' +const NOT_A_TOKEN = '0x3333333333333333333333333333333333333333' +const HASH = `0x${'a'.repeat(64)}` +const ONE = BigInt(1) +const ZERO = BigInt(0) + +export interface SwapFields { + tokenIn: string + tokenOut: string + tier: number + recipient: string + amountIn: bigint + amountOutMinimum: bigint + sqrtPriceLimitX96: bigint + deadline: bigint +} + +export interface RouterFixture { + label: string + /** The venue with its reads replaced. */ + venue: (reader: EvmReaderClient, client: EvmClient) => ChainVenue + chainId: number + otherChainId: number + router: string + quoter: string + nativeSymbol: string + wrapped: { symbol: string; id: string } + /** A stablecoin, 6 decimals, and a second one. */ + stable: { symbol: string; id: string } + stable2: { symbol: string; id: string } + /** The best and second-best tiers the fake quoter answers for, then one the router has no pool at. */ + bestTier: number + worseTier: number + unverifiedTier: number + /** Reads a tier out of a quoter call's tuple. */ + tierOf: (params: Record) => number + encodeSwap: (fields: SwapFields) => Hex + /** Wraps calls the way this venue must send them. */ + encodeOuter: (calls: readonly Hex[], deadline: bigint) => Hex + /** Calls whose deadline is outer are wrapped with it, others hold it in the swap. */ + deadlineIn: 'outer' | 'swap' + decodeSwap: (data: Hex) => SwapFields & { outer: Hex[] } +} + +const REFUND_ABI = parseAbi(['function refundETH() payable']) +const REFUND = encodeFunctionData({ abi: REFUND_ABI, functionName: 'refundETH' }) +const SWEEP_ABI = parseAbi([ + 'function sweepToken(address token, uint256 amountMinimum, address recipient) payable', + 'function unwrapWETH9(uint256 amountMinimum, address recipient) payable', +]) + +interface FakeOptions { + /** Units out per unit in, by tier, as a whole-number ratio over 10^6. Absent is no pool. */ + rate: Record + /** Replaces the output for one input size, to play a thin pool. */ + outFor?: (tier: number, amountIn: bigint) => bigint | undefined + allowance?: bigint + callFails?: boolean + quoteFails?: boolean +} + +export function fakeReader(fixture: RouterFixture, options: FakeOptions) { + const seen: string[] = [] + const reader = { + simulateContract: async ({ + address, + functionName, + args, + }: { + address: string + functionName: string + args: readonly [Record] + }) => { + seen.push(`simulate:${functionName}`) + if (address.toLowerCase() !== fixture.quoter.toLowerCase()) throw new Error('not the quoter') + if (options.quoteFails === true) throw new Error('rpc down') + const params = args[0] + const tier = fixture.tierOf(params) + const amountIn = params['amountIn'] as bigint + const rate = options.rate[tier] + if (rate === undefined) throw new Error('no pool') + const replaced = options.outFor?.(tier, amountIn) + if (replaced !== undefined) return { result: [replaced, ZERO, 0, ZERO] } + return { result: [(amountIn * rate) / BigInt(1_000_000), ZERO, 0, ZERO] } + }, + readContract: async () => { + seen.push('read') + return options.allowance ?? ZERO + }, + call: async () => { + seen.push('call') + if (options.callFails === true) throw new Error('reverted') + return { data: '0x' } + }, + } + return { reader: reader as unknown as EvmReaderClient, seen } +} + +export function fakeClient( + overrides: { + balance?: bigint + native?: bigint + receipt?: Partial + sent?: { input: string; value: bigint } + } = {} +): EvmClient { + return { + tokenBalance: async () => overrides.balance ?? BigInt('1000000000000000000000000'), + nativeBalance: async () => overrides.native ?? BigInt('1000000000000000000000000'), + waitForReceipt: async () => + ({ status: 'success', from: ACCOUNT, to: '', logs: [], ...overrides.receipt }) as EvmReceipt, + transaction: async () => overrides.sent ?? { input: '0x', value: ZERO }, + } +} + +const FUTURE = () => BigInt(Math.floor(Date.now() / 1000) + 600) + +export function describeV3SwapVenue(fixture: RouterFixture): void { + const { + router, + chainId, + otherChainId, + wrapped, + stable, + stable2, + nativeSymbol, + bestTier, + worseTier, + } = fixture + const rates = { [bestTier]: BigInt(900_000), [worseTier]: BigInt(800_000) } + + function venue( + options: Partial = {}, + client: EvmClient = fakeClient() + ): { venue: ChainVenue; seen: string[] } { + const { reader, seen } = fakeReader(fixture, { rate: rates, ...options }) + return { venue: fixture.venue(reader, client), seen } + } + + function fields(overrides: Partial = {}): SwapFields { + return { + tokenIn: stable.id, + tokenOut: wrapped.id, + tier: bestTier, + recipient: ACCOUNT, + amountIn: BigInt(10_000_000), + amountOutMinimum: BigInt(1_000), + sqrtPriceLimitX96: ZERO, + deadline: FUTURE(), + ...overrides, + } + } + + function swapCall(overrides: Partial = {}): Hex { + return fixture.encodeSwap(fields(overrides)) + } + + function outer(calls: readonly Hex[], deadline = FUTURE()): Hex { + return fixture.encodeOuter(calls, deadline) + } + + function payload( + overrides: Partial> = {}, + swap: Partial = {} + ): Extract { + return { + family: 'evm', + chainId, + to: router, + data: outer([swapCall(swap)]), + value: '0', + ...overrides, + } + } + + function approve(spender: string = router, units = BigInt(10_000_000)): string { + return encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender as Address, units], + }) + } + + function nativePayload( + overrides: Partial = {}, + value = '1000000000000000000' + ): Extract { + const swap = swapCall({ + tokenIn: wrapped.id, + tokenOut: stable.id, + amountIn: BigInt('1000000000000000000'), + ...overrides, + }) + return payload({ data: outer([swap, REFUND]), value }) + } + + describe(`${fixture.label}: quoting`, () => { + const request = { + kind: 'swap' as const, + amount: '10', + tokenIn: stable.symbol, + tokenOut: stable2.symbol, + } + + it('gives the best tier’s output, and only reads', async () => { + const { venue: v, seen } = venue() + const quoted = await v.quote?.(request, ACCOUNT) + expect(quoted?.receive).toEqual({ amount: '9', symbol: stable2.symbol }) + expect(seen.every((step) => step === 'simulate:quoteExactInputSingle')).toBe(true) + }) + + it('refuses when no tier can be read, rather than guessing', async () => { + await expect(venue({ quoteFails: true }).venue.quote?.(request, ACCOUNT)).rejects.toThrow( + ChainActionRefused + ) + await expect(venue({ rate: {} }).venue.quote?.(request, ACCOUNT)).rejects.toThrow( + ChainActionRefused + ) + }) + + it('refuses a pool too thin to take the size', async () => { + const thin = (_tier: number, amountIn: bigint) => + amountIn === BigInt(10_000_000) ? BigInt(1_000_000) : undefined + await expect(venue({ outFor: thin }).venue.quote?.(request, ACCOUNT)).rejects.toThrow( + /isn't enough liquidity/ + ) + }) + + it('allows the ordinary small drop a real pool shows', async () => { + const deep = (_tier: number, amountIn: bigint) => + amountIn === BigInt(10_000_000) ? BigInt(8_800_000) : undefined + await expect(venue({ outFor: deep }).venue.quote?.(request, ACCOUNT)).resolves.toBeDefined() + }) + + it('refuses an amount too small to price', async () => { + await expect( + venue().venue.quote?.({ ...request, amount: '0.000001' }, ACCOUNT) + ).rejects.toThrow(ChainActionRefused) + }) + + it.each([ + ['an unknown token in', { tokenIn: 'SCAM' }], + ['an unknown token out', { tokenOut: 'SCAM' }], + ['no token out', { tokenOut: undefined }], + ['the same token twice', { tokenOut: stable.symbol }], + ['a kind this venue does not do', { kind: 'supply' as const }], + ['the native coin out', { tokenOut: nativeSymbol }], + [ + 'the native coin into its own wrapped form', + { tokenIn: nativeSymbol, tokenOut: wrapped.symbol }, + ], + ])('refuses %s', async (_label, change) => { + await expect(venue().venue.quote?.({ ...request, ...change }, ACCOUNT)).rejects.toThrow( + ChainActionRefused + ) + }) + }) + + describe(`${fixture.label}: preparing`, () => { + const request = { + kind: 'swap' as const, + amount: '10', + tokenIn: stable.symbol, + tokenOut: stable2.symbol, + } + // Between 6-decimal coins, the fake's 0.9 rate gives 9 out. + const OUT = BigInt(9_000_000) + + it('sets the minimum no more than 1% under the quote, paying the person', async () => { + const prepared = await venue({ allowance: BigInt(10_000_000) }).venue.prepare( + request, + ACCOUNT + ) + if (prepared.signing.family !== 'evm') throw new Error('not evm') + const swap = fixture.decodeSwap(prepared.signing.data as Hex) + expect(swap.recipient.toLowerCase()).toBe(ACCOUNT) + expect(swap.tier).toBe(bestTier) + expect(swap.amountIn).toBe(BigInt(10_000_000)) + expect(swap.amountOutMinimum >= (OUT * BigInt(99)) / BigInt(100)).toBe(true) + expect(swap.amountOutMinimum < OUT).toBe(true) + expect(swap.sqrtPriceLimitX96).toBe(ZERO) + expect(prepared.signing.value).toBe('0') + expect(prepared.signing.to.toLowerCase()).toBe(router.toLowerCase()) + expect(prepared.receive).toEqual({ amount: '9', symbol: stable2.symbol, approximate: true }) + expect(prepared.pay).toEqual({ amount: '10', symbol: stable.symbol }) + expect(prepared.kind).toBe('swap') + }) + + it('never overstates what the quote will pay', async () => { + const prepared = await venue({ + allowance: BigInt(10_000_000), + rate: { [bestTier]: BigInt(899_999) }, + }).venue.prepare(request, ACCOUNT) + expect(prepared.receive?.amount).toBe('8.99999') + }) + + it('asks for an exact approval when none is in place, and says so', async () => { + const prepared = await venue({ allowance: BigInt(1) }).venue.prepare(request, ACCOUNT) + if (prepared.signing.family !== 'evm') throw new Error('not evm') + expect(prepared.signing.approval?.to.toLowerCase()).toBe(stable.id.toLowerCase()) + expect(prepared.signing.approval?.data).toBe(approve()) + expect(prepared.notes.join(' ')).toContain('exactly 10') + }) + + it('leaves the approval off when one is in place', async () => { + const prepared = await venue({ allowance: BigInt(10_000_000) }).venue.prepare( + request, + ACCOUNT + ) + if (prepared.signing.family !== 'evm') throw new Error('not evm') + expect(prepared.signing.approval).toBeUndefined() + }) + + it('refuses a call the chain says would fail', async () => { + await expect( + venue({ allowance: BigInt(10_000_000), callFails: true }).venue.prepare(request, ACCOUNT) + ).rejects.toThrow(ChainActionRefused) + }) + + it('refuses when the wallet holds too little', async () => { + await expect( + venue({}, fakeClient({ balance: BigInt(5) })).venue.prepare(request, ACCOUNT) + ).rejects.toThrow(ChainActionRefused) + }) + + it('refuses an account that is not an address', async () => { + await expect(venue().venue.prepare(request, 'nope')).rejects.toThrow(ChainActionRefused) + }) + + it('refuses an address whose checksum is wrong', async () => { + const mistyped = '0x00000000000000000000000000000000000fEEd1' + await expect(venue().venue.prepare(request, mistyped)).rejects.toThrow(ChainActionRefused) + }) + + it('swaps the native coin in as value, wrapped by the router, with the rest refunded', async () => { + const prepared = await venue().venue.prepare( + { kind: 'swap', amount: '1', tokenIn: nativeSymbol, tokenOut: stable.symbol }, + ACCOUNT + ) + if (prepared.signing.family !== 'evm') throw new Error('not evm') + expect(prepared.signing.value).toBe('1000000000000000000') + expect(prepared.signing.approval).toBeUndefined() + const swap = fixture.decodeSwap(prepared.signing.data as Hex) + expect(swap.tokenIn.toLowerCase()).toBe(wrapped.id.toLowerCase()) + expect(swap.amountIn).toBe(BigInt('1000000000000000000')) + expect(swap.outer[1]).toBe(REFUND) + expect(prepared.pay.symbol).toBe(nativeSymbol) + }) + + it('says plainly when what comes back is the wrapped token', async () => { + const prepared = await venue().venue.prepare( + { kind: 'swap', amount: '10', tokenIn: stable.symbol, tokenOut: wrapped.symbol }, + ACCOUNT + ) + expect(prepared.notes.join(' ')).toContain(`wrapped form of ${nativeSymbol}`) + }) + + it('states the slippage floor on the card', async () => { + const prepared = await venue().venue.prepare(request, ACCOUNT) + expect(prepared.notes.join(' ')).toContain(`at least 8.955 ${stable2.symbol}`) + }) + + it('passes its own check', async () => { + const v = venue().venue + const prepared = await v.prepare(request, ACCOUNT) + expect(() => v.assertSafe(prepared.signing, ACCOUNT)).not.toThrow() + }) + }) + + describe(`${fixture.label}: assertSafe`, () => { + const safe = (signing: SigningPayload) => venue().venue.assertSafe(signing, ACCOUNT) + + it('passes a swap of a listed pair to the person', () => { + expect(() => safe(payload())).not.toThrow() + }) + + it('passes a native swap with its refund', () => { + expect(() => safe(nativePayload())).not.toThrow() + }) + + it('passes an exact approval of the token in', () => { + expect(() => safe(payload({ approval: { to: stable.id, data: approve() } }))).not.toThrow() + }) + + it.each<[string, () => SigningPayload]>([ + ['another family', () => ({ family: 'sui', transaction: 'AA==' })], + ['another chain', () => payload({ chainId: otherChainId })], + ['an unknown contract', () => payload({ to: NOT_A_TOKEN })], + ['the quoter as the target', () => payload({ to: fixture.quoter })], + ['a token contract as the target', () => payload({ to: stable.id })], + ['a bare swap outside the multicall', () => payload({ data: swapCall() })], + [ + 'a function it may not call', + () => + payload({ + data: encodeFunctionData({ + abi: SWEEP_ABI, + functionName: 'sweepToken', + args: [stable.id as Address, ZERO, STRANGER as Address], + }), + }), + ], + ['calldata that decodes as nothing', () => payload({ data: '0xdeadbeef' })], + ['anything after the arguments', () => payload({ data: `${outer([swapCall()])}00` })], + ['anything after the inner call', () => payload({ data: outer([`${swapCall()}00`]) })], + ['an empty multicall', () => payload({ data: outer([]) })], + ['a second swap', () => payload({ data: outer([swapCall(), swapCall()]) })], + [ + 'a sweep to a stranger after the swap', + () => + payload({ + data: outer([ + swapCall(), + encodeFunctionData({ + abi: SWEEP_ABI, + functionName: 'sweepToken', + args: [wrapped.id as Address, ZERO, STRANGER as Address], + }), + ]), + }), + ], + [ + 'an unwrap to a stranger instead of a refund', + () => + payload({ + value: '1', + data: outer([ + swapCall({ tokenIn: wrapped.id, tokenOut: stable.id, amountIn: ONE }), + encodeFunctionData({ + abi: SWEEP_ABI, + functionName: 'unwrapWETH9', + args: [ZERO, STRANGER as Address], + }), + ]), + }), + ], + ['a refund first', () => payload({ data: outer([REFUND, swapCall()]) })], + ['a refund with no value', () => payload({ data: outer([swapCall(), REFUND]) })], + ['payment to someone else', () => payload({}, { recipient: STRANGER })], + [ + 'the router’s own stand-in for the sender', + () => payload({}, { recipient: `0x${'0'.repeat(39)}1` }), + ], + [ + 'the router itself as the recipient', + () => payload({}, { recipient: `0x${'0'.repeat(39)}2` }), + ], + ['a token in that is not listed', () => payload({}, { tokenIn: NOT_A_TOKEN })], + ['a token out that is not listed', () => payload({}, { tokenOut: NOT_A_TOKEN })], + ['the same token both ways', () => payload({}, { tokenOut: stable.id })], + ['a tier that is not verified', () => payload({}, { tier: fixture.unverifiedTier })], + ['no minimum out', () => payload({}, { amountOutMinimum: ZERO })], + [ + 'a zero amount in, which spends the router’s balance', + () => payload({}, { amountIn: ZERO }), + ], + [ + 'a price limit that could stop the swap short', + () => payload({}, { sqrtPriceLimitX96: ONE }), + ], + [ + 'a deadline far in the future', + () => + fixture.deadlineIn === 'outer' + ? payload({ data: outer([swapCall()], FUTURE() + BigInt(86_400)) }) + : payload({}, { deadline: FUTURE() + BigInt(86_400) }), + ], + [ + 'value with a token that is not the wrapped coin', + () => nativePayload({ tokenIn: stable2.id }), + ], + ['value short of the amount', () => nativePayload({}, '999')], + ['value over the amount', () => nativePayload({}, '1000000000000000001')], + ['value with no refund', () => payload({ value: '1' })], + ['a value that is not digits', () => payload({ value: '0x1' })], + [ + 'value and an approval together', + () => ({ + ...nativePayload(), + approval: { to: wrapped.id, data: approve(router, BigInt('1000000000000000000')) }, + }), + ], + [ + 'an unlimited approval', + () => payload({ approval: { to: stable.id, data: approve(router, maxUint256) } }), + ], + [ + 'an approval above the swap', + () => payload({ approval: { to: stable.id, data: approve(router, BigInt(10_000_001)) } }), + ], + [ + 'an approval below the swap', + () => payload({ approval: { to: stable.id, data: approve(router, BigInt(9_999_999)) } }), + ], + [ + 'an approval to a stranger', + () => payload({ approval: { to: stable.id, data: approve(STRANGER) } }), + ], + [ + 'an approval of another listed token', + () => payload({ approval: { to: stable2.id, data: approve() } }), + ], + [ + 'an approval of an unlisted token', + () => payload({ approval: { to: NOT_A_TOKEN, data: approve() } }), + ], + [ + 'an approval with trailing data', + () => payload({ approval: { to: stable.id, data: `${approve()}00` } }), + ], + ])('refuses %s', (_label, build) => { + expect(() => safe(build())).toThrow(ChainActionRefused) + }) + + it('refuses a swap built for another account', () => { + expect(() => venue().venue.assertSafe(payload(), STRANGER)).toThrow(ChainActionRefused) + }) + }) + + describe(`${fixture.label}: after the wallet sends`, () => { + const signed = { family: 'evm' as const, hash: HASH } + const submitWith = (client: EvmClient) => + venue({}, client).venue.submit?.({ + account: ACCOUNT, + venueId: venue().venue.id, + signed, + }) + + it('settles a landed swap of this venue’s, from this person, once past its deadline', async () => { + const past = BigInt(Math.floor(Date.now() / 1000) - 3_600) + const data = outer([swapCall({ deadline: past })], past) + const result = await submitWith( + fakeClient({ receipt: { to: router as Address }, sent: { input: data, value: ZERO } }) + ) + expect(result).toMatchObject({ ok: true, hash: HASH }) + }) + + it('stays pending for a transaction that pays someone else', async () => { + const data = outer([swapCall({ recipient: STRANGER })]) + const result = await submitWith( + fakeClient({ receipt: { to: router as Address }, sent: { input: data, value: ZERO } }) + ) + expect(result).toMatchObject({ ok: false, pending: true }) + }) + }) +} + +/** + * The real chain, except that an account holds what `stateOverride` gives it, so a live test can try + * a swap with no wallet. It's an `eth_call`, so nothing is sent. + */ +export function fundedReader( + reader: EvmReaderClient, + stateOverride: StateOverride, + allowance: bigint +): EvmReaderClient { + return { + simulateContract: reader.simulateContract.bind(reader), + readContract: async () => allowance, + call: (args: Parameters[0]) => reader.call({ ...args, stateOverride }), + } as unknown as EvmReaderClient +} + +/** WETH9's storage: `balanceOf` at slot 3 and `allowance` at slot 4. */ +export function wethHolding( + account: string, + spender: string, + units: bigint +): { slot: Hex; value: Hex }[] { + const value = pad(toHex(units), { size: 32 }) + const balanceSlot = keccak256( + encodeAbiParameters(parseAbiParameters('address, uint256'), [account as Address, BigInt(3)]) + ) + const outerSlot = keccak256( + encodeAbiParameters(parseAbiParameters('address, uint256'), [account as Address, BigInt(4)]) + ) + const allowanceSlot = keccak256( + encodeAbiParameters(parseAbiParameters('address, bytes32'), [spender as Address, outerSlot]) + ) + return [ + { slot: balanceSlot, value }, + { slot: allowanceSlot, value }, + ] +} diff --git a/apps/dapp/lib/__tests__/venue-integration.test.ts b/apps/dapp/lib/__tests__/venue-integration.test.ts index 5e598052..600bb026 100644 --- a/apps/dapp/lib/__tests__/venue-integration.test.ts +++ b/apps/dapp/lib/__tests__/venue-integration.test.ts @@ -132,14 +132,16 @@ describe('DeFindex is claimed as far as it has been proven', () => { describe('the integrated venues are on the right chain', () => { it('keeps every executing venue on a chain that can execute', () => { - // An EVM venue marked executable would send a user to a confirm screen that - // cannot build anything: EVM chains connect but have no signing path yet. + // Of the EVM chains only Avalanche and Monad can sign. Anywhere else, a venue marked executable + // would send a user to a confirm screen that cannot build anything. for (const v of venues) { - if (v.integration === 'executes') { - expect( - ['stellar', 'solana', 'sui', 'near'], - `${v.id} claims execution but is ${v.family}` - ).toContain(v.family) + if (v.integration !== 'executes') continue + expect( + ['stellar', 'solana', 'sui', 'near', 'evm'], + `${v.id} claims execution but is ${v.family}` + ).toContain(v.family) + if (v.family === 'evm') { + expect(v.chains.every((chain) => chain === 'Avalanche' || chain === 'Monad')).toBe(true) } } }) diff --git a/apps/dapp/lib/__tests__/walrus.live.test.ts b/apps/dapp/lib/__tests__/walrus.live.test.ts new file mode 100644 index 00000000..bde6287f --- /dev/null +++ b/apps/dapp/lib/__tests__/walrus.live.test.ts @@ -0,0 +1,140 @@ +import { fromBase64, normalizeSuiAddress } from '@mysten/sui/utils' +import { describe, expect, it } from 'vitest' + +import { suiClient } from '../chains/sui/client' +import { + STAKED_WAL_TYPE, + WAL_TYPE, + WALRUS_ORIGINAL_PACKAGE, + WALRUS_PACKAGE, + WALRUS_STAKING, + WALRUS_VERSION, + walrusVenue, +} from '../chains/sui/walrus' +import { ChainActionRefused } from '../chains/types' + +/** + * Read-only against mainnet: pinned ids, the exact Move signatures, and a real build and dry run of + * each step for public addresses whose keys nobody here has. Nothing signs or sends. When one of + * those addresses moves its funds, swap it for another holder. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const WAL_HOLDER = '0x61e95e5ebdcee91c1b8aa176fbd88e174428c3473015614972df20840f975947' +const STAKE_HOLDER = '0x054e676c2df14006de28868484740dfa2ac1b04e23f6ce97bc9f04c6a7144ce9' +const TIMEOUT = 60_000 + +async function dryRunSucceeds(transaction: string): Promise { + const result = await suiClient().core.simulateTransaction({ + transaction: fromBase64(transaction), + include: { effects: true }, + }) + return result.Transaction?.effects?.status.success === true +} + +describe.skipIf(SKIP)('Walrus against mainnet', () => { + const client = suiClient() + + it( + 'the staking object is shared and names the pinned package', + async () => { + const { object } = await client.core.getObject({ + objectId: WALRUS_STAKING, + include: { json: true }, + }) + expect(object.type).toBe(`${WALRUS_ORIGINAL_PACKAGE}::staking::Staking`) + expect(object.owner.$kind).toBe('Shared') + expect(object.json).toMatchObject({ + package_id: WALRUS_PACKAGE, + version: WALRUS_VERSION, + new_package_id: null, + }) + }, + TIMEOUT + ) + + it( + 'WAL has the pinned coin type and decimals', + async () => { + const { coinMetadata } = await client.core.getCoinMetadata({ coinType: WAL_TYPE }) + expect(coinMetadata).toMatchObject({ symbol: 'WAL', decimals: 9 }) + }, + TIMEOUT + ) + + it.each([ + ['staking', 'stake_with_pool', 4], + ['staking', 'request_withdraw_stake', 3], + ['staking', 'withdraw_stake', 3], + ['staked_wal', 'split', 3], + ])( + '%s::%s is public with the parameters it had when checked', + async (moduleName, name, parameters) => { + const { function: fn } = await client.core.getMoveFunction({ + packageId: WALRUS_PACKAGE, + moduleName, + name, + }) + expect(fn.visibility).toBe('public') + expect(fn.parameters).toHaveLength(parameters) + expect(fn.typeParameters).toHaveLength(0) + }, + TIMEOUT + ) + + it( + 'a stake builds, dry-runs and passes its own check', + async () => { + const prepared = await walrusVenue.prepare( + { kind: 'stake', amount: '1', tokenIn: 'WAL' }, + WAL_HOLDER + ) + expect(() => walrusVenue.assertSafe(prepared.signing, WAL_HOLDER)).not.toThrow() + expect(prepared.signing.family).toBe('sui') + if (prepared.signing.family !== 'sui') return + expect(await dryRunSucceeds(prepared.signing.transaction)).toBe(true) + console.log(prepared.summary, prepared.notes[1]) + }, + TIMEOUT + ) + + it( + 'a started withdrawal builds, dry-runs and passes its own check', + async () => { + const prepared = await walrusVenue.prepare( + { kind: 'unstake', amount: '1', tokenIn: 'WAL' }, + STAKE_HOLDER + ) + expect(() => walrusVenue.assertSafe(prepared.signing, STAKE_HOLDER)).not.toThrow() + expect(prepared.signing.family).toBe('sui') + if (prepared.signing.family !== 'sui') return + expect(await dryRunSucceeds(prepared.signing.transaction)).toBe(true) + console.log(prepared.summary) + }, + TIMEOUT + ) + + it( + "another account's money is not the holder's to unstake", + async () => { + const empty = normalizeSuiAddress('0x1234') + await expect( + walrusVenue.prepare({ kind: 'unstake', amount: '1', tokenIn: 'WAL' }, empty) + ).rejects.toBeInstanceOf(ChainActionRefused) + }, + TIMEOUT + ) + + it( + "a holder's positions are found by type", + async () => { + const { objects } = await client.core.listOwnedObjects({ + owner: STAKE_HOLDER, + type: STAKED_WAL_TYPE, + limit: 1, + }) + expect(objects.length).toBeGreaterThan(0) + }, + TIMEOUT + ) +}) diff --git a/apps/dapp/lib/__tests__/walrus.test.ts b/apps/dapp/lib/__tests__/walrus.test.ts new file mode 100644 index 00000000..34dbd524 --- /dev/null +++ b/apps/dapp/lib/__tests__/walrus.test.ts @@ -0,0 +1,885 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +import { bcs } from '@mysten/sui/bcs' +import { Transaction } from '@mysten/sui/transactions' +import { normalizeSuiAddress, toBase64 } from '@mysten/sui/utils' +import { describe, expect, it, vi } from 'vitest' + +import { + assertWalrusSafe, + createWalrusVenue, + phaseOf, + pickNode, + planUnstake, + STAKED_WAL_TYPE, + stakingStateFrom, + WAL_TYPE, + WALRUS_ORIGINAL_PACKAGE, + WALRUS_PACKAGE, + WALRUS_STAKING, + WALRUS_TOKENS, + WALRUS_VERSION, + walPaid, + type Position, + type StakingState, + type WalrusDeps, +} from '../chains/sui/walrus' +import { walrusListing } from '../chains/sui/walrus-listing' +import { ChainActionRefused, type ChainActionRequest } from '../chains/types' + +const ME = '0x00000000000000000000000000000000000000000000000000000000000000a1' +const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead' +const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01' +const NODE_A = '0x00000000000000000000000000000000000000000000000000000000000000aa' +const NODE_B = '0x00000000000000000000000000000000000000000000000000000000000000bb' +const NODE_C = '0x00000000000000000000000000000000000000000000000000000000000000cc' +const GONE = '0x00000000000000000000000000000000000000000000000000000000000000ee' +const DIGEST = '11111111111111111111111111111111' +const WAL = BigInt('1000000000') +const shards = (n: number) => Array.from({ length: n }, (_, i) => i) + +function committeeJson(sizes: Record) { + return { + pos0: { contents: Object.entries(sizes).map(([key, n]) => ({ key, value: shards(n) })) }, + } +} + +function innerJson(next: Record | null = null, epoch = 43) { + return { + id: '0x1', + name: '4', + value: { + epoch, + n_shards: 1000, + epoch_duration: '1209600000', + first_epoch_start: '1742914804744', + committee: committeeJson({ [NODE_A]: 26, [NODE_B]: 7, [NODE_C]: 3 }), + next_committee: next === null ? null : committeeJson(next), + }, + } +} + +const STATE: StakingState = stakingStateFrom(innerJson()) + +function poolJson( + id: string, + name: string, + commission: number, + pending: { key: number; value: string }[] = [], + state: unknown = { '@variant': 'Active' } +) { + return { + id, + state, + node_info: { name }, + commission_rate: commission, + pending_commission_rate: { pos0: { contents: pending } }, + } +} + +const STAKING_JSON = { + id: WALRUS_STAKING, + version: WALRUS_VERSION, + package_id: WALRUS_PACKAGE, + new_package_id: null, +} + +function ownedObject( + id: string, + principal: bigint, + state: unknown, + node = NODE_A, + activation = 10, + type = STAKED_WAL_TYPE +) { + return { + objectId: id, + version: '5', + digest: DIGEST, + type, + json: { + id, + state, + node_id: node, + principal: principal.toString(), + activation_epoch: activation, + }, + } +} + +const STAKED = { '@variant': 'Staked' } +const withdrawing = (epoch: number) => ({ '@variant': 'Withdrawing', withdraw_epoch: epoch }) + +interface FakeChain { + staking?: unknown + inner?: unknown + pools?: unknown[] + owned?: unknown[] + simulated?: unknown +} + +function venueOver(chain: FakeChain = {}) { + const built: Transaction[] = [] + const core = { + getObject: vi.fn(async ({ objectId }: { objectId: string }) => ({ + object: { + json: + objectId === WALRUS_STAKING + ? (chain.staking ?? STAKING_JSON) + : (chain.inner ?? innerJson()), + }, + })), + getObjects: vi.fn(async () => ({ + objects: ( + chain.pools ?? [poolJson(NODE_A, 'Big Node', 6000), poolJson(NODE_B, 'Cheap Node', 60)] + ).map((json) => ({ json })), + })), + listOwnedObjects: vi.fn(async () => ({ + objects: chain.owned ?? [], + hasNextPage: false, + cursor: null, + })), + simulateTransaction: vi.fn( + async () => chain.simulated ?? { Transaction: { balanceChanges: [] } } + ), + } + const build = vi.fn(async (tx: Transaction) => { + built.push(tx) + fund(tx) + try { + return toBase64(await tx.build()) + } catch { + // A stake's coin is only resolved against a real chain. Its commands are read from `built`. + return 'AA==' + } + }) + const deps = { client: () => ({ core }), build } as unknown as WalrusDeps + return { venue: createWalrusVenue(deps), built, core, build } +} + +function fund(tx: Transaction): void { + tx.setGasPrice(1000) + tx.setGasBudget(10_000_000) + tx.setGasPayment([{ objectId: normalizeSuiAddress('0x99'), version: '1', digest: DIGEST }]) +} + +const stake = (amount: string, tokenIn = 'WAL'): ChainActionRequest => ({ + kind: 'stake', + amount, + tokenIn, +}) +const unstake = (amount: string, tokenIn = 'WAL'): ChainActionRequest => ({ + kind: 'unstake', + amount, + tokenIn, +}) + +function calls(tx: Transaction): string[] { + return tx.getData().commands.flatMap((c) => { + if (c.$kind !== 'MoveCall') return [] + return [`${c.MoveCall.module}::${c.MoveCall.function}`] + }) +} + +function position(overrides: Partial = {}): Position { + return { + objectId: normalizeSuiAddress('0x51'), + version: '5', + digest: DIGEST, + nodeId: normalizeSuiAddress(NODE_A), + principal: BigInt(10) * WAL, + activationEpoch: 10, + withdrawEpoch: undefined, + ...overrides, + } +} + +function stakingRef(tx: Transaction, objectId = WALRUS_STAKING) { + return tx.sharedObjectRef({ objectId, initialSharedVersion: '317862159', mutable: true }) +} + +function ownedRef(tx: Transaction, id: string) { + return tx.objectRef({ objectId: normalizeSuiAddress(id), version: '5', digest: DIGEST }) +} + +const STAKE = `${WALRUS_PACKAGE}::staking::stake_with_pool` +const REQUEST = `${WALRUS_PACKAGE}::staking::request_withdraw_stake` +const WITHDRAW = `${WALRUS_PACKAGE}::staking::withdraw_stake` +const SPLIT = `${WALRUS_PACKAGE}::staked_wal::split` + +async function built(edit: (tx: Transaction) => void, sender = ME): Promise { + const tx = new Transaction() + tx.setSender(sender) + fund(tx) + edit(tx) + return toBase64(await tx.build()) +} + +function goodStake(tx: Transaction, recipient = ME, staking = WALRUS_STAKING): void { + const [staked] = tx.moveCall({ + target: STAKE, + arguments: [stakingRef(tx, staking), ownedRef(tx, '0x77'), tx.pure.id(NODE_A)], + }) + tx.transferObjects([staked as never], recipient) +} + +function check(transaction: string, account = ME): void { + assertWalrusSafe({ family: 'sui', transaction }, account) +} + +describe('assertSafe', () => { + it('passes the exact staking calls', async () => { + await expect(built((tx) => goodStake(tx))).resolves.toBeDefined() + check(await built((tx) => goodStake(tx))) + check( + await built((tx) => { + tx.moveCall({ target: REQUEST, arguments: [stakingRef(tx), ownedRef(tx, '0x51')] }) + }) + ) + check( + await built((tx) => { + const [coin] = tx.moveCall({ + target: WITHDRAW, + arguments: [stakingRef(tx), ownedRef(tx, '0x51')], + }) + tx.transferObjects([coin as never], ME) + }) + ) + }) + + it('refuses a transaction from another account', async () => { + const transaction = await built((tx) => goodStake(tx), THIEF) + expect(() => check(transaction)).toThrow(/different account/) + }) + + it('refuses a fee paid by another account', async () => { + const transaction = await built((tx) => { + goodStake(tx) + tx.setGasOwner(THIEF) + }) + expect(() => check(transaction)).toThrow(/paid by a different account/) + }) + + it('refuses a StakedWal sent to someone else', async () => { + const transaction = await built((tx) => goodStake(tx, THIEF)) + expect(() => check(transaction)).toThrow(/send funds to another address/) + }) + + it('refuses a split part sent to someone else', async () => { + const transaction = await built((tx) => { + const [part] = tx.moveCall({ + target: SPLIT, + arguments: [ownedRef(tx, '0x51'), tx.pure.u64(WAL)], + }) + tx.transferObjects([part as never], THIEF) + }) + expect(() => check(transaction)).toThrow(/send funds to another address/) + }) + + it('refuses WAL paid out to someone else', async () => { + const transaction = await built((tx) => { + const [coin] = tx.moveCall({ + target: WITHDRAW, + arguments: [stakingRef(tx), ownedRef(tx, '0x51')], + }) + tx.transferObjects([coin as never], THIEF) + }) + expect(() => check(transaction)).toThrow(/send funds to another address/) + }) + + it('refuses public_transfer, which moves anything without a transfer command', async () => { + const transaction = await built((tx) => { + tx.moveCall({ + target: '0x2::transfer::public_transfer', + typeArguments: [STAKED_WAL_TYPE], + arguments: [ownedRef(tx, '0x51'), tx.pure.address(THIEF)], + }) + }) + expect(() => check(transaction)).toThrow(/public_transfer/) + }) + + it('refuses an unknown contract with the same module and function', async () => { + const transaction = await built((tx) => { + tx.moveCall({ + target: `${FOREIGN}::staking::stake_with_pool`, + arguments: [stakingRef(tx), ownedRef(tx, '0x77'), tx.pure.id(NODE_A)], + }) + }) + expect(() => check(transaction)).toThrow(/haven't confirmed/) + }) + + it('refuses the package as it was before the upgrade', async () => { + const transaction = await built((tx) => { + tx.moveCall({ + target: `${WALRUS_ORIGINAL_PACKAGE}::staking::withdraw_stake`, + arguments: [stakingRef(tx), ownedRef(tx, '0x51')], + }) + }) + expect(() => check(transaction)).toThrow(/haven't confirmed/) + }) + + it.each([ + ['staking', 'set_next_commission'], + ['staking', 'register_candidate'], + ['staking', 'set_commission_receiver'], + ['staked_wal', 'join'], + ['system', 'reserve_space'], + ])('refuses %s::%s, which is not a staking call this venue makes', async (module, fn) => { + const transaction = await built((tx) => { + tx.moveCall({ + target: `${WALRUS_PACKAGE}::${module}::${fn}`, + arguments: [stakingRef(tx)], + }) + }) + expect(() => check(transaction)).toThrow(/haven't confirmed/) + }) + + it('refuses extra arguments', async () => { + const transaction = await built((tx) => { + tx.moveCall({ + target: WITHDRAW, + arguments: [stakingRef(tx), ownedRef(tx, '0x51'), tx.pure.address(THIEF)], + }) + }) + expect(() => check(transaction)).toThrow(/does not match/) + }) + + it('refuses a missing argument', async () => { + const transaction = await built((tx) => { + tx.moveCall({ target: WITHDRAW, arguments: [stakingRef(tx)] }) + }) + expect(() => check(transaction)).toThrow(/does not match/) + }) + + it('refuses a type argument, since no staking call takes one', async () => { + const transaction = await built((tx) => { + tx.moveCall({ + target: WITHDRAW, + typeArguments: [WAL_TYPE], + arguments: [stakingRef(tx), ownedRef(tx, '0x51')], + }) + }) + expect(() => check(transaction)).toThrow(/does not match/) + }) + + it('refuses a staking object other than the Walrus one', async () => { + const transaction = await built((tx) => goodStake(tx, ME, FOREIGN)) + expect(() => check(transaction)).toThrow(/staking object/) + }) + + it('refuses a staking object passed as something owned', async () => { + const transaction = await built((tx) => { + tx.moveCall({ + target: REQUEST, + arguments: [ownedRef(tx, WALRUS_STAKING), ownedRef(tx, '0x51')], + }) + }) + expect(() => check(transaction)).toThrow(/staking object/) + }) + + it('refuses a stake whose node is not a whole id', async () => { + const transaction = await built((tx) => { + const [staked] = tx.moveCall({ + target: STAKE, + arguments: [stakingRef(tx), ownedRef(tx, '0x77'), tx.pure(bcs.u64().serialize(1))], + }) + tx.transferObjects([staked as never], ME) + }) + expect(() => check(transaction)).toThrow(/storage node/) + }) + + it('refuses a stake whose node is not literal', async () => { + const transaction = await built((tx) => { + const [staked] = tx.moveCall({ + target: STAKE, + arguments: [stakingRef(tx), ownedRef(tx, '0x77'), ownedRef(tx, '0x78')], + }) + tx.transferObjects([staked as never], ME) + }) + expect(() => check(transaction)).toThrow(/storage node/) + }) + + it('refuses a split of nothing', async () => { + const transaction = await built((tx) => { + tx.moveCall({ target: SPLIT, arguments: [ownedRef(tx, '0x51'), tx.pure.u64(0)] }) + }) + expect(() => check(transaction)).toThrow(/amount/) + }) + + it('refuses a payload that is not Sui, or not bytes', () => { + expect(() => assertWalrusSafe({ family: 'solana', transaction: 'AA==' }, ME)).toThrow( + ChainActionRefused + ) + expect(() => check('{"not":"bcs"}')).toThrow(ChainActionRefused) + expect(() => check('')).toThrow(ChainActionRefused) + }) + + it('refuses a malformed account', async () => { + const transaction = await built((tx) => goodStake(tx)) + expect(() => check(transaction, 'nope')).toThrow(/not a Sui address/) + }) +}) + +describe('reading the staking state', () => { + it('reads the epoch and both committees', () => { + const state = stakingStateFrom(innerJson({ [NODE_A]: 30 })) + expect(state.epoch).toBe(43) + expect(state.epochDurationMs).toBe(1_209_600_000) + expect(state.committee.get(normalizeSuiAddress(NODE_B))).toBe(7) + expect(state.nextCommittee?.get(normalizeSuiAddress(NODE_A))).toBe(30) + }) + + it.each([ + ['nothing', undefined], + ['no value', { id: '0x1' }], + ['a zero epoch length', { value: { ...innerJson().value, epoch_duration: '0' } }], + ['a committee in another shape', { value: { ...innerJson().value, committee: [] } }], + ['a next committee in another shape', { value: { ...innerJson().value, next_committee: 7 } }], + ['no next committee field', { value: { ...innerJson().value, next_committee: undefined } }], + ])('refuses %s', (_name, json) => { + expect(() => stakingStateFrom(json)).toThrow(/Nothing was signed or sent/) + }) +}) + +describe('choosing a node', () => { + it('takes the lowest commission, not the largest node', () => { + const node = pickNode( + [poolJson(NODE_A, 'Big Node', 6000), poolJson(NODE_B, 'Cheap Node', 60)], + STATE + ) + expect(node).toEqual({ + id: normalizeSuiAddress(NODE_B), + name: 'Cheap Node', + shards: 7, + commissionBps: 60, + }) + }) + + it('breaks a tie on commission by shards', () => { + const node = pickNode([poolJson(NODE_B, 'Small', 6000), poolJson(NODE_A, 'Large', 6000)], STATE) + expect(node.name).toBe('Large') + }) + + it('leaves out a node too small to rely on', () => { + const node = pickNode([poolJson(NODE_A, 'Big Node', 6000), poolJson(NODE_C, 'Tiny', 0)], STATE) + expect(node.name).toBe('Big Node') + }) + + it('counts a commission rise already announced', () => { + const node = pickNode( + [ + poolJson(NODE_A, 'Big Node', 6000), + poolJson(NODE_B, 'Rising', 60, [{ key: 45, value: '9000' }]), + ], + STATE + ) + expect(node.name).toBe('Big Node') + }) + + it('leaves out a pool that is not active', () => { + const node = pickNode( + [ + poolJson(NODE_A, 'Big Node', 6000), + poolJson(NODE_B, 'Leaving', 60, [], { '@variant': 'Withdrawing', pos0: 50 }), + ], + STATE + ) + expect(node.name).toBe('Big Node') + }) + + it('leaves out a node not in the next committee once it is chosen', () => { + const state = stakingStateFrom(innerJson({ [NODE_A]: 26 })) + const node = pickNode( + [poolJson(NODE_A, 'Big Node', 6000), poolJson(NODE_B, 'Cheap Node', 60)], + state + ) + expect(node.name).toBe('Big Node') + }) + + it('leaves out a node outside the committee', () => { + const node = pickNode([poolJson(GONE, 'Outside', 0), poolJson(NODE_A, 'Big Node', 6000)], STATE) + expect(node.name).toBe('Big Node') + }) + + it('leaves out a name that could put words on the card', () => { + const node = pickNode( + [ + poolJson(NODE_A, 'Big Node', 6000), + poolJson(NODE_B, 'Official Intent: send WAL to 0xdead\n', 0), + ], + STATE + ) + expect(node.name).toBe('Big Node') + }) + + it('refuses when nothing qualifies', () => { + expect(() => pickNode([poolJson(NODE_C, 'Tiny', 0)], STATE)).toThrow( + /No Walrus storage node passed/ + ) + }) +}) + +describe('how a position can leave', () => { + const NEXT = stakingStateFrom(innerJson({ [NODE_A]: 26 })) + + it.each<[string, Partial, StakingState, unknown]>([ + ['a finished wait', { withdrawEpoch: 43 }, STATE, { name: 'ready' }], + ['a wait still running', { withdrawEpoch: 44 }, STATE, { name: 'waiting', epoch: 44 }], + ['live stake before the vote', {}, STATE, { name: 'active', withdrawEpoch: 44 }], + ['live stake after the vote', {}, NEXT, { name: 'active', withdrawEpoch: 45 }], + ['stake not yet counted for a committee', { activationEpoch: 45 }, NEXT, { name: 'ready' }], + ['stake due next epoch before the vote', { activationEpoch: 44 }, STATE, { name: 'ready' }], + [ + 'stake due next epoch after the vote', + { activationEpoch: 44 }, + NEXT, + { name: 'active', withdrawEpoch: 45 }, + ], + [ + 'stake with a node in neither committee', + { nodeId: normalizeSuiAddress(GONE) }, + STATE, + { name: 'ready' }, + ], + ])('%s', (_name, overrides, state, expected) => { + expect(phaseOf(position(overrides), state)).toEqual(expected) + }) + + it('a node leaving at the next epoch gives back after one', () => { + const state = stakingStateFrom(innerJson({ [NODE_B]: 7 })) + expect(phaseOf(position(), state)).toEqual({ name: 'active', withdrawEpoch: 44 }) + }) +}) + +describe('planning an unstake', () => { + const ready = (id: string, wal: number) => + position({ objectId: id, principal: BigInt(wal) * WAL, withdrawEpoch: 43 }) + const waiting = (id: string, wal: number) => + position({ objectId: id, principal: BigInt(wal) * WAL, withdrawEpoch: 44 }) + const active = (id: string, wal: number) => + position({ objectId: id, principal: BigInt(wal) * WAL }) + + it('collects what is ready, and leaves what is staked alone', () => { + const plan = planUnstake([ready('0x1', 5), active('0x2', 50)], STATE, BigInt(5) * WAL) + expect(plan.step).toBe('collect') + expect(plan.pieces).toHaveLength(1) + expect(plan.pieces[0]?.split).toBeUndefined() + }) + + it('splits what is ready when less is asked for', () => { + const plan = planUnstake([ready('0x1', 5)], STATE, BigInt(2) * WAL) + expect(plan.step).toBe('collect') + expect(plan.pieces[0]?.split).toBe(BigInt(2) * WAL) + }) + + it('starts a withdrawal when nothing is ready', () => { + const plan = planUnstake([active('0x2', 50)], STATE, BigInt(50) * WAL) + expect(plan).toMatchObject({ step: 'start', collectEpoch: 44, total: BigInt(50) * WAL }) + expect(plan.pieces[0]?.split).toBeUndefined() + }) + + it('never starts a second withdrawal of money already waiting', () => { + expect(() => + planUnstake([waiting('0x1', 5), active('0x2', 50)], STATE, BigInt(5) * WAL) + ).toThrow(/already on its way out.*epoch 44.*17 Nov 2026.*Nothing was signed/) + }) + + it('starts only the part not already waiting', () => { + const plan = planUnstake([waiting('0x1', 5), active('0x2', 50)], STATE, BigInt(8) * WAL) + expect(plan).toMatchObject({ step: 'start', total: BigInt(3) * WAL, waiting: BigInt(5) * WAL }) + }) + + it('refuses to mix collecting with starting', () => { + expect(() => planUnstake([ready('0x1', 5), active('0x2', 50)], STATE, BigInt(6) * WAL)).toThrow( + /5 WAL is ready to collect now/ + ) + }) + + it('refuses more than is staked', () => { + expect(() => planUnstake([active('0x2', 5)], STATE, BigInt(6) * WAL)).toThrow( + /You have 5 WAL staked/ + ) + }) + + it('refuses when nothing is staked', () => { + expect(() => planUnstake([], STATE, WAL)).toThrow(/no WAL staked/) + }) + + it('refuses a split the contract would abort', () => { + expect(() => planUnstake([active('0x2', 5)], STATE, WAL / BigInt(2))).toThrow(/under 1 WAL/) + expect(() => planUnstake([active('0x2', 5)], STATE, BigInt(5) * WAL - WAL / BigInt(2))).toThrow( + /under 1 WAL/ + ) + }) + + it('takes the largest position first', () => { + const plan = planUnstake([active('0x1', 2), active('0x2', 9)], STATE, BigInt(9) * WAL) + expect(plan.pieces.map((p) => p.position.objectId)).toEqual(['0x2']) + }) +}) + +describe('reading what a dry run pays', () => { + const change = (coinType: string, address: string, amount: string) => ({ + coinType, + address, + amount, + }) + + it('reads WAL to the account', () => { + expect(walPaid([change(WAL_TYPE, ME, '5')], ME)).toBe('5') + }) + + it.each([ + ['another coin', change('0x2::sui::SUI', ME, '5')], + ['someone else', change(WAL_TYPE, THIEF, '5')], + ['money leaving', change(WAL_TYPE, ME, '-5')], + ['nothing', change(WAL_TYPE, ME, '0')], + ])('ignores %s', (_name, entry) => { + expect(walPaid([entry], ME)).toBeUndefined() + }) +}) + +describe('staking', () => { + it('stakes WAL with the picked node and keeps the StakedWal with the person', async () => { + const { venue, built: txs } = venueOver() + const prepared = await venue.prepare(stake('10'), ME) + const tx = txs[0] as Transaction + expect(calls(tx)).toEqual(['staking::stake_with_pool']) + const data = tx.getData() + const transfer = data.commands.find((c) => c.$kind === 'TransferObjects') + expect(transfer).toBeDefined() + const address = transfer?.$kind === 'TransferObjects' ? transfer.TransferObjects.address : null + const input = address?.$kind === 'Input' ? data.inputs[address.Input] : undefined + expect(input?.$kind).toBe('Pure') + if (input?.$kind === 'Pure') { + const bytes = Uint8Array.from(Buffer.from(input.Pure.bytes, 'base64')) + expect(normalizeSuiAddress(bcs.Address.parse(bytes))).toBe(normalizeSuiAddress(ME)) + } + expect(prepared).toMatchObject({ + venueId: 'walrus', + kind: 'stake', + pay: { amount: '10', symbol: 'WAL' }, + receive: { amount: '10', symbol: 'StakedWAL', approximate: false }, + }) + expect(prepared.summary).toContain('Cheap Node') + expect(prepared.apy).toBeUndefined() + }) + + it('says which node, what it keeps, when it starts, and how to get out', async () => { + const { venue } = venueOver() + const { notes } = await venue.prepare(stake('10'), ME) + const text = notes.join(' ') + expect(text).toContain('Cheap Node') + expect(text).toContain(normalizeSuiAddress(NODE_B)) + expect(text).toContain('7 of 1000 shards') + expect(text).toContain('keeps 0.6% of your rewards') + expect(text).toContain('epoch 44, about 17 Nov 2026') + expect(text).toContain('An epoch lasts 14 days') + expect(text).toContain('up to 28 days') + expect(text).toContain("not a coin, so it can't be swapped") + expect(text).toContain('not showing a rate') + expect(text).toContain('emergency upgrade key') + }) + + it('states the later start once the next committee is chosen', async () => { + const { venue } = venueOver({ inner: innerJson({ [NODE_A]: 26, [NODE_B]: 7 }) }) + const { notes } = await venue.prepare(stake('10'), ME) + expect(notes.join(' ')).toContain('epoch 45') + }) + + it('refuses another token', async () => { + const { venue, build } = venueOver() + await expect(venue.prepare(stake('10', 'SUI'), ME)).rejects.toThrow(/takes WAL, not SUI/) + expect(build).not.toHaveBeenCalled() + }) + + it('refuses under the protocol minimum', async () => { + const { venue, build } = venueOver() + await expect(venue.prepare(stake('0.5'), ME)).rejects.toThrow(/at least 1 WAL/) + expect(build).not.toHaveBeenCalled() + }) + + it('refuses a malformed account', async () => { + const { venue } = venueOver() + await expect(venue.prepare(stake('10'), 'nope')).rejects.toThrow(ChainActionRefused) + }) + + it.each([ + ['a new package', { ...STAKING_JSON, package_id: FOREIGN }], + ['a new version', { ...STAKING_JSON, version: '5' }], + ['an upgrade under way', { ...STAKING_JSON, new_package_id: FOREIGN }], + ['an unreadable object', {}], + ])('stays blocked on %s', async (_name, staking) => { + const { venue, build } = venueOver({ staking }) + await expect(venue.prepare(stake('10'), ME)).rejects.toThrow(/stays blocked/) + await expect(venue.prepare(unstake('10'), ME)).rejects.toThrow(/stays blocked/) + expect(build).not.toHaveBeenCalled() + }) + + it('refuses when no node can be read', async () => { + const { venue } = venueOver({ pools: [] }) + await expect(venue.prepare(stake('10'), ME)).rejects.toThrow(/No Walrus storage node/) + }) + + it('skips a node that failed to read', async () => { + const { venue, core } = venueOver() + core.getObjects.mockResolvedValueOnce({ + objects: [new Error('gone'), { json: poolJson(NODE_A, 'Big Node', 6000) }], + } as never) + const prepared = await venue.prepare(stake('10'), ME) + expect(prepared.summary).toContain('Big Node') + }) + + it('quotes without building, and shows no rate', async () => { + const { venue, build } = venueOver() + const quote = await venue.quote?.(stake('10'), ME) + expect(quote).toEqual({ receive: { amount: '10', symbol: 'StakedWAL' } }) + expect(build).not.toHaveBeenCalled() + }) + + it('can follow a swap only into WAL', async () => { + const { venue } = venueOver() + await expect( + venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'WAL' }, ME) + ).resolves.toBeUndefined() + await expect(venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'USDC' }, ME)).rejects.toThrow( + /takes WAL/ + ) + }) +}) + +describe('unstaking', () => { + const ID = normalizeSuiAddress('0x51') + + it('reads positions from the wallet by type', async () => { + const { venue, core } = venueOver({ owned: [ownedObject(ID, BigInt(10) * WAL, STAKED)] }) + await venue.prepare(unstake('10'), ME) + expect(core.listOwnedObjects).toHaveBeenCalledWith( + expect.objectContaining({ owner: ME, type: STAKED_WAL_TYPE }) + ) + }) + + it('reads every page of positions', async () => { + const { venue, core } = venueOver() + core.listOwnedObjects + .mockResolvedValueOnce({ + objects: [ownedObject(ID, BigInt(3) * WAL, STAKED)], + hasNextPage: true, + cursor: 'next', + } as never) + .mockResolvedValueOnce({ + objects: [ownedObject(normalizeSuiAddress('0x52'), BigInt(7) * WAL, STAKED)], + hasNextPage: false, + cursor: null, + } as never) + const prepared = await venue.prepare(unstake('10'), ME) + expect(prepared.pay.amount).toBe('10') + expect(core.listOwnedObjects).toHaveBeenCalledTimes(2) + }) + + it('refuses rather than act on part of a list it could not finish', async () => { + const { venue, core } = venueOver() + core.listOwnedObjects.mockResolvedValue({ + objects: [ownedObject(ID, BigInt(3) * WAL, STAKED)], + hasNextPage: true, + cursor: 'next', + } as never) + await expect(venue.prepare(unstake('1'), ME)).rejects.toThrow(/more staked WAL positions/) + }) + + it('starts a withdrawal, and says when and how to collect', async () => { + const { venue, built: txs } = venueOver({ owned: [ownedObject(ID, BigInt(10) * WAL, STAKED)] }) + const prepared = await venue.prepare(unstake('10'), ME) + expect(calls(txs[0] as Transaction)).toEqual(['staking::request_withdraw_stake']) + expect(prepared).toMatchObject({ kind: 'unstake', pay: { amount: '10', symbol: 'WAL' } }) + expect(prepared.receive).toBeUndefined() + expect(prepared.summary).toContain('epoch 44, about 17 Nov 2026') + const text = prepared.notes.join(' ') + expect(text).toContain("can't be cancelled") + expect(text).toContain('ask to unstake 10 WAL again') + expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow() + }) + + it('splits part of a position off and keeps it with the person', async () => { + const { venue, built: txs } = venueOver({ owned: [ownedObject(ID, BigInt(10) * WAL, STAKED)] }) + const prepared = await venue.prepare(unstake('4'), ME) + const tx = txs[0] as Transaction + expect(calls(tx)).toEqual(['staked_wal::split', 'staking::request_withdraw_stake']) + expect(tx.getData().commands.some((c) => c.$kind === 'TransferObjects')).toBe(true) + expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow() + expect(() => venue.assertSafe(prepared.signing, THIEF)).toThrow(ChainActionRefused) + }) + + it('collects after the wait, for what a dry run says comes back', async () => { + const { venue, built: txs } = venueOver({ + owned: [ownedObject(ID, BigInt(10) * WAL, withdrawing(43))], + simulated: { + Transaction: { + balanceChanges: [{ coinType: WAL_TYPE, address: ME, amount: '10250000000' }], + }, + }, + }) + const prepared = await venue.prepare(unstake('10'), ME) + expect(calls(txs[0] as Transaction)).toEqual(['staking::withdraw_stake']) + expect(prepared.receive).toEqual({ amount: '10.25', symbol: 'WAL', approximate: true }) + expect(prepared.summary).toContain('Collect 10 WAL') + expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow() + }) + + it('refuses to collect when a dry run confirms nothing', async () => { + const { venue } = venueOver({ + owned: [ownedObject(ID, BigInt(10) * WAL, withdrawing(43))], + simulated: { FailedTransaction: {} }, + }) + await expect(venue.prepare(unstake('10'), ME)).rejects.toThrow(/couldn't confirm how much WAL/) + }) + + it('refuses during the wait, and says when', async () => { + const { venue, build } = venueOver({ + owned: [ownedObject(ID, BigInt(10) * WAL, withdrawing(44))], + }) + await expect(venue.prepare(unstake('10'), ME)).rejects.toThrow(/from epoch 44/) + expect(build).not.toHaveBeenCalled() + }) + + it('refuses a wallet with no stake', async () => { + const { venue } = venueOver() + await expect(venue.prepare(unstake('10'), ME)).rejects.toThrow(/no WAL staked/) + }) + + it('refuses another token', async () => { + const { venue } = venueOver({ owned: [ownedObject(ID, BigInt(10) * WAL, STAKED)] }) + await expect(venue.prepare(unstake('10', 'USDC'), ME)).rejects.toThrow(/takes WAL, not USDC/) + }) + + it.each([ + ['an object of another type', ownedObject(ID, WAL, STAKED, NODE_A, 10, `${FOREIGN}::x::Y`)], + ['a state it does not know', ownedObject(ID, WAL, { '@variant': 'Slashed' })], + ['a wait with no epoch', ownedObject(ID, WAL, { '@variant': 'Withdrawing' })], + [ + 'a principal that is no number', + { ...ownedObject(ID, WAL, STAKED), json: { principal: 'x' } }, + ], + ])('refuses %s rather than guess', async (_name, object) => { + const { venue, build } = venueOver({ owned: [object] }) + await expect(venue.prepare(unstake('1'), ME)).rejects.toThrow(/couldn't read/) + expect(build).not.toHaveBeenCalled() + }) +}) + +describe('what the app shows', () => { + it('lists WAL as verified, with its decimals', () => { + expect(WALRUS_TOKENS).toEqual([{ symbol: 'WAL', id: WAL_TYPE, decimals: 9, name: 'Walrus' }]) + }) + + it('lists the venue as executing, with a logo', () => { + expect(walrusListing).toMatchObject({ + id: 'walrus', + family: 'sui', + integration: 'executes', + category: 'staking', + }) + expect(existsSync(join(process.cwd(), 'public/images/venues/walrus.webp'))).toBe(true) + }) +}) diff --git a/apps/dapp/lib/__tests__/weave-orders-route.test.ts b/apps/dapp/lib/__tests__/weave-orders-route.test.ts index 7d24d7cf..fba71889 100644 --- a/apps/dapp/lib/__tests__/weave-orders-route.test.ts +++ b/apps/dapp/lib/__tests__/weave-orders-route.test.ts @@ -114,7 +114,6 @@ beforeEach(() => { isDepositAddress: (a: string) => a.endsWith('.near'), } vi.stubEnv('WEAVE_CASHOUT_CURRENCIES', 'NGN,KES') - vi.stubEnv('MAINNET_MAX_TRADE_USD', '') }) describe('checks before anything is asked of Weave', () => { @@ -251,32 +250,17 @@ describe('a retry of the same attempt', () => { }) }) -describe('the cap', () => { - it('refuses above the cap in a plain sentence, asking Weave nothing', async () => { - const res = await POST(post({ amount: '501' })) - expect(res.status).toBe(400) - const { message } = await res.json() - expect(message).toContain('$500') - expect(message).toContain('$501.00') - expect(message).not.toContain('MAINNET_MAX_TRADE_USD') - expect(called(state.weave['quote'])).not.toHaveBeenCalled() - }) - - it('reads the cap as mainnet whatever the page is on', async () => { - state.mainnet = false - expect((await POST(post({ amount: '501' }))).status).toBe(400) - }) - - it('allows exactly the cap', async () => { +describe('the size of a cash-out', () => { + it('has no dollar limit of its own', async () => { called(state.weave['quote']).mockResolvedValueOnce({ ok: true, - value: { destAmount: 1, netUsd: 500, seconds: 1 }, + value: { destAmount: 1, netUsd: 50_000, seconds: 1 }, }) called(state.weave['createOrder']).mockResolvedValueOnce({ ok: true, - value: { ...placed, deposit: { ...placed.deposit, amount: '500' } }, + value: { ...placed, deposit: { ...placed.deposit, amount: '50000' } }, }) - expect((await POST(post({ amount: '500' }))).status).toBe(200) + expect((await POST(post({ amount: '50000' }))).status).toBe(200) }) }) diff --git a/apps/dapp/lib/adapters/evm-adapter.ts b/apps/dapp/lib/adapters/evm-adapter.ts index 86d651b9..802ea950 100644 --- a/apps/dapp/lib/adapters/evm-adapter.ts +++ b/apps/dapp/lib/adapters/evm-adapter.ts @@ -9,7 +9,7 @@ import { import type { ChainDescriptor } from '@intent/types' import { BaseError, UserRejectedRequestError, type Address, type Hex } from 'viem' import { useAccount, useBalance, useConnect, useDisconnect, useSwitchChain } from 'wagmi' -import { getAccount, sendTransaction, switchChain } from 'wagmi/actions' +import { getAccount, sendTransaction, switchChain, waitForTransactionReceipt } from 'wagmi/actions' import { avalanche, monad } from 'viem/chains' import type { ChainAdapter, ChainWallet } from '../chain-adapter' @@ -117,6 +117,24 @@ function signOn(name: string, chainId: 43114 | 143): NonNullable symbols.includes(token.symbol)) +} + +const AAVE_ADMIN_NOTE = + 'Aave governance can upgrade its contracts and change its settings, and a guardian can freeze or pause a market without a vote. It froze its V3 markets, Avalanche and Monad among them, on 29 October 2026 after a Binance hot wallet exploit, and this app checks the current state each time.' + +const KELP_NOTE = + 'The KelpDAO rsETH exploit of 18 April 2026, about $292M, left Aave with bad debt estimated at $123.7M to $230.1M, mostly on Ethereum, Arbitrum and Mantle. Aave reported most of the rsETH recovered by May 2026. I could not confirm the recovery is finished.' + +const AAVE_ORACLE_NOTE = + 'A price oracle misconfiguration on Aave V3 on Ethereum cost about $862,000 on 12 March 2026.' + +export interface AaveMarket { + id: string + name: string + chain: EvmChain + pool: string + addressesProvider: string + dataProvider: string + tokens: ChainToken[] + /** What the person reads about who controls this market and what has gone wrong with it. */ + riskNotes: string[] +} + +/** Pool, provider and data provider: bgd-labs/aave-address-book, src/AaveV3Avalanche.sol. */ +export const AAVE_AVALANCHE_MARKET: AaveMarket = { + id: 'aave-avalanche', + name: 'Aave', + chain: 'avalanche', + pool: '0x794a61358D6845594F94dc1DB02A252b5b4814aD', + addressesProvider: '0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb', + dataProvider: '0x243Aa95cAC2a25651eda86e80bEe66114413c43b', + tokens: named(AVALANCHE_TOKENS, ['USDC', 'USDT', 'WAVAX', 'WETH.e']), + riskNotes: [ + AAVE_ADMIN_NOTE, + KELP_NOTE, + 'This Avalanche market listed wrsETH, now frozen. Read from the chain on 5 November 2026, it carried bad debt not yet covered of about 202,000 USDT out of 24.9M supplied, and about 7,600 USDC out of 67.8M.', + AAVE_ORACLE_NOTE, + ], +} + +/** Pool, provider and data provider: bgd-labs/aave-address-book, src/AaveV3Monad.sol. */ +export const AAVE_MONAD_MARKET: AaveMarket = { + id: 'aave-monad', + name: 'Aave', + chain: 'monad', + pool: '0x69a5F9AD4f96ebf0a0C792dD42a01cC5C0102fef', + addressesProvider: '0x34793Fb9935F7bB5E5aE920fb963F39063E7A615', + dataProvider: '0xB65A68B98274ef7D9a60E0C0747dD1BEc3D32fad', + tokens: named(MONAD_TOKENS, ['USDC', 'USDT0', 'WETH']), + riskNotes: [ + AAVE_ADMIN_NOTE, + `${KELP_NOTE} This Monad market does not list rsETH.`, + AAVE_ORACLE_NOTE, + ], +} + +/** The main market only. https://docs.neverland.money/smart-contracts.md, "Core Lending". */ +export const NEVERLAND_MARKET: AaveMarket = { + id: 'neverland', + name: 'Neverland', + chain: 'monad', + pool: '0x80F00661b13CC5F6ccd3885bE7b4C9c67545D585', + addressesProvider: '0x49D75170F55C964dfdd6726c74fdEDEe75553A0f', + dataProvider: '0xfd0b6b6F736376F7B99ee989c749007c7757fDba', + tokens: named(MONAD_TOKENS, ['USDC', 'USDT0']), + riskNotes: [ + 'Neverland is a separate app built from Aave V3’s code, and is not run by Aave. It holds far less, about $5.3M on DefiLlama in November 2026, so one large withdrawal can leave little free for a while.', + 'Neverland’s team controls it through a multisig. Upgrades and governance changes wait out a 24-hour timelock and risk settings a 1-hour one, and a separate multisig can pause it with no delay. DefiLlama lists no incident for it.', + ], +} + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +function checkPoolCall(market: AaveMarket, call: DecodedCall, context: CheckContext): void { + const { functionName, args } = call + const { account, approval } = context + const [asset, amount] = args as readonly [string, bigint] + const token = market.tokens.find((t) => sameAddress(t.id, asset)) + if (token === undefined) { + refuse(`This uses a token I haven't confirmed for ${market.name}.`) + } + if (amount === BigInt(0) || amount === maxUint256) { + refuse('This transaction’s amount is not one the person asked for.') + } + + let forWhom: string + let pulls = false + if (functionName === 'supply') { + const [, , onBehalfOf, referralCode] = args as readonly [string, bigint, string, number] + if (referralCode !== 0) refuse('This transaction carries a referral code.') + forWhom = onBehalfOf + pulls = true + } else if (functionName === 'withdraw') { + forWhom = (args as readonly [string, bigint, string])[2] + } else if (functionName === 'borrow') { + const [, , rateMode, referralCode, onBehalfOf] = args as readonly [ + string, + bigint, + bigint, + number, + string, + ] + if (rateMode !== VARIABLE_RATE) refuse('Only a variable-rate loan is allowed.') + if (referralCode !== 0) refuse('This transaction carries a referral code.') + forWhom = onBehalfOf + } else if (functionName === 'repay') { + const [, , rateMode, onBehalfOf] = args as readonly [string, bigint, bigint, string] + if (rateMode !== VARIABLE_RATE) refuse('Only a variable-rate loan is allowed.') + forWhom = onBehalfOf + pulls = true + } else { + refuse(`This transaction does something I haven't confirmed for ${market.name}.`) + } + + if (!sameAddress(forWhom, account)) { + refuse('This transaction credits, pays or borrows for someone other than you.') + } + if (approval === undefined) return + if (!pulls) refuse('This action takes no approval.') + if (!sameAddress(approval.token, asset) || approval.units !== amount) { + refuse('This approval doesn’t match the amount and token of the action.') + } +} + +export function aavePolicy(market: AaveMarket): EvmCallPolicy { + return { + venueName: market.name, + chain: market.chain, + contracts: { + [market.pool]: { + abi: POOL_ABI, + functions: ['supply', 'withdraw', 'borrow', 'repay'], + check: (call, context) => checkPoolCall(market, call, context), + }, + }, + spenders: [market.pool], + approvable: market.tokens.map((token) => token.id), + } +} + +async function readReserve(market: AaveMarket, token: ChainToken, reader: EvmReaderClient) { + const contract = { address: market.dataProvider as Address, abi: DATA_PROVIDER_ABI } + const args = [token.id as Address] as const + const [config, data, paused, caps] = await Promise.all([ + reader.readContract({ ...contract, functionName: 'getReserveConfigurationData', args }), + reader.readContract({ ...contract, functionName: 'getReserveData', args }), + reader.readContract({ ...contract, functionName: 'getPaused', args }), + reader.readContract({ ...contract, functionName: 'getReserveCaps', args }), + ]) + const [, ltv, threshold, , , , borrowingEnabled, , active, frozen] = config + const [, , totalAToken, totalStableDebt, totalVariableDebt, liquidityRate, variableBorrowRate] = + data + const totalDebt = totalStableDebt + totalVariableDebt + return { + ltv, + threshold, + borrowingEnabled, + active, + frozen, + paused, + totalAToken, + totalDebt, + free: totalAToken > totalDebt ? totalAToken - totalDebt : BigInt(0), + liquidityRate, + variableBorrowRate, + borrowCap: caps[0], + supplyCap: caps[1], + } +} + +type Reserve = Awaited> + +/** The rate as a year's compounded percent, kept to two places. The chain states a yearly ray rate. */ +function ratePercent(rayRate: bigint): number { + const perSecond = Number(rayRate) / RAY / SECONDS_PER_YEAR + return Number(((Math.pow(1 + perSecond, SECONDS_PER_YEAR) - 1) * 100).toFixed(2)) +} + +function display(units: bigint, token: ChainToken): string { + return formatUnits(units, token.decimals) +} + +function healthText(bps: bigint): string { + return (Number(bps) / 10_000).toFixed(2) +} + +function assertTakesAny(market: AaveMarket, token: ChainToken, reserve: Reserve): void { + const { name, chain } = market + const where = `${name} on ${EVM_CHAINS[chain].name}` + if (!reserve.active) refuse(`${where} isn't open for ${token.symbol} right now.`) + if (reserve.paused) refuse(`${where} has paused ${token.symbol} right now.`) +} + +function assertOpenForNew(market: AaveMarket, token: ChainToken, reserve: Reserve): void { + assertTakesAny(market, token, reserve) + if (reserve.frozen) { + refuse( + `${market.name} has frozen ${token.symbol} on ${EVM_CHAINS[market.chain].name} right now, so nothing new can start. Repaying and withdrawing stay open.` + ) + } +} + +function assertSupplyOpen( + market: AaveMarket, + token: ChainToken, + reserve: Reserve, + amount: bigint +): void { + assertOpenForNew(market, token, reserve) + const unit = BigInt(10) ** BigInt(token.decimals) + if (reserve.supplyCap !== BigInt(0) && reserve.totalAToken + amount > reserve.supplyCap * unit) { + refuse(`${market.name}'s supply cap for ${token.symbol} leaves no room for this right now.`) + } +} + +function accountUnits(request: ChainActionRequest, account: string, market: AaveMarket) { + if (!isAddress(account, { strict: false }) || sameAddress(account, zeroAddress)) { + refuse('That is not a wallet address on this chain.') + } + const token = tokenBySymbol(market.tokens, request.tokenIn) + if (token === undefined) { + const symbols = market.tokens.map((t) => t.symbol).join(', ') + refuse(`${market.name} takes ${symbols} here, not ${request.tokenIn}.`) + } + return { token, amount: BigInt(toBaseUnits(request.amount, token.decimals)) } +} + +function poolCalldata( + kind: 'supply' | 'withdraw' | 'borrow' | 'repay', + asset: Address, + amount: bigint, + account: Address +): Hex { + const abi = POOL_ABI + switch (kind) { + case 'supply': + return encodeFunctionData({ + abi, + functionName: 'supply', + args: [asset, amount, account, 0], + }) + case 'withdraw': + return encodeFunctionData({ abi, functionName: 'withdraw', args: [asset, amount, account] }) + case 'borrow': + return encodeFunctionData({ + abi, + functionName: 'borrow', + args: [asset, amount, VARIABLE_RATE, 0, account], + }) + case 'repay': + return encodeFunctionData({ + abi, + functionName: 'repay', + args: [asset, amount, VARIABLE_RATE, account], + }) + } +} + +async function readAccount(market: AaveMarket, account: Address, reader: EvmReaderClient) { + const [collateral, debt, available, threshold] = await reader.readContract({ + address: market.pool as Address, + abi: POOL_READS_ABI, + functionName: 'getUserAccountData', + args: [account], + }) + return { collateral, debt, available, threshold } +} + +/** From the oracle the Pool itself prices with, so a check here matches the one it will run. */ +async function oraclePrice( + market: AaveMarket, + token: ChainToken, + reader: EvmReaderClient +): Promise { + const oracle = await reader.readContract({ + address: market.addressesProvider as Address, + abi: PROVIDER_ABI, + functionName: 'getPriceOracle', + }) + return reader.readContract({ + address: oracle, + abi: ORACLE_ABI, + functionName: 'getAssetPrice', + args: [token.id as Address], + }) +} + +/** The base-currency value of an amount, rounded up so a risk check never reads it as less. */ +async function valueOf( + market: AaveMarket, + token: ChainToken, + amount: bigint, + reader: EvmReaderClient +): Promise { + const price = await oraclePrice(market, token, reader) + if (price === BigInt(0)) { + refuse(`${market.name} has no price for ${token.symbol} right now, so I can't check this.`) + } + const unit = BigInt(10) ** BigInt(token.decimals) + return (amount * price + unit - BigInt(1)) / unit +} + +async function borrowNotes( + market: AaveMarket, + token: ChainToken, + reserve: Reserve, + amount: bigint, + account: Address, + reader: EvmReaderClient +): Promise { + assertOpenForNew(market, token, reserve) + if (!reserve.borrowingEnabled) { + refuse(`${market.name} doesn't lend ${token.symbol} here right now, so you can't borrow it.`) + } + const unit = BigInt(10) ** BigInt(token.decimals) + if (reserve.borrowCap !== BigInt(0) && reserve.totalDebt + amount > reserve.borrowCap * unit) { + refuse(`${market.name}'s borrow cap for ${token.symbol} leaves no room for this right now.`) + } + if (amount > reserve.free) { + refuse( + `Only about ${display(reserve.free, token)} ${token.symbol} is free to borrow on ${market.name} right now.` + ) + } + const position = await readAccount(market, account, reader) + if (position.collateral === BigInt(0)) { + refuse(`You have no collateral on ${market.name} here, so there is nothing to borrow against.`) + } + const value = await valueOf(market, token, amount, reader) + if (value > position.available) { + refuse( + `Your collateral on ${market.name} can't back this loan right now. Not every token counts toward a new loan.` + ) + } + const health = (position.collateral * position.threshold) / (position.debt + value) + if (health < MIN_HEALTH_BPS) { + refuse( + `This borrow would leave your health factor at ${healthText(health)}. This app stops at ${MIN_HEALTH_FACTOR}, to keep room before liquidation. Borrow less or add collateral.` + ) + } + return [ + `A borrow can be liquidated. If your collateral falls in value or this debt grows, and your health factor drops under 1.00, anyone can sell your collateral to pay the debt, and you lose a penalty on top. This loan leaves your health factor at ${healthText(health)}. This app refuses a borrow under ${MIN_HEALTH_FACTOR}, but it does not watch the loan afterwards.`, + `The rate is variable, about ${ratePercent(reserve.variableBorrowRate)}% a year now, and it can rise quickly. To close the loan, ask to repay ${token.symbol} on ${market.name}.`, + ] +} + +async function withdrawNotes( + market: AaveMarket, + token: ChainToken, + reserve: Reserve, + amount: bigint, + account: Address, + reader: EvmReaderClient +): Promise { + assertTakesAny(market, token, reserve) + const [supplied, , , , , , , , usedAsCollateral] = await reader.readContract({ + address: market.dataProvider as Address, + abi: DATA_PROVIDER_ABI, + functionName: 'getUserReserveData', + args: [token.id as Address, account], + }) + if (amount > supplied) { + refuse( + `You have ${display(supplied, token)} ${token.symbol} supplied to ${market.name} here, which is less than this.` + ) + } + if (amount > reserve.free) { + refuse( + `Only about ${display(reserve.free, token)} ${token.symbol} is free on ${market.name} right now, since the rest is lent out. It frees up as borrowers repay.` + ) + } + const position = await readAccount(market, account, reader) + const notes = [ + `You have about ${display(supplied, token)} ${token.symbol} supplied here. Interest keeps adding to it, so a small remainder can stay after this.`, + ] + if (position.debt === BigInt(0) || !usedAsCollateral) return notes + + const eMode = await reader.readContract({ + address: market.pool as Address, + abi: POOL_READS_ABI, + functionName: 'getUserEMode', + args: [account], + }) + if (eMode !== BigInt(0)) { + refuse( + `Your account is in E-Mode with a loan open, and I can't check what this withdraw would do to it. Repay the loan first.` + ) + } + const value = await valueOf(market, token, amount, reader) + const held = position.collateral * position.threshold + const removed = value * reserve.threshold + const health = held > removed ? (held - removed) / position.debt : BigInt(0) + if (health < MIN_HEALTH_BPS) { + refuse( + `This withdraw would leave your health factor at ${healthText(health)}. This app stops at ${MIN_HEALTH_FACTOR}, to keep room before liquidation. Repay some of the loan first, or withdraw less.` + ) + } + return [ + ...notes, + `This supply backs your loan, so taking it out leaves your health factor at ${healthText(health)}.`, + ] +} + +async function assertHolds( + token: ChainToken, + needed: bigint, + account: Address, + reader: EvmReaderClient +): Promise { + const held = await reader.readContract({ + address: token.id as Address, + abi: BALANCE_ABI, + functionName: 'balanceOf', + args: [account], + }) + if (held < needed) { + refuse( + `You hold ${display(held, token)} ${token.symbol}, which is less than the ${display(needed, token)} this needs.` + ) + } +} + +async function repayNotes( + market: AaveMarket, + token: ChainToken, + reserve: Reserve, + amount: bigint, + account: Address, + reader: EvmReaderClient +): Promise { + assertTakesAny(market, token, reserve) + const [, , owed] = await reader.readContract({ + address: market.dataProvider as Address, + abi: DATA_PROVIDER_ABI, + functionName: 'getUserReserveData', + args: [token.id as Address, account], + }) + if (owed === BigInt(0)) { + refuse(`You don't owe any ${token.symbol} on ${market.name} here.`) + } + await assertHolds(token, amount < owed ? amount : owed, account, reader) + const owes = `You owe about ${display(owed, token)} ${token.symbol}. Interest keeps adding, so paying that exact amount can leave a small remainder.` + return amount > owed + ? [ + owes, + 'Only what you owe is taken, and the rest stays in your wallet. An approval for the rest stays until it is used or replaced.', + ] + : [owes] +} + +function supplyNotes(market: AaveMarket, token: ChainToken, reserve: Reserve): string[] { + return [ + `This rate is variable, and is the base rate only: any rewards are not counted. To get out, ask to withdraw ${token.symbol} on ${market.name}. You can while the pool has money free, and if most is lent out you wait for borrowers to repay.`, + ...(reserve.ltv === BigInt(0) + ? [`${token.symbol} can't back a new loan on ${market.name} here right now.`] + : []), + ] +} + +const SUMMARY: Record< + 'supply' | 'withdraw' | 'borrow' | 'repay', + (amount: string, symbol: string, where: string) => string +> = { + supply: (amount, symbol, where) => `Supply ${amount} ${symbol} to ${where}.`, + withdraw: (amount, symbol, where) => `Withdraw ${amount} ${symbol} from ${where}.`, + borrow: (amount, symbol, where) => `Borrow ${amount} ${symbol} from ${where} at a variable rate.`, + repay: (amount, symbol, where) => `Repay ${amount} ${symbol} of your loan on ${where}.`, +} + +/** A market's token price in dollars, from the oracle its Pool uses. Any doubt is no price. */ +export async function aaveOraclePriceUsd( + market: AaveMarket, + token: ChainToken, + reader: EvmReaderClient = evmReader(market.chain) +): Promise { + try { + const price = await oraclePrice(market, token, reader) + return price === BigInt(0) ? undefined : Number(price) / ORACLE_UNIT + } catch { + return undefined + } +} + +export function createAaveVenue( + market: AaveMarket, + givenReader?: EvmReaderClient, + client?: EvmClient +): ChainVenue { + const policy = aavePolicy(market) + const readerNow = () => givenReader ?? evmReader(market.chain) + const chainName = EVM_CHAINS[market.chain].name + + /** Any read that fails stops here, since none of these checks may be skipped. */ + async function failingClosed(work: () => Promise): Promise { + try { + return await work() + } catch (e) { + if (e instanceof ChainActionRefused) throw e + return refuse( + `I couldn't read ${market.name} on ${chainName} just now, so this wasn't built.` + ) + } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + const { token, amount } = accountUnits(request, account, market) + return failingClosed(async () => { + const reserve = await readReserve(market, token, readerNow()) + assertSupplyOpen(market, token, reserve, amount) + return quotedApy({ basePercent: ratePercent(reserve.liquidityRate) }) + }) + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { kind } = request + if (kind !== 'supply' && kind !== 'withdraw' && kind !== 'borrow' && kind !== 'repay') { + refuse(`${market.name} can't ${kind} here.`) + } + const { token, amount } = accountUnits(request, account, market) + const person = account as Address + return failingClosed(async () => { + const reader = readerNow() + const reserve = await readReserve(market, token, reader) + + let notes: string[] + if (kind === 'supply') { + assertSupplyOpen(market, token, reserve, amount) + await assertHolds(token, amount, person, reader) + notes = supplyNotes(market, token, reserve) + } else if (kind === 'withdraw') { + notes = await withdrawNotes(market, token, reserve, amount, person, reader) + } else if (kind === 'borrow') { + notes = await borrowNotes(market, token, reserve, amount, person, reader) + } else { + notes = await repayNotes(market, token, reserve, amount, person, reader) + } + + const pullsToken = kind === 'supply' || kind === 'repay' + const { signing, needsApproval } = await buildEvmCall( + { + chain: market.chain, + account, + to: market.pool, + data: poolCalldata(kind, token.id as Address, amount, person), + ...(pullsToken ? { pull: { token: token.id, spender: market.pool, units: amount } } : {}), + }, + reader + ) + return { + venueId: market.id, + venueName: market.name, + kind, + summary: SUMMARY[kind](request.amount, token.symbol, `${market.name} on ${chainName}`), + pay: { amount: request.amount, symbol: token.symbol }, + ...(kind === 'supply' ? { apy: { basePercent: ratePercent(reserve.liquidityRate) } } : {}), + notes: [ + ...notes, + ...(needsApproval ? [approvalNote(market.name, request.amount, token.symbol)] : []), + ...market.riskNotes, + ], + signing, + } + }) + } + + return { + id: market.id, + name: market.name, + kinds: ['supply', 'withdraw', 'borrow', 'repay'], + assertCanFollow: takesOnly( + market.name, + market.tokens.map((token) => token.symbol) + ), + quote, + prepare, + ...evmCallVenueParts(policy, client), + } +} + +export const aaveAvalancheVenue = createAaveVenue(AAVE_AVALANCHE_MARKET) +export const aaveMonadVenue = createAaveVenue(AAVE_MONAD_MARKET) +export const neverlandVenue = createAaveVenue(NEVERLAND_MARKET) diff --git a/apps/dapp/lib/chains/evm/beefy-listing.ts b/apps/dapp/lib/chains/evm/beefy-listing.ts new file mode 100644 index 00000000..be298fc6 --- /dev/null +++ b/apps/dapp/lib/chains/evm/beefy-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const beefyMonadListing: Venue = { + id: 'beefy-monad', + family: 'evm', + name: 'Beefy', + category: 'savings', + chains: ['Monad'], + url: 'https://app.beefy.com/vault/aavev3-monad-usdc', + bestFor: 'Earning on USDC through Aave on Monad, with rewards reinvested for you', + integration: 'executes', + capability: 'Supplies and withdraws USDC and shows the current rate', +} diff --git a/apps/dapp/lib/chains/evm/beefy.ts b/apps/dapp/lib/chains/evm/beefy.ts new file mode 100644 index 00000000..1705316d --- /dev/null +++ b/apps/dapp/lib/chains/evm/beefy.ts @@ -0,0 +1,348 @@ +import { + encodeFunctionData, + erc20Abi, + formatUnits, + isAddress, + parseAbi, + zeroAddress, + type Address, +} from 'viem' + +import { + ChainActionRefused, + quotedApy, + takesOnly, + toBaseUnits, + tokenBySymbol, + type ChainActionRequest, + type ChainVenue, + type PreparedAction, + type VenueQuote, +} from '../types' +import { + approvalNote, + buildEvmCall, + evmCallVenueParts, + type CheckContext, + type DecodedCall, + type EvmCallPolicy, + type EvmReaderClient, +} from './call' +import { evmReader, type EvmClient } from './client' +import { sameAddress } from './submit' +import { MONAD_TOKENS } from './tokens' + +// Vault aavev3-monad-usdc in Beefy's registry, beefyfinance/beefy-v2 src/config/vault/monad.json. On +// chain: a minimal proxy of BeefyVaultV7 (verified on Monadscan), whose want() is this USDC, and +// whose balance() held about $3.01M when reviewed in November 2026. The strategy is what strategy() +// returned then, a StrategyAaveSupplyOnly whose vault() is this vault, into Aave's USDC aToken as +// bgd-labs/aave-address-book AaveV3Monad.sol lists it. +export const BEEFY_MONAD_VAULT = '0xb3e09fb538186983661f1c581B3eDc2778d24EF8' +export const BEEFY_MONAD_STRATEGY = '0x6105E2bF96283800781d31C6Ad18c6FBBde688CD' +export const BEEFY_MONAD_USDC = '0x754704Bc059F8C67012fEd69BC8A327a5aafb603' + +const NAME = 'Beefy' +const VAULT_NAME = "Beefy's Aave USDC vault on Monad" +const USDC_DECIMALS = 6 +const RATE_URL = 'https://api.beefy.finance/apy/breakdown' +const RATE_VAULT_ID = 'aavev3-monad-usdc' +const RATE_TIMEOUT_MS = 5_000 + +const CALL_ABI = parseAbi(['function deposit(uint256 amount)', 'function withdraw(uint256 shares)']) + +const VAULT_READ_ABI = parseAbi([ + 'function want() view returns (address)', + 'function strategy() view returns (address)', + 'function stratCandidate() view returns (address implementation, uint256 proposedTime)', + 'function balance() view returns (uint256)', + 'function totalSupply() view returns (uint256)', + 'function balanceOf(address account) view returns (uint256)', +]) + +const STRATEGY_READ_ABI = parseAbi(['function paused() view returns (bool)']) + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +/** A read this needs that fails stops the build: it decides whether the person's money is safe to move. */ +async function orRefuse(work: () => Promise): Promise { + try { + return await work() + } catch (error) { + if (error instanceof ChainActionRefused) throw error + return refuse(`I couldn't read ${NAME}'s vault just now, so this was not built.`) + } +} + +/** Neither call names an address: the vault takes from, credits and pays only whoever sends it. */ +function checkCall(call: DecodedCall, { account, approval }: CheckContext): void { + if (!isAddress(account)) refuse('That is not a Monad address.') + const [amount] = call.args as readonly [bigint] + if (call.functionName === 'deposit') { + if (amount === BigInt(0)) refuse('This deposit is empty.') + if (approval !== undefined && approval.units !== amount) { + refuse('This approval does not match the deposit.') + } + return + } + if (call.functionName === 'withdraw') { + if (amount === BigInt(0)) refuse('This withdrawal is empty.') + if (approval !== undefined) refuse('A withdrawal needs no approval.') + return + } + refuse(`This transaction does something I haven't confirmed for ${NAME}.`) +} + +export const BEEFY_MONAD_POLICY: EvmCallPolicy = { + venueName: NAME, + chain: 'monad', + contracts: { + [BEEFY_MONAD_VAULT]: { abi: CALL_ABI, functions: ['deposit', 'withdraw'], check: checkCall }, + }, + spenders: [BEEFY_MONAD_VAULT], + approvable: [BEEFY_MONAD_USDC], +} + +function usdcUnits(request: ChainActionRequest, account: string): bigint { + if (!isAddress(account)) refuse('That is not a Monad address.') + const token = tokenBySymbol(MONAD_TOKENS, request.tokenIn) + if (token === undefined || !sameAddress(token.id, BEEFY_MONAD_USDC)) { + refuse(`${NAME} takes USDC here, not ${request.tokenIn}. Try "supply 10 USDC on ${NAME}".`) + } + if (request.recipient !== undefined && !sameAddress(request.recipient, account)) { + refuse(`${NAME} only pays the wallet that signs, so this can't go to another address.`) + } + return BigInt(toBaseUnits(request.amount, USDC_DECIMALS)) +} + +function usdc(units: bigint): string { + return formatUnits(units, USDC_DECIMALS) +} + +function readVault< + const F extends 'want' | 'strategy' | 'stratCandidate' | 'balance' | 'totalSupply', +>(reader: EvmReaderClient, functionName: F) { + return reader.readContract({ address: BEEFY_MONAD_VAULT, abi: VAULT_READ_ABI, functionName }) +} + +/** + * Beefy's own figures, for the rate only, so anything odd here means no rate and never stops a build. + * Aave's lending rate is the base, and what Beefy adds by compounding rewards is the reward part. + */ +async function readApy(fetcher: typeof fetch): Promise { + try { + const response = await fetcher(RATE_URL, { + signal: AbortSignal.timeout(RATE_TIMEOUT_MS), + cache: 'no-store', + }) + if (!response.ok) return undefined + const rates = (await response.json()) as Record< + string, + { totalApy?: unknown; lendingApr?: unknown } | undefined + > + const { totalApy: total, lendingApr: base } = rates[RATE_VAULT_ID] ?? {} + if (typeof total !== 'number' || typeof base !== 'number') return undefined + if (!(base >= 0 && base <= total && total <= 1)) return undefined + const percent = (fraction: number): number => Math.round(fraction * 10_000) / 100 + const reward = percent(total - base) + return { basePercent: percent(base), ...(reward > 0 ? { rewardPercent: reward } : {}) } + } catch { + return undefined + } +} + +/** A new strategy blocks new money until it is reviewed. It never blocks a withdrawal. */ +async function assertTakingDeposits(reader: EvmReaderClient): Promise { + const [want, strategy, [candidate], paused] = await orRefuse(() => + Promise.all([ + readVault(reader, 'want'), + readVault(reader, 'strategy'), + readVault(reader, 'stratCandidate'), + reader.readContract({ + address: BEEFY_MONAD_STRATEGY, + abi: STRATEGY_READ_ABI, + functionName: 'paused', + }), + ]) + ) + if (!sameAddress(want, BEEFY_MONAD_USDC)) refuse(`${VAULT_NAME} no longer takes USDC.`) + if (!sameAddress(strategy, BEEFY_MONAD_STRATEGY)) { + refuse( + `${NAME} moved this vault to a strategy I haven't confirmed, so new deposits stay blocked until it is checked. Withdrawals still work.` + ) + } + if (!sameAddress(candidate, zeroAddress)) { + refuse( + `${NAME} has proposed a new strategy for this vault, so new deposits stay blocked until it is checked. Withdrawals still work.` + ) + } + if (paused) refuse(`${VAULT_NAME} isn't taking deposits right now. Withdrawals still work.`) +} + +const PAYS_WHAT_IT_RETURNS = + 'Share value can go down as well as up. A withdrawal pays what the strategy returns, which can be less than you put in if the strategy or Aave takes a loss.' +const AAVE_LIQUIDITY = + "If Aave's USDC is fully lent out, a withdrawal can fail until borrowers repay or new lenders arrive." + +const SUPPLY_NOTES = [ + `${NAME} lends this USDC on Aave V3 on Monad and reinvests the rewards for you.`, + PAYS_WHAT_IT_RETURNS, + "This rate is not guaranteed. Aave's part moves with borrowing demand, and the reward part stops if the rewards do.", + `There is no lockup, and no deposit or withdrawal fee. ${AAVE_LIQUIDITY} ${NAME} keeps 9.5% of the rewards it harvests.`, + "You get mooAaveV3MonadUSDC shares, and your wallet may show a very small number of them. The token is marked as 18 decimals but its amounts are USDC-sized, so that doesn't mean your money is gone.", + `${NAME}'s admins hold real power here. After a 6-hour delay they can move this vault to a new strategy or replace the current strategy's code, and a bad change could lose your deposit. A keeper can pause new deposits at any time, which doesn't stop withdrawals.`, + `This vault opened in July 2026. DefiLlama lists no exploit of ${NAME} as of November 2026. It does list an $862,000 oracle loss on Aave V3 on Ethereum in March 2026, a different market from this one. Neither fact rules out a contract bug here.`, +] + +async function prepareSupply( + request: ChainActionRequest, + account: string, + reader: EvmReaderClient, + fetcher: typeof fetch +): Promise { + const units = usdcUnits(request, account) + await assertTakingDeposits(reader) + + const [held, vaultBalance, totalSupply] = await orRefuse(() => + Promise.all([ + reader.readContract({ + address: BEEFY_MONAD_USDC, + abi: erc20Abi, + functionName: 'balanceOf', + args: [account as Address], + }), + readVault(reader, 'balance'), + readVault(reader, 'totalSupply'), + ]) + ) + if (held < units) refuse(`You hold ${usdc(held)} USDC, which is less than ${request.amount}.`) + if (vaultBalance > BigInt(0) && (units * totalSupply) / vaultBalance === BigInt(0)) { + refuse('This amount is too small to deposit. Try a larger one.') + } + + const { signing, needsApproval } = await orRefuse(() => + buildEvmCall( + { + chain: 'monad', + account, + to: BEEFY_MONAD_VAULT, + data: encodeFunctionData({ abi: CALL_ABI, functionName: 'deposit', args: [units] }), + pull: { token: BEEFY_MONAD_USDC, spender: BEEFY_MONAD_VAULT, units }, + }, + reader + ) + ) + return { + venueId: 'beefy-monad', + venueName: NAME, + kind: 'supply', + summary: `Supply ${request.amount} USDC to ${VAULT_NAME}.`, + pay: { amount: request.amount, symbol: 'USDC' }, + apy: await readApy(fetcher), + notes: [ + ...SUPPLY_NOTES, + ...(needsApproval ? [approvalNote(NAME, request.amount, 'USDC')] : []), + ], + signing, + } +} + +async function prepareWithdraw( + request: ChainActionRequest, + account: string, + reader: EvmReaderClient +): Promise { + const units = usdcUnits(request, account) + + const [want, vaultBalance, totalSupply, shares] = await orRefuse(() => + Promise.all([ + readVault(reader, 'want'), + readVault(reader, 'balance'), + readVault(reader, 'totalSupply'), + reader.readContract({ + address: BEEFY_MONAD_VAULT, + abi: VAULT_READ_ABI, + functionName: 'balanceOf', + args: [account as Address], + }), + ]) + ) + if (!sameAddress(want, BEEFY_MONAD_USDC)) refuse(`${VAULT_NAME} no longer pays USDC.`) + if (shares === BigInt(0) || totalSupply === BigInt(0) || vaultBalance === BigInt(0)) { + refuse(`You have no USDC in ${VAULT_NAME}.`) + } + const worth = (shares * vaultBalance) / totalSupply + if (units > worth) { + refuse(`You have about ${usdc(worth)} USDC in ${VAULT_NAME}. Try that amount or less.`) + } + + // Rounded up, so the shares pay at least what was typed. Since units <= worth, this can't pass shares. + const burn = (units * totalSupply + vaultBalance - BigInt(1)) / vaultBalance + + const { signing } = await orRefuse(() => + buildEvmCall( + { + chain: 'monad', + account, + to: BEEFY_MONAD_VAULT, + data: encodeFunctionData({ abi: CALL_ABI, functionName: 'withdraw', args: [burn] }), + }, + reader + ) + ) + return { + venueId: 'beefy-monad', + venueName: NAME, + kind: 'withdraw', + summary: `Withdraw ${request.amount} USDC from ${VAULT_NAME}.`, + pay: { amount: request.amount, symbol: 'USDC' }, + receive: { + amount: usdc((burn * vaultBalance) / totalSupply), + symbol: 'USDC', + approximate: true, + }, + notes: [ + PAYS_WHAT_IT_RETURNS, + AAVE_LIQUIDITY, + 'Earnings keep adding to what you hold here, so a small amount can stay behind after you withdraw what you saw as your full balance.', + ], + signing, + } +} + +/** Reads go through `getReader`, so a venue built at import doesn't open a client until it is used. */ +export function createBeefyMonadVenue( + getReader: () => EvmReaderClient = () => evmReader('monad'), + client?: EvmClient, + fetcher: typeof fetch = fetch +): ChainVenue { + const quote = async (request: ChainActionRequest, account: string): Promise => { + if (request.kind !== 'supply') refuse(`${NAME} only gives a rate for a supply.`) + usdcUnits(request, account) + await assertTakingDeposits(getReader()) + return quotedApy(await readApy(fetcher)) + } + + const prepare = (request: ChainActionRequest, account: string): Promise => { + if (request.kind === 'supply') return prepareSupply(request, account, getReader(), fetcher) + if (request.kind === 'withdraw') return prepareWithdraw(request, account, getReader()) + return Promise.reject( + new ChainActionRefused( + `${NAME} supplies and withdraws USDC here, and doesn't ${request.kind}. Nothing was signed or sent.` + ) + ) + } + + return { + id: 'beefy-monad', + name: NAME, + kinds: ['supply', 'withdraw'], + assertCanFollow: takesOnly(NAME, ['USDC']), + quote, + prepare, + ...evmCallVenueParts(BEEFY_MONAD_POLICY, client), + } +} + +export const beefyMonadVenue = createBeefyMonadVenue() diff --git a/apps/dapp/lib/chains/evm/bulla-listing.ts b/apps/dapp/lib/chains/evm/bulla-listing.ts new file mode 100644 index 00000000..d3550680 --- /dev/null +++ b/apps/dapp/lib/chains/evm/bulla-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const bullaListing: Venue = { + id: 'bulla', + family: 'evm', + name: 'Bulla', + category: 'payments', + chains: ['Avalanche'], + url: 'https://www.bulla.network', + bestFor: 'Invoices held onchain, billed and paid from your own wallet', + integration: 'executes', + capability: 'Bills and pays invoices in USDC and USDT', +} diff --git a/apps/dapp/lib/chains/evm/bulla.ts b/apps/dapp/lib/chains/evm/bulla.ts new file mode 100644 index 00000000..00cfabde --- /dev/null +++ b/apps/dapp/lib/chains/evm/bulla.ts @@ -0,0 +1,461 @@ +import { + encodeFunctionData, + formatUnits, + isAddress, + parseAbi, + zeroAddress, + type Abi, + type Address, + type Hex, +} from 'viem' + +import { sendRefusal } from '../recipient' +import { + ChainActionRefused, + toBaseUnits, + tokenBySymbol, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, +} from '../types' +import { + approvalNote, + buildEvmCall, + evmCallVenueParts, + type ApprovalSeen, + type DecodedCall, + type EvmCallPolicy, + type EvmReaderClient, +} from './call' +import { evmReader, type EvmClient } from './client' +import { sameAddress } from './submit' +import { AVALANCHE_TOKENS } from './tokens' + +// https://github.com/bulla-network/bulla-contracts-V2/blob/main/deployments/avalanche-latest.json. +// Each has code on Avalanche, verified source on Routescan, and no proxy, checked by RPC. +export const BULLA_CLAIM = '0xf28C50EB5996508B558F0FFdDe637C4385117430' +export const BULLA_INVOICE = '0xd60F8Fc651BdcEe2C6Ed9914d610c0E22cE190d6' +// The same file's adminAddress. `owner()` on both contracts returns it, and it has no code. +export const BULLA_OWNER = '0x81f1e947811496453dBBaeA0AC2c250e056BBd96' + +const VENUE_NAME = 'Bulla' +const UINT40_MAX = BigInt('1099511627775') +const UINT128_MAX = BigInt('340282366920938463463374607431768211455') +const ZERO = BigInt(0) + +const LOCK_NO_NEW_CLAIMS = 1 +const LOCK_LOCKED = 2 + +/** Fixed, so a bill carries no text the person didn't see. */ +export const BULLA_DESCRIPTION = 'Invoice created with Intent' + +/** + * BullaInvoice.createInvoice needs an earlier `approveCreateClaim` from the person, which the + * approval step here can't carry, so a bill is a plain claim made directly on BullaClaimV2. + */ +export const BULLA_CREATE_ABI = parseAbi([ + 'struct CreateClaimParams { address creditor; address debtor; uint256 claimAmount; string description; address token; uint8 binding; uint256 dueBy; uint256 impairmentGracePeriod; }', + 'function createClaim(CreateClaimParams params) payable returns (uint256)', +]) + +export const BULLA_PAY_ABI = parseAbi([ + 'function payClaim(uint256 claimId, uint256 amount) payable', +]) + +export const BULLA_INVOICE_PAY_ABI = parseAbi([ + 'function payInvoice(uint256 claimId, uint256 paymentAmount) payable', +]) + +export const BULLA_READ_ABI = parseAbi([ + 'struct Claim { uint256 claimAmount; uint256 paidAmount; uint256 dueBy; uint256 impairmentGracePeriod; address originalCreditor; address debtor; address creditor; address token; address controller; uint8 status; uint8 binding; }', + 'struct PaidClaimCallback { address callbackContract; bytes4 callbackSelector; }', + 'struct PurchaseOrderState { uint256 deliveryDate; uint256 depositAmount; bool isDelivered; }', + 'struct InterestConfig { uint16 interestRateBps; uint16 numberOfPeriodsPerYear; }', + 'struct InterestComputationState { uint256 accruedInterest; uint256 latestPeriodNumber; uint16 protocolFeeBps; uint256 totalGrossInterestPaid; }', + 'struct Invoice { uint256 claimAmount; uint256 paidAmount; uint256 dueBy; address creditor; address debtor; address token; uint8 status; uint8 binding; PurchaseOrderState purchaseOrder; InterestConfig lateFeeConfig; InterestComputationState interestComputationState; }', + 'function lockState() view returns (uint8)', + 'function currentClaimId() view returns (uint256)', + 'function CORE_PROTOCOL_FEE() view returns (uint256)', + 'function owner() view returns (address)', + 'function _bullaClaim() view returns (address)', + 'function protocolFeeBPS() view returns (uint16)', + 'function getClaim(uint256 claimId) view returns (Claim)', + 'function getPaidClaimCallback(uint256 claimId) view returns (PaidClaimCallback)', + 'function getInvoice(uint256 claimId) view returns (Invoice)', +]) + +/** A claim in the native coin pays by value, which an ERC-20 claim would silently keep. */ +const TAKES = AVALANCHE_TOKENS.filter((token) => ['USDC', 'USDT'].includes(token.symbol)) + +const OWNER_NOTE = + 'One wallet, not a multisig contract, owns Bulla’s contracts here. It can lock them, which stops new invoices and payments, and it can set the fee. The contracts themselves can’t be upgraded.' + +// audits/report-2025-09-04.pdf names LPHAC. audits/AUDIT_CHANGELOG_POST_V2.md lists the later versions. +const AUDIT_NOTE = + 'LPHAC audited Bulla’s contracts in its report of Sept 4, 2025. Bulla’s own changelog lists v2.2 and v2.4 as added after that audit, and v2.4 changed the code that runs when an invoice is paid.' + +const STATUS_REFUSALS: Record = { + 2: 'has already been paid', + 3: 'was rejected by the one billed', + 4: 'was cancelled by the one who sent it', + 5: 'was marked impaired by the one who sent it', +} + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +async function reading(read: () => Promise): Promise { + try { + return await read() + } catch (error) { + if (error instanceof ChainActionRefused) throw error + return refuse(`I couldn't read ${VENUE_NAME} on Avalanche just now, so I can't confirm this.`) + } +} + +interface BillArgs { + creditor: string + debtor: string + claimAmount: bigint + description: string + token: string + binding: number + dueBy: bigint + impairmentGracePeriod: bigint +} + +/** + * The due date is left to the chain here, which reverts a past one: this also runs on a sent + * transaction, which may have been signed just before its date passed. + */ +function checkBill(args: BillArgs, account: string, approval: ApprovalSeen | undefined): void { + if (approval !== undefined) refuse('A bill moves nothing, so it takes no approval.') + if (!sameAddress(args.creditor, account)) refuse('This bill does not name you as the one owed.') + if (sameAddress(args.debtor, account) || sameAddress(args.debtor, zeroAddress)) { + refuse('This bills an address it should not.') + } + if (!TAKES.some((token) => sameAddress(token.id, args.token))) { + refuse(`This bills in a token I haven't confirmed for ${VENUE_NAME}.`) + } + const plainTerms = + args.binding === 0 && + args.impairmentGracePeriod === ZERO && + args.description === BULLA_DESCRIPTION && + args.dueBy > ZERO && + args.dueBy <= UINT40_MAX && + args.claimAmount > ZERO && + args.claimAmount <= UINT128_MAX + if (!plainTerms) refuse('This invoice has terms this app does not offer.') +} + +function checkPayment( + call: DecodedCall, + approval: ApprovalSeen | undefined, + contract: string +): void { + const units = call.args[1] as bigint + if (units <= ZERO) refuse('This pays nothing.') + if (approval === undefined) return + if (!sameAddress(approval.spender, contract) || approval.units !== units) { + refuse('This approval does not match the payment exactly.') + } +} + +const POLICY: EvmCallPolicy = { + venueName: VENUE_NAME, + chain: 'avalanche', + contracts: { + [BULLA_CLAIM]: { + abi: [...BULLA_CREATE_ABI, ...BULLA_PAY_ABI] as Abi, + functions: ['createClaim', 'payClaim'], + check: (call, { account, approval }) => { + if (call.functionName === 'createClaim') { + checkBill(call.args[0] as BillArgs, account, approval) + return + } + checkPayment(call, approval, BULLA_CLAIM) + }, + }, + [BULLA_INVOICE]: { + abi: BULLA_INVOICE_PAY_ABI as Abi, + functions: ['payInvoice'], + check: (call, { approval }) => checkPayment(call, approval, BULLA_INVOICE), + }, + }, + spenders: [BULLA_CLAIM, BULLA_INVOICE], + approvable: TAKES.map((token) => token.id), +} + +function takenToken(symbol: string): ChainToken { + return ( + tokenBySymbol(TAKES, symbol) ?? + refuse( + `${VENUE_NAME} takes ${TAKES.map((token) => token.symbol).join(' or ')} here, not ${symbol}.` + ) + ) +} + +/** The end of that day in UTC, so a bill due today is still good for the rest of today. */ +function dueByFrom(dueDate: string | undefined): bigint { + const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dueDate?.trim() ?? '') + const notADate = "That isn't a real due date. Use YYYY-MM-DD." + if (parts === null) return refuse(notADate) + const [year, month, day] = [Number(parts[1]), Number(parts[2]), Number(parts[3])] + const end = new Date(Date.UTC(year, month - 1, day, 23, 59, 59)) + if ( + end.getUTCFullYear() !== year || + end.getUTCMonth() !== month - 1 || + end.getUTCDate() !== day + ) { + return refuse(notADate) + } + if (end.getTime() <= Date.now()) { + return refuse(`${parts[0]} has already passed. A due date must be today or later.`) + } + return BigInt(end.getTime() / 1000) +} + +async function prepareBill( + request: ChainActionRequest, + account: string, + reader: EvmReaderClient +): Promise { + const token = takenToken(request.tokenIn) + const { recipient } = request + if (recipient === undefined) return refuse('An invoice needs an address to bill.') + const badRecipient = sendRefusal('avalanche', token.symbol, recipient, account) + if (badRecipient !== undefined) throw new ChainActionRefused(badRecipient) + const dueBy = dueByFrom(request.dueDate) + const claimAmount = BigInt(toBaseUnits(request.amount, token.decimals)) + + const [lock, fee] = await reading(() => + Promise.all([ + reader.readContract({ address: BULLA_CLAIM, abi: BULLA_READ_ABI, functionName: 'lockState' }), + reader.readContract({ + address: BULLA_CLAIM, + abi: BULLA_READ_ABI, + functionName: 'CORE_PROTOCOL_FEE', + }), + ]) + ) + if (lock === LOCK_NO_NEW_CLAIMS || lock === LOCK_LOCKED) { + return refuse(`${VENUE_NAME} is locked for new invoices right now.`) + } + if (lock !== 0) return refuse(`${VENUE_NAME} is in a state I can't confirm.`) + if (fee !== ZERO) { + return refuse( + `${VENUE_NAME} now charges ${formatUnits(fee, 18)} AVAX to create an invoice, so this would move more than gas from your wallet. This app doesn't pay that fee yet.` + ) + } + + const data = encodeFunctionData({ + abi: BULLA_CREATE_ABI, + functionName: 'createClaim', + args: [ + { + creditor: account as Address, + debtor: recipient as Address, + claimAmount, + description: BULLA_DESCRIPTION, + token: token.id as Address, + binding: 0, + dueBy, + impairmentGracePeriod: ZERO, + }, + ], + }) + const { signing } = await reading(() => + buildEvmCall({ chain: 'avalanche', account, to: BULLA_CLAIM, data }, reader) + ) + return { + venueId: 'bulla', + venueName: VENUE_NAME, + kind: 'invoice', + summary: `Bill ${recipient} ${request.amount} ${token.symbol} on ${VENUE_NAME}, due ${request.dueDate}.`, + pay: { amount: request.amount, symbol: token.symbol }, + notes: [ + 'This creates an invoice anyone can read on chain. It moves nothing from your wallet except the network fee.', + `Nothing forces ${recipient} to pay. They can reject it or ignore it. You can cancel it on ${VENUE_NAME}’s contract while it is unpaid, but this app can’t do that for you yet.`, + 'You hold the invoice as an NFT, and whoever holds it is the one paid. The person you bill needs its number to pay, which the transaction shows on the explorer.', + `${OWNER_NOTE} ${VENUE_NAME}’s fee to create an invoice is 0 today.`, + AUDIT_NOTE, + ], + signing, + } +} + +/** What is owed beyond the claim itself, read from BullaInvoice for an invoice it controls. */ +interface LateFees { + accrued: bigint + notes: string[] +} + +async function readLateFees( + id: bigint, + token: ChainToken, + reader: EvmReaderClient +): Promise { + const invoice = await reading(() => + reader.readContract({ + address: BULLA_INVOICE, + abi: BULLA_READ_ABI, + functionName: 'getInvoice', + args: [id], + }) + ) + if (invoice.purchaseOrder.deliveryDate !== ZERO) { + return refuse(`Invoice ${id} is a purchase order, which this app can't pay yet.`) + } + const { interestRateBps } = invoice.lateFeeConfig + const { accruedInterest, protocolFeeBps } = invoice.interestComputationState + const notes: string[] = [] + if (interestRateBps > 0) { + notes.push( + `${formatUnits(accruedInterest, token.decimals)} ${token.symbol} of this is late-fee interest, at ${interestRateBps / 100}% a year. If more accrues before this lands, a small balance stays owed.` + ) + if (protocolFeeBps > 0) { + notes.push( + `${VENUE_NAME}’s fee adds ${protocolFeeBps / 100}% a year to that rate, and that part goes to ${VENUE_NAME}.` + ) + } + } + return { accrued: accruedInterest, notes } +} + +async function preparePayment( + request: ChainActionRequest, + account: string, + reader: EvmReaderClient +): Promise { + const token = takenToken(request.tokenIn) + const idText = request.invoiceId?.trim() ?? '' + if (!/^\d{1,78}$/.test(idText)) return refuse('That is not an invoice number.') + const id = BigInt(idText) + const typed = BigInt(toBaseUnits(request.amount, token.decimals)) + + const [lock, count] = await reading(() => + Promise.all([ + reader.readContract({ address: BULLA_CLAIM, abi: BULLA_READ_ABI, functionName: 'lockState' }), + reader.readContract({ + address: BULLA_CLAIM, + abi: BULLA_READ_ABI, + functionName: 'currentClaimId', + }), + ]) + ) + if (lock === LOCK_LOCKED) return refuse(`${VENUE_NAME} is locked right now.`) + if (lock !== 0 && lock !== LOCK_NO_NEW_CLAIMS) { + return refuse(`${VENUE_NAME} is in a state I can't confirm.`) + } + if (id >= count) return refuse(`There is no invoice ${id} on ${VENUE_NAME} on Avalanche.`) + + const [claim, callback] = await reading(() => + Promise.all([ + reader.readContract({ + address: BULLA_CLAIM, + abi: BULLA_READ_ABI, + functionName: 'getClaim', + args: [id], + }), + reader.readContract({ + address: BULLA_CLAIM, + abi: BULLA_READ_ABI, + functionName: 'getPaidClaimCallback', + args: [id], + }), + ]) + ) + + const plain = sameAddress(claim.controller, zeroAddress) + if (!plain && !sameAddress(claim.controller, BULLA_INVOICE)) { + return refuse(`Number ${id} on ${VENUE_NAME} isn't an invoice this app can pay.`) + } + if (claim.status !== 0 && claim.status !== 1) { + return refuse(`Invoice ${id} ${STATUS_REFUSALS[claim.status] ?? "can't be paid"}.`) + } + if (!sameAddress(claim.debtor, account)) { + return refuse(`Invoice ${id} is billed to ${claim.debtor}, not this wallet.`) + } + if (!sameAddress(claim.token, token.id)) { + const actual = TAKES.find((listed) => sameAddress(listed.id, claim.token)) + return refuse( + actual === undefined + ? `Invoice ${id} is in a different token, not ${token.symbol}.` + : `Invoice ${id} is in ${actual.symbol}, not ${token.symbol}.` + ) + } + // v2.4's callback post-dates the audit and runs inside this payment, on a contract the sender + // picked from a list Bulla's owner alone keeps. + if (!sameAddress(callback.callbackContract, zeroAddress)) { + return refuse( + `Paying invoice ${id} runs another contract the sender chose, which this app hasn't reviewed.` + ) + } + + const late: LateFees = plain + ? { accrued: ZERO, notes: [] } + : await readLateFees(id, token, reader) + const owed = claim.claimAmount - claim.paidAmount + late.accrued + if (owed !== typed) { + return refuse( + `Invoice ${id} owes ${formatUnits(owed, token.decimals)} ${token.symbol}, not ${request.amount}.` + ) + } + + const to = plain ? BULLA_CLAIM : BULLA_INVOICE + const data: Hex = plain + ? encodeFunctionData({ abi: BULLA_PAY_ABI, functionName: 'payClaim', args: [id, typed] }) + : encodeFunctionData({ + abi: BULLA_INVOICE_PAY_ABI, + functionName: 'payInvoice', + args: [id, typed], + }) + const { signing, needsApproval } = await reading(() => + buildEvmCall( + { + chain: 'avalanche', + account, + to, + data, + pull: { token: token.id, spender: to, units: typed }, + }, + reader + ) + ) + return { + venueId: 'bulla', + venueName: VENUE_NAME, + kind: 'pay_invoice', + summary: `Pay invoice ${id}: ${request.amount} ${token.symbol} to ${claim.creditor} on ${VENUE_NAME}.`, + pay: { amount: request.amount, symbol: token.symbol }, + notes: [ + ...(needsApproval ? [approvalNote(VENUE_NAME, request.amount, token.symbol)] : []), + `This pays ${claim.creditor}, who holds invoice ${id} now. If they pass it on before this lands, the new holder is paid. A payment can’t be taken back.`, + ...late.notes, + OWNER_NOTE, + AUDIT_NOTE, + ], + signing, + } +} + +/** Bills and pays invoices on Avalanche only: Bulla's repo has no Monad deployment. */ +export function createBullaVenue(reader?: EvmReaderClient, client?: EvmClient): ChainVenue { + return { + id: 'bulla', + name: VENUE_NAME, + kinds: ['invoice', 'pay_invoice'], + prepare: async (request, account) => { + if (!isAddress(account, { strict: false })) { + return refuse('That account is not an Avalanche address.') + } + const chainReader = reader ?? evmReader('avalanche') + if (request.kind === 'invoice') return prepareBill(request, account, chainReader) + if (request.kind === 'pay_invoice') return preparePayment(request, account, chainReader) + return refuse(`${VENUE_NAME} can't ${request.kind} here.`) + }, + ...evmCallVenueParts(POLICY, client), + } +} diff --git a/apps/dapp/lib/chains/evm/call.ts b/apps/dapp/lib/chains/evm/call.ts new file mode 100644 index 00000000..bdce2f6f --- /dev/null +++ b/apps/dapp/lib/chains/evm/call.ts @@ -0,0 +1,300 @@ +import { + decodeFunctionData, + encodeFunctionData, + erc20Abi, + maxUint256, + type Abi, + type Address, + type Hash, + type Hex, +} from 'viem' + +import { + ChainActionRefused, + type ChainVenue, + type SignedPayload, + type SigningPayload, + type SubmitInput, + type SubmitResult, +} from '../types' +import { evmClient, evmReader, type EvmClient, type EvmReceipt } from './client' +import { EVM_HASH, sameAddress, sameCalldata } from './submit' +import { EVM_CHAINS, evmExplorerUrl, type EvmChain } from './tokens' + +/** + * Calling a venue's contract from the person's own wallet on Avalanche or Monad. The wallet sends + * this itself, so what the server checks is the calldata before it is offered, and afterwards the + * transaction the chain says was sent. + */ + +export interface DecodedCall { + functionName: string + args: readonly unknown[] +} + +export interface ApprovalSeen { + token: string + spender: string + units: bigint +} + +export interface CheckContext { + account: string + /** Native coin the call spends, in base units. */ + value: bigint + approval?: ApprovalSeen | undefined +} + +export interface EvmContractRule { + abi: Abi + /** Every function this venue may call here. Nothing else decodes as allowed. */ + functions: readonly string[] + /** Must refuse anything that pays or credits someone else, or that doesn't match the approval. */ + check: (call: DecodedCall, context: CheckContext) => void +} + +export interface EvmCallPolicy { + venueName: string + chain: EvmChain + /** By contract address. An address not here is refused, so an upgrade blocks until reviewed. */ + contracts: Readonly> + /** The contracts the venue may be approved to pull from the person. Empty means none. */ + spenders: readonly string[] + /** The tokens that may be approved. */ + approvable: readonly string[] + /** Only a call that takes the native coin may carry value. */ + mayPayNative?: boolean | undefined +} + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +function ruleFor(policy: EvmCallPolicy, to: string): EvmContractRule { + const found = Object.entries(policy.contracts).find(([address]) => sameAddress(address, to)) + return ( + found?.[1] ?? + refuse( + `This transaction calls a contract I haven't confirmed for ${policy.venueName}. If ${policy.venueName} moved its contracts, this stays blocked until the new ones are checked.` + ) + ) +} + +/** Decodes against the allowed functions, and proves nothing follows the arguments. */ +function decodeAllowed(rule: EvmContractRule, data: string, venueName: string): DecodedCall { + let functionName: string + let args: readonly unknown[] + try { + const decoded = decodeFunctionData({ abi: rule.abi, data: data as Hex }) + functionName = decoded.functionName + args = decoded.args ?? [] + } catch { + return refuse(`This transaction does something I haven't confirmed for ${venueName}.`) + } + if (!rule.functions.includes(functionName)) { + refuse(`This transaction calls ${functionName}, which I haven't confirmed for ${venueName}.`) + } + const again = encodeFunctionData({ abi: rule.abi, functionName, args }) + if (!sameCalldata(again, data)) { + refuse('This transaction carries extra data, and must be exactly one call.') + } + return { functionName, args } +} + +function readApproval(approval: { to: string; data: string }, policy: EvmCallPolicy): ApprovalSeen { + if (!policy.approvable.some((token) => sameAddress(token, approval.to))) { + refuse(`This asks to approve a token I haven't confirmed for ${policy.venueName}.`) + } + let args: readonly [Address, bigint] + try { + const decoded = decodeFunctionData({ abi: erc20Abi, data: approval.data as Hex }) + if (decoded.functionName !== 'approve') throw new Error('not an approval') + args = decoded.args + } catch { + return refuse('This first step is not a token approval.') + } + const [spender, units] = args + if (!policy.spenders.some((allowed) => sameAddress(allowed, spender))) { + refuse(`This approves an address that isn't confirmed for ${policy.venueName}.`) + } + if (units === maxUint256) refuse('This approval is unlimited, and only an exact one is allowed.') + const again = encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender, units], + }) + if (!sameCalldata(again, approval.data)) { + refuse('This approval carries extra data.') + } + return { token: approval.to, spender, units } +} + +/** Refuses anything but the calls this venue is confirmed to make, for this person. */ +export function assertEvmCallSafe( + payload: SigningPayload, + account: string, + policy: EvmCallPolicy +): void { + const { name, chain } = EVM_CHAINS[policy.chain] + if (payload.family !== 'evm') refuse(`That is not a ${name} transaction.`) + if (payload.chainId !== chain.id) refuse(`That transaction is not for ${name}.`) + if (!/^\d+$/.test(payload.value)) refuse('That transaction’s value is not valid.') + const value = BigInt(payload.value) + if (value !== BigInt(0) && policy.mayPayNative !== true) { + refuse(`That transaction also spends ${EVM_CHAINS[policy.chain].nativeSymbol} as value.`) + } + const rule = ruleFor(policy, payload.to) + const call = decodeAllowed(rule, payload.data, policy.venueName) + const approval = + payload.approval === undefined ? undefined : readApproval(payload.approval, policy) + rule.check(call, { account, value, approval }) +} + +export interface EvmCallPlan { + chain: EvmChain + account: string + to: string + data: string + /** Native coin sent with the call, in base units. */ + value?: bigint | undefined + /** A token the contract pulls from the person, which needs an approval unless one is in place. */ + pull?: { token: string; spender: string; units: bigint } | undefined +} + +export interface EvmCallBuild { + signing: SigningPayload + needsApproval: boolean +} + +export type EvmReaderClient = ReturnType + +/** + * The transaction to offer, with an exact approval only when the allowance falls short. Tried + * against the chain first, except where an approval still has to land, since it would fail for + * that reason alone. A call that would fail is refused here rather than costing the person gas. + */ +export async function buildEvmCall( + plan: EvmCallPlan, + reader: EvmReaderClient = evmReader(plan.chain) +): Promise { + const { chain, account, to, data, value = BigInt(0), pull } = plan + let needsApproval = false + if (pull !== undefined) { + const allowance = await reader.readContract({ + address: pull.token as Address, + abi: erc20Abi, + functionName: 'allowance', + args: [account as Address, pull.spender as Address], + }) + needsApproval = allowance < pull.units + } + if (!needsApproval) { + try { + await reader.call({ + account: account as Address, + to: to as Address, + data: data as Hex, + value, + }) + } catch { + refuse( + 'This would fail on the chain right now. Check your balance and that you have enough for the network fee.' + ) + } + } + const signing: SigningPayload = { + family: 'evm', + chainId: EVM_CHAINS[chain].chain.id, + to, + data, + value: value.toString(), + ...(needsApproval && pull !== undefined + ? { + approval: { + to: pull.token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [pull.spender as Address, pull.units], + }), + }, + } + : {}), + } + return { signing, needsApproval } +} + +/** The line for the card when a wallet will ask twice. */ +export function approvalNote(venueName: string, amount: string, symbol: string): string { + return `Your wallet asks twice: first to let ${venueName} take exactly ${amount} ${symbol}, then for this action. If you stop after the first, nothing moves, and the approval stays until it is used or replaced.` +} + +function pending(error: string): SubmitResult { + return { ok: false, pending: true, error } +} + +/** + * Asks the chain what the wallet sent. It went out already, so nothing here can stop it, and an + * answer may lack `pending` only if it proves the funds did not move: a revert of a transaction + * that is shown to be this venue's, from this person. + */ +export async function confirmEvmCall( + policy: EvmCallPolicy, + account: string, + signed: SignedPayload, + client: EvmClient = evmClient(policy.chain) +): Promise { + const { name, chain } = EVM_CHAINS[policy.chain] + if (signed.family !== 'evm' || !EVM_HASH.test(signed.hash)) { + return pending( + "The wallet didn't return a transaction this app can follow. Check your wallet's activity before trying again, in case it was sent." + ) + } + const { hash } = signed + let receipt: EvmReceipt + try { + receipt = await client.waitForReceipt(hash as Hash) + } catch { + return pending( + `This isn't confirmed on ${name} yet, and it may still land, so don't send it again. Its transaction is ${hash}.` + ) + } + const notThis = pending( + `Transaction ${hash} is not this action. Check your wallet's activity before trying again, in case the real one went out.` + ) + if (!sameAddress(receipt.from, account) || receipt.to === null) return notThis + try { + const sent = await client.transaction(hash as Hash) + assertEvmCallSafe( + { + family: 'evm', + chainId: chain.id, + to: receipt.to, + data: sent.input, + value: sent.value.toString(), + }, + account, + policy + ) + } catch { + return notThis + } + if (receipt.status === 'reverted') { + return { + ok: false, + error: `This failed on ${name}. Your funds did not move, and only the gas was spent.`, + } + } + return { ok: true, hash, explorerUrl: evmExplorerUrl(policy.chain, hash) } +} + +/** What a venue built on this path gives `ChainVenue`. */ +export function evmCallVenueParts( + policy: EvmCallPolicy, + client?: EvmClient +): Pick { + return { + assertSafe: (payload, account) => assertEvmCallSafe(payload, account, policy), + submit: (input: SubmitInput) => confirmEvmCall(policy, input.account, input.signed, client), + } +} diff --git a/apps/dapp/lib/chains/evm/capabilities.ts b/apps/dapp/lib/chains/evm/capabilities.ts index 1c0cc5f1..3481805f 100644 --- a/apps/dapp/lib/chains/evm/capabilities.ts +++ b/apps/dapp/lib/chains/evm/capabilities.ts @@ -2,9 +2,23 @@ import type { ChainCapability } from '../explain' /** What actually works here, for telling someone who asked for something else. Client-safe. */ export const avalancheCapabilities: ChainCapability[] = [ - { kind: 'swap', venue: 'NEAR Intents 1Click', symbol: 'AVAX', then: ['cashout', 'send'] }, + { kind: 'swap', venue: 'Uniswap', symbol: 'AVAX', then: ['cashout', 'supply', 'send'] }, + { kind: 'supply', venue: 'Aave', symbol: 'USDC' }, + { kind: 'withdraw', venue: 'Aave', symbol: 'USDC' }, + { kind: 'borrow', venue: 'Aave', symbol: 'USDC' }, + { kind: 'repay', venue: 'Aave', symbol: 'USDC' }, + { kind: 'stream', venue: 'Sablier', symbol: 'USDC' }, + { kind: 'invoice', venue: 'Bulla', symbol: 'USDC' }, + { kind: 'pay_invoice', venue: 'Bulla', symbol: 'USDC' }, ] export const monadCapabilities: ChainCapability[] = [ - { kind: 'swap', venue: 'NEAR Intents 1Click', symbol: 'MON', then: ['send'] }, + { kind: 'swap', venue: 'Uniswap', symbol: 'MON', then: ['supply', 'send'] }, + { kind: 'supply', venue: 'Aave', symbol: 'USDC' }, + { kind: 'withdraw', venue: 'Aave', symbol: 'USDC' }, + { kind: 'borrow', venue: 'Aave', symbol: 'USDC' }, + { kind: 'repay', venue: 'Aave', symbol: 'USDC' }, + { kind: 'stake', venue: 'FastLane', symbol: 'MON' }, + { kind: 'unstake', venue: 'FastLane', symbol: 'shMON' }, + { kind: 'stream', venue: 'Sablier', symbol: 'USDC' }, ] diff --git a/apps/dapp/lib/chains/evm/client.ts b/apps/dapp/lib/chains/evm/client.ts index 20926501..933b5334 100644 --- a/apps/dapp/lib/chains/evm/client.ts +++ b/apps/dapp/lib/chains/evm/client.ts @@ -29,15 +29,20 @@ export interface EvmClient { transaction: (hash: Hash) => Promise } -/** Reads this chain only, so a hash from another chain isn't found here. */ -export function evmClient(chain: EvmChain, transport?: Transport): EvmClient { +/** A viem client that reads this chain, for a venue reading its own contracts. Never signs. */ +export function evmReader(chain: EvmChain, transport?: Transport) { const { chain: viemChain, rpcEnv } = EVM_CHAINS[chain] - const client = createPublicClient({ + return createPublicClient({ chain: viemChain, transport: transport ?? http(process.env[rpcEnv]?.trim() || undefined, { timeout: RPC_TIMEOUT_MS, retryCount: 1 }), }) +} + +/** Reads this chain only, so a hash from another chain isn't found here. */ +export function evmClient(chain: EvmChain, transport?: Transport): EvmClient { + const client = evmReader(chain, transport) return { tokenBalance: (token, account) => client.readContract({ diff --git a/apps/dapp/lib/chains/evm/curvance-listing.ts b/apps/dapp/lib/chains/evm/curvance-listing.ts new file mode 100644 index 00000000..8a94dad3 --- /dev/null +++ b/apps/dapp/lib/chains/evm/curvance-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const curvanceListing: Venue = { + id: 'curvance', + family: 'evm', + name: 'Curvance', + category: 'lending', + chains: ['Monad'], + url: 'https://app.curvance.com', + bestFor: 'Lending USDC in an isolated market against WMON, at a variable rate', + integration: 'executes', + capability: 'Supplies and withdraws USDC and shows the current rate', +} diff --git a/apps/dapp/lib/chains/evm/curvance.ts b/apps/dapp/lib/chains/evm/curvance.ts new file mode 100644 index 00000000..10504446 --- /dev/null +++ b/apps/dapp/lib/chains/evm/curvance.ts @@ -0,0 +1,435 @@ +import { encodeFunctionData, erc20Abi, formatUnits, isAddress, parseAbi, type Address } from 'viem' + +import { + ChainActionRefused, + quotedApy, + takesOnly, + toBaseUnits, + tokenBySymbol, + type ChainActionRequest, + type ChainVenue, + type PreparedAction, + type VenueQuote, +} from '../types' +import { + approvalNote, + buildEvmCall, + evmCallVenueParts, + type CheckContext, + type DecodedCall, + type EvmCallPolicy, + type EvmReaderClient, +} from './call' +import { evmReader, type EvmClient } from './client' +import { sameAddress } from './submit' +import { MONAD_TOKENS } from './tokens' + +// The "WMON | USDC" market at https://docs.curvance.com/app/protocol-overview/monad-contract-addresses.md, +// and in npm curvance@5.11.1 contracts/monad-mainnet.json. On chain: cUSDC.asset() is CURVANCE_USDC, +// cUSDC.marketManager() is this manager, and manager.isListed(cUSDC) is true. Curvance's other USDC +// markets each have their own cUSDC, and are refused here. +export const CURVANCE_CUSDC = '0x8EE9FC28B8Da872c38A496e9dDB9700bb7261774' +export const CURVANCE_MARKET_MANAGER = '0xa6A2A92F126b79Ee0804845ee6B52899b4491093' +export const CURVANCE_USDC = '0x754704Bc059F8C67012fEd69BC8A327a5aafb603' + +const NAME = 'Curvance' +const MARKET = 'WMON | USDC' +const USDC_DECIMALS = 6 +const SECONDS_PER_YEAR = 31_536_000 +/** MarketManagerIsolated.MIN_HOLD_PERIOD, a constant. Posting collateral or borrowing starts it. */ +const HOLD_SECONDS = BigInt(1200) +/** The manager stores pause flags as 1 for off and 2 for on. */ +const NOT_PAUSED = 1 + +const CALL_ABI = parseAbi([ + 'function deposit(uint256 assets, address receiver) returns (uint256 shares)', + 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256 shares)', +]) + +const CTOKEN_READ_ABI = parseAbi([ + 'function balanceOf(address account) view returns (uint256)', + 'function collateralPosted(address account) view returns (uint256)', + 'function previewDeposit(uint256 assets) view returns (uint256)', + 'function previewWithdraw(uint256 assets) view returns (uint256)', + 'function convertToAssets(uint256 shares) view returns (uint256)', + 'function assetsHeld() view returns (uint256)', + 'function marketOutstandingDebt() view returns (uint256)', + 'function interestFee() view returns (uint16)', + 'function IRM() view returns (address)', +]) + +const MANAGER_READ_ABI = parseAbi([ + 'function isListed(address cToken) view returns (bool)', + 'function actionsPaused(address cToken) view returns (bool mintPaused, bool collateralizationPaused, bool borrowPaused)', + 'function redeemPaused() view returns (uint8)', + 'function accountAssets(address account) view returns (uint256 cooldownTimestamp)', +]) + +const IRM_READ_ABI = parseAbi([ + 'function supplyRate(uint256 assetsHeld, uint256 debt, uint256 interestFee) view returns (uint256)', +]) + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +/** A read this needs that fails stops the build: it decides whether the person's money is safe to move. */ +async function orRefuse(work: () => Promise): Promise { + try { + return await work() + } catch (error) { + if (error instanceof ChainActionRefused) throw error + return refuse(`I couldn't read ${NAME}'s market just now, so this was not built.`) + } +} + +function checkCall(call: DecodedCall, { account, approval }: CheckContext): void { + if (call.functionName === 'deposit') { + const [assets, receiver] = call.args as readonly [bigint, string] + if (!sameAddress(receiver, account)) refuse('This deposit credits someone else.') + if (assets === BigInt(0)) refuse('This deposit is empty.') + if (approval !== undefined && approval.units !== assets) { + refuse('This approval does not match the deposit.') + } + return + } + if (call.functionName === 'withdraw') { + const [assets, receiver, owner] = call.args as readonly [bigint, string, string] + if (!sameAddress(receiver, account)) refuse('This withdrawal pays someone else.') + if (!sameAddress(owner, account)) refuse("This withdrawal takes someone else's deposit.") + if (assets === BigInt(0)) refuse('This withdrawal is empty.') + if (approval !== undefined) refuse('A withdrawal needs no approval.') + return + } + refuse(`This transaction does something I haven't confirmed for ${NAME}.`) +} + +export const CURVANCE_POLICY: EvmCallPolicy = { + venueName: NAME, + chain: 'monad', + contracts: { + [CURVANCE_CUSDC]: { abi: CALL_ABI, functions: ['deposit', 'withdraw'], check: checkCall }, + }, + spenders: [CURVANCE_CUSDC], + approvable: [CURVANCE_USDC], +} + +function usdcUnits(request: ChainActionRequest, account: string): bigint { + if (!isAddress(account)) refuse('That is not a Monad address.') + const token = tokenBySymbol(MONAD_TOKENS, request.tokenIn) + if (token === undefined || !sameAddress(token.id, CURVANCE_USDC)) { + refuse(`${NAME} takes USDC here, not ${request.tokenIn}. Try "supply 10 USDC on ${NAME}".`) + } + return BigInt(toBaseUnits(request.amount, USDC_DECIMALS)) +} + +function usdc(units: bigint): string { + return formatUnits(units, USDC_DECIMALS) +} + +interface Liquidity { + held: bigint + debt: bigint +} + +function readLiquidity(reader: EvmReaderClient): Promise { + return Promise.all([ + reader.readContract({ + address: CURVANCE_CUSDC, + abi: CTOKEN_READ_ABI, + functionName: 'assetsHeld', + }), + reader.readContract({ + address: CURVANCE_CUSDC, + abi: CTOKEN_READ_ABI, + functionName: 'marketOutstandingDebt', + }), + ]).then(([held, debt]) => ({ held, debt })) +} + +/** + * The rate lenders earn, as Curvance's own SDK derives it: the model's per-second rate in WAD, after + * the fee, times a year. The model is read from the market each time, since admins can replace it. + * Rewards aren't on chain here, so they are not counted. Unreadable means no rate. + */ +async function readApy(reader: EvmReaderClient): Promise { + try { + const [{ held, debt }, fee, irm] = await Promise.all([ + readLiquidity(reader), + reader.readContract({ + address: CURVANCE_CUSDC, + abi: CTOKEN_READ_ABI, + functionName: 'interestFee', + }), + reader.readContract({ address: CURVANCE_CUSDC, abi: CTOKEN_READ_ABI, functionName: 'IRM' }), + ]) + const perSecond = await reader.readContract({ + address: irm, + abi: IRM_READ_ABI, + functionName: 'supplyRate', + args: [held, debt, BigInt(fee)], + }) + const percent = (Number(perSecond) * SECONDS_PER_YEAR) / 1e16 + if (!Number.isFinite(percent) || percent < 0) return undefined + return { basePercent: Math.round(percent * 100) / 100 } + } catch { + return undefined + } +} + +async function assertTakingDeposits(reader: EvmReaderClient): Promise { + const [listed, [mintPaused]] = await orRefuse(() => + Promise.all([ + reader.readContract({ + address: CURVANCE_MARKET_MANAGER, + abi: MANAGER_READ_ABI, + functionName: 'isListed', + args: [CURVANCE_CUSDC], + }), + reader.readContract({ + address: CURVANCE_MARKET_MANAGER, + abi: MANAGER_READ_ABI, + functionName: 'actionsPaused', + args: [CURVANCE_CUSDC], + }), + ]) + ) + if (!listed || mintPaused) refuse(`${NAME}'s ${MARKET} market isn't taking deposits right now.`) +} + +function freeNote(liquidity: Liquidity | undefined): string { + if (liquidity === undefined) { + return 'Most of a lending market is lent out, so a withdrawal can fail until borrowers repay or new lenders arrive.' + } + const { held, debt } = liquidity + const total = held + debt + const lent = total === BigInt(0) ? BigInt(0) : (debt * BigInt(100)) / total + return `About ${held / BigInt(10 ** USDC_DECIMALS)} USDC of this market can be withdrawn right now, and ${lent}% is lent out. A withdrawal larger than what is free fails until borrowers repay or new lenders arrive.` +} + +const SUPPLY_NOTES = [ + `This lends into ${NAME}'s ${MARKET} market only, where borrowers post WMON. If WMON falls faster than loans can be liquidated, or a price feed fails, the bad debt comes off what USDC lenders here hold.`, + 'This rate is not guaranteed. It moves with borrowing demand, and it counts no rewards.', + `There is no hold on this deposit, because it isn't posted as collateral. If you post collateral or borrow in this market in ${NAME}'s own app, withdrawals stop for 20 minutes after each time.`, + "These market contracts aren't behind an upgrade proxy, but admins hold real power over them. One signer on Curvance's rapid-response multisig can pause withdrawals across its markets, and you couldn't get out until they are unpaused. Its timelock and 4-of-5 emergency council can replace this market's rate model and raise the share of interest it keeps, up to 60%.", + 'Curvance has been audited by Trail of Bits, Cantina, Sherlock and TrustSec, and DefiLlama lists no exploit of it as of November 2026. That lowers the risk of a contract bug but does not remove it.', +] + +async function prepareSupply( + request: ChainActionRequest, + account: string, + reader: EvmReaderClient +): Promise { + const units = usdcUnits(request, account) + await assertTakingDeposits(reader) + + const [balance, shares] = await orRefuse(() => + Promise.all([ + reader.readContract({ + address: CURVANCE_USDC, + abi: erc20Abi, + functionName: 'balanceOf', + args: [account as Address], + }), + reader.readContract({ + address: CURVANCE_CUSDC, + abi: CTOKEN_READ_ABI, + functionName: 'previewDeposit', + args: [units], + }), + ]) + ) + if (balance < units) { + refuse(`You hold ${usdc(balance)} USDC, which is less than ${request.amount}.`) + } + if (shares === BigInt(0)) refuse('This amount is too small to deposit. Try a larger one.') + + const { signing, needsApproval } = await orRefuse(() => + buildEvmCall( + { + chain: 'monad', + account, + to: CURVANCE_CUSDC, + data: encodeFunctionData({ + abi: CALL_ABI, + functionName: 'deposit', + args: [units, account as Address], + }), + pull: { token: CURVANCE_USDC, spender: CURVANCE_CUSDC, units }, + }, + reader + ) + ) + const [apy, liquidity] = await Promise.all([ + readApy(reader), + readLiquidity(reader).catch(() => undefined), + ]) + return { + venueId: 'curvance', + venueName: NAME, + kind: 'supply', + summary: `Supply ${request.amount} USDC to ${NAME}'s ${MARKET} market.`, + pay: { amount: request.amount, symbol: 'USDC' }, + receive: { amount: usdc(shares), symbol: 'cUSDC', approximate: true }, + apy, + notes: [ + ...SUPPLY_NOTES.slice(0, 2), + freeNote(liquidity), + ...SUPPLY_NOTES.slice(2), + ...(needsApproval ? [approvalNote(NAME, request.amount, 'USDC')] : []), + ], + signing, + } +} + +async function prepareWithdraw( + request: ChainActionRequest, + account: string, + reader: EvmReaderClient +): Promise { + const units = usdcUnits(request, account) + const owner = account as Address + + const [listed, redeemPaused, cooldown, block, balance, posted, shares, liquidity] = + await orRefuse(() => + Promise.all([ + reader.readContract({ + address: CURVANCE_MARKET_MANAGER, + abi: MANAGER_READ_ABI, + functionName: 'isListed', + args: [CURVANCE_CUSDC], + }), + reader.readContract({ + address: CURVANCE_MARKET_MANAGER, + abi: MANAGER_READ_ABI, + functionName: 'redeemPaused', + }), + reader.readContract({ + address: CURVANCE_MARKET_MANAGER, + abi: MANAGER_READ_ABI, + functionName: 'accountAssets', + args: [owner], + }), + reader.getBlock(), + reader.readContract({ + address: CURVANCE_CUSDC, + abi: CTOKEN_READ_ABI, + functionName: 'balanceOf', + args: [owner], + }), + reader.readContract({ + address: CURVANCE_CUSDC, + abi: CTOKEN_READ_ABI, + functionName: 'collateralPosted', + args: [owner], + }), + reader.readContract({ + address: CURVANCE_CUSDC, + abi: CTOKEN_READ_ABI, + functionName: 'previewWithdraw', + args: [units], + }), + readLiquidity(reader), + ]) + ) + + if (!listed || redeemPaused !== NOT_PAUSED) { + refuse(`Withdrawals from ${NAME}'s ${MARKET} market are paused right now.`) + } + const holdEnds = cooldown + HOLD_SECONDS + if (holdEnds > block.timestamp) { + const minutes = (holdEnds - block.timestamp + BigInt(59)) / BigInt(60) + refuse( + `${NAME} is holding this account for about ${minutes} minutes, because collateral was posted or a loan was taken in this market. Try again after that.` + ) + } + if (balance === BigInt(0)) refuse(`You have no USDC lent in ${NAME}'s ${MARKET} market.`) + + // Past the idle shares the contract takes posted collateral instead, which can weaken a loan. + const idle = balance > posted ? balance - posted : BigInt(0) + if (shares > idle) { + const free = await orRefuse(() => + reader.readContract({ + address: CURVANCE_CUSDC, + abi: CTOKEN_READ_ABI, + functionName: 'convertToAssets', + args: [idle], + }) + ) + refuse( + posted > BigInt(0) + ? `About ${usdc(free)} USDC of yours can be withdrawn here without touching the collateral you posted. Try that amount or less, or remove the collateral in ${NAME}'s own app first.` + : `You have about ${usdc(free)} USDC in ${NAME}'s ${MARKET} market. Try that amount or less.` + ) + } + if (units > liquidity.held) { + refuse( + `This market has only ${usdc(liquidity.held)} USDC free right now, because the rest is lent out. Try a smaller amount, or try later.` + ) + } + + const { signing } = await orRefuse(() => + buildEvmCall( + { + chain: 'monad', + account, + to: CURVANCE_CUSDC, + data: encodeFunctionData({ + abi: CALL_ABI, + functionName: 'withdraw', + args: [units, owner, owner], + }), + }, + reader + ) + ) + return { + venueId: 'curvance', + venueName: NAME, + kind: 'withdraw', + summary: `Withdraw ${request.amount} USDC from ${NAME}'s ${MARKET} market.`, + pay: { amount: request.amount, symbol: 'USDC' }, + notes: [ + 'This takes only what you lent. Anything you posted as collateral in this market stays posted.', + freeNote(liquidity), + 'Interest keeps adding to what you hold here, so a small amount can stay behind after you withdraw what you saw as your full balance.', + ], + signing, + } +} + +/** Reads go through `getReader`, so a venue built at import doesn't open a client until it is used. */ +export function createCurvanceVenue( + getReader: () => EvmReaderClient = () => evmReader('monad'), + client?: EvmClient +): ChainVenue { + const quote = async (request: ChainActionRequest, account: string): Promise => { + if (request.kind !== 'supply') refuse(`${NAME} only gives a rate for a supply.`) + usdcUnits(request, account) + const reader = getReader() + await assertTakingDeposits(reader) + return quotedApy(await readApy(reader)) + } + + const prepare = (request: ChainActionRequest, account: string): Promise => { + if (request.kind === 'supply') return prepareSupply(request, account, getReader()) + if (request.kind === 'withdraw') return prepareWithdraw(request, account, getReader()) + return Promise.reject( + new ChainActionRefused( + `${NAME} supplies and withdraws USDC here, and doesn't ${request.kind}. Nothing was signed or sent.` + ) + ) + } + + return { + id: 'curvance', + name: NAME, + kinds: ['supply', 'withdraw'], + assertCanFollow: takesOnly(NAME, ['USDC']), + quote, + prepare, + ...evmCallVenueParts(CURVANCE_POLICY, client), + } +} + +export const curvanceVenue = createCurvanceVenue() diff --git a/apps/dapp/lib/chains/evm/erc4626.ts b/apps/dapp/lib/chains/evm/erc4626.ts new file mode 100644 index 00000000..00b3856d --- /dev/null +++ b/apps/dapp/lib/chains/evm/erc4626.ts @@ -0,0 +1,348 @@ +import { encodeFunctionData, erc20Abi, formatUnits, isAddress, parseAbi, type Address } from 'viem' + +import { + ChainActionRefused, + quotedApy, + takesOnly, + tokenBySymbol, + toBaseUnits, + withArticle, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type VenueQuote, +} from '../types' +import { + approvalNote, + buildEvmCall, + evmCallVenueParts, + type CheckContext, + type DecodedCall, + type EvmCallPolicy, + type EvmReaderClient, +} from './call' +import { evmReader, type EvmClient } from './client' +import { sameAddress } from './submit' +import { EVM_CHAINS, type EvmChain } from './tokens' + +/** + * Earning in one reviewed ERC-4626 vault. A request can't name a vault, so each venue pins the one + * it was reviewed for, and every other vault is an unknown contract. + */ + +export interface ReviewedVault { + address: string + name: string + shareSymbol: string + shareDecimals: number + asset: ChainToken + curator: string +} + +export interface VaultVenueConfig { + id: string + name: string + chain: EvmChain + vault: ReviewedVault + /** The curator, what is lent against, who can change what, and past incidents. */ + notes: readonly string[] + /** What can be taken out right now, in the asset's base units. */ + readLiquidity: (reader: EvmReaderClient) => Promise + /** No rate read means no rate. Throws a refusal only for a reading that should stop a deposit. */ + readApy: (reader: EvmReaderClient) => Promise + /** Refuses a vault that has drifted from what was reviewed, such as new collateral. */ + assertAsReviewed: (reader: EvmReaderClient) => Promise + /** How much more it will take, where it can say. Morpho's `maxDeposit` always answers zero. */ + readRoom?: ((reader: EvmReaderClient, account: Address) => Promise) | undefined +} + +export interface VaultVenueDeps { + reader?: EvmReaderClient | undefined + client?: EvmClient | undefined +} + +const VAULT_CALLS = parseAbi([ + 'function deposit(uint256 assets, address receiver) returns (uint256 shares)', + 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256 shares)', +]) + +const VAULT_READS = parseAbi([ + 'function totalAssets() view returns (uint256)', + 'function balanceOf(address owner) view returns (uint256)', + 'function convertToAssets(uint256 shares) view returns (uint256)', + 'function previewDeposit(uint256 assets) view returns (uint256)', +]) + +/** Under this much free, a vault is the kind Stream left stuck on Avalanche, so it takes no more. */ +const MIN_FREE_PERCENT = BigInt(5) +const HUNDRED = BigInt(100) +const SHARE_DISPLAY_DECIMALS = 6 + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +function checkVaultCall(vault: ReviewedVault, call: DecodedCall, context: CheckContext): void { + const { account, approval } = context + if (call.functionName === 'deposit') { + const [assets, receiver] = call.args as readonly [bigint, string] + if (!sameAddress(receiver, account)) refuse('This deposit credits someone else.') + if (assets <= BigInt(0)) refuse('This deposit is for nothing.') + if (approval === undefined) return + if ( + !sameAddress(approval.token, vault.asset.id) || + !sameAddress(approval.spender, vault.address) + ) { + refuse("This approval doesn't match the vault's own token and address.") + } + if (approval.units !== assets) refuse("This approval doesn't match the amount.") + return + } + const [assets, receiver, owner] = call.args as readonly [bigint, string, string] + if (!sameAddress(receiver, account)) refuse('This withdrawal pays someone else.') + if (!sameAddress(owner, account)) refuse("This withdrawal takes from someone else's position.") + if (assets <= BigInt(0)) refuse('This withdrawal is for nothing.') + if (approval !== undefined) refuse('A withdrawal needs no approval.') +} + +export function vaultPolicy( + venueName: string, + chain: EvmChain, + vault: ReviewedVault +): EvmCallPolicy { + return { + venueName, + chain, + contracts: { + [vault.address]: { + abi: VAULT_CALLS, + functions: ['deposit', 'withdraw'], + check: (call, context) => checkVaultCall(vault, call, context), + }, + }, + spenders: [vault.address], + approvable: [vault.asset.id], + } +} + +/** Whole units, for a figure that moves by the block. */ +function roughly(units: bigint, decimals: number): string { + return Math.floor(Number(formatUnits(units, decimals))).toLocaleString('en-US') +} + +function shareAmount(units: bigint, decimals: number): string { + const [whole = '0', fraction = ''] = formatUnits(units, decimals).split('.') + const kept = fraction.slice(0, SHARE_DISPLAY_DECIMALS).replace(/0+$/, '') + return kept === '' ? whole : `${whole}.${kept}` +} + +export function createVaultVenue(config: VaultVenueConfig, deps: VaultVenueDeps = {}): ChainVenue { + const { id, name, chain, vault, notes } = config + const { asset } = vault + const chainName = EVM_CHAINS[chain].name + const policy = vaultPolicy(name, chain, vault) + const readerNow = (): EvmReaderClient => deps.reader ?? evmReader(chain) + + const readBalance = (reader: EvmReaderClient, token: string, account: string) => + reader.readContract({ + address: token as Address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [account as Address], + }) + + /** Reads that must succeed, so a failed one refuses rather than passing as fine. */ + async function mustRead(reading: () => Promise): Promise { + try { + return await reading() + } catch (error) { + if (error instanceof ChainActionRefused) throw error + return refuse(`I couldn't read ${vault.name} on ${chainName} just now, so this wasn't built.`) + } + } + + function checkedRequest(request: ChainActionRequest, account: string) { + if (!isAddress(account)) refuse(`That is not ${withArticle(chainName)} account.`) + const token = tokenBySymbol(EVM_CHAINS[chain].tokens, request.tokenIn) + if (token === undefined || !sameAddress(token.id, asset.id)) { + refuse(`${name} takes ${asset.symbol} here, not ${request.tokenIn}.`) + } + return { units: BigInt(toBaseUnits(request.amount, asset.decimals)), owner: account as Address } + } + + /** Everything a deposit needs to stand, read without building anything. */ + async function checkedSupply( + request: ChainActionRequest, + account: string, + reader: EvmReaderClient + ) { + const { units, owner } = checkedRequest(request, account) + const { total, free } = await mustRead(async () => { + await config.assertAsReviewed(reader) + const [totalAssets, liquidity] = await Promise.all([ + reader.readContract({ + address: vault.address as Address, + abi: VAULT_READS, + functionName: 'totalAssets', + }), + config.readLiquidity(reader), + ]) + return { total: totalAssets, free: liquidity } + }) + if (free * HUNDRED < total * MIN_FREE_PERCENT) { + refuse( + `Only about ${roughly(free, asset.decimals)} of the ${roughly(total, asset.decimals)} ${asset.symbol} in ${vault.name} can be taken out right now, so I won't add to it.` + ) + } + let apy: PreparedAction['apy'] + try { + apy = await config.readApy(reader) + } catch (error) { + if (error instanceof ChainActionRefused) throw error + apy = undefined + } + return { units, owner, total, free, apy } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + if (request.kind !== 'supply') refuse(`${name} only quotes a supply.`) + const { apy } = await checkedSupply(request, account, readerNow()) + return quotedApy(apy) + } + + async function prepareSupply( + request: ChainActionRequest, + account: string + ): Promise { + const reader = readerNow() + const { units, owner, total, free, apy } = await checkedSupply(request, account, reader) + const { held, shares, room } = await mustRead(async () => { + const [balance, preview, space] = await Promise.all([ + readBalance(reader, asset.id, owner), + reader.readContract({ + address: vault.address as Address, + abi: VAULT_READS, + functionName: 'previewDeposit', + args: [units], + }), + config.readRoom?.(reader, owner), + ]) + return { held: balance, shares: preview, room: space } + }) + if (held < units) { + refuse( + `You hold ${formatUnits(held, asset.decimals)} ${asset.symbol}, which is less than ${request.amount}.` + ) + } + if (room !== undefined && room < units) { + refuse( + `${vault.name} only has room for about ${roughly(room, asset.decimals)} more ${asset.symbol}. Try a smaller amount.` + ) + } + const { signing, needsApproval } = await buildEvmCall( + { + chain, + account: owner, + to: vault.address, + data: encodeFunctionData({ + abi: VAULT_CALLS, + functionName: 'deposit', + args: [units, owner], + }), + pull: { token: asset.id, spender: vault.address, units }, + }, + reader + ) + return { + venueId: id, + venueName: name, + kind: 'supply', + summary: `Supply ${request.amount} ${asset.symbol} to ${vault.name} on ${name}, curated by ${vault.curator}.`, + pay: { amount: request.amount, symbol: asset.symbol }, + receive: { + amount: shareAmount(shares, vault.shareDecimals), + symbol: vault.shareSymbol, + approximate: true, + }, + ...(apy !== undefined ? { apy } : {}), + notes: [ + ...notes, + `About ${roughly(free, asset.decimals)} of the ${roughly(total, asset.decimals)} ${asset.symbol} in this vault can be taken out right now. The rest is lent out, so a larger withdrawal has to wait for repayments.`, + ...(needsApproval ? [approvalNote(name, request.amount, asset.symbol)] : []), + ], + signing, + } + } + + async function prepareWithdraw( + request: ChainActionRequest, + account: string + ): Promise { + const reader = readerNow() + const { units, owner } = checkedRequest(request, account) + const position = await mustRead(async () => { + const shares = await readBalance(reader, vault.address, owner) + return reader.readContract({ + address: vault.address as Address, + abi: VAULT_READS, + functionName: 'convertToAssets', + args: [shares], + }) + }) + if (position === BigInt(0)) refuse(`You have no ${asset.symbol} in ${vault.name}.`) + if (position < units) { + refuse( + `You have about ${formatUnits(position, asset.decimals)} ${asset.symbol} in ${vault.name}, which is less than ${request.amount}.` + ) + } + // A way out must not depend on this reading: if it fails, trying the call below still decides. + const free = await config.readLiquidity(reader).catch(() => undefined) + if (free !== undefined && free < units) { + refuse( + `${vault.name} can pay out about ${roughly(free, asset.decimals)} ${asset.symbol} right now, because the rest is lent out. Your position is still there. Try a smaller amount, or try again later.` + ) + } + const { signing } = await buildEvmCall( + { + chain, + account: owner, + to: vault.address, + data: encodeFunctionData({ + abi: VAULT_CALLS, + functionName: 'withdraw', + args: [units, owner, owner], + }), + }, + reader + ) + return { + venueId: id, + venueName: name, + kind: 'withdraw', + summary: `Withdraw ${request.amount} ${asset.symbol} from ${vault.name} on ${name}, back to your own wallet.`, + pay: { amount: request.amount, symbol: asset.symbol }, + notes: ['Whatever you leave in the vault keeps earning, with the same risks as before.'], + signing, + } + } + + const followsAs = takesOnly(name, [asset.symbol]) + + return { + id, + name, + kinds: ['supply', 'withdraw'], + assertCanFollow: async (step, account) => { + if (step.kind !== 'supply') refuse(`${name} can only supply after a swap.`) + await followsAs(step, account) + }, + quote, + prepare: async (request, account) => { + if (request.kind === 'supply') return prepareSupply(request, account) + if (request.kind === 'withdraw') return prepareWithdraw(request, account) + return refuse(`${name} can supply and withdraw here, not ${request.kind}.`) + }, + ...evmCallVenueParts(policy, deps.client), + } +} diff --git a/apps/dapp/lib/chains/evm/euler-listing.ts b/apps/dapp/lib/chains/evm/euler-listing.ts new file mode 100644 index 00000000..df705f53 --- /dev/null +++ b/apps/dapp/lib/chains/evm/euler-listing.ts @@ -0,0 +1,27 @@ +import type { Venue } from '@intent/types' + +export const eulerMonadListing: Venue = { + id: 'euler-monad', + family: 'evm', + name: 'Euler', + category: 'lending', + chains: ['Monad'], + url: 'https://app.euler.finance', + bestFor: 'Earning on USDC lent against FXRP in a vault curated by Clearstar', + integration: 'executes', + capability: + 'Supplies and withdraws USDC in the Clearstar Reactor vault, and shows the current rate', +} + +export const eulerAvalancheListing: Venue = { + id: 'euler-avalanche', + family: 'evm', + name: 'Euler', + category: 'lending', + chains: ['Avalanche'], + url: 'https://app.euler.finance', + bestFor: + 'Earning on USDC in a small vault curated by 9 Summits, with none of Stream’s tokens as collateral', + integration: 'executes', + capability: 'Supplies and withdraws USDC in the 9 Summits vault, and shows the current rate', +} diff --git a/apps/dapp/lib/chains/evm/euler.ts b/apps/dapp/lib/chains/evm/euler.ts new file mode 100644 index 00000000..08ca4a48 --- /dev/null +++ b/apps/dapp/lib/chains/evm/euler.ts @@ -0,0 +1,199 @@ +import { parseAbi, type Address } from 'viem' + +import { ChainActionRefused, type ChainToken, type PreparedAction } from '../types' +import type { EvmReaderClient } from './call' +import { + createVaultVenue, + type ReviewedVault, + type VaultVenueConfig, + type VaultVenueDeps, +} from './erc4626' +import { sameAddress } from './submit' + +/** + * Euler's lending vaults. Every address below comes from Euler's own repos: euler-xyz/euler-labels + * /products.json names a vault and its curator, and euler-xyz/euler-interfaces + * addresses//CoreAddresses.json names the factory, whose `isProxy` confirms the vault. + * + * Refused by leaving them out on Avalanche: the Re7 and K3 Earn vaults, which euler-labels marks + * as deprecated for exposure to Stream's insolvent positions, and 9Summits' own Earn vault, whose + * queue still includes Re7's eUSDC-2 (0x39dE0f...08f9), which lends against deUSD and sdeUSD and + * holds almost no cash. On Monad: K3's PT and yzPrime vaults, whose collateral is structured + * credit, and K3's smaller vaults, which lend at 79 to 85% where Clearstar's lends at 58%. + */ + +// Monad's factory, from euler-interfaces addresses/143/CoreAddresses.json. Code confirmed by RPC. +export const EULER_FACTORY_MONAD = '0xba4Dd672062dE8FeeDb665DD4410658864483f1E' +// Avalanche's factory, from euler-interfaces addresses/43114/CoreAddresses.json. Confirmed by RPC. +export const EULER_FACTORY_AVALANCHE = '0xaf4B4c18B17F6a2B32F6c398a3910bdCD7f26181' + +const MONAD_USDC: ChainToken = { + symbol: 'USDC', + id: '0x754704Bc059F8C67012fEd69BC8A327a5aafb603', + decimals: 6, + name: 'USD Coin', +} + +const AVALANCHE_USDC: ChainToken = { + symbol: 'USDC', + id: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', + decimals: 6, + name: 'USD Coin', +} + +// euler-labels 143/products.json "clearstar-reactor". Governor 0xb3CF59...2B44 is Clearstar's +// multisig in euler-labels entities.json and in Morpho's curator registry. +export const EULER_MONAD_VAULT: ReviewedVault = { + address: '0x1905EDDF5943ef6C92Ccf1469bd40fC2cB4A77b0', + name: 'Clearstar Reactor USDC', + shareSymbol: 'eUSDC-12', + shareDecimals: 6, + asset: MONAD_USDC, + curator: 'Clearstar', +} + +// euler-labels 43114/products.json "9summits-avalanche". Governor 0x23E6ae...AEF is 9 Summits' +// multisig in euler-labels entities.json. +export const EULER_AVALANCHE_VAULT: ReviewedVault = { + address: '0x37ca03aD51B8ff79aAD35FadaCBA4CEDF0C3e74e', + name: '9 Summits USDC', + shareSymbol: 'eUSDC-19', + shareDecimals: 6, + asset: AVALANCHE_USDC, + curator: '9 Summits', +} + +/** The vault's `LTVList`, FXRP only. Euler only ever appends to this list. */ +export const EULER_MONAD_COLLATERAL: readonly string[] = [ + '0x144290085Da25E3078425C6F4A477fD8caB0c43e', +] + +/** The vault's `LTVList`: sUSDe, BTC.b, savUSD, sAVAX and WETH.e, some in more than one vault. */ +export const EULER_AVALANCHE_COLLATERAL: readonly string[] = [ + '0x3F96e387eC6462DFBe9F05838943D19307dD2752', + '0xF983f92bd962A94EAc85a8c58237C1CC1cDfBBBa', + '0xbaC3983342b805E66F8756E265b3B0DdF4B685Fc', + '0x5497210b19f91bE6446092C38D4cB6116D52578e', + '0x6b640afFdE7cFa8322cA5c7F8bB7d276de3d30A3', + '0x9B1DC432150cEF8f129A9eEa3CA8364d0b06904f', + '0x4eC5B9a4d4FD9E35d4f881D35AAB9877bfdA5803', + '0x38a559c2b6eF3fF7Cdc40a800D6351a2B70b2243', + '0x7485484Dab196C615AE21453C3ABd732977562f2', + '0xf3aCc3Fc22E376fa3dD21CF883B60DDE9cf4E34f', +] + +const EVK_ABI = parseAbi([ + 'function cash() view returns (uint256)', + 'function totalAssets() view returns (uint256)', + 'function totalBorrows() view returns (uint256)', + 'function interestRate() view returns (uint256)', + 'function interestFee() view returns (uint16)', + 'function maxDeposit(address account) view returns (uint256)', + 'function LTVList() view returns (address[])', +]) + +/** `interestRate` is a per-second rate scaled by 1e27, and `interestFee` is in hundredths of a percent. */ +const RATE_SCALE = 1e27 +const FEE_SCALE = 1e4 +const YEAR_SECONDS = 365.2425 * 86_400 + +/** + * What lenders earn right now, worked out as Euler's own lens does: the borrow rate, on the share + * of the vault that is lent, after the fee. Over a year, as if nothing moved. + */ +function readSupplyApy(vault: ReviewedVault) { + const address = vault.address as Address + return async (reader: EvmReaderClient): Promise => { + const [rate, fee, borrows, total] = await Promise.all([ + reader.readContract({ address, abi: EVK_ABI, functionName: 'interestRate' }), + reader.readContract({ address, abi: EVK_ABI, functionName: 'interestFee' }), + reader.readContract({ address, abi: EVK_ABI, functionName: 'totalBorrows' }), + reader.readContract({ address, abi: EVK_ABI, functionName: 'totalAssets' }), + ]) + if (total === BigInt(0) || rate === BigInt(0) || borrows === BigInt(0)) return undefined + const perSecond = + (Number(rate) / RATE_SCALE) * (Number(borrows) / Number(total)) * (1 - fee / FEE_SCALE) + return { basePercent: Math.expm1(YEAR_SECONDS * Math.log1p(perSecond)) * 100 } + } +} + +/** The card names the collateral, so a vault taking anything else refuses until it is reviewed. */ +function assertCollateral(vault: ReviewedVault, reviewed: readonly string[]) { + return async (reader: EvmReaderClient): Promise => { + const listed = await reader.readContract({ + address: vault.address as Address, + abi: EVK_ABI, + functionName: 'LTVList', + }) + if (!listed.every((entry) => reviewed.some((known) => sameAddress(known, entry)))) { + throw new ChainActionRefused( + `${vault.name} takes new collateral since I reviewed it, so this stays blocked until it is checked again. Nothing was signed or sent.` + ) + } + } +} + +function eulerConfig( + id: string, + chain: VaultVenueConfig['chain'], + vault: ReviewedVault, + collateral: readonly string[], + notes: readonly string[] +): VaultVenueConfig { + const address = vault.address as Address + return { + id, + name: 'Euler', + chain, + vault, + notes, + readLiquidity: (reader) => reader.readContract({ address, abi: EVK_ABI, functionName: 'cash' }), + readApy: readSupplyApy(vault), + assertAsReviewed: assertCollateral(vault, collateral), + readRoom: (reader, account) => + reader.readContract({ + address, + abi: EVK_ABI, + functionName: 'maxDeposit', + args: [account], + }), + } +} + +const sharedNotes = (curator: string): readonly string[] => [ + `${curator} can change this vault's collateral, limits and rates with no delay the vault enforces, and the admin of Euler's vault factory can upgrade its code.`, + 'Any rate shown is worked out from the vault’s current borrow rate, how much is lent out and its fee. It moves as people borrow and repay, and it is not promised.', + 'Euler’s older V1 lost $197 million to an exploit in March 2023. This vault runs on V2, a separate codebase.', +] + +export const EULER_MONAD_CONFIG = eulerConfig( + 'euler-monad', + 'monad', + EULER_MONAD_VAULT, + EULER_MONAD_COLLATERAL, + [ + 'Curated by Clearstar. This vault lends your USDC only to borrowers holding FXRP, a wrapped form of XRP. If FXRP or its price feed fails, the USDC lent against it may not come back, and that loss falls on everyone in the vault.', + 'Almost all the money in this vault comes from Clearstar’s own Earn vault. If that moves out, little may be free to withdraw until borrowers repay.', + ...sharedNotes('Clearstar'), + ] +) + +export const EULER_AVALANCHE_CONFIG = eulerConfig( + 'euler-avalanche', + 'avalanche', + EULER_AVALANCHE_VAULT, + EULER_AVALANCHE_COLLATERAL, + [ + 'Curated by 9 Summits. This small vault lends your USDC to borrowers holding mostly savUSD, plus sUSDe, BTC.b, sAVAX and WETH.e. savUSD and sUSDe are synthetic dollars whose backing depends on the companies behind them. If one fails, that loss falls on everyone in the vault.', + 'Stream Finance reported losing about $93 million in November 2025, and Euler has since deprecated other vaults on Avalanche for exposure to it, with depositors offered about 80% back for a limited time. This vault lists none of Stream’s or Elixir’s tokens as collateral.', + ...sharedNotes('9 Summits'), + ] +) + +export function createEulerMonadVenue(deps: VaultVenueDeps = {}) { + return createVaultVenue(EULER_MONAD_CONFIG, deps) +} + +export function createEulerAvalancheVenue(deps: VaultVenueDeps = {}) { + return createVaultVenue(EULER_AVALANCHE_CONFIG, deps) +} diff --git a/apps/dapp/lib/chains/evm/fastlane-listing.ts b/apps/dapp/lib/chains/evm/fastlane-listing.ts new file mode 100644 index 00000000..76d19b69 --- /dev/null +++ b/apps/dapp/lib/chains/evm/fastlane-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const fastlaneListing: Venue = { + id: 'fastlane', + family: 'evm', + name: 'FastLane', + category: 'staking', + chains: ['Monad'], + url: 'https://shmonad.xyz', + bestFor: 'Liquid staking MON for shMON, with an instant way out for a small fee', + integration: 'executes', + capability: 'Stakes MON for shMON, and unstakes through its instant pool', +} diff --git a/apps/dapp/lib/chains/evm/fastlane.ts b/apps/dapp/lib/chains/evm/fastlane.ts new file mode 100644 index 00000000..e8142602 --- /dev/null +++ b/apps/dapp/lib/chains/evm/fastlane.ts @@ -0,0 +1,368 @@ +import { encodeFunctionData, isAddress, parseAbi, zeroAddress, type Address, type Hash } from 'viem' + +import { formatUnits, toDisplayNumber } from '../near/tokens' +import { + ChainActionRefused, + takesOnly, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SubmitInput, + type SubmitResult, + type VenueQuote, +} from '../types' +import { + assertEvmCallSafe, + buildEvmCall, + confirmEvmCall, + type CheckContext, + type DecodedCall, + type EvmCallPolicy, + type EvmReaderClient, +} from './call' +import { evmClient, evmReader, type EvmClient } from './client' +import { sameAddress } from './submit' +import { EVM_CHAINS } from './tokens' + +/** + * Liquid staking of MON with FastLane's shMON. A stake is `deposit`, paid in MON. An unstake is + * the atomic exit through FastLane's pool, which pays out in the same transaction. + * + * The free queue is left out: `requestUnstake` needs a later `completeUnstake`, and this app has + * no step to come back and claim with, so someone could be left holding an unfinished exit. + */ + +const VENUE_NAME = 'FastLane' +const DECIMALS = 18 + +// https://shmonad-user-docs.pages.dev/addresses. A proxy. Its implementation is an exact match on +// Sourcify, chain 143, 0x8Ee4FF5FF50198dcbB7B882A0781F42724Dd095f, and the live test pins it. +export const SHMON = '0x1B68626dCa36c7fE922fD2d55E4f631d962dE19c' + +export const FASTLANE_TOKENS: ChainToken[] = [ + { symbol: 'shMON', id: SHMON, decimals: DECIMALS, name: 'ShMonad' }, +] + +// src/shmonad/FLERC4626.sol in the verified source. `deposit` reverts unless msg.value == assets. +const SHMON_ABI = parseAbi([ + 'function deposit(uint256 assets, address receiver) payable returns (uint256)', + 'function redeemWithSlippageProtection(uint256 shares, address receiver, address owner, uint256 minAssetsOut) returns (uint256)', + 'function previewDeposit(uint256 assets) view returns (uint256)', + 'function previewRedeemDetailed(uint256 shares) view returns (uint256 grossAssets, uint256 feeAssets, uint256 netAssets)', + 'function previewUnstake(uint256 shares) view returns (uint256)', + 'function maxRedeem(address owner) view returns (uint256)', + 'function balanceOf(address owner) view returns (uint256)', +]) + +/** The exit's fee can rise with the pool's use before it lands, so this bounds what that may cost. */ +const MIN_OUT_PERCENT = BigInt(99) + +const ZERO = BigInt(0) + +const QUEUE_NOTE = + 'FastLane also has a queue with no fee, which takes about 4 to 5 epochs, roughly a day, or 2 more when redemptions are heavy. This app does not offer it yet. It is requestUnstake and then completeUnstake on the shMON contract.' + +const ADMIN_NOTE = + 'Your MON sits in contracts that can fail. A 5 of 7 multisig can upgrade them after a 3-day wait. With no wait, it can change fees, including the instant exit’s, and close new deposits and the queue.' + +const AUDIT_NOTE = + 'FastLane has published audits of versions 1.0 and 1.1. The contract running now is newer, and I found no published audit of this version.' + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +function checkCall(call: DecodedCall, { account, value }: CheckContext): void { + if (call.functionName === 'deposit') { + const [assets, receiver] = call.args as [bigint, string] + if (!sameAddress(receiver, account)) refuse('This stake credits the shMON to someone else.') + if (assets === ZERO || value !== assets) { + refuse('This stake pays a different amount of MON than it names.') + } + return + } + const [shares, receiver, owner, minAssetsOut] = call.args as [bigint, string, string, bigint] + if (!sameAddress(owner, account)) refuse('This unstakes shMON that belongs to someone else.') + if (!sameAddress(receiver, account)) refuse('This unstake pays the MON to someone else.') + if (value !== ZERO) refuse('An unstake does not spend MON as value.') + if (shares === ZERO || minAssetsOut === ZERO) { + refuse('This unstake has no amount or no minimum to receive.') + } +} + +// No spender and nothing approvable: the person pays in MON, and burns their own shMON. +export const FASTLANE_POLICY: EvmCallPolicy = { + venueName: VENUE_NAME, + chain: 'monad', + contracts: { + [SHMON]: { + abi: SHMON_ABI, + functions: ['deposit', 'redeemWithSlippageProtection'], + check: checkCall, + }, + }, + spenders: [], + approvable: [], + mayPayNative: true, +} + +function assertAccount(account: string): void { + if (!isAddress(account, { strict: false }) || sameAddress(account, zeroAddress)) { + refuse('That is not a Monad account.') + } +} + +function unitsOf(amount: string): bigint { + try { + return BigInt(toBaseUnits(amount, DECIMALS)) + } catch (e) { + return refuse(e instanceof Error ? e.message : 'That is not an amount.') + } +} + +function display(units: bigint): string { + return formatUnits(units.toString(), DECIMALS) +} + +/** The wording is written for a swap, and the rest of it holds for a stake. */ +function reserveNote(held: bigint, units: bigint): string[] { + const { spendReserve } = EVM_CHAINS.monad + return spendReserve !== undefined && held - units < BigInt(spendReserve.units) + ? [spendReserve.note.replace('this swap', 'this stake')] + : [] +} + +/** + * Dollars per shMON, for ranking routes, from the MON price and what the contract says a shMON is + * worth before the exit fee. 1Click lists no shMON, so nothing else can value it. Any doubt is no + * price. + */ +export async function shmonPriceUsd( + monUsd: number | undefined, + reader: EvmReaderClient = evmReader('monad') +): Promise { + if (monUsd === undefined) return undefined + try { + const mon = await reader.readContract({ + address: SHMON, + abi: SHMON_ABI, + functionName: 'previewUnstake', + args: [BigInt(10) ** BigInt(DECIMALS)], + }) + return toDisplayNumber(mon.toString(), DECIMALS) * monUsd + } catch { + return undefined + } +} + +export function createFastlaneVenue( + reader: EvmReaderClient = evmReader('monad'), + client: EvmClient = evmClient('monad') +): ChainVenue { + const { gasReserve, spendReserve } = EVM_CHAINS.monad + const reserve = BigInt(gasReserve) + const kept = `${formatUnits(gasReserve, DECIMALS)} MON` + + async function readStake(request: ChainActionRequest, account: string) { + if (request.kind !== 'stake') refuse(`${VENUE_NAME} cannot ${request.kind} here.`) + assertAccount(account) + if (request.tokenIn.trim().toUpperCase() !== 'MON') { + refuse(`${VENUE_NAME} stakes MON, not ${request.tokenIn}. Try "stake 1 MON with FastLane".`) + } + const units = unitsOf(request.amount) + let shares: bigint + try { + shares = await reader.readContract({ + address: SHMON, + abi: SHMON_ABI, + functionName: 'previewDeposit', + args: [units], + }) + } catch { + return refuse(`I could not read from ${VENUE_NAME} what this stake would return.`) + } + if (shares === ZERO) refuse('This stake is too small to return any shMON.') + return { units, shares } + } + + /** No apyPercent: no rate can be read from the contract, and a rate is never invented. */ + async function quote(request: ChainActionRequest, account: string): Promise { + const { shares } = await readStake(request, account) + return { receive: { amount: display(shares), symbol: 'shMON' } } + } + + async function prepareStake( + request: ChainActionRequest, + account: string + ): Promise { + const { units, shares } = await readStake(request, account) + let held: bigint + try { + held = await reader.getBalance({ address: account as Address }) + } catch { + return refuse('I could not read this account’s MON balance from Monad.') + } + if (held < units + reserve) { + refuse( + `This account needs ${request.amount} MON for the stake and about ${kept} more for the network fee, and holds less than that.` + ) + } + const { signing } = await buildEvmCall( + { + chain: 'monad', + account, + to: SHMON, + data: encodeFunctionData({ + abi: SHMON_ABI, + functionName: 'deposit', + args: [units, account as Address], + }), + value: units, + }, + reader + ) + const receive = display(shares) + return { + venueId: 'fastlane', + venueName: VENUE_NAME, + kind: 'stake', + summary: `Stake ${request.amount} MON with ${VENUE_NAME} for about ${receive} shMON.`, + pay: { amount: request.amount, symbol: 'MON' }, + receive: { amount: receive, symbol: 'shMON', approximate: true }, + notes: [ + ...reserveNote(held, units), + 'Staking rewards are not guaranteed, and I am not showing a rate because I could not read one from the contract. FastLane’s docs put its cut at 5% of staking rewards and 50% of other rewards.', + 'shMON grows in value against MON, so you get fewer shMON than the MON you put in.', + `To get out, unstake here. That is instant through FastLane’s pool, for a fee of 0.005% to 1.005% depending on how much of the pool is in use, and it only works up to what the pool holds. ${QUEUE_NOTE}`, + ADMIN_NOTE, + AUDIT_NOTE, + ], + signing, + } + } + + async function prepareUnstake( + request: ChainActionRequest, + account: string + ): Promise { + assertAccount(account) + if (request.tokenIn.trim().toUpperCase() !== 'SHMON') { + refuse( + `${VENUE_NAME} unstakes shMON, not ${request.tokenIn}. Try "unstake 1 shMON from FastLane".` + ) + } + const shares = unitsOf(request.amount) + const owner = account as Address + let holding: bigint + let redeemable: bigint + let fee: bigint + let net: bigint + let held: bigint + try { + const [balance, max, preview, coin] = await Promise.all([ + reader.readContract({ + address: SHMON, + abi: SHMON_ABI, + functionName: 'balanceOf', + args: [owner], + }), + reader.readContract({ + address: SHMON, + abi: SHMON_ABI, + functionName: 'maxRedeem', + args: [owner], + }), + reader.readContract({ + address: SHMON, + abi: SHMON_ABI, + functionName: 'previewRedeemDetailed', + args: [shares], + }), + reader.getBalance({ address: owner }), + ]) + holding = balance + redeemable = max + fee = preview[1] + net = preview[2] + held = coin + } catch { + return refuse(`I could not read from ${VENUE_NAME} what this unstake would return.`) + } + if (holding < shares) { + refuse(`You hold ${display(holding)} shMON, which is less than ${request.amount}.`) + } + if (shares > redeemable) { + refuse( + `${VENUE_NAME}’s instant pool can only cover about ${display(redeemable)} of your shMON right now. Try a smaller amount. ${QUEUE_NOTE}` + ) + } + if (net === ZERO) refuse('This unstake is too small to return any MON.') + if (held < reserve) { + refuse(`This account has too little MON for the network fee. Keep about ${kept}.`) + } + const minOut = (net * MIN_OUT_PERCENT) / BigInt(100) + if (minOut === ZERO) refuse('This unstake is too small to protect with a minimum.') + const { signing } = await buildEvmCall( + { + chain: 'monad', + account, + to: SHMON, + data: encodeFunctionData({ + abi: SHMON_ABI, + functionName: 'redeemWithSlippageProtection', + args: [shares, owner, owner, minOut], + }), + }, + reader + ) + const receive = display(net) + return { + venueId: 'fastlane', + venueName: VENUE_NAME, + kind: 'unstake', + summary: `Unstake ${request.amount} shMON from ${VENUE_NAME} for about ${receive} MON, straight away.`, + pay: { amount: request.amount, symbol: 'shMON' }, + receive: { amount: receive, symbol: 'MON', approximate: true }, + notes: [ + `This is the instant exit through FastLane’s pool. Its fee right now is about ${display(fee)} MON, already taken off what you receive, and goes to the remaining shMON holders. The rate runs from 0.005% to 1.005% depending on how much of the pool is in use.`, + `If the fee rises before this lands and you would get under ${display(minOut)} MON, the transaction fails and your shMON stays put. Only the gas is spent.`, + QUEUE_NOTE, + ADMIN_NOTE, + AUDIT_NOTE, + ], + signing, + } + } + + return { + id: 'fastlane', + name: VENUE_NAME, + kinds: ['stake', 'unstake'], + assertCanFollow: async (step, account) => { + if (step.kind !== 'stake') refuse(`${VENUE_NAME} can only stake after a swap.`) + await takesOnly(VENUE_NAME, ['MON'])(step, account) + }, + quote, + prepare: (request, account) => + request.kind === 'unstake' + ? prepareUnstake(request, account) + : prepareStake(request, account), + assertSafe: (payload, account) => assertEvmCallSafe(payload, account, FASTLANE_POLICY), + /** A reverted stake spent MON, so Monad's reserve is the likely cause. An unstake spends none. */ + submit: async (input: SubmitInput): Promise => { + const result = await confirmEvmCall(FASTLANE_POLICY, input.account, input.signed, client) + if (result.ok || result.pending === true) return result + if (spendReserve === undefined || input.signed.family !== 'evm') return result + try { + const { value } = await client.transaction(input.signed.hash as Hash) + return value > ZERO + ? { ...result, error: `${result.error} ${spendReserve.reverted}` } + : result + } catch { + return result + } + }, + } +} diff --git a/apps/dapp/lib/chains/evm/module.ts b/apps/dapp/lib/chains/evm/module.ts index 1f843f16..5d08677f 100644 --- a/apps/dapp/lib/chains/evm/module.ts +++ b/apps/dapp/lib/chains/evm/module.ts @@ -2,20 +2,60 @@ import { oneClickPrice } from '../near/oneclick' import { canIssueOneClick, oneClickAssetId, oneClickOriginVenue } from '../oneclick-origin' import { createSendVenue, SEND_VENUE_ID } from '../send' import type { ChainModule, ChainToken, ChainVenue } from '../types' +import { + AAVE_AVALANCHE_MARKET, + AAVE_MONAD_MARKET, + aaveAvalancheVenue, + aaveMonadVenue, + aaveOraclePriceUsd, + neverlandVenue, +} from './aave' import { readEvmBalances } from './balances' -import { EVM_CHAINS, type EvmChain } from './tokens' +import { beefyMonadVenue } from './beefy' +import { createBullaVenue } from './bulla' +import { curvanceVenue } from './curvance' +import { createEulerMonadVenue } from './euler' +import { createFastlaneVenue, SHMON, shmonPriceUsd } from './fastlane' +import { createMorphoVenue } from './morpho' +import { createPharaohVenue } from './pharaoh' +import { sablierAvalancheVenue, sablierMonadVenue } from './sablier' +import { EVM_CHAINS, EVM_NATIVE_ID, type EvmChain } from './tokens' +import { createUniswapVenue } from './uniswap' + +/** Needing no secret, so these run whether or not this deployment can issue 1Click. */ +const ONCHAIN_VENUES: Record ChainVenue[]> = { + avalanche: () => [ + createUniswapVenue('avalanche'), + createPharaohVenue(), + aaveAvalancheVenue, + sablierAvalancheVenue, + createBullaVenue(), + ], + monad: () => [ + createUniswapVenue('monad'), + aaveMonadVenue, + neverlandVenue, + createMorphoVenue(), + createEulerMonadVenue(), + curvanceVenue, + beefyMonadVenue, + createFastlaneVenue(), + sablierMonadVenue, + ], +} export function buildEvmVenues( chain: EvmChain, env: Record = process.env ): ChainVenue[] { - return canIssueOneClick(env) ? [oneClickOriginVenue(chain), createSendVenue(chain)] : [] + const oneClick = canIssueOneClick(env) + return [ + ...(oneClick ? [oneClickOriginVenue(chain)] : []), + ...ONCHAIN_VENUES[chain](), + ...(oneClick ? [createSendVenue(chain)] : []), + ] } -/** - * Priced from 1Click's list, as NEAR is. Every route here comes from that one venue, so one source - * can't rank two routes by a gap between sources. Any doubt is no price, which refuses the trade. - */ async function priceFromOneClick(chain: EvmChain, token: ChainToken): Promise { try { const assetId = await oneClickAssetId(chain, token) @@ -25,13 +65,45 @@ async function priceFromOneClick(chain: EvmChain, token: ChainToken): Promise): ChainModule { +const AAVE_MARKET = { avalanche: AAVE_AVALANCHE_MARKET, monad: AAVE_MONAD_MARKET } +const WRAPPED_SYMBOL = { avalanche: 'WAVAX', monad: 'WMON' } + +function tokenNamed(chain: EvmChain, symbol: string): ChainToken | undefined { + return EVM_CHAINS[chain].tokens.find((t) => t.symbol === symbol) +} + +/** A token 1Click doesn't list, or any token while 1Click can't answer, from Aave's own oracle. */ +async function priceFromOracle(chain: EvmChain, token: ChainToken): Promise { + const priced = token.id === EVM_NATIVE_ID ? tokenNamed(chain, WRAPPED_SYMBOL[chain]) : token + return priced === undefined ? undefined : aaveOraclePriceUsd(AAVE_MARKET[chain], priced) +} + +/** + * Priced from 1Click's list, as NEAR is, then from the oracle. A wrapped coin is priced as the coin, + * and shMON from what it redeems for. Each token has one answer, so every route out to it is ranked + * at the same price. Any doubt is no price, which refuses the trade. + */ +async function priceOf(chain: EvmChain, token: ChainToken): Promise { + const native = tokenNamed(chain, EVM_CHAINS[chain].nativeSymbol) + if (token.id === SHMON) { + return shmonPriceUsd(native === undefined ? undefined : await priceOf(chain, native)) + } + const listed = token.symbol === WRAPPED_SYMBOL[chain] ? native : token + const fromOneClick = listed === undefined ? undefined : await priceFromOneClick(chain, listed) + return fromOneClick ?? priceFromOracle(chain, token) +} + +function evmModule( + chain: EvmChain, + defaults: ChainModule['defaultVenue'], + tokenHints?: Record +): ChainModule { return { chain, tokens: EVM_CHAINS[chain].tokens, venues: buildEvmVenues(chain), - defaultVenue: { swap: `1click-${chain}`, send: SEND_VENUE_ID }, - priceUsd: (token) => priceFromOneClick(chain, token), + defaultVenue: { swap: `1click-${chain}`, send: SEND_VENUE_ID, ...defaults }, + priceUsd: (token) => priceOf(chain, token), // Every venue here lands its own transactions, and no venue means no submit gets this far. submit: async () => ({ ok: false, @@ -44,12 +116,30 @@ function evmModule(chain: EvmChain, tokenHints?: Record): ChainM } } -export const avalancheModule = evmModule('avalanche') +export const avalancheModule = evmModule('avalanche', { + supply: 'aave-avalanche', + withdraw: 'aave-avalanche', + borrow: 'aave-avalanche', + repay: 'aave-avalanche', + stream: 'sablier-avalanche', + invoice: 'bulla', + pay_invoice: 'bulla', +}) /** * USDT is not matched to USDT0. A person is told instead, so nothing gets signed under a name they * didn't type, and the one token keeps the one name on the card, in the wallet and in 1Click's list. */ -export const monadModule = evmModule('monad', { - USDT: 'On Monad, Tether’s dollar is called USDT0, so ask for USDT0.', -}) +export const monadModule = evmModule( + 'monad', + { + supply: 'aave-monad', + withdraw: 'aave-monad', + borrow: 'aave-monad', + repay: 'aave-monad', + stake: 'fastlane', + unstake: 'fastlane', + stream: 'sablier-monad', + }, + { USDT: 'On Monad, Tether’s dollar is called USDT0, so ask for USDT0.' } +) diff --git a/apps/dapp/lib/chains/evm/morpho-listing.ts b/apps/dapp/lib/chains/evm/morpho-listing.ts new file mode 100644 index 00000000..3c3e0ff5 --- /dev/null +++ b/apps/dapp/lib/chains/evm/morpho-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const morphoListing: Venue = { + id: 'morpho', + family: 'evm', + name: 'Morpho', + category: 'lending', + chains: ['Monad'], + url: 'https://app.morpho.org', + bestFor: 'Earning on USDC in one vault curated by Steakhouse Financial, at a rate that moves', + integration: 'executes', + capability: 'Supplies and withdraws USDC in the Steakhouse High Yield USDC vault', +} diff --git a/apps/dapp/lib/chains/evm/morpho.ts b/apps/dapp/lib/chains/evm/morpho.ts new file mode 100644 index 00000000..98c96be8 --- /dev/null +++ b/apps/dapp/lib/chains/evm/morpho.ts @@ -0,0 +1,249 @@ +import { + decodeAbiParameters, + encodeAbiParameters, + erc20Abi, + keccak256, + parseAbi, + zeroAddress, + type Address, + type Hex, +} from 'viem' + +import { ChainActionRefused, type ChainToken, type PreparedAction } from '../types' +import type { EvmReaderClient } from './call' +import { + createVaultVenue, + type ReviewedVault, + type VaultVenueConfig, + type VaultVenueDeps, +} from './erc4626' +import { sameAddress } from './submit' + +/** + * Morpho on Monad only. Morpho Blue holds $536 on Avalanche (DefiLlama), which is not worth a venue. + * + * Reviewed and left out on Monad: Hyperithm USDC Apex (none free to withdraw), SharpByte USDC High + * Yield (lends against principal tokens at 91.5%, and its curator can add a withdrawal gate with + * no notice), August USDC V2 (a rate near 18%, so not taken further). + */ + +// Morpho's Monad table at docs.morpho.org/developers/contracts/addresses, code confirmed by RPC. +export const MORPHO_BLUE_MONAD = '0xD5D960E8C380B724a48AC59E2DfF1b2CB4a1eAee' +export const VAULT_V2_FACTORY_MONAD = '0x8B2F922162FBb60A6a072cC784A2E4168fB0bb0c' + +const MONAD_USDC: ChainToken = { + symbol: 'USDC', + id: '0x754704Bc059F8C67012fEd69BC8A327a5aafb603', + decimals: 6, + name: 'USD Coin', +} + +// Listed by Morpho's registry (blue-api.morpho.org) with curator 0x827e86...aDeCdB, which that +// registry names Steakhouse Financial. `isVaultV2` is true on the factory above. +export const MORPHO_VAULT: ReviewedVault = { + address: '0xbeEFf443C3CbA3E369DA795002243BeaC311aB83', + name: 'Steakhouse High Yield USDC', + shareSymbol: 'bbqUSDC', + shareDecimals: 18, + asset: MONAD_USDC, + curator: 'Steakhouse Financial', +} + +/** The vault's only adapter, read from the vault. Another one means another review. */ +export const MORPHO_ADAPTER = '0x607560Aa49da53bC550E40751E90D2057486D0f5' + +/** Morpho Blue market ids the adapter lends to, read from it: XAUt0, WMON, WETH, WBTC. */ +export const MORPHO_REVIEWED_MARKETS: readonly string[] = [ + '0x51efed9df487fd3e316421212683e04cfdb198d470c5c936ae4f36efb45b213d', + '0xcaa68590c9fd1815f5f2e9f61c2001061813f6e346f07505b9e9b3d228b4aa13', + '0x409f2824aee2d8391d4a5924935e13312e157055e262b923b60c9dcb47e6311d', + '0xe35c5abc6418b6319b014e07aa3c86163a870a957284128f03cf7a9e414f8899', +] + +const VAULT_V2_ABI = parseAbi([ + 'function adaptersLength() view returns (uint256)', + 'function adapters(uint256 index) view returns (address)', + 'function liquidityAdapter() view returns (address)', + 'function liquidityData() view returns (bytes)', + 'function convertToAssets(uint256 shares) view returns (uint256)', +]) + +const ADAPTER_ABI = parseAbi([ + 'function marketIdsLength() view returns (uint256)', + 'function marketIds(uint256 index) view returns (bytes32)', +]) + +const MORPHO_BLUE_ABI = parseAbi([ + 'function market(bytes32 id) view returns (uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee)', + 'function position(bytes32 id, address user) view returns (uint256 supplyShares, uint128 borrowShares, uint128 collateral)', +]) + +const MARKET_PARAMS = [ + { type: 'address' }, + { type: 'address' }, + { type: 'address' }, + { type: 'address' }, + { type: 'uint256' }, +] as const + +const vaultAddress = MORPHO_VAULT.address as Address +const WEEK_SECONDS = 7 * 86_400 +const YEAR_SECONDS = 365 * 86_400 +const MONAD_BLOCK_SECONDS = 0.4 +/** Enough shares that the price reads to about twelve significant figures. */ +const PRICE_SHARES = BigInt('1000000000000000000000000') + +/** + * Only cash in the vault and the market its liquidity adapter points at can pay a withdrawal, so + * `maxWithdraw`, which always answers zero on this vault type, couldn't say. + */ +export async function readMorphoLiquidity(reader: EvmReaderClient): Promise { + const [idle, adapter, data] = await Promise.all([ + reader.readContract({ + address: MORPHO_VAULT.asset.id as Address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [vaultAddress], + }), + reader.readContract({ + address: vaultAddress, + abi: VAULT_V2_ABI, + functionName: 'liquidityAdapter', + }), + reader.readContract({ + address: vaultAddress, + abi: VAULT_V2_ABI, + functionName: 'liquidityData', + }), + ]) + if (adapter === zeroAddress || data === '0x') return idle + + const params = decodeAbiParameters(MARKET_PARAMS, data as Hex) + if (!sameAddress(params[0], MORPHO_VAULT.asset.id)) { + throw new Error('The liquidity market lends another token.') + } + const id = keccak256(encodeAbiParameters(MARKET_PARAMS, params)) + const [market, position] = await Promise.all([ + reader.readContract({ + address: MORPHO_BLUE_MONAD as Address, + abi: MORPHO_BLUE_ABI, + functionName: 'market', + args: [id], + }), + reader.readContract({ + address: MORPHO_BLUE_MONAD as Address, + abi: MORPHO_BLUE_ABI, + functionName: 'position', + args: [id, adapter], + }), + ]) + const [totalSupplyAssets, totalSupplyShares, totalBorrowAssets] = market + const [supplyShares] = position + const free = totalSupplyAssets - totalBorrowAssets + const held = + totalSupplyShares === BigInt(0) + ? BigInt(0) + : (supplyShares * totalSupplyAssets) / totalSupplyShares + return idle + (held < free ? held : free) +} + +/** + * What the vault actually paid over the past week, from its share price at both ends. The vault + * has no current rate of its own to read, and the price is already after its fee and before any + * rewards. An RPC with no history gives no rate. A price that fell is a loss, which stops a deposit. + */ +export async function readMorphoApy(reader: EvmReaderClient): Promise { + const latest = await reader.getBlock() + const past = latest.number - BigInt(Math.floor(WEEK_SECONDS / MONAD_BLOCK_SECONDS)) + const [earlier, now, was] = await Promise.all([ + reader.getBlock({ blockNumber: past }), + reader.readContract({ + address: vaultAddress, + abi: VAULT_V2_ABI, + functionName: 'convertToAssets', + args: [PRICE_SHARES], + blockNumber: latest.number, + }), + reader.readContract({ + address: vaultAddress, + abi: VAULT_V2_ABI, + functionName: 'convertToAssets', + args: [PRICE_SHARES], + blockNumber: past, + }), + ]) + if (now < was) { + throw new ChainActionRefused( + `${MORPHO_VAULT.name} lost value over the past week, so I won't add to it. Nothing was signed or sent.` + ) + } + const elapsed = Number(latest.timestamp - earlier.timestamp) + if (elapsed < WEEK_SECONDS / 2 || was === BigInt(0) || now === was) return undefined + const growth = Number(now) / Number(was) + return { basePercent: (Math.pow(growth, YEAR_SECONDS / elapsed) - 1) * 100 } +} + +function drifted(what: string): never { + throw new ChainActionRefused( + `${MORPHO_VAULT.name} ${what} since I reviewed it, so this stays blocked until it is checked again. Nothing was signed or sent.` + ) +} + +/** The card names what the vault lends against, so a vault lending against anything else refuses. */ +export async function assertMorphoAsReviewed(reader: EvmReaderClient): Promise { + const [adapterCount, marketCount] = await Promise.all([ + reader.readContract({ + address: vaultAddress, + abi: VAULT_V2_ABI, + functionName: 'adaptersLength', + }), + reader.readContract({ + address: MORPHO_ADAPTER as Address, + abi: ADAPTER_ABI, + functionName: 'marketIdsLength', + }), + ]) + if (adapterCount !== BigInt(1)) drifted('changed how it lends') + const [adapter, ...markets] = await Promise.all([ + reader.readContract({ + address: vaultAddress, + abi: VAULT_V2_ABI, + functionName: 'adapters', + args: [BigInt(0)], + }), + ...Array.from({ length: Number(marketCount) }, (_, index) => + reader.readContract({ + address: MORPHO_ADAPTER as Address, + abi: ADAPTER_ABI, + functionName: 'marketIds', + args: [BigInt(index)], + }) + ), + ]) + if (!sameAddress(adapter, MORPHO_ADAPTER)) drifted('changed how it lends') + const reviewed = (market: string) => + MORPHO_REVIEWED_MARKETS.some((known) => known.toLowerCase() === market.toLowerCase()) + if (!markets.every(reviewed)) drifted('added a market') +} + +const NOTES: readonly string[] = [ + 'Curated by Steakhouse Financial. This vault lends your USDC to borrowers against XAUt0, WMON, WETH and WBTC. If a price falls too fast or an oracle fails, bad debt falls on everyone in the vault.', + 'Steakhouse must give 7 days’ notice to add a way of lending, raise a limit or block withdrawals, so there is time to leave first. It can move money between these markets, and raise its fee on interest (10% today), at once.', + 'A different Morpho market lost about $250,000 to a misconfigured oracle in October 2024. It is not one of this vault’s markets, but the oracles here are the curator’s choice too.', + 'Any rate shown is what the vault paid over the past 7 days, after its fee, read from its share price. It is not promised, and any rewards are not included.', +] + +export const MORPHO_CONFIG: VaultVenueConfig = { + id: 'morpho', + name: 'Morpho', + chain: 'monad', + vault: MORPHO_VAULT, + notes: NOTES, + readLiquidity: readMorphoLiquidity, + readApy: readMorphoApy, + assertAsReviewed: assertMorphoAsReviewed, +} + +export function createMorphoVenue(deps: VaultVenueDeps = {}) { + return createVaultVenue(MORPHO_CONFIG, deps) +} diff --git a/apps/dapp/lib/chains/evm/neverland-listing.ts b/apps/dapp/lib/chains/evm/neverland-listing.ts new file mode 100644 index 00000000..def7cb81 --- /dev/null +++ b/apps/dapp/lib/chains/evm/neverland-listing.ts @@ -0,0 +1,14 @@ +import type { Venue } from '@intent/types' + +export const neverlandListing: Venue = { + id: 'neverland', + family: 'evm', + name: 'Neverland', + category: 'lending', + chains: ['Monad'], + url: 'https://app.neverland.money', + bestFor: 'A smaller Monad lending market built from Aave V3’s code, at variable rates', + integration: 'executes', + capability: + 'Supplies, withdraws, borrows at a variable rate, and repays USDC and USDT0 in its main market', +} diff --git a/apps/dapp/lib/chains/evm/pharaoh-listing.ts b/apps/dapp/lib/chains/evm/pharaoh-listing.ts new file mode 100644 index 00000000..ec2b6185 --- /dev/null +++ b/apps/dapp/lib/chains/evm/pharaoh-listing.ts @@ -0,0 +1,14 @@ +import type { Venue } from '@intent/types' + +export const pharaohVenue: Venue = { + id: 'pharaoh', + family: 'evm', + name: 'Pharaoh', + category: 'swap', + chains: ['Avalanche'], + url: 'https://www.phar.gg', + bestFor: + 'Swaps from the concentrated-liquidity pool on Avalanche that pays most for the size, priced on chain', + integration: 'executes', + capability: 'Swaps AVAX, WAVAX, USDC and USDT. Pays out WAVAX, not AVAX', +} diff --git a/apps/dapp/lib/chains/evm/pharaoh.ts b/apps/dapp/lib/chains/evm/pharaoh.ts new file mode 100644 index 00000000..4ce260e0 --- /dev/null +++ b/apps/dapp/lib/chains/evm/pharaoh.ts @@ -0,0 +1,106 @@ +import { parseAbi, type Address, type Hex } from 'viem' + +import type { ChainToken, ChainVenue } from '../types' +import { AVALANCHE_TOKENS } from './tokens' +import { + createV3SwapVenue, + REFUND_ABI, + WAVAX, + type SingleSwap, + type V3Router, + type V3SwapDeps, +} from './v3-swap' + +/** + * Pharaoh's concentrated-liquidity pools on Avalanche, a fork of Ramses V3 whose pools are named by + * tick spacing rather than fee. Its DLMM pools and UniversalRouter are left out. + * + * Addresses: https://docs.phar.gg/pages/contract-addresses, as SwapRouter, QuoterV2 and + * RamsesV3PoolDeployer. Routescan verifies the source of both. On chain, each answers `deployer()` + * with that deployer, and `pharaoh.live.test.ts` holds them to it. + */ + +const ROUTER_ABI = [ + ...parseAbi([ + 'struct ExactInputSingleParams { address tokenIn; address tokenOut; int24 tickSpacing; address recipient; uint256 deadline; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; }', + 'function exactInputSingle(ExactInputSingleParams params) payable returns (uint256 amountOut)', + 'function multicall(bytes[] data) payable returns (bytes[] results)', + ]), + ...REFUND_ABI, +] + +const QUOTER_ABI = parseAbi([ + 'struct QuoteExactInputSingleParams { address tokenIn; address tokenOut; uint256 amountIn; int24 tickSpacing; uint160 sqrtPriceLimitX96; }', + 'function quoteExactInputSingle(QuoteExactInputSingleParams params) returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)', +]) + +interface RouterParams { + tokenIn: string + tokenOut: string + tickSpacing: number + recipient: string + deadline: bigint + amountIn: bigint + amountOutMinimum: bigint + sqrtPriceLimitX96: bigint +} + +/** Each is switched on in the factory, read with `tickSpacingInitialFee`. */ +const TICK_SPACINGS = [1, 5, 10, 50, 100, 200] + +export const PHARAOH_TOKENS: ChainToken[] = [WAVAX] + +const AUDIT_NOTE = + 'Pharaoh’s audits cover the Ramses V3 and Shadow code it is built on, and its own docs say they are not a review of Pharaoh’s deployment. Ramses lost about $93,000 to a reward logic flaw on Arbitrum on Oct 24, 2024, per DefiLlama.' + +const ADMIN_NOTE = + 'The router and pool here aren’t upgradeable proxies, but Pharaoh’s governance can change a pool’s fee. The minimum above still holds if it does.' + +export function pharaohRouter(): V3Router { + return { + id: 'pharaoh', + name: 'Pharaoh', + chain: 'avalanche', + router: '0xc8B8fCbDb5C019D7802fFb0b39603395D7d3915c', + quoter: '0xB7297301b7CC659BB96D51754643A0Df6eEA2138', + wrapped: WAVAX, + tokens: [ + ...AVALANCHE_TOKENS.filter((token) => ['AVAX', 'USDC', 'USDT'].includes(token.symbol)), + WAVAX, + ], + tiers: TICK_SPACINGS, + abi: ROUTER_ABI, + quoterAbi: QUOTER_ABI, + quoteParams: (tokenIn, tokenOut, amountIn, tier) => ({ + tokenIn, + tokenOut, + amountIn, + tickSpacing: tier, + sqrtPriceLimitX96: BigInt(0), + }), + swapParams: (swap: SingleSwap) => ({ + tokenIn: swap.tokenIn as Address, + tokenOut: swap.tokenOut as Address, + tickSpacing: swap.tier, + recipient: swap.recipient as Address, + deadline: swap.deadline, + amountIn: swap.amountIn, + amountOutMinimum: swap.amountOutMinimum, + sqrtPriceLimitX96: swap.sqrtPriceLimitX96, + }), + readSwap: (params) => { + const swap = params as RouterParams + return { ...swap, tier: swap.tickSpacing } + }, + multicallArgs: (calls) => [calls], + readMulticall: (args) => { + const [calls] = args as readonly [readonly Hex[]] + return { calls } + }, + notes: [AUDIT_NOTE, ADMIN_NOTE], + } +} + +export function createPharaohVenue(deps?: V3SwapDeps): ChainVenue { + return createV3SwapVenue(pharaohRouter(), deps) +} diff --git a/apps/dapp/lib/chains/evm/sablier-listing.ts b/apps/dapp/lib/chains/evm/sablier-listing.ts new file mode 100644 index 00000000..198bc811 --- /dev/null +++ b/apps/dapp/lib/chains/evm/sablier-listing.ts @@ -0,0 +1,20 @@ +import type { Venue } from '@intent/types' + +function listed(id: string, chain: string, tokens: string): Venue { + return { + id, + family: 'evm', + name: 'Sablier', + category: 'payments', + chains: [chain], + url: 'https://app.sablier.com', + bestFor: 'Paying someone second by second over a set number of days, funded up front', + integration: 'executes', + capability: `Streams ${tokens} to an address you type, and you can cancel the unstreamed part`, + } +} + +export const sablierVenues: Venue[] = [ + listed('sablier-avalanche', 'Avalanche', 'USDC and USDT'), + listed('sablier-monad', 'Monad', 'USDC and USDT0'), +] diff --git a/apps/dapp/lib/chains/evm/sablier.ts b/apps/dapp/lib/chains/evm/sablier.ts new file mode 100644 index 00000000..6ab2c2ea --- /dev/null +++ b/apps/dapp/lib/chains/evm/sablier.ts @@ -0,0 +1,453 @@ +import { + decodeFunctionData, + encodeFunctionData, + formatUnits, + isAddress, + maxUint128, + parseAbi, + type Abi, + type Address, + type Hex, +} from 'viem' + +import { isValidRecipient } from '../recipient' +import { + ChainActionRefused, + toBaseUnits, + tokenBySymbol, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, +} from '../types' +import { + approvalNote, + assertEvmCallSafe, + buildEvmCall, + evmCallVenueParts, + type CheckContext, + type DecodedCall, + type EvmCallPolicy, + type EvmReaderClient, +} from './call' +import { evmReader, type EvmClient } from './client' +import { sameAddress } from './submit' +import { EVM_CHAINS, type EvmChain } from './tokens' + +/** + * Fixed payment streams on Sablier Lockup: the whole amount is funded when the stream is created and + * unlocks evenly, second by second, over the days the person typed. + */ + +// SablierLockup v4.0, immutable. https://docs.sablier.com/guides/lockup/deployments and the `sablier` +// npm package's evm/releases/lockup/v4.0/deployments. Code confirmed by eth_getCode, name() and the +// createWithDurationsLL selector in the runtime, on both chains: see sablier.live.test.ts. +export const SABLIER_LOCKUP: Record = { + avalanche: '0xB891b41533776Ec20f7738c647a11506AA44b8A8', + monad: '0x82723C1ffEc9D43dE5FA80b25Da8df99AfD470ba', +} + +// SablierComptroller, read only, for the withdrawal fee. The `sablier` npm package's +// evm/comptroller/deployments, and what Lockup's own comptroller() returns on both chains. +export const SABLIER_COMPTROLLER = '0x0000008ABbFf7a84a2fE09f9A9b74D3BC2072399' + +// The ERC-20s in tokens.ts. The native coin can't be streamed: Lockup only takes ERC-20s. +export const SABLIER_STREAMABLE: Record = { + avalanche: ['USDC', 'USDT'], + monad: ['USDC', 'USDT0'], +} + +// From @sablier/lockup 4.0.1's artifacts. v3.0 removed the broker, so no one else can be paid here. +export const SABLIER_LOCKUP_ABI = [ + { + type: 'function', + name: 'createWithDurationsLL', + stateMutability: 'payable', + inputs: [ + { + name: 'params', + type: 'tuple', + components: [ + { name: 'sender', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'depositAmount', type: 'uint128' }, + { name: 'token', type: 'address' }, + { name: 'cancelable', type: 'bool' }, + { name: 'transferable', type: 'bool' }, + { name: 'shape', type: 'string' }, + ], + }, + { + name: 'unlockAmounts', + type: 'tuple', + components: [ + { name: 'start', type: 'uint128' }, + { name: 'cliff', type: 'uint128' }, + ], + }, + { name: 'granularity', type: 'uint40' }, + { + name: 'durations', + type: 'tuple', + components: [ + { name: 'cliff', type: 'uint40' }, + { name: 'total', type: 'uint40' }, + ], + }, + ], + outputs: [{ name: 'streamId', type: 'uint256' }], + }, +] as const satisfies Abi + +const FEE_READS = parseAbi([ + 'function comptroller() view returns (address)', + 'function calculateMinFeeWeiFor(uint8 protocol, address user) view returns (uint256)', + 'function getMinFeeUSDFor(uint8 protocol, address user) view returns (uint256)', + 'function MAX_FEE_USD() view returns (uint256)', +]) + +/** `ISablierComptroller.Protocol.Lockup`, in @sablier/evm-utils. */ +const LOCKUP_PROTOCOL = 2 + +const DAY_SECONDS = 86_400 +const MAX_DAYS = 99_999 +const SHAPE = 'linear' +/** Seconds between unlocks. */ +const GRANULARITY = 1 +const SABLIER_APP = 'https://app.sablier.com' + +/** The route's own limit on what can be typed. */ +const WHOLE_DAYS = /^[1-9]\d{0,4}$/ + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +interface Stream { + sender: string + recipient: string + depositAmount: bigint + token: string + cancelable: boolean + transferable: boolean + shape: string + start: bigint + cliffUnlock: bigint + granularity: number + cliff: number + total: number +} + +function streamOf(args: readonly unknown[]): Stream { + const [params, unlocks, granularity, durations] = args as [ + { + sender: string + recipient: string + depositAmount: bigint + token: string + cancelable: boolean + transferable: boolean + shape: string + }, + { start: bigint; cliff: bigint }, + number, + { cliff: number; total: number }, + ] + return { + ...params, + start: unlocks.start, + cliffUnlock: unlocks.cliff, + granularity, + cliff: durations.cliff, + total: durations.total, + } +} + +function streamableTokens(chain: EvmChain): ChainToken[] { + return EVM_CHAINS[chain].tokens.filter((token) => + SABLIER_STREAMABLE[chain].includes(token.symbol) + ) +} + +function isSablierContract(chain: EvmChain, address: string): boolean { + return sameAddress(address, SABLIER_LOCKUP[chain]) || sameAddress(address, SABLIER_COMPTROLLER) +} + +function checkStream(chain: EvmChain) { + return (call: DecodedCall, { account, approval }: CheckContext): void => { + const stream = streamOf(call.args) + if (!sameAddress(stream.sender, account)) { + refuse('This stream is not from your wallet, so someone else could cancel it.') + } + if ( + !isValidRecipient(chain, stream.recipient) || + sameAddress(stream.recipient, account) || + isSablierContract(chain, stream.recipient) + ) { + refuse('This stream pays an address that can’t receive it.') + } + if (!streamableTokens(chain).some((token) => sameAddress(token.id, stream.token))) { + refuse('This stream pays a token I haven’t confirmed for Sablier.') + } + if (!stream.cancelable || stream.transferable) { + refuse('This stream must stay cancelable by you and stay with the address you typed.') + } + if ( + stream.start !== BigInt(0) || + stream.cliffUnlock !== BigInt(0) || + stream.cliff !== 0 || + stream.granularity !== GRANULARITY || + stream.shape !== SHAPE + ) { + refuse('This stream doesn’t pay evenly from start to end.') + } + if ( + stream.total < DAY_SECONDS || + stream.total % DAY_SECONDS !== 0 || + stream.total > MAX_DAYS * DAY_SECONDS + ) { + refuse('This stream’s length is not a whole number of days.') + } + if (approval === undefined) return + if (!sameAddress(approval.token, stream.token)) { + refuse('This approves a different token than the stream pays.') + } + if (approval.units !== stream.depositAmount) { + refuse('This approval must be for exactly the amount streamed.') + } + } +} + +export function sablierPolicy(chain: EvmChain): EvmCallPolicy { + const lockup = SABLIER_LOCKUP[chain] + return { + venueName: 'Sablier', + chain, + contracts: { + [lockup]: { + abi: SABLIER_LOCKUP_ABI, + functions: ['createWithDurationsLL'], + check: checkStream(chain), + }, + }, + spenders: [lockup], + approvable: streamableTokens(chain).map((token) => token.id), + } +} + +/** + * What the policy can't check, since a sent transaction says nothing about the words behind it: + * that this stream is the one that was typed. + */ +export function assertStreamAsAsked( + signing: SigningPayload, + request: ChainActionRequest, + account: string, + chain: EvmChain +): void { + if (signing.family !== 'evm') refuse('That is not a stream on this chain.') + let stream: Stream + try { + const decoded = decodeFunctionData({ abi: SABLIER_LOCKUP_ABI, data: signing.data as Hex }) + stream = streamOf(decoded.args) + } catch { + return refuse('That is not a Sablier stream.') + } + const token = tokenBySymbol(streamableTokens(chain), request.tokenIn) + if (token === undefined || !sameAddress(token.id, stream.token)) { + refuse('This stream pays a different token than you asked for.') + } + if (!sameAddress(stream.sender, account)) refuse('This stream is not from your wallet.') + if (request.recipient === undefined || !sameAddress(stream.recipient, request.recipient)) { + refuse('This stream doesn’t pay the address you typed.') + } + if (BigInt(toBaseUnits(request.amount, token.decimals)) !== stream.depositAmount) { + refuse('This stream’s amount is not what you asked for.') + } + if (stream.total !== Number(request.durationDays) * DAY_SECONDS) { + refuse('This stream runs for a different number of days than you asked for.') + } +} + +interface WithdrawalFee { + wei: bigint + usd: bigint + maxUsd: bigint +} + +/** + * The fee is the comptroller's to set, so it's read from there, for this sender. A failed read + * shows no figure. A comptroller that isn't the pinned one refuses, as it hasn't been reviewed. + */ +async function readWithdrawalFee( + chain: EvmChain, + account: string, + reader: EvmReaderClient +): Promise { + let comptroller: string + try { + comptroller = await reader.readContract({ + address: SABLIER_LOCKUP[chain] as Address, + abi: FEE_READS, + functionName: 'comptroller', + }) + } catch { + return undefined + } + if (!sameAddress(comptroller, SABLIER_COMPTROLLER)) { + refuse('Sablier now uses a fee contract I haven’t checked, so this stays blocked until it is.') + } + const address = SABLIER_COMPTROLLER as Address + const args = [LOCKUP_PROTOCOL, account as Address] as const + try { + const [wei, usd, maxUsd] = await Promise.all([ + reader.readContract({ address, abi: FEE_READS, functionName: 'calculateMinFeeWeiFor', args }), + reader.readContract({ address, abi: FEE_READS, functionName: 'getMinFeeUSDFor', args }), + reader.readContract({ address, abi: FEE_READS, functionName: 'MAX_FEE_USD' }), + ]) + return { wei, usd, maxUsd } + } catch { + return undefined + } +} + +function feeNote(fee: WithdrawalFee | undefined, chain: EvmChain): string { + const { nativeSymbol } = EVM_CHAINS[chain] + // The admin was read on both chains as an account with no code. sablier.live.test.ts holds this. + const change = + 'Withdrawals ask a separate Sablier fee contract first. A single key can upgrade that contract and change the fee with no waiting period, even on a running stream, so a bad upgrade could stall withdrawals until it is fixed. Canceling doesn’t ask it.' + if (fee === undefined) { + return `I couldn't read Sablier's withdrawal fee just now, so no figure is shown. ${change}` + } + const cap = `The fee is capped at ${formatUnits(fee.maxUsd, 8)} dollars. ${change}` + if (fee.wei === BigInt(0)) { + // https://docs.sablier.com/concepts/fees + return `Sablier's contract asks no withdrawal fee: it reads as zero right now. Sablier's docs say its own site charges 0.99 dollars per withdrawal, in ${nativeSymbol}. ${cap}` + } + const coin = Number(Number(formatUnits(fee.wei, 18)).toPrecision(4)) + return `Sablier charges whoever withdraws a fee, now ${coin} ${nativeSymbol} (about ${formatUnits(fee.usd, 8)} dollars) each time. ${cap}` +} + +function streamNotes( + chain: EvmChain, + recipient: string, + days: number, + fee: WithdrawalFee | undefined +): string[] { + const { nativeSymbol } = EVM_CHAINS[chain] + const lockup = SABLIER_LOCKUP[chain] + return [ + `The full amount leaves your wallet now and sits in Sablier's contract. It unlocks evenly, second by second, for ${days} ${days === 1 ? 'day' : 'days'}, starting when this lands.`, + `You can cancel until it ends. The part not yet streamed comes back to you, and ${recipient} keeps what has already streamed, so they can't count on the rest. This app can't cancel for you yet: use ${SABLIER_APP}, or call cancel on ${lockup} from this wallet with the stream’s id, which is in this transaction's logs.`, + `The money doesn't arrive by itself. ${recipient} has to withdraw what has streamed, at ${SABLIER_APP} or on the same contract, and needs ${nativeSymbol} for gas and the fee.`, + feeNote(fee, chain), + `Check that address is a wallet they can sign from. The stream stays with it and can't be moved to another address, and an exchange deposit address may not be able to claim it.`, + `Sablier takes no fee to create this stream, and it pays no one else: you pay only gas.`, + `Sablier's stream contract can't be upgraded and was audited by Cantina and CodeHawks, and neither its docs nor DefiLlama's hack list shows an exploit. Its bug bounty ended on May 1, 2026, so there's no paid reward now for reporting a flaw.`, + ] +} + +function asked(request: ChainActionRequest, account: string, chain: EvmChain) { + if (request.kind !== 'stream') refuse('Sablier only streams here.') + if (!isAddress(account, { strict: false })) refuse('That isn’t a wallet this can build for.') + const token = tokenBySymbol(streamableTokens(chain), request.tokenIn) + if (token === undefined) { + const names = SABLIER_STREAMABLE[chain].join(' or ') + return refuse( + `Sablier streams ${names} on ${EVM_CHAINS[chain].name} here, not ${request.tokenIn}.` + ) + } + const { recipient } = request + if (recipient === undefined) return refuse('A stream needs an address to go to.') + if (!isValidRecipient(chain, recipient)) { + refuse(`That is not an address that can receive on ${EVM_CHAINS[chain].name}.`) + } + if (sameAddress(recipient, account)) refuse('That address is your own wallet.') + if (isSablierContract(chain, recipient)) { + refuse('That address is one of Sablier’s own contracts, and a stream to it would be stuck.') + } + const durationDays = request.durationDays ?? '' + if (!WHOLE_DAYS.test(durationDays)) { + refuse(`A stream needs a length in whole days, from 1 to ${MAX_DAYS} days.`) + } + const units = BigInt(toBaseUnits(request.amount, token.decimals)) + if (units > maxUint128) refuse('That amount is too large for a stream.') + return { token, recipient, days: Number(durationDays), units } +} + +async function prepareStream( + chain: EvmChain, + policy: EvmCallPolicy, + request: ChainActionRequest, + account: string, + reader: EvmReaderClient +): Promise { + const { token, recipient, days, units } = asked(request, account, chain) + const fee = await readWithdrawalFee(chain, account, reader) + const lockup = SABLIER_LOCKUP[chain] + + const data = encodeFunctionData({ + abi: SABLIER_LOCKUP_ABI, + functionName: 'createWithDurationsLL', + args: [ + { + sender: account as Address, + recipient: recipient as Address, + depositAmount: units, + token: token.id as Address, + cancelable: true, + transferable: false, + shape: SHAPE, + }, + { start: BigInt(0), cliff: BigInt(0) }, + GRANULARITY, + { cliff: 0, total: days * DAY_SECONDS }, + ], + }) + + const { signing, needsApproval } = await buildEvmCall( + { + chain, + account, + to: lockup, + data, + pull: { token: token.id, spender: lockup, units }, + }, + reader + ) + assertEvmCallSafe(signing, account, policy) + assertStreamAsAsked(signing, request, account, chain) + + const amount = request.amount.trim() + const notes = streamNotes(chain, recipient, days, fee) + return { + venueId: `sablier-${chain}`, + venueName: 'Sablier', + kind: 'stream', + summary: `Streams ${amount} ${token.symbol} to ${recipient} over ${days} ${days === 1 ? 'day' : 'days'}.`, + pay: { amount, symbol: token.symbol }, + notes: needsApproval ? [approvalNote('Sablier', amount, token.symbol), ...notes] : notes, + signing, + } +} + +/** `reader` and `client` are for tests. A chain's real ones are made when they are needed. */ +export function createSablierVenue( + chain: EvmChain, + reader?: EvmReaderClient, + client?: EvmClient +): ChainVenue { + const policy = sablierPolicy(chain) + return { + id: `sablier-${chain}`, + name: 'Sablier', + kinds: ['stream'], + prepare: (request, account) => + prepareStream(chain, policy, request, account, reader ?? evmReader(chain)), + ...evmCallVenueParts(policy, client), + } +} + +export const sablierAvalancheVenue = createSablierVenue('avalanche') +export const sablierMonadVenue = createSablierVenue('monad') diff --git a/apps/dapp/lib/chains/evm/tokens.ts b/apps/dapp/lib/chains/evm/tokens.ts index abc24be5..8e67c288 100644 --- a/apps/dapp/lib/chains/evm/tokens.ts +++ b/apps/dapp/lib/chains/evm/tokens.ts @@ -4,9 +4,9 @@ import type { Chain } from 'viem' import type { ChainToken } from '../types' /** - * Avalanche and Monad, which swap only through 1Click. Each contract here is matched against - * 1Click's list by `oneClickAssetId` on chain, contract and decimals, so a wrong entry finds no - * route rather than a wrong token. + * Avalanche and Monad. Each contract here is matched against 1Click's list by `oneClickAssetId` on + * chain, contract and decimals, so a wrong entry finds no route rather than a wrong token. The + * wrapped coins and shMON aren't on that list, and their sources are at the venues that use them. */ export type EvmChain = 'avalanche' | 'monad' @@ -30,6 +30,18 @@ export const AVALANCHE_TOKENS: readonly ChainToken[] = [ decimals: 6, name: 'Tether USD', }, + { + symbol: 'WAVAX', + id: '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7', + decimals: 18, + name: 'Wrapped AVAX', + }, + { + symbol: 'WETH.e', + id: '0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB', + decimals: 18, + name: 'Wrapped Ether (bridged)', + }, ] export const MONAD_TOKENS: readonly ChainToken[] = [ @@ -46,6 +58,24 @@ export const MONAD_TOKENS: readonly ChainToken[] = [ decimals: 6, name: 'USDT0', }, + { + symbol: 'WMON', + id: '0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A', + decimals: 18, + name: 'Wrapped MON', + }, + { + symbol: 'WETH', + id: '0xEE8c0E9f1BFFb4Eb878d8f15f368A02a35481242', + decimals: 18, + name: 'Wrapped Ether', + }, + { + symbol: 'shMON', + id: '0x1B68626dCa36c7fE922fD2d55E4f631d962dE19c', + decimals: 18, + name: 'ShMonad', + }, ] export interface EvmChainConfig { diff --git a/apps/dapp/lib/chains/evm/uniswap-listing.ts b/apps/dapp/lib/chains/evm/uniswap-listing.ts new file mode 100644 index 00000000..68d8f7b5 --- /dev/null +++ b/apps/dapp/lib/chains/evm/uniswap-listing.ts @@ -0,0 +1,19 @@ +import type { Venue } from '@intent/types' + +function listing(chain: 'avalanche' | 'monad', chainName: string, symbols: string): Venue { + return { + id: `uniswap-${chain}`, + family: 'evm', + name: 'Uniswap', + category: 'swap', + chains: [chainName], + url: 'https://app.uniswap.org', + bestFor: 'Swaps from the v3 pool that pays most for the size, priced on chain', + integration: 'executes', + capability: `Swaps ${symbols}. Pays out the wrapped coin, not the native one`, + } +} + +export const uniswapAvalancheVenue = listing('avalanche', 'Avalanche', 'AVAX, WAVAX, USDC and USDT') + +export const uniswapMonadVenue = listing('monad', 'Monad', 'MON, WMON, USDC and USDT0') diff --git a/apps/dapp/lib/chains/evm/uniswap.ts b/apps/dapp/lib/chains/evm/uniswap.ts new file mode 100644 index 00000000..1b784393 --- /dev/null +++ b/apps/dapp/lib/chains/evm/uniswap.ts @@ -0,0 +1,118 @@ +import { parseAbi, type Address, type Hex } from 'viem' + +import type { ChainToken, ChainVenue } from '../types' +import { AVALANCHE_TOKENS, MONAD_TOKENS, type EvmChain } from './tokens' +import { + createV3SwapVenue, + REFUND_ABI, + WAVAX, + WMON, + type SingleSwap, + type V3Router, + type V3SwapDeps, +} from './v3-swap' + +/** + * Uniswap v3 through SwapRouter02, on Avalanche and Monad. v4 and the Universal Router would need + * Permit2's second approval, and UniswapX depends on fillers, so neither is used. + * + * Addresses: Uniswap's registry at https://developers.uniswap.org/deployments.json, as + * `v3-swaprouter02-`, `v3-quoterv2-` and `v3-uniswapv3factory-`. The Avalanche + * set is also in Uniswap's sdk-core `src/addresses.ts`. On chain, each router and quoter answers + * with that factory, and `uniswap.live.test.ts` holds them to it. + */ + +const ROUTER_ABI = [ + ...parseAbi([ + 'struct ExactInputSingleParams { address tokenIn; address tokenOut; uint24 fee; address recipient; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; }', + 'function exactInputSingle(ExactInputSingleParams params) payable returns (uint256 amountOut)', + 'function multicall(uint256 deadline, bytes[] data) payable returns (bytes[] results)', + ]), + ...REFUND_ABI, +] + +const QUOTER_ABI = parseAbi([ + 'struct QuoteExactInputSingleParams { address tokenIn; address tokenOut; uint256 amountIn; uint24 fee; uint160 sqrtPriceLimitX96; }', + 'function quoteExactInputSingle(QuoteExactInputSingleParams params) returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)', +]) + +interface RouterParams { + tokenIn: string + tokenOut: string + fee: number + recipient: string + amountIn: bigint + amountOutMinimum: bigint + sqrtPriceLimitX96: bigint +} + +/** Each is switched on in both chains' factories, read with `feeAmountTickSpacing`. */ +const FEE_TIERS = [100, 500, 3000, 10000] + +const RISK_NOTE = + 'Uniswap v3’s pools and this router can’t be upgraded or paused. Its factory’s owner can only add fee tiers and turn on a share of the swap fees. DefiLlama records two old incidents, both on Ethereum and neither in these contracts: about $220,000 taken from Uniswap V1 by reentrancy on Apr 18, 2020, and about $8M phished from liquidity providers on Jul 11, 2022.' + +function pick(tokens: readonly ChainToken[], symbols: readonly string[]): ChainToken[] { + return tokens.filter((token) => symbols.includes(token.symbol)) +} + +export const UNISWAP_AVALANCHE_TOKENS: ChainToken[] = [WAVAX] +export const UNISWAP_MONAD_TOKENS: ChainToken[] = [WMON] + +const CHAINS: Record> = { + avalanche: { + router: '0xbb00FF08d01D300023C629E8fFfFcb65A5a578cE', + quoter: '0xbe0F5544EC67e9B3b2D979aaA43f18Fd87E6257F', + wrapped: WAVAX, + tokens: [...pick(AVALANCHE_TOKENS, ['AVAX', 'USDC', 'USDT']), WAVAX], + }, + monad: { + router: '0xfE31F71C1b106EAc32F1A19239c9a9A72ddfb900', + quoter: '0x661E93cca42AfacB172121EF892830cA3b70F08d', + wrapped: WMON, + tokens: [...pick(MONAD_TOKENS, ['MON', 'USDC', 'USDT0']), WMON], + }, +} + +export function uniswapRouter(chain: EvmChain): V3Router { + return { + id: `uniswap-${chain}`, + name: 'Uniswap', + chain, + ...CHAINS[chain], + tiers: FEE_TIERS, + abi: ROUTER_ABI, + quoterAbi: QUOTER_ABI, + quoteParams: (tokenIn, tokenOut, amountIn, tier) => ({ + tokenIn, + tokenOut, + amountIn, + fee: tier, + sqrtPriceLimitX96: BigInt(0), + }), + swapParams: (swap: SingleSwap) => ({ + tokenIn: swap.tokenIn as Address, + tokenOut: swap.tokenOut as Address, + fee: swap.tier, + recipient: swap.recipient as Address, + amountIn: swap.amountIn, + amountOutMinimum: swap.amountOutMinimum, + sqrtPriceLimitX96: swap.sqrtPriceLimitX96, + }), + // This router's struct has no deadline. Its multicall holds one, which `readMulticall` reads. + readSwap: (params) => { + const swap = params as RouterParams + return { ...swap, tier: swap.fee, deadline: BigInt(0) } + }, + multicallArgs: (calls, deadline) => [deadline, calls], + readMulticall: (args) => { + const [deadline, calls] = args as readonly [bigint, readonly Hex[]] + return { calls, deadline } + }, + notes: [RISK_NOTE], + } +} + +export function createUniswapVenue(chain: EvmChain, deps?: V3SwapDeps): ChainVenue { + return createV3SwapVenue(uniswapRouter(chain), deps) +} diff --git a/apps/dapp/lib/chains/evm/v3-swap.ts b/apps/dapp/lib/chains/evm/v3-swap.ts new file mode 100644 index 00000000..7e2685de --- /dev/null +++ b/apps/dapp/lib/chains/evm/v3-swap.ts @@ -0,0 +1,405 @@ +import { + decodeFunctionData, + encodeFunctionData, + isAddress, + parseAbi, + type Abi, + type Address, + type Hex, +} from 'viem' + +import { formatUnits } from '../near/tokens' +import { + ChainActionRefused, + toBaseUnits, + tokenBySymbol, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type VenueQuote, +} from '../types' +import { + approvalNote, + buildEvmCall, + evmCallVenueParts, + type CheckContext, + type DecodedCall, + type EvmCallPolicy, + type EvmReaderClient, +} from './call' +import { evmClient, evmReader, type EvmClient } from './client' +import { createEvmDeposit } from './deposit' +import { sameAddress, sameCalldata } from './submit' +import { EVM_CHAINS, EVM_NATIVE_ID, type EvmChain } from './tokens' + +/** + * One exact-input swap through a concentrated-liquidity pool, for routers built on Uniswap v3's. + * Each router's calldata goes out as a multicall holding that swap, then a refund of any native + * coin the pool didn't take. A person's whole request is single-hop, so the pool is decided by the + * two tokens and a tier, and the router derives its address itself rather than taking one. + */ + +// Router's `WETH9()` on chain, and Uniswap's sdk-core `src/entities/weth9.ts`. +export const WAVAX: ChainToken = { + symbol: 'WAVAX', + id: '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7', + decimals: 18, + name: 'Wrapped AVAX', +} + +// Router's `WETH9()` on chain, and https://docs.monad.xyz/developer-essentials/network-information. +export const WMON: ChainToken = { + symbol: 'WMON', + id: '0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A', + decimals: 18, + name: 'Wrapped MON', +} + +export const REFUND_ABI = parseAbi(['function refundETH() payable']) + +const REFUND_CALL = encodeFunctionData({ abi: REFUND_ABI, functionName: 'refundETH' }) + +/** The price can move this far between the quote and the swap before it reverts instead. */ +const SLIPPAGE_BPS = BigInt(50) +/** The worst rate a size may get against a small trade on the same pool. Jupiter's limit here too. */ +const MAX_IMPACT_BPS = BigInt(300) +const WHOLE_BPS = BigInt(10_000) +/** How much smaller the trade is that the rate is compared with. */ +const PROBE_DIVISOR = BigInt(100) +const DEADLINE_SECONDS = 20 * 60 +/** + * The furthest ahead a deadline may be when it's checked. This is the only side of it that can be + * checked: a landed swap is read back later, when its deadline has rightly passed. The extra is for + * two servers' clocks. + */ +const LONGEST_DEADLINE_SECONDS = DEADLINE_SECONDS + 5 * 60 + +export interface SingleSwap { + tokenIn: string + tokenOut: string + /** The fee or tick spacing that picks the pool, as this router names it. */ + tier: number + recipient: string + amountIn: bigint + amountOutMinimum: bigint + sqrtPriceLimitX96: bigint + deadline: bigint +} + +/** What one router does differently from another. */ +export interface V3Router { + id: string + name: string + chain: EvmChain + router: string + quoter: string + wrapped: ChainToken + /** All this venue trades: the native coin, the wrapped coin and stablecoins with verified pools. */ + tokens: readonly ChainToken[] + /** Only tiers its factory has switched on. */ + tiers: readonly number[] + /** The router's `exactInputSingle`, `multicall` and `refundETH`, and no other way in. */ + abi: Abi + quoterAbi: Abi + quoteParams: (tokenIn: string, tokenOut: string, amountIn: bigint, tier: number) => object + swapParams: (swap: SingleSwap) => object + readSwap: (params: unknown) => SingleSwap + multicallArgs: (calls: readonly Hex[], deadline: bigint) => readonly unknown[] + /** A deadline here, on a router whose multicall holds it, stands in for the swap's. */ + readMulticall: (args: readonly unknown[]) => { calls: readonly Hex[]; deadline?: bigint } + /** Risks the card always states. */ + notes: readonly string[] +} + +export interface V3SwapDeps { + reader?: () => EvmReaderClient + client?: () => EvmClient +} + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +function readSwapCall(router: V3Router, data: Hex): SingleSwap { + let decoded: { functionName: string; args?: readonly unknown[] | undefined } + try { + decoded = decodeFunctionData({ abi: router.abi, data }) + } catch { + return refuse(`This transaction does something I haven't confirmed for ${router.name}.`) + } + if (decoded.functionName !== 'exactInputSingle') { + return refuse(`This transaction does something I haven't confirmed for ${router.name}.`) + } + const args = decoded.args ?? [] + const again = encodeFunctionData({ abi: router.abi, functionName: 'exactInputSingle', args }) + if (!sameCalldata(again, data)) refuse('This transaction carries extra data.') + return router.readSwap(args[0]) +} + +function checkCall(router: V3Router, call: DecodedCall, context: CheckContext): void { + const { account, value, approval } = context + const { calls, deadline: outerDeadline } = router.readMulticall(call.args) + const paysNative = value > BigInt(0) + if (calls.length !== (paysNative ? 2 : 1)) { + refuse(`This transaction holds steps I haven't confirmed for ${router.name}.`) + } + const [swapCall, refundCall] = calls + if (swapCall === undefined) return refuse('This transaction holds no swap.') + if (paysNative && (refundCall === undefined || !sameCalldata(refundCall, REFUND_CALL))) { + refuse('This swap pays the native coin without returning what the pool leaves.') + } + const swap = readSwapCall(router, swapCall) + const deadline = outerDeadline ?? swap.deadline + + if (!sameAddress(swap.recipient, account)) refuse('This swap pays someone else.') + const tradable = router.tokens.filter((token) => token.id !== EVM_NATIVE_ID) + if ( + !tradable.some((token) => sameAddress(token.id, swap.tokenIn)) || + !tradable.some((token) => sameAddress(token.id, swap.tokenOut)) + ) { + refuse(`This swap uses a token I haven't confirmed for ${router.name}.`) + } + if (sameAddress(swap.tokenIn, swap.tokenOut)) + refuse('This swap has the same token on both sides.') + if (!router.tiers.includes(swap.tier)) { + refuse(`This swap uses a pool I haven't confirmed for ${router.name}.`) + } + // Uniswap's router reads a zero amount as spending what it holds itself. + if (swap.amountIn <= BigInt(0)) refuse('This swap has no amount.') + if (swap.amountOutMinimum <= BigInt(0)) refuse('This swap has no minimum, so its price is open.') + if (swap.sqrtPriceLimitX96 !== BigInt(0)) refuse('This swap could stop short of its amount.') + const now = BigInt(Math.floor(Date.now() / 1000)) + if (deadline > now + BigInt(LONGEST_DEADLINE_SECONDS)) { + refuse('This swap could sit unsent for too long.') + } + + if (paysNative) { + if (!sameAddress(swap.tokenIn, router.wrapped.id)) { + refuse(`This swap sends ${EVM_CHAINS[router.chain].nativeSymbol} for another token.`) + } + if (swap.amountIn !== value) refuse('The value sent is not the amount of this swap.') + if (approval !== undefined) + refuse('This swap pays in the native coin, so it needs no approval.') + return + } + if (approval !== undefined) { + if (!sameAddress(approval.token, swap.tokenIn)) + refuse('This approves a token the swap doesn’t spend.') + if (approval.units !== swap.amountIn) refuse('This approval is not the amount of this swap.') + } +} + +export function v3SwapPolicy(router: V3Router): EvmCallPolicy { + return { + venueName: router.name, + chain: router.chain, + contracts: { + [router.router]: { + abi: router.abi, + functions: ['multicall'], + check: (call, context) => checkCall(router, call, context), + }, + }, + spenders: [router.router], + approvable: router.tokens + .filter((token) => token.id !== EVM_NATIVE_ID) + .map((token) => token.id), + mayPayNative: true, + } +} + +interface Trade { + /** What the person spends, which is the native coin if they said so. */ + tokenIn: ChainToken + /** What the pool takes in: the wrapped form where they spend the native coin. */ + sold: ChainToken + tokenOut: ChainToken + units: bigint +} + +function readTrade(router: V3Router, request: ChainActionRequest): Trade { + const { name, chain, tokens, wrapped } = router + const chainName = EVM_CHAINS[chain].name + if (request.kind !== 'swap') refuse(`${name} only swaps here.`) + const tokenIn = tokenBySymbol(tokens, request.tokenIn) + const tokenOut = tokenBySymbol(tokens, request.tokenOut ?? '') + if (tokenIn === undefined || tokenOut === undefined) { + return refuse( + `${name} doesn't trade ${tokenIn === undefined ? request.tokenIn : (request.tokenOut ?? 'that')} on ${chainName} in this app.` + ) + } + if (tokenOut.id === EVM_NATIVE_ID) { + refuse( + `${name} pays out ${wrapped.symbol} here, not ${tokenOut.symbol}. Ask for ${wrapped.symbol} instead.` + ) + } + const sold = tokenIn.id === EVM_NATIVE_ID ? wrapped : tokenIn + if (sameAddress(sold.id, tokenOut.id)) refuse('Those are the same token.') + return { tokenIn, sold, tokenOut, units: BigInt(toBaseUnits(request.amount, tokenIn.decimals)) } +} + +async function readOut( + router: V3Router, + reader: EvmReaderClient, + trade: Trade, + amountIn: bigint, + tier: number +): Promise { + const { result } = await reader.simulateContract({ + address: router.quoter as Address, + abi: router.quoterAbi, + functionName: 'quoteExactInputSingle', + args: [router.quoteParams(trade.sold.id, trade.tokenOut.id, amountIn, tier)], + }) + const [amountOut] = result as readonly [bigint, ...unknown[]] + return amountOut +} + +interface BestQuote { + tier: number + amountOut: bigint +} + +/** + * The tier that pays most, from the router's own quoter. Most pools on these chains hold almost + * nothing, so paying most doesn't mean enough: the rate this size gets is held against a far smaller + * trade on that pool, and a pool that can't take the size is refused rather than quoted. + */ +async function bestQuote( + router: V3Router, + reader: EvmReaderClient, + trade: Trade +): Promise { + const reads = await Promise.allSettled( + router.tiers.map((tier) => readOut(router, reader, trade, trade.units, tier)) + ) + let best: BestQuote | undefined + reads.forEach((read, index) => { + const tier = router.tiers[index] + if (read.status !== 'fulfilled' || tier === undefined) return + if (read.value > (best?.amountOut ?? BigInt(0))) best = { tier, amountOut: read.value } + }) + if (best === undefined) { + return refuse(`I couldn't read a price for this pair on ${router.name} right now.`) + } + const probeIn = trade.units / PROBE_DIVISOR + if (probeIn === BigInt(0)) return refuse('This amount is too small to price.') + let probeOut: bigint + try { + probeOut = await readOut(router, reader, trade, probeIn, best.tier) + } catch { + return refuse(`I couldn't read a price for this pair on ${router.name} right now.`) + } + if (probeOut === BigInt(0)) return refuse('This amount is too small to price.') + const worstAllowed = probeOut * trade.units * (WHOLE_BPS - MAX_IMPACT_BPS) + if (best.amountOut * probeIn * WHOLE_BPS < worstAllowed) { + refuse(`There isn't enough liquidity on ${router.name} for this size. Try a smaller amount.`) + } + return best +} + +function shown(units: bigint, token: ChainToken): string { + return formatUnits(units.toString(), token.decimals) +} + +/** A `ChainVenue` for this router, whose reads only happen when a request comes. */ +export function createV3SwapVenue(router: V3Router, deps: V3SwapDeps = {}): ChainVenue { + const { id, name, chain, wrapped } = router + const readerOf = deps.reader ?? (() => evmReader(chain)) + const clientOf = deps.client ?? (() => evmClient(chain)) + const policy = v3SwapPolicy(router) + const { nativeSymbol } = EVM_CHAINS[chain] + + async function quote(request: ChainActionRequest): Promise { + const trade = readTrade(router, request) + const { amountOut } = await bestQuote(router, readerOf(), trade) + return { receive: { amount: shown(amountOut, trade.tokenOut), symbol: trade.tokenOut.symbol } } + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + if (!isAddress(account)) refuse(`That is not an account on ${EVM_CHAINS[chain].name}.`) + const trade = readTrade(router, request) + const { tokenIn, sold, tokenOut, units } = trade + const reader = readerOf() + const { tier, amountOut } = await bestQuote(router, reader, trade) + const amountOutMinimum = (amountOut * (WHOLE_BPS - SLIPPAGE_BPS)) / WHOLE_BPS + if (amountOutMinimum === BigInt(0)) refuse('This amount is too small to price.') + const fundsNotes = await createEvmDeposit(chain, clientOf).assertFunds( + account, + tokenIn, + request.amount + ) + + const paysNative = tokenIn.id === EVM_NATIVE_ID + const deadline = BigInt(Math.floor(Date.now() / 1000) + DEADLINE_SECONDS) + const swapCall = encodeFunctionData({ + abi: router.abi, + functionName: 'exactInputSingle', + args: [ + router.swapParams({ + tokenIn: sold.id, + tokenOut: tokenOut.id, + tier, + recipient: account, + amountIn: units, + amountOutMinimum, + sqrtPriceLimitX96: BigInt(0), + deadline, + }), + ], + }) + const calls = paysNative ? [swapCall, REFUND_CALL] : [swapCall] + const { signing, needsApproval } = await buildEvmCall( + { + chain, + account, + to: router.router, + data: encodeFunctionData({ + abi: router.abi, + functionName: 'multicall', + args: router.multicallArgs(calls, deadline), + }), + ...(paysNative + ? { value: units } + : { pull: { token: sold.id, spender: router.router, units } }), + }, + reader + ) + const out = shown(amountOut, tokenOut) + return { + venueId: id, + venueName: name, + kind: 'swap', + summary: `Swap ${request.amount} ${tokenIn.symbol} for about ${out} ${tokenOut.symbol} on ${name}.`, + pay: { amount: request.amount, symbol: tokenIn.symbol }, + receive: { amount: out, symbol: tokenOut.symbol, approximate: true }, + notes: [ + `You get at least ${shown(amountOutMinimum, tokenOut)} ${tokenOut.symbol}, or the swap fails and only the network fee is spent. It stops being valid ${DEADLINE_SECONDS / 60} minutes after this was built.`, + ...(needsApproval ? [approvalNote(name, request.amount, tokenIn.symbol)] : []), + ...(paysNative + ? [ + `The router wraps your ${nativeSymbol} for the pool, and sends back anything the pool doesn't take in the same transaction.`, + ] + : []), + ...(sameAddress(tokenOut.id, wrapped.id) + ? [ + `${wrapped.symbol} is the wrapped form of ${nativeSymbol}. It stays ${wrapped.symbol} in your wallet, and network fees still need ${nativeSymbol}.`, + ] + : []), + ...fundsNotes, + ...router.notes, + ], + signing, + } + } + + return { + id, + name, + kinds: ['swap'], + quote, + prepare, + ...evmCallVenueParts(policy, deps.client?.()), + } +} diff --git a/apps/dapp/lib/chains/evm/venues.ts b/apps/dapp/lib/chains/evm/venues.ts index bfdd94e7..e7e39057 100644 --- a/apps/dapp/lib/chains/evm/venues.ts +++ b/apps/dapp/lib/chains/evm/venues.ts @@ -1,5 +1,17 @@ import type { Venue } from '@intent/types' +import { aaveListings } from './aave-listing' +import { beefyMonadListing } from './beefy-listing' +import { bullaListing } from './bulla-listing' +import { curvanceListing } from './curvance-listing' +import { eulerMonadListing } from './euler-listing' +import { fastlaneListing } from './fastlane-listing' +import { morphoListing } from './morpho-listing' +import { neverlandListing } from './neverland-listing' +import { pharaohVenue } from './pharaoh-listing' +import { sablierVenues } from './sablier-listing' +import { uniswapAvalancheVenue, uniswapMonadVenue } from './uniswap-listing' + const ONECLICK_DOCS = 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api' @@ -23,4 +35,16 @@ export const evmVenues: Venue[] = [ url: ONECLICK_DOCS, bestFor: 'Swaps between MON, USDC and USDT0, priced by competing market makers', }, + uniswapAvalancheVenue, + uniswapMonadVenue, + pharaohVenue, + ...aaveListings, + neverlandListing, + morphoListing, + eulerMonadListing, + curvanceListing, + beefyMonadListing, + fastlaneListing, + ...sablierVenues, + bullaListing, ] diff --git a/apps/dapp/lib/chains/explain.ts b/apps/dapp/lib/chains/explain.ts index 805905bd..e1d92727 100644 --- a/apps/dapp/lib/chains/explain.ts +++ b/apps/dapp/lib/chains/explain.ts @@ -6,6 +6,13 @@ const EXAMPLE: Record string supply: (_symbol, venue) => `supply 25 USDC on ${venue}`, stake: (symbol, venue) => `stake 1 ${symbol} with ${venue}`, send: (symbol) => `send 5 ${symbol} to an address`, + withdraw: (symbol, venue) => `withdraw 25 ${symbol} from ${venue}`, + unstake: (symbol, venue) => `unstake 1 ${symbol} from ${venue}`, + borrow: (symbol, venue) => `borrow 25 ${symbol} on ${venue}`, + repay: (symbol, venue) => `repay 25 ${symbol} on ${venue}`, + stream: (symbol) => `stream 100 ${symbol} to an address over 30 days`, + invoice: (symbol) => `invoice an address 100 ${symbol} due 2027-01-31`, + pay_invoice: (symbol) => `pay invoice 12 with 100 ${symbol}`, } /** What can come after a swap, in the words someone types. */ diff --git a/apps/dapp/lib/chains/names/alldomains-monad.ts b/apps/dapp/lib/chains/names/alldomains-monad.ts new file mode 100644 index 00000000..90211f23 --- /dev/null +++ b/apps/dapp/lib/chains/names/alldomains-monad.ts @@ -0,0 +1,136 @@ +import { + concat, + getAddress, + keccak256, + parseAbi, + toBytes, + zeroAddress, + type Address, + type Hex, +} from 'viem' + +import { evmReader } from '../evm/client' +import { EXPIRED, nameRefused, NOT_CONFIRMED, NOT_REGISTERED } from './tlds' + +/** + * Root and resolver: docs.alldomains.id/protocol/developer-guide/programs-svm-and-smart-contracts- + * evm. Registry: `registryContractAddress` in @onsol/tldparser 1.2.1, and root `registry()` returned + * it on chain 2026-11-05. All three hold code on Monad, read with eth_getCode that day. + */ +export const ANS_MONAD_ROOT: Address = '0xE6d461c863987F2a1096eA3476137F30f75B3d46' +export const ANS_MONAD_REGISTRY: Address = '0xa4338eadf4D2e0851eFb225b0Eab90bE47A095F1' +export const ANS_MONAD_RESOLVER: Address = '0x314582158A0a72802aD8F6EeE6243C73dCf1F562' + +const ROOT_ABI = parseAbi([ + 'struct Tld { address controller; address registrar; string tld; string name; string symbol; bool locked; bytes32 node; bytes32 label; }', + 'function getTld(bytes32 label) view returns (Tld)', +]) +const REGISTRY_ABI = parseAbi([ + 'function owner(bytes32 node) view returns (address)', + 'function resolver(bytes32 node) view returns (address)', +]) +const REGISTRAR_ABI = parseAbi([ + 'struct NameData { string name; uint256 expiry; bool frozen; string url; address owner; }', + 'function nameData(uint256 tokenId) view returns (NameData)', +]) +const RESOLVER_ABI = parseAbi(['function addr(bytes32 node) view returns (address)']) + +const ZERO_NODE = `0x${'0'.repeat(64)}` as Hex + +export interface MonadNameReader { + read: (call: { + address: Address + abi: readonly unknown[] + functionName: string + args?: readonly unknown[] + }) => Promise +} + +export function monadNameReader(): MonadNameReader { + const client = evmReader('monad') + return { read: (call) => client.readContract(call as never) as Promise } +} + +function hashed(text: string): Hex { + return keccak256(toBytes(text)) +} + +/** Not ENS's namehash. ANS hashes the ending with its dot, as @onsol/tldparser's `ansNamehash` does. */ +export function ansNode(label: string, tld: string): Hex { + const tldNode = keccak256(concat([ZERO_NODE, hashed(`.${tld}`)])) + return keccak256(concat([tldNode, hashed(label)])) +} + +function same(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + +/** + * The owner, which the registry and the registrar's NFT must both say, with time left on the name. + * An address record set to anyone else is two answers, so it is refused, not chosen between. + */ +export async function resolveAllDomainsMonad( + reader: MonadNameReader, + name: string, + label: string, + tld: string +): Promise { + const tldData = (await reader.read({ + address: ANS_MONAD_ROOT, + abi: ROOT_ABI, + functionName: 'getTld', + args: [hashed(`.${tld}`)], + })) as { tld: string; registrar: Address } + if (tldData.tld !== `.${tld}` || same(tldData.registrar, zeroAddress)) { + throw nameRefused(name, NOT_CONFIRMED) + } + + const node = ansNode(label, tld) + const [owner, resolver, data] = (await Promise.all([ + reader.read({ + address: ANS_MONAD_REGISTRY, + abi: REGISTRY_ABI, + functionName: 'owner', + args: [node], + }), + reader.read({ + address: ANS_MONAD_REGISTRY, + abi: REGISTRY_ABI, + functionName: 'resolver', + args: [node], + }), + reader.read({ + address: tldData.registrar, + abi: REGISTRAR_ABI, + functionName: 'nameData', + args: [BigInt(hashed(label))], + }), + ])) as [Address, Address, { name: string; expiry: bigint; owner: Address }] + + if (same(owner, zeroAddress)) throw nameRefused(name, NOT_REGISTERED) + if (data.name !== label) throw nameRefused(name, NOT_CONFIRMED) + if (data.expiry <= BigInt(Math.floor(Date.now() / 1000))) throw nameRefused(name, EXPIRED) + if (!same(data.owner, owner)) { + throw nameRefused( + name, + `points to two different wallets: ${owner} in the registry and ${data.owner} holding the name, so I won't pick one.` + ) + } + + if (!same(resolver, zeroAddress)) { + if (!same(resolver, ANS_MONAD_RESOLVER)) throw nameRefused(name, NOT_CONFIRMED) + const addr = (await reader.read({ + address: ANS_MONAD_RESOLVER, + abi: RESOLVER_ABI, + functionName: 'addr', + args: [node], + })) as Address + if (!same(addr, zeroAddress) && !same(addr, owner)) { + throw nameRefused( + name, + `points to two different wallets: ${owner} owns it and its record says ${addr}, so I won't pick one.` + ) + } + } + return getAddress(owner) +} diff --git a/apps/dapp/lib/chains/names/alldomains-solana.ts b/apps/dapp/lib/chains/names/alldomains-solana.ts new file mode 100644 index 00000000..a5f6fcc2 --- /dev/null +++ b/apps/dapp/lib/chains/names/alldomains-solana.ts @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto' + +import { getProgramDerivedAddress, type Address } from '@solana/kit' + +import { + addressAt, + addressBytes, + soleHolder, + TOKEN_PROGRAM, + u64At, + type SolanaNameRpc, +} from './solana-rpc' +import { EXPIRED, nameRefused, NOT_CONFIRMED, NOT_REGISTERED } from './tlds' + +/** + * ANS, TLD House and Name House: docs.alldomains.id/protocol/developer-guide/programs-svm-and-smart- + * contracts-evm, all executable on chain 2026-11-05. The origin is `ROOT_ANS_PUBLIC_KEY` in + * @onsol/tldparser 1.2.1, owned by ANS on chain and derived again from "ANS" in the live test. + */ +const ANS_PROGRAM = 'ALTNSZ46uaAUU7XUV6awvdorLGqAsPwa9shm7h4uP2FK' +const TLD_HOUSE_PROGRAM = 'TLDHkysf5pCnKsVA4gXpNvmy7psXLPEu4LAdDJthT9S' +const NAME_HOUSE_PROGRAM = 'NH3uX6FtVE2fNREAioP7hm5RaozotZxeL6khU1EHx51' +const ANS_ORIGIN = '3mX9b4AZaQehNoQGfckVcmgmA6bkBoFcbLj9RMmMyNcU' + +const NAME_RECORD_DISCRIMINATOR = '4448582c0fa767f3' +const NAME_RECORD_LENGTH = 200 +const NFT_RECORD_DISCRIMINATOR = 'aebe7264b10e5afe' +const NFT_RECORD_LENGTH = 138 +/** Unlike SNS, where active is 2. */ +const NFT_ACTIVE = 1 + +const NO_HOLDER = 'is held as a token with no single holder, so there is no one wallet to send to.' + +function hex(data: Uint8Array, length: number): string { + return Buffer.from(data.subarray(0, length)).toString('hex') +} + +async function ansAddress(text: string, parent: string): Promise { + const hashed = new Uint8Array(createHash('sha256').update(`ALT Name Service${text}`).digest()) + const [address] = await getProgramDerivedAddress({ + programAddress: ANS_PROGRAM as Address, + seeds: [hashed, new Uint8Array(32), addressBytes(parent)], + }) + return address +} + +export async function ansAccounts( + label: string, + tld: string +): Promise<{ parent: string; name: string; nftRecord: string }> { + const parent = await ansAddress(`.${tld}`, ANS_ORIGIN) + const name = await ansAddress(label, parent) + const [tldHouse] = await getProgramDerivedAddress({ + programAddress: TLD_HOUSE_PROGRAM as Address, + seeds: ['tld_house', `.${tld}`], + }) + const [nameHouse] = await getProgramDerivedAddress({ + programAddress: NAME_HOUSE_PROGRAM as Address, + seeds: ['name_house', addressBytes(tldHouse)], + }) + const [nftRecord] = await getProgramDerivedAddress({ + programAddress: NAME_HOUSE_PROGRAM as Address, + seeds: ['nft_record', addressBytes(nameHouse), addressBytes(name)], + }) + return { parent, name, nftRecord } +} + +/** + * The owner, or the holder when the name is wrapped as an NFT, which is what @onsol/tldparser's + * `getNameOwner` reads. A name past its expiry is refused, including through the grace period: the + * docs say an expired name "can no longer receive tokens". + */ +export async function resolveAllDomainsSolana( + rpc: SolanaNameRpc, + name: string, + label: string, + tld: string +): Promise { + const accounts = await ansAccounts(label, tld) + const [account] = await rpc.accounts([accounts.name]) + if (account === null || account === undefined) throw nameRefused(name, NOT_REGISTERED) + const { data } = account + if ( + account.program !== ANS_PROGRAM || + data.length < NAME_RECORD_LENGTH || + hex(data, 8) !== NAME_RECORD_DISCRIMINATOR || + addressAt(data, 8) !== accounts.parent + ) { + throw nameRefused(name, NOT_CONFIRMED) + } + const expiry = u64At(data, 104) + if (expiry !== BigInt(0) && expiry <= BigInt(Math.floor(Date.now() / 1000))) { + throw nameRefused(name, EXPIRED) + } + const owner = addressAt(data, 40) + if (owner !== accounts.nftRecord) return owner + + const [record] = await rpc.accounts([accounts.nftRecord]) + if ( + record === null || + record === undefined || + record.program !== NAME_HOUSE_PROGRAM || + record.data.length < NFT_RECORD_LENGTH || + hex(record.data, 8) !== NFT_RECORD_DISCRIMINATOR || + record.data[8] !== NFT_ACTIVE || + addressAt(record.data, 10) !== accounts.name + ) { + throw nameRefused(name, NOT_CONFIRMED) + } + const holder = await soleHolder(rpc, addressAt(record.data, 74), TOKEN_PROGRAM) + if (holder === undefined) throw nameRefused(name, NO_HOLDER) + return holder +} diff --git a/apps/dapp/lib/chains/names/resolve.ts b/apps/dapp/lib/chains/names/resolve.ts new file mode 100644 index 00000000..7e237360 --- /dev/null +++ b/apps/dapp/lib/chains/names/resolve.ts @@ -0,0 +1,139 @@ +import { isOffCurveAddress, type Address } from '@solana/kit' + +import { sendRefusal } from '../recipient' +import { ChainActionRefused, type ChainWithActions, type PreparedAction } from '../types' +import { monadNameReader, resolveAllDomainsMonad, type MonadNameReader } from './alldomains-monad' +import { resolveAllDomainsSolana } from './alldomains-solana' +import { resolveSnsName, resolveSolName } from './sns' +import { solanaNameRpc, type SolanaNameRpc } from './solana-rpc' +import { resolveSuiName, suiNameReader, type SuiNameReader } from './suins' +import { nameRefused, notReadWords, recipientName, type RecipientName } from './tlds' + +/** Where a name read to. Everything after this point deals only in `address`. */ +export interface ResolvedName { + name: string + address: string + /** Named on the card. */ + source: string + caution?: string | undefined +} + +export interface NameReaders { + solana: () => SolanaNameRpc + monad: () => MonadNameReader + sui: () => SuiNameReader +} + +const LIVE: NameReaders = { solana: solanaNameRpc, monad: monadNameReader, sui: suiNameReader } + +const SOLD_NAME = + 'A SuiNS name keeps pointing to the address last set, even after the name is sold, so check this is still who you mean.' + +async function read( + typed: RecipientName, + readers: NameReaders +): Promise> { + const { name, label, tld } = typed + if (typed.chain === 'sui') { + return { + address: await resolveSuiName(readers.sui(), name, label), + source: 'SuiNS', + caution: SOLD_NAME, + } + } + if (typed.chain === 'monad') { + return { + address: await resolveAllDomainsMonad(readers.monad(), name, label, tld), + source: 'AllDomains', + } + } + const rpc = readers.solana() + const sns = tld === 'sol' || tld === 'sns' + const address = + tld === 'sol' + ? await resolveSolName(rpc, name, label) + : tld === 'sns' + ? await resolveSnsName(rpc, name, label) + : await resolveAllDomainsSolana(rpc, name, label, tld) + // A program holds no key, so what is sent to it is stranded. sns-sdk-kit's default refuses too. + if (isOffCurveAddress(address as Address)) { + throw nameRefused( + name, + 'is held by a program, not a wallet, so money sent there could be stuck.' + ) + } + return { address, source: sns ? 'the Solana Name Service' : 'AllDomains' } +} + +/** + * Reads a name on its own chain. Any failure is a refusal, never a guess: what a person can act on + * passes through in its own words, and anything else (a dead node, bytes that don't decode) is + * reported as an unread name. + */ +export async function resolveName( + chain: ChainWithActions, + typed: RecipientName, + readers: Partial = {} +): Promise { + const refusal = sendRefusal(chain, undefined, typed.name) + if (refusal !== undefined) throw new ChainActionRefused(refusal) + try { + return { name: typed.name, ...(await read(typed, { ...LIVE, ...readers })) } + } catch (e) { + if (e instanceof ChainActionRefused) throw e + throw new ChainActionRefused(notReadWords(typed.name)) + } +} + +/** + * What a recipient resolves to, or nothing if it is an address already. The resolved address has to + * pass everything a typed one does, so the same check runs on it. + */ +export async function resolveRecipient( + chain: ChainWithActions, + symbol: string, + recipient: string, + account: string, + readers: Partial = {} +): Promise { + const refusal = sendRefusal(chain, symbol, recipient, account) + if (refusal !== undefined) throw new ChainActionRefused(refusal) + const typed = recipientName(recipient) + if (typed === undefined) return undefined + const resolved = await resolveName(chain, typed, readers) + const afterName = sendRefusal(chain, symbol, resolved.address, account) + if (afterName !== undefined) { + throw new ChainActionRefused(`${resolved.name} points to ${resolved.address}. ${afterName}`) + } + return resolved +} + +/** Only an EVM or Sui address may differ by case, and base58 never. */ +function sameAddress(a: string, b: string): boolean { + return a.startsWith('0x') ? a.toLowerCase() === b.toLowerCase() : a === b +} + +/** + * The card for a request that named someone. A venue that issued any other recipient than the one + * the name read to is stopped here, since the person would read one address and sign another. + */ +export function withResolvedName(prepared: PreparedAction, named: ResolvedName): PreparedAction { + const { name, address, source, caution } = named + const issued = prepared.context?.['recipient'] + if (issued !== undefined && !sameAddress(issued, address)) { + throw new ChainActionRefused( + `This was built for a different address than ${name} points to, so it was stopped. Nothing was signed or sent.` + ) + } + return { + ...prepared, + summary: prepared.summary.includes(address) + ? prepared.summary.replace(address, `${name} (${address})`) + : `${prepared.summary} ${name} is ${address}.`, + notes: [ + `${name} points to ${address}, read just now from ${source}. That address is what gets paid, so check it is who you mean.`, + ...(caution === undefined ? [] : [caution]), + ...prepared.notes, + ], + } +} diff --git a/apps/dapp/lib/chains/names/sns.ts b/apps/dapp/lib/chains/names/sns.ts new file mode 100644 index 00000000..58bdb87d --- /dev/null +++ b/apps/dapp/lib/chains/names/sns.ts @@ -0,0 +1,277 @@ +import { createHash } from 'node:crypto' + +import { + getProgramDerivedAddress, + getPublicKeyFromAddress, + verifySignature, + type Address, + type SignatureBytes, +} from '@solana/kit' + +import { + addressAt, + addressBytes, + soleHolder, + TOKEN_2022_PROGRAM, + TOKEN_PROGRAM, + type NameAccount, + type SolanaNameRpc, +} from './solana-rpc' +import { EXPIRED, nameRefused, NOT_CONFIRMED, NOT_REGISTERED } from './tlds' + +/** + * `namesLP...`, `nftD3...`, the root and the records central state are in @solana-name-service/ + * sns-sdk-kit 1.0.1 `constants/addresses`, and the root in docs.sns.id/dev/tld. `srsWjm...` is in + * its `config`. All checked on chain 2026-11-05: both programs executable, the root owned by the + * name program, and records read back from the PDAs derived here. + */ +const NAME_PROGRAM = 'namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX' +const SNS_ROOT = '58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx' +const NAME_TOKENIZER = 'nftD3vbNkNqfj2Sd3HZwbpw4BxxKWr4AjGb9X38JeZk' +const RECORDS_CENTRAL_STATE = '2pMnqHvei2N5oDcVGCRdZx48gqti199wr5CsyTTafsbo' +const SRS_PROGRAM = 'srsWjm76StJucL7atFyPSdXFaVLNPFqEt1uFEDPrZsn' +/** From sns-sdk-kit 1.1.0-beta.1. 1.0.1 carries an older value that no live record we read holds. */ +const SRS_SOL_CLASS = 'Dqc9TrYg1AZLEa6UkZUqCDrr9zsHQveo1szGTE1BTW5' + +const REGISTRY_HEADER = 96 +const V1_LENGTH = REGISTRY_HEADER + 32 + 64 +const V2_ID_LENGTH = 32 +const V2_HEADER = 8 +const V2_LENGTH = REGISTRY_HEADER + V2_HEADER + V2_ID_LENGTH * 2 + 32 +const VALIDATION_SOLANA = 1 + +const NFT_RECORD_LENGTH = 98 +const NFT_ACTIVE = 2 +const NFT_INACTIVE = 3 + +const SRS_HEADER = 75 +const SRS_RECORD = 2 +const SRS_OWNER_WALLET = 0 +const SRS_OWNER_TOKEN = 1 + +const NOT_SIGNED = "has a wallet record its owner has not signed, so it can't be trusted." +const STALE = 'has a wallet record set by a previous owner, so it may point to the wrong person.' +const NOT_PROVEN = "has a wallet record that doesn't prove the wallet agreed to it." +const NO_HOLDER = 'is held as a token with no single holder, so there is no one wallet to send to.' + +function nameHash(text: string): Uint8Array { + return new Uint8Array(createHash('sha256').update(`SPL Name Service${text}`).digest()) +} + +async function nameAddress( + text: string, + nameClass: string | undefined, + parent: string +): Promise { + const [address] = await getProgramDerivedAddress({ + programAddress: NAME_PROGRAM as Address, + seeds: [ + nameHash(text), + nameClass === undefined ? new Uint8Array(32) : addressBytes(nameClass), + addressBytes(parent), + ], + }) + return address +} + +async function tokenizerAddress(seed: string, domain: string): Promise { + const [address] = await getProgramDerivedAddress({ + programAddress: NAME_TOKENIZER as Address, + seeds: [seed, addressBytes(domain)], + }) + return address +} + +/** Derived the same way as sns-sdk-kit's `getRecordV1Address`, `getRecordV2Address` and `NftState`. */ +export async function snsAccounts(label: string): Promise<{ + domain: string + nft: string + mint: string + v1: string + v2: string +}> { + const domain = await nameAddress(label, undefined, SNS_ROOT) + const [nft, mint, v1, v2] = await Promise.all([ + tokenizerAddress('nft_record', domain), + tokenizerAddress('tokenized_name', domain), + nameAddress('\x01SOL', undefined, domain), + nameAddress('\x02SOL', RECORDS_CENTRAL_STATE, domain), + ]) + return { domain, nft, mint, v1, v2 } +} + +export async function solAccounts(label: string): Promise<{ record: string; mint: string }> { + const hashed = new Uint8Array(createHash('sha256').update(`name${label}`).digest()) + const [record] = await getProgramDerivedAddress({ + programAddress: SRS_PROGRAM as Address, + seeds: ['record', addressBytes(SRS_SOL_CLASS), hashed], + }) + const [mint] = await getProgramDerivedAddress({ + programAddress: SRS_PROGRAM as Address, + seeds: ['mint', addressBytes(record)], + }) + return { record, mint } +} + +function ownedRecord(account: NameAccount, parent: string, minLength: number): boolean { + return ( + account.program === NAME_PROGRAM && + account.data.length >= minLength && + addressAt(account.data, 0) === parent + ) +} + +/** + * SNS checks a V2 record two ways. Staleness: the owner who set it must still own the name. Right + * of association: the wallet it names must have agreed. sns-sdk-kit's `resolveSns` reads the same + * bytes, but it passes over a stale record. Here any record that fails is the end of the read. + */ +function readV2(name: string, account: NameAccount, domain: string, owner: string): string { + if (!ownedRecord(account, domain, V2_LENGTH)) throw nameRefused(name, NOT_PROVEN) + const { data } = account + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + const staleness = view.getUint16(REGISTRY_HEADER, true) + const right = view.getUint16(REGISTRY_HEADER + 2, true) + const contentLength = view.getUint32(REGISTRY_HEADER + 4, true) + if (staleness !== VALIDATION_SOLANA || right !== VALIDATION_SOLANA || contentLength !== 32) { + throw nameRefused(name, NOT_PROVEN) + } + const ids = REGISTRY_HEADER + V2_HEADER + if (addressAt(data, ids) !== owner) throw nameRefused(name, STALE) + const content = addressAt(data, ids + V2_ID_LENGTH * 2) + if (addressAt(data, ids + V2_ID_LENGTH) !== content) throw nameRefused(name, NOT_PROVEN) + return content +} + +/** + * The docs call the signature one over the two addresses' bytes. Live records sign the hex of those + * bytes as text, as sns-sdk-kit verifies, and the raw form fails on them (checked on `erizzo`). + */ +async function readV1( + name: string, + account: NameAccount, + record: string, + domain: string, + owner: string +): Promise { + if (!ownedRecord(account, domain, V1_LENGTH)) throw nameRefused(name, NOT_SIGNED) + const { data } = account + const target = data.subarray(REGISTRY_HEADER, REGISTRY_HEADER + 32) + const signed = Buffer.concat([target, addressBytes(record)]).toString('hex') + const signature = data.slice(REGISTRY_HEADER + 32, REGISTRY_HEADER + 32 + 64) + let valid = false + try { + valid = await verifySignature( + await getPublicKeyFromAddress(owner as Address), + signature as unknown as SignatureBytes, + new TextEncoder().encode(signed) + ) + } catch { + valid = false + } + if (!valid) throw nameRefused(name, NOT_SIGNED) + return addressAt(data, REGISTRY_HEADER) +} + +/** + * docs.sns.id/dev/on-chain-resolution: the token holder, then a verified V2 record, then a signed + * V1 record, then the owner. The docs send a bad record on to the owner. This refuses instead: a + * record that can't be checked means the name changed hands or was tampered with, and a refused + * send costs nothing while a wrong one can't be undone. + */ +export async function resolveSnsName( + rpc: SolanaNameRpc, + name: string, + label: string +): Promise { + const { domain, nft, mint, v1, v2 } = await snsAccounts(label) + const [domainAccount, nftAccount, v1Account, v2Account] = await rpc.accounts([ + domain, + nft, + v1, + v2, + ]) + if (domainAccount === null || domainAccount === undefined) { + throw nameRefused(name, NOT_REGISTERED) + } + if (!ownedRecord(domainAccount, SNS_ROOT, REGISTRY_HEADER)) { + throw nameRefused(name, NOT_CONFIRMED) + } + const owner = addressAt(domainAccount.data, 32) + + if (nftAccount !== null && nftAccount !== undefined) { + const tag = nftAccount.data[0] + if ( + nftAccount.program !== NAME_TOKENIZER || + nftAccount.data.length !== NFT_RECORD_LENGTH || + addressAt(nftAccount.data, 2) !== domain || + (tag !== NFT_ACTIVE && tag !== NFT_INACTIVE) + ) { + throw nameRefused(name, NOT_CONFIRMED) + } + if (tag === NFT_ACTIVE) { + if (owner !== nft) throw nameRefused(name, NOT_CONFIRMED) + if (addressAt(nftAccount.data, 66) !== mint) throw nameRefused(name, NO_HOLDER) + const holder = await soleHolder(rpc, mint, TOKEN_PROGRAM) + if (holder === undefined) throw nameRefused(name, NO_HOLDER) + return holder + } + } + if (v2Account !== null && v2Account !== undefined) return readV2(name, v2Account, domain, owner) + if (v1Account !== null && v1Account !== undefined) { + return readV1(name, v1Account, v1, domain, owner) + } + return owner +} + +/** The record's owner, as sns-sdk-kit 1.1.0-beta.1's `resolveSol` reads it. */ +async function resolveSrsRecord(rpc: SolanaNameRpc, name: string, label: string): Promise { + const { record, mint } = await solAccounts(label) + const [account] = await rpc.accounts([record]) + if (account === null || account === undefined) throw nameRefused(name, NOT_REGISTERED) + const { data } = account + if ( + account.program !== SRS_PROGRAM || + data.length < SRS_HEADER || + data[0] !== SRS_RECORD || + addressAt(data, 1) !== SRS_SOL_CLASS || + data[66] !== 0 + ) { + throw nameRefused(name, NOT_CONFIRMED) + } + const expiry = new DataView(data.buffer, data.byteOffset, data.byteLength).getBigInt64(67, true) + if (expiry !== BigInt(0) && expiry <= BigInt(Math.floor(Date.now() / 1000))) { + throw nameRefused(name, EXPIRED) + } + const owner = addressAt(data, 34) + if (data[33] === SRS_OWNER_WALLET) return owner + if (data[33] !== SRS_OWNER_TOKEN) throw nameRefused(name, NOT_CONFIRMED) + if (owner !== mint) throw nameRefused(name, NO_HOLDER) + const holder = await soleHolder(rpc, mint, TOKEN_2022_PROGRAM) + if (holder === undefined) throw nameRefused(name, NO_HOLDER) + return holder +} + +/** + * `.sol` has moved. SNS's stable SDK now refuses `.sol` past slot 452,825,395, and its beta reads it + * from the Solana Registration Service, whose records live apart from SNS and can name someone else + * (`mert` did on 2026-11-05). Which one a person means can't be known, so both must give one wallet, + * as the beta's `safeResolve` requires. + */ +export async function resolveSolName( + rpc: SolanaNameRpc, + name: string, + label: string +): Promise { + const [registry, sns] = await Promise.all([ + resolveSrsRecord(rpc, name, label), + resolveSnsName(rpc, name, label), + ]) + if (registry !== sns) { + throw nameRefused( + name, + `points to two different wallets: ${registry} in the .sol registry and ${sns} in the Solana Name Service, so I won't pick one.` + ) + } + return registry +} diff --git a/apps/dapp/lib/chains/names/solana-rpc.ts b/apps/dapp/lib/chains/names/solana-rpc.ts new file mode 100644 index 00000000..c5bed4c3 --- /dev/null +++ b/apps/dapp/lib/chains/names/solana-rpc.ts @@ -0,0 +1,137 @@ +import { getAddressDecoder, getAddressEncoder, type Address } from '@solana/kit' +import { z } from 'zod' + +import { venueFetch } from '../solana/http' + +const DEFAULT_RPC_URL = 'https://api.mainnet-beta.solana.com' + +export const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +export const TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' + +const MINT_MIN = 82 +const TOKEN_ACCOUNT_MIN = 165 + +export interface NameAccount { + /** The program that owns this account. */ + program: string + data: Uint8Array +} + +export interface HeldToken { + address: string + data: Uint8Array +} + +/** A read that fails throws. An account the chain says is absent is `null`, which is not a failure. */ +export interface SolanaNameRpc { + accounts: (addresses: readonly string[]) => Promise<(NameAccount | null)[]> + tokenAccounts: (mint: string, tokenProgram: string) => Promise +} + +const Encoded = z.tuple([z.string(), z.literal('base64')]) +const Accounts = z.object({ + result: z.object({ + value: z.array(z.object({ owner: z.string(), data: Encoded }).nullable()), + }), +}) +const Held = z.object({ + result: z.array(z.object({ pubkey: z.string(), account: z.object({ data: Encoded }) })), +}) + +function decoded(base64: string): Uint8Array { + return new Uint8Array(Buffer.from(base64, 'base64')) +} + +async function call(method: string, params: unknown[]): Promise { + const url = process.env['SOLANA_RPC_URL']?.trim() || DEFAULT_RPC_URL + const { status, body } = await venueFetch(url, { + label: 'Solana', + method: 'POST', + body: { jsonrpc: '2.0', id: 1, method, params }, + }) + if (status !== 200) throw new Error(`${method} answered ${status}`) + return body +} + +export function solanaNameRpc(): SolanaNameRpc { + return { + accounts: async (addresses) => { + const { result } = Accounts.parse( + await call('getMultipleAccounts', [ + addresses, + { encoding: 'base64', commitment: 'confirmed' }, + ]) + ) + if (result.value.length !== addresses.length) throw new Error('short answer') + return result.value.map((account) => + account === null ? null : { program: account.owner, data: decoded(account.data[0]) } + ) + }, + tokenAccounts: async (mint, tokenProgram) => { + // Not getTokenLargestAccounts, which the public endpoint answers almost only with a 429. + const { result } = Held.parse( + await call('getProgramAccounts', [ + tokenProgram, + { + encoding: 'base64', + commitment: 'confirmed', + filters: [ + ...(tokenProgram === TOKEN_PROGRAM ? [{ dataSize: TOKEN_ACCOUNT_MIN }] : []), + { memcmp: { offset: 0, bytes: mint } }, + ], + }, + ]) + ) + return result.map(({ pubkey, account }) => ({ + address: pubkey, + data: decoded(account.data[0]), + })) + }, + } +} + +export function addressAt(data: Uint8Array, offset: number): string { + return getAddressDecoder().decode(data.subarray(offset, offset + 32)) +} + +export function addressBytes(address: string): Uint8Array { + return new Uint8Array(getAddressEncoder().encode(address as Address)) +} + +export function u64At(data: Uint8Array, offset: number): bigint { + return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigUint64(offset, true) +} + +const ONE = BigInt(1) +const NONE = BigInt(0) + +/** + * The one wallet holding this one-of-one token, or nothing. The mint must be initialized, hold no + * decimals and exactly one token, and exactly one account must hold it. Anything else has no answer. + */ +export async function soleHolder( + rpc: SolanaNameRpc, + mint: string, + tokenProgram: string +): Promise { + const [mintAccount] = await rpc.accounts([mint]) + if ( + mintAccount === null || + mintAccount === undefined || + mintAccount.program !== tokenProgram || + mintAccount.data.length < MINT_MIN || + u64At(mintAccount.data, 36) !== ONE || + mintAccount.data[44] !== 0 || + mintAccount.data[45] !== 1 + ) { + return undefined + } + const holding = (await rpc.tokenAccounts(mint, tokenProgram)).filter( + ({ data }) => + data.length >= TOKEN_ACCOUNT_MIN && addressAt(data, 0) === mint && u64At(data, 64) !== NONE + ) + const only = holding[0] + if (only === undefined || holding.length !== 1) return undefined + if (u64At(only.data, 64) !== ONE || only.data[108] === 0) return undefined + return addressAt(only.data, 32) +} diff --git a/apps/dapp/lib/chains/names/suins.ts b/apps/dapp/lib/chains/names/suins.ts new file mode 100644 index 00000000..67f26d6e --- /dev/null +++ b/apps/dapp/lib/chains/names/suins.ts @@ -0,0 +1,75 @@ +import { bcs } from '@mysten/sui/bcs' + +import { suiClient, withTimeout } from '../sui/client' +import { EXPIRED, nameRefused, NOT_CONFIRMED, NOT_REGISTERED } from './tlds' + +/** + * `packageIdV1` and `registryTableId` are `mainPackage.mainnet` in @mysten/suins 2.0.19. On chain + * 2026-11-05: the package is immutable, and the SuiNS object's `RegistryKey` field holds a + * registry whose table is this id. + */ +const SUINS_PACKAGE_V1 = '0xd22b24490e0bae52676651b4f56660a5ff8022a2576e0089f79b3c88d44e08f0' +export const SUINS_REGISTRY_TABLE = + '0xe64cd9db9f829c6cc405d9790bd71567ae07259855f4fba6f02c84f52298c106' +export const SUINS_NAME_RECORD_TYPE = `${SUINS_PACKAGE_V1}::name_record::NameRecord` + +const READ_MS = 8_000 + +const Domain = bcs.struct('Domain', { labels: bcs.vector(bcs.string()) }) +export const NameRecord = bcs.struct('NameRecord', { + nft_id: bcs.Address, + expiration_timestamp_ms: bcs.u64(), + target_address: bcs.option(bcs.Address), + data: bcs.struct('VecMap', { + contents: bcs.vector(bcs.struct('Entry', { key: bcs.string(), value: bcs.string() })), + }), +}) + +export interface SuiNameReader { + /** The value stored for these labels, ending first, or nothing if the chain has none. */ + nameRecord: (labels: readonly string[]) => Promise<{ value: Uint8Array; type: string } | null> +} + +export function suiNameReader(): SuiNameReader { + return { + nameRecord: async (labels) => { + try { + const { dynamicField } = await withTimeout( + suiClient().core.getDynamicField({ + parentId: SUINS_REGISTRY_TABLE, + name: { + type: `${SUINS_PACKAGE_V1}::domain::Domain`, + bcs: Domain.serialize({ labels: [...labels] }).toBytes(), + }, + }), + READ_MS, + 'SuiNS read' + ) + return { value: dynamicField.value.bcs, type: dynamicField.value.type } + } catch (e) { + // The node answers a missing field with an error, not an empty result. + if (e instanceof Error && /not found/i.test(e.message)) return null + throw e + } + }, + } +} + +/** The address a SuiNS name targets, as `SuinsClient.getNameRecord` reads it, over the chain's RPC. */ +export async function resolveSuiName( + reader: SuiNameReader, + name: string, + label: string +): Promise { + const stored = await reader.nameRecord(['sui', label]) + if (stored === null) throw nameRefused(name, NOT_REGISTERED) + if (stored.type !== SUINS_NAME_RECORD_TYPE) throw nameRefused(name, NOT_CONFIRMED) + const record = NameRecord.parse(stored.value) + if (BigInt(record.expiration_timestamp_ms) <= BigInt(Date.now())) { + throw nameRefused(name, EXPIRED) + } + if (record.target_address === null || record.target_address === undefined) { + throw nameRefused(name, 'has no address set, so there is nowhere to send.') + } + return record.target_address +} diff --git a/apps/dapp/lib/chains/names/tlds.ts b/apps/dapp/lib/chains/names/tlds.ts new file mode 100644 index 00000000..be86f646 --- /dev/null +++ b/apps/dapp/lib/chains/names/tlds.ts @@ -0,0 +1,71 @@ +import { ChainActionRefused, type ChainWithActions } from '../types' + +/** Client-safe: the chat refuses a name on the wrong chain from here, before any read. */ + +/** + * The endings this app reads, each on the one chain it reads them on. Solana's AllDomains list is + * kept to four on purpose: its TLD House also holds `.sol`, `.eth`, `.btc`, `.com`, `.x` and others + * that read as somebody else's name, so a new ending has to be added here by hand. Monad's are what + * ANS root `listTlds()` returned on 2026-11-05. + */ +export const NAME_TLDS: Record = { + solana: ['sol', 'sns', 'abc', 'bonk', 'poor', 'skr'], + sui: ['sui'], + monad: ['mon', 'nad', 'chog', 'molandak', 'moyaki', 'salmonad', 'mokadel', 'mouch'], + near: [], + avalanche: [], +} + +const TLD_CHAIN: ReadonlyMap = new Map( + (Object.entries(NAME_TLDS) as [ChainWithActions, readonly string[]][]).flatMap(([chain, tlds]) => + tlds.map((tld) => [tld, chain] as const) + ) +) + +/** Plain letters and digits only, so no look-alike character can stand in for a real name. */ +const LABEL = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +export interface RecipientName { + /** In lower case, the only form these registries hold. */ + name: string + /** The part before the ending. */ + label: string + tld: string + chain: ChainWithActions + sub: boolean + readable: boolean +} + +/** The name in what a person typed, or nothing if it ends in no ending this app reads. */ +export function recipientName(text: string): RecipientName | undefined { + const name = text.trim().toLowerCase() + const parts = name.split('.') + if (parts.length < 2) return undefined + const tld = parts[parts.length - 1] as string + const chain = TLD_CHAIN.get(tld) + if (chain === undefined) return undefined + const labels = parts.slice(0, -1) + return { + name, + label: labels[labels.length - 1] as string, + tld, + chain, + sub: labels.length > 1, + readable: labels.every((label) => LABEL.test(label)), + } +} + +export const NOTHING_MOVED = 'Nothing was signed or sent.' +const USE_ADDRESS = 'Use the wallet address instead.' + +export function nameRefused(name: string, words: string): ChainActionRefused { + return new ChainActionRefused(`${name} ${words} ${USE_ADDRESS} ${NOTHING_MOVED}`) +} + +export const NOT_REGISTERED = "isn't registered, so there is no one to send to." +export const NOT_CONFIRMED = "could not be confirmed as a real record, so I won't send to it." +export const EXPIRED = 'has expired, so it may belong to someone else by now.' + +export function notReadWords(name: string): string { + return `${name} could not be read from the chain just now, so I won't guess where it points. Try again, or use the wallet address. ${NOTHING_MOVED}` +} diff --git a/apps/dapp/lib/chains/near/safety.ts b/apps/dapp/lib/chains/near/safety.ts index ff7b6b38..dfd423d0 100644 --- a/apps/dapp/lib/chains/near/safety.ts +++ b/apps/dapp/lib/chains/near/safety.ts @@ -15,8 +15,7 @@ import { METAPOOL_CONTRACT, RNEAR_CONTRACT, TGAS, USDC_NEAR, USDT_NEAR, WRAP_NEA * - Storage attaches exactly 0.00125 NEAR, the `storage_balance_bounds` minimum * on every contract here. * - A stake or wrap attaches at most 1,000 NEAR. Stablecoin transfers carry at most - * 10,000 tokens. Dollar caps run earlier, in `prepareChainAction`. These ceilings - * only stop a payload far outside any trade that cap would pass. + * 10,000 tokens. * - Gas is limited per method below, and to 300 TGas for a whole transaction. * * What this cannot do: a 1Click deposit address comes from 1Click's API, and any diff --git a/apps/dapp/lib/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index f7c9088a..2126b43b 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -28,9 +28,45 @@ const SEND = new RegExp( 'i' ) +const FROM_VENUE = String.raw`(?:\s+(?:from|on|at|in|via|with|using)\s+([a-z0-9][a-z0-9 .-]{1,30}?))?` +const ADDRESS = String.raw`([a-z0-9](?:[a-z0-9._:-]*[a-z0-9])?)` + +const WITHDRAW = new RegExp( + String.raw`^(?:please\s+)?(?:withdraw|redeem)\s+${AMOUNT}\s*${TOKEN}${FROM_VENUE}${END}`, + 'i' +) +const UNSTAKE = new RegExp( + String.raw`^(?:please\s+)?unstake\s+${AMOUNT}\s*${TOKEN}${FROM_VENUE}${END}`, + 'i' +) +const BORROW = new RegExp( + String.raw`^(?:please\s+)?borrow\s+${AMOUNT}\s*${TOKEN}${FROM_VENUE}${END}`, + 'i' +) +const REPAY = new RegExp( + String.raw`^(?:please\s+)?(?:repay|pay back)\s+${AMOUNT}\s*${TOKEN}${FROM_VENUE}${END}`, + 'i' +) +/** The length is said in days or months, a month read as 30 days, so nothing is left to a default. */ +const STREAM = new RegExp( + String.raw`^(?:please\s+)?(?:stream|pay)\s+${AMOUNT}\s*${TOKEN}\s+to\s+${ADDRESS}\s+over\s+(\d{1,4})\s+(day|days|month|months)${FROM_VENUE}${END}`, + 'i' +) +const INVOICE = new RegExp( + String.raw`^(?:please\s+)?invoice\s+${ADDRESS}\s+(?:for\s+)?${AMOUNT}\s*${TOKEN}\s+due\s+(\d{4}-\d{2}-\d{2})${FROM_VENUE}${END}`, + 'i' +) +/** The amount is said too, so what is paid is what the person read, and the invoice must match it. */ +const PAY_INVOICE = new RegExp( + String.raw`^(?:please\s+)?pay\s+invoice\s+#?(\d{1,30})\s+(?:with\s+)?${AMOUNT}\s*${TOKEN}${FROM_VENUE}${END}`, + 'i' +) + +const DAYS_IN_A_MONTH = 30 + /** Sentences that ask to do something, so a miss gets told what works rather than a guess. */ const ACTION_VERB = - /\b(swap|convert|exchange|trade|change|buy|sell|supply|lend|deposit|save|earn|stake|borrow|cash out|send|bridge|long|short)\b/i + /(swap|convert|exchange|trade|change|buy|sell|supply|lend|deposit|save|earn|stake|unstake|withdraw|redeem|borrow|repay|stream|invoice|cash out|send|bridge|long|short)/i /** People say the chain for its coin. `sui` and `near` are already their symbols. */ const TOKEN_OF_CHAIN_NAME: ReadonlyMap = new Map([ @@ -119,12 +155,75 @@ export function parseChainAction(text: string): ChainActionRequest | null { recipient: send[3] as string, } } + return parsePositionAction(text.trim()) ?? parsePaymentAction(text.trim()) +} + +/** Taking out of a position, or borrowing against one: an amount, a token and maybe a venue. */ +function parsePositionAction(text: string): ChainActionRequest | null { + const shapes = [ + ['withdraw', WITHDRAW], + ['unstake', UNSTAKE], + ['borrow', BORROW], + ['repay', REPAY], + ] as const + for (const [kind, shape] of shapes) { + const match = shape.exec(text) + if (match !== null) { + return { + kind, + amount: amountOf(match[1] as string), + tokenIn: chainTokenWord(match[2] as string), + venue: match[3]?.trim(), + } + } + } return null } -/** Whether the agents price this first. A send has no price to race on and nothing to choose. */ +function parsePaymentAction(text: string): ChainActionRequest | null { + const stream = STREAM.exec(text) + if (stream !== null) { + const count = Number(stream[4]) + const months = (stream[5] as string).toLowerCase().startsWith('month') + if (count === 0) return null + return { + kind: 'stream', + amount: amountOf(stream[1] as string), + tokenIn: chainTokenWord(stream[2] as string), + recipient: stream[3] as string, + durationDays: String(months ? count * DAYS_IN_A_MONTH : count), + venue: stream[6]?.trim(), + } + } + const invoice = INVOICE.exec(text) + if (invoice !== null) { + return { + kind: 'invoice', + recipient: invoice[1] as string, + amount: amountOf(invoice[2] as string), + tokenIn: chainTokenWord(invoice[3] as string), + dueDate: invoice[4] as string, + venue: invoice[5]?.trim(), + } + } + const pay = PAY_INVOICE.exec(text) + if (pay !== null) { + return { + kind: 'pay_invoice', + invoiceId: pay[1] as string, + amount: amountOf(pay[2] as string), + tokenIn: chainTokenWord(pay[3] as string), + venue: pay[4]?.trim(), + } + } + return null +} + +const RACED_KINDS: readonly ChainActionRequest['kind'][] = ['swap', 'supply', 'stake'] + +/** Whether the agents price this first. Only a choice between venues on a price has a race to run. */ export function racesFirst(request: Pick): boolean { - return request.kind !== 'send' + return RACED_KINDS.includes(request.kind) } export function looksLikeChainAction(text: string): boolean { diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index 814e6be8..71c679ee 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -1,13 +1,13 @@ import { CHAIN_DESCRIPTORS } from '@intent/config' -import { assertWithinCap, tradeCapUsd, TradeCapExceeded } from '../server/trade-cap' +import { resolveRecipient, withResolvedName, type ResolvedName } from './names/resolve' import { WRAP_NEAR } from './near/tokens' -import { sendRefusal } from './recipient' import { fromBase, toBase } from './sequence' import { holdingOf } from './sequence-flow' import { ChainActionRefused, tokenBySymbol, + type ChainActionKind, type ChainActionRequest, type ChainModule, type ChainToken, @@ -59,7 +59,9 @@ function pickVenue(chainApi: ChainModule, request: ChainActionRequest): ChainVen } return named } - const defaultId = chainApi.defaultVenue[request.kind] + const defaultId = + chainApi.venueForToken?.[request.kind]?.[request.tokenIn.trim().toUpperCase()] ?? + chainApi.defaultVenue[request.kind] const venue = usable.find((v) => v.id === defaultId) ?? usable[0] if (venue === undefined) { throw new ChainActionRefused(`Nothing here can ${request.kind} on this chain yet.`) @@ -99,66 +101,58 @@ export function assertKnownTokens(chainApi: ChainModule, request: ChainActionReq return tokenIn } -/** - * Refuses an amount that is not a positive number, a token with no live price, - * and a trade over the dollar cap. Shared with the agent race so a refused - * amount is refused in the same words before any agent or venue is called. - * - * The cap always reads as mainnet, because these chains only run on mainnet. The - * Stellar network a deployment defaults to says nothing about Solana's funds. - */ -export async function assertAmountWithinCap( - chainApi: ChainModule, - request: ChainActionRequest, - tokenIn: ChainToken, - env: Record = process.env -): Promise { - const price = await chainApi.priceUsd(tokenIn) +/** Refuses an amount that is not a positive number, in the same words on every route. */ +export function assertValidAmount(request: Pick): void { const amount = Number(request.amount) if (!Number.isFinite(amount) || amount <= 0) { throw new ChainActionRefused(`"${request.amount}" is not an amount.`) } - // The cap's own wording is for whoever runs the deployment, so a person gets this instead. - const usd = price === undefined ? Number.NaN : amount * price - const noun = request.kind === 'send' ? 'transfer' : 'trade' - try { - assertWithinCap(usd, env, 'mainnet') - } catch (e) { - if (!(e instanceof TradeCapExceeded)) throw e - throw new ChainActionRefused( - Number.isFinite(usd) - ? `This app caps each ${noun} at ${tradeCapUsd(env, 'mainnet')}, and this one is about ${usd.toFixed(2)}. Try a smaller amount.` - : `This ${noun} could not be valued in dollars right now, so it was not built. Try again shortly.` - ) - } } -/** A send names who it pays and nothing else does. Refused before anything is priced or read. */ -function assertRecipientRule( +const NAMES_SOMEONE: ReadonlySet = new Set(['send', 'stream', 'invoice']) + +/** The kinds that pay out of the wallet what they name, so the wallet must hold it. */ +const SPENDS_FROM_WALLET: ReadonlySet = new Set([ + 'swap', + 'supply', + 'stake', + 'send', + 'repay', + 'stream', + 'pay_invoice', +]) + +/** + * Only these name who they pay or bill, and each must. A person typed it, so it is checked here on + * every chain, before anything is priced. A name is read here too, and what it reads to must pass + * the same checks: from here on the action knows only that address. + */ +async function assertRecipientRule( chainApi: ChainModule, request: ChainActionRequest, tokenIn: ChainToken, account: string -): void { - if (request.kind !== 'send') { +): Promise { + if (!NAMES_SOMEONE.has(request.kind)) { if (request.recipient !== undefined) { throw new ChainActionRefused( - 'Only a send takes an address, so this was not built. Nothing was signed or sent.' + `A ${request.kind.replace('_', ' ')} doesn't take an address, so this was not built. Nothing was signed or sent.` ) } - return + return undefined } if (request.recipient === undefined) { - throw new ChainActionRefused('A send needs an address to go to. Nothing was signed or sent.') + throw new ChainActionRefused( + `A ${request.kind} needs an address to go to. Nothing was signed or sent.` + ) } - const refusal = sendRefusal(chainApi.chain, tokenIn.symbol, request.recipient, account) - if (refusal !== undefined) throw new ChainActionRefused(refusal) + return resolveRecipient(chainApi.chain, tokenIn.symbol, request.recipient, account) } /** * Whether a step could run after a swap, asked while the swap is unsigned: what the chat alone * can't know, such as the venues this deployment offers and whether a receiver can take the token. - * The amount isn't known yet, so the dollar cap and the reserve are asked again at the step itself. + * The amount isn't known yet, so the fee reserve is asked again at the step itself. */ export async function assertFollowOnCanRun( chainApi: ChainModule, @@ -176,29 +170,26 @@ export async function assertFollowOnCanRun( /** * Prepares an action, with the same order of checks as every other money route: - * known tokens, a dollar value under the cap, then the build, then a check of - * what the venue's API returned before anyone is asked to sign it. - * - * The cap always reads as mainnet, because these chains only run on mainnet. The - * Stellar network a deployment defaults to says nothing about Solana's funds. + * known tokens, a real amount, then the build, then a check of what the venue's + * API returned before anyone is asked to sign it. */ export async function prepareChainAction( chainApi: ChainModule, - request: ChainActionRequest, - account: string, - env: Record = process.env + typed: ChainActionRequest, + account: string ): Promise { - const tokenIn = assertKnownTokens(chainApi, request) - assertRecipientRule(chainApi, request, tokenIn, account) + const tokenIn = assertKnownTokens(chainApi, typed) + const named = await assertRecipientRule(chainApi, typed, tokenIn, account) + const request = named === undefined ? typed : { ...typed, recipient: named.address } - await assertAmountWithinCap(chainApi, request, tokenIn, env) + assertValidAmount(request) await assertNativeReserve(chainApi, request, account) await assertHeldForSend(chainApi, request, tokenIn, account) const venue = pickVenue(chainApi, request) const prepared = await venue.prepare(request, account) venue.assertSafe(prepared.signing, account, prepared.context) - return prepared + return named === undefined ? prepared : withResolvedName(prepared, named) } /** Native coin kept back for network fees, in display units: a trade may not spend into it. */ @@ -259,7 +250,7 @@ async function assertHeldForSend( tokenIn: ChainToken, account: string ): Promise { - if (request.kind !== 'send') return + if (request.kind !== 'send' && request.kind !== 'stream' && request.kind !== 'pay_invoice') return if (tokenIn.symbol.toUpperCase() === NATIVE_SYMBOL[chainApi.chain]) return const reading = await readBalancesBriefly(chainApi, account) if (reading === undefined) return @@ -287,6 +278,8 @@ export async function assertNativeReserve( request: ChainActionRequest, account: string ): Promise { + // Taking out of a position or borrowing adds to the wallet, so there is no fee money to protect. + if (!SPENDS_FROM_WALLET.has(request.kind)) return const tokenIn = tokenBySymbol(chainApi.tokens, request.tokenIn) if (tokenIn === undefined) return if (tokenIn.symbol.toUpperCase() !== NATIVE_SYMBOL[chainApi.chain]) return diff --git a/apps/dapp/lib/chains/recipient.ts b/apps/dapp/lib/chains/recipient.ts index 8906d9ef..7e046748 100644 --- a/apps/dapp/lib/chains/recipient.ts +++ b/apps/dapp/lib/chains/recipient.ts @@ -6,8 +6,9 @@ import { AVALANCHE_TOKENS, EVM_NATIVE_ID, MONAD_TOKENS } from './evm/tokens' import { NEAR_ACCOUNT } from './near/account' import { nearTokens } from './near/tokens' import { SOLANA_TOKENS } from './solana/tokens' +import { recipientName, NAME_TLDS, type RecipientName } from './names/tlds' import { SUI_TOKENS } from './sui/tokens' -import type { ChainWithActions } from './types' +import { withArticle, type ChainWithActions } from './types' /** Client-safe: the chat refuses from here before any swap, and the server checks again. */ @@ -55,9 +56,11 @@ const SUI_PACKAGES = SUI_TOKENS.map((token) => { * Whether money can be sent to this address on this chain, exactly as typed. An EVM address may be * all lower case, which carries no checksum, but one with mixed case must pass its checksum: a * wrong one means a typo, and a typo here can't be undone. Zero addresses and the token contracts - * this app knows are refused, since funds sent to either are gone. + * this app knows are refused, since funds sent to either are gone. A name is never an address, even + * where it fits the shape: `ada.sol` is a well-formed NEAR account, and paying that would be a guess. */ export function isValidRecipient(chain: ChainWithActions, address: string): boolean { + if (recipientName(address) !== undefined) return false switch (chain) { case 'solana': return ( @@ -92,9 +95,27 @@ function isSameAccount(a: string, b: string): boolean { return a.startsWith('0x') ? a.toLowerCase() === b.toLowerCase() : a === b } +/** The sentence for a name this chain can't read, or nothing. Asked with no read at all. */ +function nameRefusal(chain: ChainWithActions, typed: RecipientName): string | undefined { + const { name } = typed + if (!NAME_TLDS[chain].includes(typed.tld)) { + const home = CHAIN_NAME[typed.chain] + const here = CHAIN_NAME[chain] + return `${name} is ${withArticle(home)} name, and this is on ${here}, so it can't be read here. Switch to ${home}, or use ${withArticle(here)} address. ${NOTHING_MOVED}` + } + if (!typed.readable) { + return `${name} can't be read as a name here. Use the wallet address instead. ${NOTHING_MOVED}` + } + if (typed.sub) { + return `${name} is a subname, and those can't be paid by name here. Use the wallet address instead. ${NOTHING_MOVED}` + } + return undefined +} + /** * The sentence for a send that can't go, or nothing. `symbol` and `account` are left out where they - * aren't known yet, and the check runs again where they are. + * aren't known yet, and the check runs again where they are. A name its chain can read passes here, + * and the same check runs again on the address it reads to, before anything is built. */ export function sendRefusal( chain: ChainWithActions, @@ -103,11 +124,17 @@ export function sendRefusal( account?: string ): string | undefined { const name = CHAIN_NAME[chain] - if (!isValidRecipient(chain, recipient)) { - return `That is not an address that can receive on ${name}, so I won't send to it. Check the address. ${NOTHING_MOVED}` - } - if (account !== undefined && isSameAccount(recipient, account)) { - return `That address is your own wallet, so there is nothing to send. ${NOTHING_MOVED}` + const typed = recipientName(recipient) + if (typed !== undefined) { + const refusal = nameRefusal(chain, typed) + if (refusal !== undefined) return refusal + } else { + if (!isValidRecipient(chain, recipient)) { + return `That is not an address that can receive on ${name}, so I won't send to it. Check the address. ${NOTHING_MOVED}` + } + if (account !== undefined && isSameAccount(recipient, account)) { + return `That address is your own wallet, so there is nothing to send. ${NOTHING_MOVED}` + } } if (symbol === undefined) return undefined const wanted = symbol.trim().toUpperCase() diff --git a/apps/dapp/lib/chains/send.ts b/apps/dapp/lib/chains/send.ts index b88cf715..c5bd4be5 100644 --- a/apps/dapp/lib/chains/send.ts +++ b/apps/dapp/lib/chains/send.ts @@ -12,6 +12,8 @@ import { evmDeposit } from './evm/deposit' import { EVM_HASH } from './evm/submit' import { EVM_CHAINS, evmExplorerUrl, type EvmChain } from './evm/tokens' import { NEAR_ACCOUNT, NEAR_TX_HASH } from './near/account' +import { resolveRecipient } from './names/resolve' +import { recipientName } from './names/tlds' import { isRegistered } from './near/rpc' import { assertTokenTransferSafe } from './near/safety' import { buildDepositTransaction } from './near/swap' @@ -37,9 +39,10 @@ import { } from './types' /** - * Moves one token to an address a person typed. It can't be undone, so nothing here decides what a - * transfer is. Each chain's deposit builder builds and checks exactly one, as it does for a Weave - * order and a 1Click swap, with the recipient standing where the deposit address does. + * Moves one token to an address a person typed, or one a name they typed was read to before this + * was asked. It can't be undone, so nothing here decides what a transfer is. Each chain's deposit + * builder builds and checks exactly one, as it does for a Weave order and a 1Click swap, with the + * recipient standing where the deposit address does. * * The browser carries `context` between prepare and submit, so the recipient, token and amount in * it are believed only once `readIssued` proves this server wrote them. With no secret no proof can @@ -269,6 +272,10 @@ export function createSendVenue(chain: ChainWithActions): ChainVenue { } const refusal = sendRefusal(chain, symbol, recipient, account) if (refusal !== undefined) throw new ChainActionRefused(refusal) + // Names are read before a venue is asked. One that got here unread must not be built on. + if (recipientName(recipient) !== undefined) { + throw new ChainActionRefused(`A send needs an address to go to. ${NOTHING_MOVED}`) + } return recipient } @@ -383,7 +390,11 @@ export function createSendVenue(chain: ChainWithActions): ChainVenue { submit, assertCanFollow: async ({ tokenIn, recipient }, account) => { if (secret() === undefined) throw new ChainActionRefused(UNAVAILABLE) - const to = assertSendable(tokenIn, recipient, account) + const named = + recipient === undefined + ? undefined + : await resolveRecipient(chain, tokenIn, recipient, account) + const to = assertSendable(tokenIn, named?.address ?? recipient, account) await inSendWords(async () => mover.assertReceiver?.(to, tokenFor(tokenIn))) }, } diff --git a/apps/dapp/lib/chains/solana/capabilities.ts b/apps/dapp/lib/chains/solana/capabilities.ts index 960d7a56..14084038 100644 --- a/apps/dapp/lib/chains/solana/capabilities.ts +++ b/apps/dapp/lib/chains/solana/capabilities.ts @@ -4,4 +4,6 @@ import type { ChainCapability } from '../explain' export const solanaCapabilities: ChainCapability[] = [ { kind: 'swap', venue: 'Jupiter', symbol: 'SOL', then: ['cashout', 'supply', 'send'] }, { kind: 'supply', venue: 'Jupiter Lend', symbol: 'USDC' }, + { kind: 'stake', venue: 'Jito', symbol: 'SOL' }, + { kind: 'unstake', venue: 'Jito', symbol: 'JitoSOL' }, ] diff --git a/apps/dapp/lib/chains/solana/jito-listing.ts b/apps/dapp/lib/chains/solana/jito-listing.ts new file mode 100644 index 00000000..d61352b3 --- /dev/null +++ b/apps/dapp/lib/chains/solana/jito-listing.ts @@ -0,0 +1,15 @@ +import type { Venue } from '@intent/types' + +/** Client-safe: no chain SDK. */ +export const jitoListing: Venue = { + id: 'jito', + family: 'solana', + name: 'Jito', + category: 'staking', + chains: ['Solana'], + bestFor: 'Minting JitoSOL directly from its stake pool, with no market in between', + url: 'https://www.jito.network', + integration: 'executes', + capability: + 'Stakes SOL for JitoSOL and unstakes JitoSOL for SOL from the pool’s reserve, at the rate read on chain. No yearly rate is shown.', +} diff --git a/apps/dapp/lib/chains/solana/jito.ts b/apps/dapp/lib/chains/solana/jito.ts new file mode 100644 index 00000000..935d42ac --- /dev/null +++ b/apps/dapp/lib/chains/solana/jito.ts @@ -0,0 +1,221 @@ +import { isAddress } from '@solana/kit' + +import { + ChainActionRefused, + takesOnly, + toBaseUnits, + type ChainActionRequest, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type VenueQuote, +} from '../types' +import { + assertStakeShape, + compileForSigning, + computeBudget, + createTokenAccount, + createTokenAccountRule, + depositSol, + depositSolRule, + feePercentText, + lamportsForWithdrawal, + poolTokensForDeposit, + poolWithdrawAuthority, + rateText, + readAccount, + readOnlyRpc, + readStakePool, + stakedTokenOrRefuse, + withdrawSol, + withdrawSolRule, + withSlippage, + type SolanaRpc, + type StakePoolState, + type StakeShape, +} from './stake-pool' +import { fromBaseUnits } from './tokens' + +/** + * https://go.jito.network/docs/jitosol/jitosol-liquid-staking/for-developers/staking-integration/ + * names both. On chain on 5 November 2026 the pool is owned by the SPL stake pool program and its + * `pool_mint` field is this mint. + */ +export const JITO_STAKE_POOL = 'Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb' +export const JITOSOL_MINT = 'J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn' + +const SOL_DECIMALS = 9 +const JITOSOL_DECIMALS = 9 +/** Depositing and creating the token account use under 100,000 units. */ +const COMPUTE_UNITS = 150_000 +/** Left in the reserve, since the program won't empty it past what the account must keep. */ +const RESERVE_MARGIN_LAMPORTS = BigInt(10_000_000) + +export function jitoShapes(account: string): readonly StakeShape[] { + return [ + { + rules: [ + createTokenAccountRule(account, JITOSOL_MINT), + depositSolRule(account, JITO_STAKE_POOL, JITOSOL_MINT), + ], + }, + { rules: [withdrawSolRule(account, JITO_STAKE_POOL, JITOSOL_MINT)] }, + ] +} + +function assertPersonAlone(state: StakePoolState, kind: 'stake' | 'unstake'): void { + const gate = kind === 'stake' ? state.solDepositAuthority : state.solWithdrawAuthority + if (gate !== undefined) { + throw new ChainActionRefused( + `Jito’s stake pool now needs another signature to ${kind} SOL, so I stopped. Nothing was signed or sent.` + ) + } +} + +export interface JitoOptions { + env?: Record + rpc?: SolanaRpc +} + +export function createJitoVenue(options: JitoOptions = {}): ChainVenue { + const rpc = options.rpc ?? readOnlyRpc(options.env) + + async function checkedStake(request: ChainActionRequest, account: string) { + if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') + stakedTokenOrRefuse('Jito', 'stake', request.tokenIn, 'SOL', 'stake 1 SOL with Jito') + const lamports = BigInt(toBaseUnits(request.amount, SOL_DECIMALS)) + const state = await readStakePool(rpc, 'Jito', JITO_STAKE_POOL, JITOSOL_MINT) + assertPersonAlone(state, 'stake') + const poolTokens = poolTokensForDeposit(state, lamports) + if (poolTokens === BigInt(0)) { + throw new ChainActionRefused( + 'That amount is too small to mint any JitoSOL. Nothing was signed or sent.' + ) + } + return { state, lamports, poolTokens } + } + + async function checkedUnstake(request: ChainActionRequest, account: string) { + if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') + stakedTokenOrRefuse( + 'Jito', + 'unstake', + request.tokenIn, + 'JitoSOL', + 'unstake 1 JitoSOL from Jito' + ) + const poolTokens = BigInt(toBaseUnits(request.amount, JITOSOL_DECIMALS)) + const state = await readStakePool(rpc, 'Jito', JITO_STAKE_POOL, JITOSOL_MINT) + assertPersonAlone(state, 'unstake') + const lamports = lamportsForWithdrawal(state, poolTokens, state.solWithdrawalFee) + if (lamports === BigInt(0)) { + throw new ChainActionRefused( + 'That amount is too small to return any SOL. Nothing was signed or sent.' + ) + } + const reserve = await readAccount(rpc, state.reserveStake) + if (reserve.lamports < lamports + RESERVE_MARGIN_LAMPORTS) { + throw new ChainActionRefused( + 'Jito’s pool doesn’t hold enough ready SOL for this right now. Nothing was signed or sent. Try a smaller amount, or swap JitoSOL for SOL on Jupiter instead.' + ) + } + return { state, lamports, poolTokens } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + const { poolTokens } = await checkedStake(request, account) + return { + receive: { + amount: fromBaseUnits(poolTokens.toString(), JITOSOL_DECIMALS), + symbol: 'JitoSOL', + }, + } + } + + async function prepareStake( + request: ChainActionRequest, + account: string + ): Promise { + const { state, lamports, poolTokens } = await checkedStake(request, account) + const transaction = await compileForSigning(rpc, account, [ + ...computeBudget(COMPUTE_UNITS), + createTokenAccount(account, JITOSOL_MINT), + depositSol({ + pool: JITO_STAKE_POOL, + state, + withdrawAuthority: await poolWithdrawAuthority(JITO_STAKE_POOL), + account, + lamports, + minimumPoolTokens: withSlippage(poolTokens), + }), + ]) + const receive = fromBaseUnits(poolTokens.toString(), JITOSOL_DECIMALS) + return { + venueId: 'jito', + venueName: 'Jito', + kind: 'stake', + summary: `Stake ${request.amount} SOL directly into Jito’s stake pool for about ${receive} JitoSOL.`, + pay: { amount: request.amount, symbol: 'SOL' }, + receive: { amount: receive, symbol: 'JitoSOL', approximate: true }, + notes: [ + `1 JitoSOL is worth about ${rateText(state.totalLamports, state.poolTokenSupply)} SOL, read from the pool on chain just now. It rises as rewards arrive, so you get fewer JitoSOL than the SOL you put in.`, + `I’m not showing a yearly rate because the pool doesn’t state one, and rewards aren’t guaranteed. Jito keeps ${feePercentText(state.epochFee)} of rewards, read on chain.`, + `To get out, "unstake" JitoSOL here for SOL straight away, minus the pool’s ${feePercentText(state.solWithdrawalFee)} fee, while its reserve holds enough. If it doesn’t, you can sell JitoSOL on the market, where the price can differ.`, + 'Your SOL sits in the SPL stake pool program, which can be upgraded by its authority, and Jito’s manager and staker keys can change fees and validators. Smart contract failure or poor validator performance could lose you money.', + ], + signing: { family: 'solana', transaction }, + } + } + + async function prepareUnstake( + request: ChainActionRequest, + account: string + ): Promise { + const { state, lamports, poolTokens } = await checkedUnstake(request, account) + const transaction = await compileForSigning(rpc, account, [ + ...computeBudget(COMPUTE_UNITS), + withdrawSol({ + pool: JITO_STAKE_POOL, + state, + withdrawAuthority: await poolWithdrawAuthority(JITO_STAKE_POOL), + account, + poolTokens, + minimumLamports: withSlippage(lamports), + }), + ]) + const receive = fromBaseUnits(lamports.toString(), SOL_DECIMALS) + return { + venueId: 'jito', + venueName: 'Jito', + kind: 'unstake', + summary: `Unstake ${request.amount} JitoSOL from Jito’s stake pool for about ${receive} SOL, paid to your wallet now.`, + pay: { amount: request.amount, symbol: 'JitoSOL' }, + receive: { amount: receive, symbol: 'SOL', approximate: true }, + notes: [ + `This takes SOL from the pool’s ready reserve, so there is no waiting. The pool charges ${feePercentText(state.solWithdrawalFee)} in JitoSOL, read on chain, and the amount above is after that fee.`, + `1 JitoSOL is worth about ${rateText(state.totalLamports, state.poolTokenSupply)} SOL, read on chain just now. This fails with nothing moved if the rate falls more than 0.1% before it lands.`, + ], + signing: { family: 'solana', transaction }, + } + } + + return { + id: 'jito', + name: 'Jito', + kinds: ['stake', 'unstake'], + assertCanFollow: takesOnly('Jito', ['SOL']), + quote, + prepare: (request, account) => + request.kind === 'unstake' + ? prepareUnstake(request, account) + : request.kind === 'stake' + ? prepareStake(request, account) + : Promise.reject( + new ChainActionRefused( + `Jito here can stake and unstake, not ${request.kind}. Nothing was signed or sent.` + ) + ), + assertSafe: (payload: SigningPayload, account: string) => + assertStakeShape(payload, account, 'Jito', jitoShapes(account)), + } +} diff --git a/apps/dapp/lib/chains/solana/marinade-listing.ts b/apps/dapp/lib/chains/solana/marinade-listing.ts new file mode 100644 index 00000000..de5e00f9 --- /dev/null +++ b/apps/dapp/lib/chains/solana/marinade-listing.ts @@ -0,0 +1,15 @@ +import type { Venue } from '@intent/types' + +/** Client-safe: no chain SDK. */ +export const marinadeListing: Venue = { + id: 'marinade', + family: 'solana', + name: 'Marinade', + category: 'staking', + chains: ['Solana'], + bestFor: 'Liquid staking SOL for mSOL, with an instant exit through its own pool for a fee', + url: 'https://marinade.finance', + integration: 'executes', + capability: + 'Stakes SOL for mSOL and unstakes mSOL straight away through Marinade’s pool, showing the fee read on chain. No yearly rate is shown. Native staking and delayed unstake aren’t wired here.', +} diff --git a/apps/dapp/lib/chains/solana/marinade.ts b/apps/dapp/lib/chains/solana/marinade.ts new file mode 100644 index 00000000..e5da701e --- /dev/null +++ b/apps/dapp/lib/chains/solana/marinade.ts @@ -0,0 +1,427 @@ +import { isAddress, type Address, type Instruction } from '@solana/kit' + +import { + ChainActionRefused, + takesOnly, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type VenueQuote, +} from '../types' +import { associatedTokenAccount, SYSTEM_PROGRAM, TOKEN_PROGRAM } from './assert-safe' +import { + accountRoles, + anchorAmountData, + assertStakeShape, + compileForSigning, + computeBudget, + createTokenAccount, + createTokenAccountRule, + rateText, + readAccount, + readAddress, + readOnlyRpc, + readU32, + readU64, + stakedTokenOrRefuse, + type InstructionRule, + type SolanaRpc, + type StakeShape, +} from './stake-pool' +import { fromBaseUnits } from './tokens' + +const { readonly, signer, writable } = accountRoles + +/** + * https://docs.marinade.finance/developers/contract-addresses: "Liquid-staking-program", its "Main + * state account" and "mSOL mint (mainnet-beta)". On chain on 5 November 2026 the program is + * executable, the state is owned by it and its first field is this mint, and the mint decimals are 9. + */ +export const MARINADE_PROGRAM = 'MarBmsSgKXdrN1egZf5sqe1TMai9K1rChYNDJgjq7aD' +export const MARINADE_STATE = '8szGkuLTAux9XMgZ2vtY39jVSowEcpBfFfD8hXSEqdGC' +export const MSOL_MINT = 'mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So' + +export const MARINADE_TOKENS: ChainToken[] = [ + { symbol: 'mSOL', id: MSOL_MINT, decimals: 9, name: 'Marinade staked SOL' }, +] + +/** + * The program's own addresses, seen in live `deposit` and `liquid_unstake` transactions on + * 5 November 2026. The leg and treasury are also fields of the state, checked at every read. The + * others are derived from the state by `seeds` the program checks itself. + */ +const LIQ_POOL_SOL_LEG = 'UefNb6z6yvArqe4cJHTXCqStRsKmWhGxnZzuHbikP5Q' +const LIQ_POOL_MSOL_LEG = '7GgPYjS5Dza89wV6FpZ23kUJRG5vbQ1GM25ezspYFSoE' +const LIQ_POOL_MSOL_LEG_AUTHORITY = 'EyaSjUtSgo9aRD1f8LWXwdvkpDTmXAW54yoSHZRF14WL' +const TREASURY_MSOL = 'B1aLzaNMeFVAyQ6f3XbbUyKcH2YPHu2fqiEagmiF23VR' +const RESERVE = 'Du3Ysj1wKbxPKkuPPnvzQLQh8oMSVifs3jGZjJWXFmHN' +const MSOL_MINT_AUTHORITY = '3JLPCS1qM2zRw3Dp6V4hZnYHd4toMNPkNesXdX9tg6KM' + +/** Anchor discriminators, sha256("global:"), and seen in the same live transactions. */ +const DEPOSIT = 'f223c68952e1f2b6' +const LIQUID_UNSTAKE = '1e1e77f0bfe30c10' + +const SOL_DECIMALS = 9 +const MSOL_DECIMALS = 9 +const COMPUTE_UNITS = 200_000 +const BASIS_POINTS = BigInt(10_000) +const BP_CENTS = BigInt(1_000_000) + +/** + * A liquid unstake can't carry a minimum out: the instruction takes only an amount. So the fee is + * read and shown, and a fee above this is refused. On 5 November 2026 the pool's curve ran from + * 0.03% to a maximum of 1.5%, and it stood near 1.45% with the pool far under its target. + */ +const MAX_UNSTAKE_FEE_BP = BigInt(200) + +export interface MarinadeState { + msolMint: string + treasury: string + msolLeg: string + rentExemptForTokenAcc: bigint + liquidityTarget: bigint + lpMaxFeeBp: bigint + lpMinFeeBp: bigint + totalVirtualStaked: bigint + msolSupply: bigint + minDeposit: bigint + minWithdraw: bigint + stakingCap: bigint + totalUnderControl: bigint + paused: boolean + delayedUnstakeFeeBpCents: bigint + depositFeeBpCents: bigint +} + +/** `programs/marinade-finance/src/state/mod.rs` `State`, in its Borsh layout. */ +export function decodeMarinadeState(data: Uint8Array): MarinadeState | undefined { + if (data.length < 647) return undefined + const activeBalance = readU64(data, 376) + const coolingDown = readU64(data, 226) + readU64(data, 568) + const reserve = readU64(data, 496) + const tickets = readU64(data, 528) + const totalUnderControl = activeBalance + coolingDown + reserve + // A fixed offset ends here, because this enum's size depends on its variant. + const upgrader = data[638] + const after = 639 + (upgrader === 0 ? 20 : upgrader === 1 ? 12 : 0) + if (upgrader === undefined || upgrader > 2 || after + 4 > data.length) return undefined + return { + msolMint: readAddress(data, 8), + treasury: readAddress(data, 104), + msolLeg: readAddress(data, 420), + rentExemptForTokenAcc: readU64(data, 138), + liquidityTarget: readU64(data, 452), + lpMaxFeeBp: BigInt(readU32(data, 460)), + lpMinFeeBp: BigInt(readU32(data, 464)), + totalVirtualStaked: totalUnderControl > tickets ? totalUnderControl - tickets : BigInt(0), + msolSupply: readU64(data, 504), + minDeposit: readU64(data, 544), + minWithdraw: readU64(data, 552), + stakingCap: readU64(data, 560), + totalUnderControl, + paused: data[608] !== 0, + delayedUnstakeFeeBpCents: BigInt(readU32(data, 609)), + depositFeeBpCents: BigInt(readU32(data, after)), + } +} + +/** mSOL a SOL deposit gives, at the rate read. `deposit.rs`. */ +export function msolForDeposit(state: MarinadeState, lamports: bigint): bigint { + const afterFee = lamports - (lamports * state.depositFeeBpCents) / BP_CENTS + return (afterFee * state.msolSupply) / state.totalVirtualStaked +} + +export interface LiquidUnstake { + feeBp: bigint + lamports: bigint +} + +/** What `liquid_unstake.rs` pays for an amount of mSOL. The fee falls as the SOL leg fills. */ +export function liquidUnstake( + state: MarinadeState, + msol: bigint, + solLegLamports: bigint +): LiquidUnstake { + const toValue = (amount: bigint): bigint => (amount * state.totalVirtualStaked) / state.msolSupply + const removed = toValue(msol) + const available = + solLegLamports > state.rentExemptForTokenAcc + ? solLegLamports - state.rentExemptForTokenAcc + : BigInt(0) + let feeBp = state.lpMaxFeeBp + if (removed < available) { + const left = available - removed + feeBp = + left >= state.liquidityTarget + ? state.lpMinFeeBp + : state.lpMaxFeeBp - ((state.lpMaxFeeBp - state.lpMinFeeBp) * left) / state.liquidityTarget + } + return { feeBp, lamports: toValue(msol - (msol * feeBp) / BASIS_POINTS) } +} + +function percentText(basisPoints: bigint): string { + return `${Number(basisPoints) / 100}%` +} + +function delayedUnstakeText(state: MarinadeState): string { + return `Marinade’s delayed unstake charges ${Number(state.delayedUnstakeFeeBpCents) / 10_000}%, read on chain, but waits for the Solana epoch to end, about two days and sometimes one epoch more, and isn’t wired here.` +} + +function hasAmount(data: Uint8Array): boolean { + return readU64(data, 8) > BigInt(0) +} + +function depositRule(account: string): InstructionRule { + return { + program: MARINADE_PROGRAM, + prefix: DEPOSIT, + length: 16, + accountCount: 11, + accounts: { + 0: MARINADE_STATE, + 1: MSOL_MINT, + 6: account, + 7: associatedTokenAccount(account, MSOL_MINT), + 9: SYSTEM_PROGRAM, + 10: TOKEN_PROGRAM, + }, + also: ({ data }) => hasAmount(data), + } +} + +function liquidUnstakeRule(account: string): InstructionRule { + return { + program: MARINADE_PROGRAM, + prefix: LIQUID_UNSTAKE, + length: 16, + accountCount: 10, + accounts: { + 0: MARINADE_STATE, + 1: MSOL_MINT, + 5: associatedTokenAccount(account, MSOL_MINT), + 6: account, + 7: account, + 8: SYSTEM_PROGRAM, + 9: TOKEN_PROGRAM, + }, + also: ({ data }) => hasAmount(data), + } +} + +export function marinadeShapes(account: string): readonly StakeShape[] { + return [ + { rules: [createTokenAccountRule(account, MSOL_MINT), depositRule(account)] }, + { rules: [liquidUnstakeRule(account)] }, + ] +} + +function depositInstruction(account: string, lamports: bigint): Instruction { + return { + programAddress: MARINADE_PROGRAM as Address, + accounts: [ + writable(MARINADE_STATE), + writable(MSOL_MINT), + writable(LIQ_POOL_SOL_LEG), + writable(LIQ_POOL_MSOL_LEG), + readonly(LIQ_POOL_MSOL_LEG_AUTHORITY), + writable(RESERVE), + signer(account), + writable(associatedTokenAccount(account, MSOL_MINT)), + readonly(MSOL_MINT_AUTHORITY), + readonly(SYSTEM_PROGRAM), + readonly(TOKEN_PROGRAM), + ], + data: anchorAmountData(DEPOSIT, lamports), + } +} + +function liquidUnstakeInstruction(account: string, msol: bigint): Instruction { + return { + programAddress: MARINADE_PROGRAM as Address, + accounts: [ + writable(MARINADE_STATE), + writable(MSOL_MINT), + writable(LIQ_POOL_SOL_LEG), + writable(LIQ_POOL_MSOL_LEG), + writable(TREASURY_MSOL), + writable(associatedTokenAccount(account, MSOL_MINT)), + signer(account), + writable(account), + readonly(SYSTEM_PROGRAM), + readonly(TOKEN_PROGRAM), + ], + data: anchorAmountData(LIQUID_UNSTAKE, msol), + } +} + +export interface MarinadeOptions { + env?: Record + rpc?: SolanaRpc +} + +/** + * mSOL only. Marinade Native isn't here: Marinade's docs list a staker authority and a proxy for it, + * but no page I could read says how a wallet enters and leaves it unaided. + */ +export function createMarinadeVenue(options: MarinadeOptions = {}): ChainVenue { + const rpc = options.rpc ?? readOnlyRpc(options.env) + + async function readState(): Promise { + const account = await readAccount(rpc, MARINADE_STATE) + const state = account.owner === MARINADE_PROGRAM ? decodeMarinadeState(account.data) : undefined + if ( + state === undefined || + state.msolMint !== MSOL_MINT || + state.msolLeg !== LIQ_POOL_MSOL_LEG || + state.treasury !== TREASURY_MSOL || + state.msolSupply === BigInt(0) || + state.totalVirtualStaked === BigInt(0) + ) { + throw new ChainActionRefused( + 'Marinade’s state doesn’t look as expected on chain, so I stopped. Nothing was signed or sent.' + ) + } + if (state.paused) { + throw new ChainActionRefused( + 'Marinade is paused on chain right now. Nothing was signed or sent, so try again later.' + ) + } + return state + } + + async function checkedStake(request: ChainActionRequest, account: string) { + if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') + stakedTokenOrRefuse('Marinade', 'stake', request.tokenIn, 'SOL', 'stake 1 SOL with Marinade') + const lamports = BigInt(toBaseUnits(request.amount, SOL_DECIMALS)) + const state = await readState() + if (lamports < state.minDeposit) { + throw new ChainActionRefused( + `Marinade takes at least ${fromBaseUnits(state.minDeposit.toString(), SOL_DECIMALS)} SOL. Nothing was signed or sent.` + ) + } + if (state.totalUnderControl + lamports > state.stakingCap) { + throw new ChainActionRefused( + 'Marinade’s staking cap is full right now. Nothing was signed or sent.' + ) + } + const msol = msolForDeposit(state, lamports) + if (msol === BigInt(0)) { + throw new ChainActionRefused( + 'That amount is too small to give any mSOL. Nothing was signed or sent.' + ) + } + return { state, lamports, msol } + } + + async function checkedUnstake(request: ChainActionRequest, account: string) { + if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') + stakedTokenOrRefuse( + 'Marinade', + 'unstake', + request.tokenIn, + 'mSOL', + 'unstake 1 mSOL from Marinade' + ) + const msol = BigInt(toBaseUnits(request.amount, MSOL_DECIMALS)) + const [state, solLeg] = await Promise.all([readState(), readAccount(rpc, LIQ_POOL_SOL_LEG)]) + const { feeBp, lamports } = liquidUnstake(state, msol, solLeg.lamports) + if (lamports + state.rentExemptForTokenAcc > solLeg.lamports) { + throw new ChainActionRefused( + 'Marinade’s instant-unstake pool doesn’t hold enough SOL for this right now. Nothing was signed or sent. Try a smaller amount, or swap mSOL for SOL on the market instead.' + ) + } + if (lamports < state.minWithdraw || lamports === BigInt(0)) { + throw new ChainActionRefused( + 'That amount is too small to unstake from Marinade. Nothing was signed or sent.' + ) + } + if (feeBp > MAX_UNSTAKE_FEE_BP) { + throw new ChainActionRefused( + `Marinade’s instant-unstake fee is ${percentText(feeBp)} right now, which is too high to build. Nothing was signed or sent. Try a smaller amount, or wait for the pool to refill.` + ) + } + return { state, msol, lamports, feeBp } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + const { msol } = await checkedStake(request, account) + return { receive: { amount: fromBaseUnits(msol.toString(), MSOL_DECIMALS), symbol: 'mSOL' } } + } + + async function prepareStake( + request: ChainActionRequest, + account: string + ): Promise { + const { state, lamports, msol } = await checkedStake(request, account) + const transaction = await compileForSigning(rpc, account, [ + ...computeBudget(COMPUTE_UNITS), + createTokenAccount(account, MSOL_MINT), + depositInstruction(account, lamports), + ]) + const receive = fromBaseUnits(msol.toString(), MSOL_DECIMALS) + return { + venueId: 'marinade', + venueName: 'Marinade', + kind: 'stake', + summary: `Stake ${request.amount} SOL with Marinade for about ${receive} mSOL.`, + pay: { amount: request.amount, symbol: 'SOL' }, + receive: { amount: receive, symbol: 'mSOL', approximate: true }, + notes: [ + `1 mSOL is worth about ${rateText(state.totalVirtualStaked, state.msolSupply)} SOL, read from Marinade’s state on chain just now. It rises as rewards arrive, so you get fewer mSOL than the SOL you put in.`, + 'I’m not showing a yearly rate because Marinade’s program doesn’t state one, and rewards aren’t guaranteed.', + `To get out, "unstake" mSOL here for SOL straight away through Marinade’s pool, for a fee that depends on how much SOL it holds, ${percentText(state.lpMinFeeBp)} to ${percentText(state.lpMaxFeeBp)} right now. ${delayedUnstakeText(state)}`, + 'Marinade’s program can be upgraded by a 6-of-13 multisig, and a 3-of-5 council holds its admin powers, including the fees. It has audits from Neodyme, Ackee and Kudelski, which don’t rule out a failure that could lose you money.', + ], + signing: { family: 'solana', transaction }, + } + } + + async function prepareUnstake( + request: ChainActionRequest, + account: string + ): Promise { + const { state, msol, lamports, feeBp } = await checkedUnstake(request, account) + const transaction = await compileForSigning(rpc, account, [ + ...computeBudget(COMPUTE_UNITS), + liquidUnstakeInstruction(account, msol), + ]) + const receive = fromBaseUnits(lamports.toString(), SOL_DECIMALS) + return { + venueId: 'marinade', + venueName: 'Marinade', + kind: 'unstake', + summary: `Unstake ${request.amount} mSOL through Marinade’s pool for about ${receive} SOL, paid to your wallet now, after a ${percentText(feeBp)} fee.`, + pay: { amount: request.amount, symbol: 'mSOL' }, + receive: { amount: receive, symbol: 'SOL', approximate: true }, + notes: [ + `This is a sale into Marinade’s SOL pool, not a release of your stake. The fee is ${percentText(feeBp)} right now, read on chain, and depends on how much SOL the pool holds.`, + `Marinade’s program takes no minimum for this, so if the pool empties before this lands the fee can rise to its ${percentText(state.lpMaxFeeBp)} maximum, and you’d get less than shown.`, + `${delayedUnstakeText(state)} A swap on the market may also pay better.`, + ], + signing: { family: 'solana', transaction }, + } + } + + return { + id: 'marinade', + name: 'Marinade', + kinds: ['stake', 'unstake'], + assertCanFollow: takesOnly('Marinade', ['SOL']), + quote, + prepare: (request, account) => + request.kind === 'unstake' + ? prepareUnstake(request, account) + : request.kind === 'stake' + ? prepareStake(request, account) + : Promise.reject( + new ChainActionRefused( + `Marinade here can stake and unstake, not ${request.kind}. Nothing was signed or sent.` + ) + ), + assertSafe: (payload: SigningPayload, account: string) => + assertStakeShape(payload, account, 'Marinade', marinadeShapes(account)), + } +} diff --git a/apps/dapp/lib/chains/solana/module.ts b/apps/dapp/lib/chains/solana/module.ts index 22e5fc0a..61c4ce83 100644 --- a/apps/dapp/lib/chains/solana/module.ts +++ b/apps/dapp/lib/chains/solana/module.ts @@ -2,11 +2,15 @@ import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' import { createSendVenue, SEND_VENUE_ID } from '../send' import type { ChainModule, ChainVenue } from '../types' import { readSolanaBalances } from './balances' +import { createJitoVenue } from './jito' import { createJupiterLendVenue } from './jupiter-lend' import { createJupiterSwapVenue } from './jupiter-swap' import { createKaminoVenue } from './kamino' import { createLuloVenue } from './lulo' +import { createMarinadeVenue } from './marinade' import { createPriceSource } from './price' +import { createSolayerVenue } from './solayer' +import { createStreamflowVenue } from './streamflow' import { createSolanaSubmit } from './submit' import { SOLANA_TOKENS } from './tokens' @@ -25,6 +29,10 @@ export function buildSolanaVenues( createJupiterLendVenue({ env }), createKaminoVenue(), ...(luloKey ? [createLuloVenue({ apiKey: luloKey })] : []), + createJitoVenue({ env }), + createMarinadeVenue({ env }), + createSolayerVenue({ env }), + ...(canIssueOneClick(env) ? [createStreamflowVenue()] : []), ...(canIssueOneClick(env) ? [createSendVenue('solana')] : []), ] } @@ -33,7 +41,15 @@ export const solanaModule: ChainModule = { chain: 'solana', tokens: SOLANA_TOKENS, venues: buildSolanaVenues(), - defaultVenue: { swap: 'jupiter', supply: 'jupiter-lend', send: SEND_VENUE_ID }, + defaultVenue: { + swap: 'jupiter', + supply: 'jupiter-lend', + stake: 'jito', + unstake: 'jito', + stream: 'streamflow', + send: SEND_VENUE_ID, + }, + venueForToken: { unstake: { MSOL: 'marinade', JITOSOL: 'jito', SSOL: 'solayer' } }, priceUsd, submit: createSolanaSubmit({ deadlineMs: SUBMIT_DEADLINE_MS }), balances: readSolanaBalances, diff --git a/apps/dapp/lib/chains/solana/solayer-listing.ts b/apps/dapp/lib/chains/solana/solayer-listing.ts new file mode 100644 index 00000000..510f5724 --- /dev/null +++ b/apps/dapp/lib/chains/solana/solayer-listing.ts @@ -0,0 +1,15 @@ +import type { Venue } from '@intent/types' + +/** Client-safe: no chain SDK. */ +export const solayerListing: Venue = { + id: 'solayer', + family: 'solana', + name: 'Solayer', + category: 'staking', + chains: ['Solana'], + bestFor: 'Restaking SOL for sSOL. Much smaller than it was, and leaving takes about two days', + url: 'https://solayer.org', + integration: 'executes', + capability: + 'Restakes SOL for sSOL. Unstaking sSOL gives you a stake account of your own that cools down first, and withdrawing from it isn’t wired here. sSOL only, not sUSD, Pay, the card or the chain.', +} diff --git a/apps/dapp/lib/chains/solana/solayer.ts b/apps/dapp/lib/chains/solana/solayer.ts new file mode 100644 index 00000000..2eb77159 --- /dev/null +++ b/apps/dapp/lib/chains/solana/solayer.ts @@ -0,0 +1,376 @@ +import { isAddress, type Address, type Instruction } from '@solana/kit' + +import { + ChainActionRefused, + takesOnly, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type VenueQuote, +} from '../types' +import { + ASSOCIATED_TOKEN_PROGRAM, + associatedTokenAccount, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, +} from './assert-safe' +import { + accountRoles, + addressWithSeed, + anchorAmountData, + assertStakeShape, + compileForSigning, + computeBudget, + createStakeAccountRule, + createStakeAccountWithSeed, + createTokenAccount, + createTokenAccountRule, + deactivateStake, + deactivateStakeRule, + decodeValidatorList, + depositSol, + depositSolRule, + feePercentText, + lamportsForWithdrawal, + poolTokensForDeposit, + poolWithdrawAuthority, + rateText, + readAccount, + readMinimumDelegation, + readOnlyRpc, + readStakeAccountRent, + readStakePool, + readU64, + STAKE_PROGRAM, + stakedTokenOrRefuse, + validatorStakeAccount, + withdrawStake, + withdrawStakeRule, + withSlippage, + type InstructionRule, + type PoolValidator, + type SolanaRpc, + type StakePoolState, + type StakeShape, +} from './stake-pool' +import { fromBaseUnits } from './tokens' + +const { readonly, signer, writable } = accountRoles + +/** + * https://docs.solayer.org/resources/solayer-programs.md lists the Pool Manager and calls sSOL's + * mint a program. https://github.com/solayer-labs/solayer-cli, `restaking/utils/constants.ts` and + * its README, name the restaking pool, the LP mint and the stake pool. On chain on 5 November 2026 + * the program is executable, the restaking pool is owned by it and holds these two mints, the stake + * pool is owned by the SPL stake pool program with this LP mint, and both mints use the Token Program. + */ +export const SOLAYER_RESTAKING_PROGRAM = 'sSo1iU21jBrU9VaJ8PJib1MtorefUV4fzC9GURa2KNn' +export const SOLAYER_RESTAKING_POOL = '3sk58CzpitB9jsnVzZWwqeCn2zcXVherhALBh88Uw9GQ' +export const SOLAYER_STAKE_POOL = 'po1osKDWYF9oiVEGmzKA4eTs8eMveFRMox3bUKazGN2' +export const SOLAYER_LP_MINT = 'sSo1wxKKr6zW2hqf5hZrp2CawLibcwi1pMBqk5bg2G4' +export const SSOL_MINT = 'sSo14endRuUbvQaJS3dq36Q829a3A6BEfoeeRGJywEh' + +export const SOLAYER_TOKENS: ChainToken[] = [ + { symbol: 'sSOL', id: SSOL_MINT, decimals: 9, name: 'Solayer restaked SOL' }, +] + +/** `getAssociatedTokenAddressSync(LP mint, restaking pool)`, as `unrestake_ssol.ts` derives it. */ +const SOLAYER_VAULT = associatedTokenAccount(SOLAYER_RESTAKING_POOL, SOLAYER_LP_MINT) + +/** Anchor discriminators, sha256("global:"), and seen in live transactions on 4 November 2026. */ +const RESTAKE = '61a1f1a70620d535' +const UNRESTAKE = '0ab1a1eefa257818' + +const SOL_DECIMALS = 9 +const SSOL_DECIMALS = 9 +const COMPUTE_UNITS = 400_000 + +/** + * Restaking asks for a fixed amount, so it must not ask for more than the deposit before it minted. + * Other deposits landing first move the rate by under a unit, and this margin covers that. It stays + * in the person's wallet as LP tokens, worth under a millionth of a SOL. + */ +const RESTAKE_MARGIN_UNITS = BigInt(16) + +function restakingRule(account: string, prefix: string): InstructionRule { + return { + program: SOLAYER_RESTAKING_PROGRAM, + prefix, + length: 16, + accountCount: 10, + accounts: { + 0: account, + 1: SOLAYER_LP_MINT, + 2: associatedTokenAccount(account, SOLAYER_LP_MINT), + 3: associatedTokenAccount(account, SSOL_MINT), + 4: SSOL_MINT, + 5: SOLAYER_VAULT, + 6: SOLAYER_RESTAKING_POOL, + 7: ASSOCIATED_TOKEN_PROGRAM, + 8: TOKEN_PROGRAM, + 9: SYSTEM_PROGRAM, + }, + also: ({ data }) => readU64(data, 8) > BigInt(0), + } +} + +export function solayerShapes(account: string): readonly StakeShape[] { + return [ + { + rules: [ + createTokenAccountRule(account, SOLAYER_LP_MINT), + depositSolRule(account, SOLAYER_STAKE_POOL, SOLAYER_LP_MINT), + restakingRule(account, RESTAKE), + ], + also: ([, deposit, restake]) => + deposit !== undefined && + restake !== undefined && + readU64(deposit.data, 9) === readU64(restake.data, 8) + ? undefined + : 'This transaction’s restake doesn’t match its deposit.', + }, + { + rules: [ + createTokenAccountRule(account, SOLAYER_LP_MINT), + restakingRule(account, UNRESTAKE), + createStakeAccountRule(account), + withdrawStakeRule(account, SOLAYER_STAKE_POOL, SOLAYER_LP_MINT), + deactivateStakeRule(account), + ], + also: ([, unrestake, created, withdrawn, deactivated]) => + unrestake !== undefined && + created !== undefined && + withdrawn !== undefined && + deactivated !== undefined && + readU64(unrestake.data, 8) === readU64(withdrawn.data, 1) && + created.accounts[1] === withdrawn.accounts[4] && + created.accounts[1] === deactivated.accounts[0] + ? undefined + : 'This transaction’s unstake doesn’t fit together as one exit to your own stake account.', + }, + ] +} + +function restakeInstruction(discriminator: string, account: string, amount: bigint): Instruction { + return { + programAddress: SOLAYER_RESTAKING_PROGRAM as Address, + accounts: [ + signer(account), + readonly(SOLAYER_LP_MINT), + writable(associatedTokenAccount(account, SOLAYER_LP_MINT)), + writable(associatedTokenAccount(account, SSOL_MINT)), + writable(SSOL_MINT), + writable(SOLAYER_VAULT), + readonly(SOLAYER_RESTAKING_POOL), + readonly(ASSOCIATED_TOKEN_PROGRAM), + readonly(TOKEN_PROGRAM), + readonly(SYSTEM_PROGRAM), + ], + data: anchorAmountData(discriminator, amount), + } +} + +/** The stake must leave the validator holding it, so this picks the one holding the most. */ +export function chooseValidator( + state: Pick, + validators: readonly PoolValidator[] +): PoolValidator | undefined { + if (state.preferredWithdrawValidator !== undefined) { + return validators.find((v) => v.vote === state.preferredWithdrawValidator) + } + let best: PoolValidator | undefined + for (const validator of validators) { + if (best === undefined || validator.activeLamports > best.activeLamports) best = validator + } + return best +} + +export interface SolayerOptions { + env?: Record + rpc?: SolanaRpc + /** Names the new stake account's seed. Tests fix it. */ + now?: () => number +} + +/** sSOL only: Solayer's sUSD, Pay, card and chain fail the wallet-only test or aren't on this chain. */ +export function createSolayerVenue(options: SolayerOptions = {}): ChainVenue { + const rpc = options.rpc ?? readOnlyRpc(options.env) + const now = options.now ?? Date.now + + async function readRestakingPoolOrRefuse(): Promise { + const account = await readAccount(rpc, SOLAYER_RESTAKING_POOL) + if (account.owner !== SOLAYER_RESTAKING_PROGRAM) { + throw new ChainActionRefused( + 'Solayer’s restaking pool doesn’t look as expected on chain, so I stopped. Nothing was signed or sent.' + ) + } + } + + async function checkedStake(request: ChainActionRequest, account: string) { + if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') + stakedTokenOrRefuse('Solayer', 'restake', request.tokenIn, 'SOL', 'stake 1 SOL with Solayer') + const lamports = BigInt(toBaseUnits(request.amount, SOL_DECIMALS)) + const [state] = await Promise.all([ + readStakePool(rpc, 'Solayer', SOLAYER_STAKE_POOL, SOLAYER_LP_MINT), + readRestakingPoolOrRefuse(), + ]) + if (state.solDepositAuthority !== undefined) { + throw new ChainActionRefused( + 'Solayer’s stake pool now needs another signature to deposit SOL, so I stopped. Nothing was signed or sent.' + ) + } + const minted = poolTokensForDeposit(state, lamports) + if (minted <= RESTAKE_MARGIN_UNITS) { + throw new ChainActionRefused( + 'That amount is too small to give any sSOL. Nothing was signed or sent.' + ) + } + return { state, lamports, restaked: minted - RESTAKE_MARGIN_UNITS } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + const { restaked } = await checkedStake(request, account) + return { + receive: { amount: fromBaseUnits(restaked.toString(), SSOL_DECIMALS), symbol: 'sSOL' }, + } + } + + async function prepareStake( + request: ChainActionRequest, + account: string + ): Promise { + const { state, lamports, restaked } = await checkedStake(request, account) + const minimum = await readMinimumDelegation(rpc) + const transaction = await compileForSigning(rpc, account, [ + ...computeBudget(COMPUTE_UNITS), + createTokenAccount(account, SOLAYER_LP_MINT), + depositSol({ + pool: SOLAYER_STAKE_POOL, + state, + withdrawAuthority: await poolWithdrawAuthority(SOLAYER_STAKE_POOL), + account, + lamports, + minimumPoolTokens: restaked, + }), + restakeInstruction(RESTAKE, account, restaked), + ]) + const receive = fromBaseUnits(restaked.toString(), SSOL_DECIMALS) + const poolSol = (state.totalLamports / BigInt(1_000_000_000)).toLocaleString('en-US') + return { + venueId: 'solayer', + venueName: 'Solayer', + kind: 'stake', + summary: `Restake ${request.amount} SOL with Solayer for about ${receive} sSOL.`, + pay: { amount: request.amount, symbol: 'SOL' }, + receive: { amount: receive, symbol: 'sSOL', approximate: true }, + notes: [ + `1 sSOL is worth about ${rateText(state.totalLamports, state.poolTokenSupply)} SOL, read from Solayer’s stake pool on chain just now. I’m not showing a yearly rate because the pool doesn’t state one, and rewards aren’t guaranteed.`, + `Leaving is slow. "unstake" here gives you a stake account of your own, which cools until the Solana epoch ends, about two days at most, and then you withdraw the SOL from it yourself.`, + `That way out needs at least ${fromBaseUnits(minimum.toString(), SOL_DECIMALS)} SOL worth of sSOL at once, Solana’s minimum for a stake account, read on chain. Below that, your only way out here is selling sSOL on the market, where the price can differ and depth may be thin.`, + `Solayer has shrunk: its stake pool holds about ${poolSol} SOL on chain, about $10M in November 2026, against a peak of about $520.67M in January 2025. A small pool can mean thin markets for sSOL.`, + 'Solayer’s program can be upgraded by a 3-of-5 multisig. sSOL’s freeze authority is held by that program’s pool, so an upgrade could freeze it. A failure there could lose you money.', + ], + signing: { family: 'solana', transaction }, + } + } + + async function prepareUnstake( + request: ChainActionRequest, + account: string + ): Promise { + if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') + stakedTokenOrRefuse( + 'Solayer', + 'unstake', + request.tokenIn, + 'sSOL', + 'unstake 1 sSOL from Solayer' + ) + const poolTokens = BigInt(toBaseUnits(request.amount, SSOL_DECIMALS)) + + const [state, rent, minimum] = await Promise.all([ + readStakePool(rpc, 'Solayer', SOLAYER_STAKE_POOL, SOLAYER_LP_MINT), + readStakeAccountRent(rpc), + readMinimumDelegation(rpc), + readRestakingPoolOrRefuse(), + ]) + const list = await readAccount(rpc, state.validatorList) + const validators = decodeValidatorList(list.data) + const validator = validators === undefined ? undefined : chooseValidator(state, validators) + if (validator === undefined) { + throw new ChainActionRefused( + 'Solayer’s stake pool has no validator to withdraw from right now. Nothing was signed or sent.' + ) + } + const lamports = lamportsForWithdrawal(state, poolTokens, state.stakeWithdrawalFee) + if (lamports < minimum) { + throw new ChainActionRefused( + `This unstake would be worth about ${fromBaseUnits(lamports.toString(), SOL_DECIMALS)} SOL, under the ${fromBaseUnits(minimum.toString(), SOL_DECIMALS)} SOL Solana requires in a stake account. Nothing was signed or sent. Unstake more at once, or sell sSOL on the market instead.` + ) + } + if (lamports > validator.activeLamports) { + throw new ChainActionRefused( + 'That amount can’t be withdrawn from Solayer’s stake pool right now. Nothing was signed or sent. Try a smaller amount.' + ) + } + + const seed = `intent-${now()}` + const stakeAccount = addressWithSeed(account, seed, STAKE_PROGRAM) + const transaction = await compileForSigning(rpc, account, [ + ...computeBudget(COMPUTE_UNITS), + // Someone who bought sSOL on the market may never have held the LP token this passes through. + createTokenAccount(account, SOLAYER_LP_MINT), + restakeInstruction(UNRESTAKE, account, poolTokens), + createStakeAccountWithSeed(account, seed, rent), + withdrawStake({ + pool: SOLAYER_STAKE_POOL, + state, + withdrawAuthority: await poolWithdrawAuthority(SOLAYER_STAKE_POOL), + account, + validatorStake: await validatorStakeAccount(SOLAYER_STAKE_POOL, validator), + stakeAccount, + poolTokens, + minimumLamports: withSlippage(lamports), + }), + deactivateStake(account, stakeAccount), + ]) + const receive = fromBaseUnits(lamports.toString(), SOL_DECIMALS) + return { + venueId: 'solayer', + venueName: 'Solayer', + kind: 'unstake', + summary: `Unstake ${request.amount} sSOL from Solayer into a stake account of your own holding about ${receive} SOL, which then cools down before you can withdraw it.`, + pay: { amount: request.amount, symbol: 'sSOL' }, + receive: { amount: receive, symbol: 'SOL', approximate: true }, + notes: [ + `This exit is delayed. The SOL does not reach your balance now. It goes to a new stake account you control, ${stakeAccount}, which cools until the Solana epoch ends, about two days at most.`, + 'After that you withdraw the SOL from that stake account yourself, using a wallet that manages stake accounts or the Solana command line. That last step isn’t wired here yet, and it needs nobody else’s signature.', + `The stake pool charges ${feePercentText(state.stakeWithdrawalFee)} on the way out, read on chain, and the amount above is after that fee. About ${fromBaseUnits(rent.toString(), SOL_DECIMALS)} SOL from your wallet funds the stake account, and comes back when you withdraw.`, + ], + signing: { family: 'solana', transaction }, + } + } + + return { + id: 'solayer', + name: 'Solayer', + kinds: ['stake', 'unstake'], + assertCanFollow: takesOnly('Solayer', ['SOL']), + quote, + prepare: (request, account) => + request.kind === 'unstake' + ? prepareUnstake(request, account) + : request.kind === 'stake' + ? prepareStake(request, account) + : Promise.reject( + new ChainActionRefused( + `Solayer here can stake and unstake, not ${request.kind}. Nothing was signed or sent.` + ) + ), + assertSafe: (payload: SigningPayload, account: string) => + assertStakeShape(payload, account, 'Solayer', solayerShapes(account)), + } +} diff --git a/apps/dapp/lib/chains/solana/stake-pool.ts b/apps/dapp/lib/chains/solana/stake-pool.ts new file mode 100644 index 00000000..f3d90329 --- /dev/null +++ b/apps/dapp/lib/chains/solana/stake-pool.ts @@ -0,0 +1,888 @@ +import { createHash } from 'node:crypto' + +import { + AccountRole, + appendTransactionMessageInstructions, + compileTransaction, + createTransactionMessage, + getAddressDecoder, + getAddressEncoder, + getBase64EncodedWireTransaction, + getProgramDerivedAddress, + getU32Encoder, + getU64Encoder, + isAddress, + isBlockhash, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + type Address, + type Blockhash, + type Instruction, +} from '@solana/kit' +import { z } from 'zod' + +import { bytesFromBase64 } from '../base64' +import { ChainActionRefused, type SigningPayload } from '../types' +import { + ASSOCIATED_TOKEN_PROGRAM, + associatedTokenAccount, + decodeSolanaTransaction, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, +} from './assert-safe' + +/** + * What the staking venues share: reading the chain, the SPL stake pool's instructions, and a check + * that a transaction is exactly one of the shapes a venue builds. + * + * `solanaAssertSafe` can't express these. Its discriminators are eight bytes and the stake pool's + * are one, so it could only allow every pool instruction, and it refuses the account creation a + * stake account needs. It also doesn't look at who an instruction's accounts belong to, which is + * what decides where unstaked SOL lands. + */ + +/** + * https://github.com/solana-program/stake-pool, `program/src/lib.rs` `declare_id!`. Seen on chain + * as an executable upgradeable program owning both Jito's and Solayer's pools on 5 November 2026. + */ +export const STAKE_POOL_PROGRAM = 'SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy' +export const STAKE_PROGRAM = 'Stake11111111111111111111111111111111111111' +const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111' +const CLOCK_SYSVAR = 'SysvarC1ock11111111111111111111111111111111' +const STAKE_HISTORY_SYSVAR = 'SysvarStakeHistory1111111111111111111111111' + +const DEFAULT_RPC_URL = 'https://solana-rpc.publicnode.com' +const RPC_TIMEOUT_MS = 15_000 + +/** Enough to land without a live fee estimate: under 0.0001 SOL at the limits used here. */ +const COMPUTE_UNIT_PRICE = BigInt(100_000) +/** The ceiling `solanaAssertSafe` holds every other venue to, about 0.005 SOL. */ +const MAX_PRIORITY_FEE_LAMPORTS = BigInt(5_000_000) +/** Rent for a stake account is about 0.0017 SOL today. It stays the person's, and this bounds it. */ +const MAX_STAKE_ACCOUNT_LAMPORTS = BigInt(5_000_000) +const STAKE_ACCOUNT_SPACE = BigInt(200) +/** How far a stake-pool rate may move before the transaction lands. The rule's ceiling is 1%. */ +const SLIPPAGE_DIVISOR = BigInt(1000) + +const DEPOSIT_SOL_WITH_SLIPPAGE = 25 +const WITHDRAW_SOL_WITH_SLIPPAGE = 26 +const WITHDRAW_STAKE_WITH_SLIPPAGE = 24 +const CREATE_ACCOUNT_WITH_SEED = 3 +const STAKE_DEACTIVATE = 5 +const CREATE_IDEMPOTENT = 1 + +const VALIDATOR_ENTRY_BYTES = 73 +const VALIDATOR_LIST_HEADER_BYTES = 9 +const VALIDATOR_ACTIVE = 0 + +export type SolanaRpc = (method: string, params: unknown[]) => Promise + +function unreadable(): never { + throw new ChainActionRefused( + 'Solana’s network could not be read just now. Nothing was signed or sent, so try again in a moment.' + ) +} + +/** Reads only: this never sends anything. A failed read refuses, so nothing is built on a guess. */ +export function readOnlyRpc(env: Record = process.env): SolanaRpc { + return async (method, params) => { + const url = env['SOLANA_RPC_URL']?.trim() || DEFAULT_RPC_URL + let body: { result?: unknown; error?: unknown } + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(RPC_TIMEOUT_MS), + }) + if (!response.ok) return unreadable() + body = (await response.json()) as { result?: unknown; error?: unknown } + } catch (e) { + if (e instanceof ChainActionRefused) throw e + return unreadable() + } + if (body.error !== undefined || body.result === undefined) return unreadable() + return body.result + } +} + +const AccountReply = z.object({ + value: z + .object({ + owner: z.string(), + lamports: z.number().int().nonnegative(), + data: z.tuple([z.string(), z.literal('base64')]), + }) + .nullable(), +}) + +export interface ChainAccount { + owner: string + lamports: bigint + data: Uint8Array +} + +export async function readAccount(rpc: SolanaRpc, address: string): Promise { + const reply = AccountReply.safeParse( + await rpc('getAccountInfo', [address, { encoding: 'base64', commitment: 'confirmed' }]) + ) + if (!reply.success || reply.data.value === null) return unreadable() + const { owner, lamports, data } = reply.data.value + return { owner, lamports: BigInt(lamports), data: bytesFromBase64(data[0]) } +} + +const EpochReply = z.object({ epoch: z.number().int().nonnegative() }) + +async function readEpoch(rpc: SolanaRpc): Promise { + const reply = EpochReply.safeParse(await rpc('getEpochInfo', [{ commitment: 'confirmed' }])) + return reply.success ? BigInt(reply.data.epoch) : unreadable() +} + +const BlockhashReply = z.object({ + value: z.object({ + blockhash: z.string().refine(isBlockhash), + lastValidBlockHeight: z.number().int().nonnegative(), + }), +}) + +const RentReply = z.number().int().positive() + +export async function readStakeAccountRent(rpc: SolanaRpc): Promise { + const reply = RentReply.safeParse( + await rpc('getMinimumBalanceForRentExemption', [Number(STAKE_ACCOUNT_SPACE)]) + ) + return reply.success ? BigInt(reply.data) : unreadable() +} + +const MinimumDelegationReply = z.object({ value: z.number().int().nonnegative() }) + +/** The least a stake account may hold delegated, so the least that can split off a pool's stake. */ +export async function readMinimumDelegation(rpc: SolanaRpc): Promise { + const reply = MinimumDelegationReply.safeParse( + await rpc('getStakeMinimumDelegation', [{ commitment: 'confirmed' }]) + ) + return reply.success ? BigInt(reply.data.value) : unreadable() +} + +export function readU64(data: Uint8Array, offset: number): bigint { + let value = BigInt(0) + for (let i = 7; i >= 0; i -= 1) { + value = (value << BigInt(8)) | BigInt(data[offset + i] ?? 0) + } + return value +} + +export function readU32(data: Uint8Array, offset: number): number { + return Number(readU64(Uint8Array.from([...data.subarray(offset, offset + 4), 0, 0, 0, 0]), 0)) +} + +export function readAddress(data: Uint8Array, offset: number): string { + return getAddressDecoder().decode(data.subarray(offset, offset + 32)) +} + +export interface PoolFee { + numerator: bigint + denominator: bigint +} + +export interface StakePoolState { + manager: string + validatorList: string + reserveStake: string + poolMint: string + managerFeeAccount: string + tokenProgram: string + totalLamports: bigint + poolTokenSupply: bigint + lastUpdateEpoch: bigint + epochFee: PoolFee + preferredWithdrawValidator: string | undefined + stakeWithdrawalFee: PoolFee + solDepositAuthority: string | undefined + solDepositFee: PoolFee + solWithdrawAuthority: string | undefined + solWithdrawalFee: PoolFee +} + +/** The SPL stake pool's account, in its Borsh layout: `program/src/state.rs` `StakePool`. */ +export function decodeStakePool(data: Uint8Array): StakePoolState | undefined { + let at = 0 + const take = (length: number): number => { + const start = at + at += length + return start + } + const u64 = (): bigint => readU64(data, take(8)) + const pubkey = (): string => readAddress(data, take(32)) + const fee = (): PoolFee => { + const denominator = u64() + return { denominator, numerator: u64() } + } + const futureFee = (): void => { + const tag = data[take(1)] + if (tag === 1 || tag === 2) fee() + } + const optional = (): string | undefined => (data[take(1)] === 0 ? undefined : pubkey()) + + if (data[take(1)] !== 1) return undefined + try { + const manager = pubkey() + take(32 + 32 + 1) // staker, stake deposit authority, withdraw bump + const validatorList = pubkey() + const reserveStake = pubkey() + const poolMint = pubkey() + const managerFeeAccount = pubkey() + const tokenProgram = pubkey() + const totalLamports = u64() + const poolTokenSupply = u64() + const lastUpdateEpoch = u64() + take(8 + 8 + 32) // lockup + const epochFee = fee() + futureFee() + optional() // preferred deposit validator + const preferredWithdrawValidator = optional() + fee() // stake deposit fee + const stakeWithdrawalFee = fee() + futureFee() + take(1) // stake referral fee + const solDepositAuthority = optional() + const solDepositFee = fee() + take(1) // sol referral fee + const solWithdrawAuthority = optional() + const solWithdrawalFee = fee() + if (at > data.length) return undefined + return { + manager, + validatorList, + reserveStake, + poolMint, + managerFeeAccount, + tokenProgram, + totalLamports, + poolTokenSupply, + lastUpdateEpoch, + epochFee, + preferredWithdrawValidator, + stakeWithdrawalFee, + solDepositAuthority, + solDepositFee, + solWithdrawAuthority, + solWithdrawalFee, + } + } catch { + return undefined + } +} + +/** + * The pool as it stands now, refused unless it is the pool and mint this venue pins and has been + * updated this epoch. A pool behind the epoch rejects deposits and withdrawals until it catches up. + */ +export async function readStakePool( + rpc: SolanaRpc, + venueName: string, + pool: string, + mint: string +): Promise { + const [account, epoch] = await Promise.all([readAccount(rpc, pool), readEpoch(rpc)]) + const state = account.owner === STAKE_POOL_PROGRAM ? decodeStakePool(account.data) : undefined + if ( + state === undefined || + state.poolMint !== mint || + state.tokenProgram !== TOKEN_PROGRAM || + state.poolTokenSupply === BigInt(0) || + state.totalLamports === BigInt(0) + ) { + throw new ChainActionRefused( + `${venueName}’s stake pool doesn’t look as expected on chain, so I stopped. Nothing was signed or sent.` + ) + } + if (state.lastUpdateEpoch !== epoch) { + throw new ChainActionRefused( + `${venueName}’s stake pool is still updating for the new Solana epoch. Nothing was signed or sent, so try again in a few minutes.` + ) + } + return state +} + +/** Rounds up, as the program does: `Fee::apply` in `program/src/state.rs`. */ +function applyFee(fee: PoolFee, amount: bigint): bigint { + if (fee.denominator === BigInt(0)) return BigInt(0) + return (amount * fee.numerator + fee.denominator - BigInt(1)) / fee.denominator +} + +/** Pool tokens a SOL deposit mints to the depositor at the rate read. */ +export function poolTokensForDeposit(state: StakePoolState, lamports: bigint): bigint { + const minted = (lamports * state.poolTokenSupply) / state.totalLamports + return minted - applyFee(state.solDepositFee, minted) +} + +/** SOL that burning pool tokens is worth after the given withdrawal fee, at the rate read. */ +export function lamportsForWithdrawal( + state: StakePoolState, + poolTokens: bigint, + fee: PoolFee +): bigint { + const burned = poolTokens - applyFee(fee, poolTokens) + return (burned * state.totalLamports) / state.poolTokenSupply +} + +export function withSlippage(amount: bigint): bigint { + return amount - amount / SLIPPAGE_DIVISOR +} + +/** SOL per pool token, to six places, for telling a person the rate read on chain. */ +export function rateText(lamports: bigint, tokens: bigint): string { + const scaled = (lamports * BigInt(1_000_000)) / tokens + const whole = scaled / BigInt(1_000_000) + const fraction = (scaled % BigInt(1_000_000)).toString().padStart(6, '0').replace(/0+$/, '') + return fraction === '' ? whole.toString() : `${whole}.${fraction}` +} + +export function feePercentText(fee: PoolFee): string { + if (fee.denominator === BigInt(0)) return '0%' + return `${Number((fee.numerator * BigInt(1_000_000)) / fee.denominator) / 10_000}%` +} + +export interface PoolValidator { + vote: string + seedSuffix: number + activeLamports: bigint +} + +export function decodeValidatorList(data: Uint8Array): PoolValidator[] | undefined { + if (data[0] !== 2 || data.length < VALIDATOR_LIST_HEADER_BYTES) return undefined + const count = readU32(data, 5) + if (VALIDATOR_LIST_HEADER_BYTES + count * VALIDATOR_ENTRY_BYTES > data.length) return undefined + const validators: PoolValidator[] = [] + for (let i = 0; i < count; i += 1) { + const at = VALIDATOR_LIST_HEADER_BYTES + i * VALIDATOR_ENTRY_BYTES + if (data[at + 40] !== VALIDATOR_ACTIVE) continue + validators.push({ + activeLamports: readU64(data, at), + seedSuffix: readU32(data, at + 36), + vote: readAddress(data, at + 41), + }) + } + return validators +} + +async function poolProgramAddress(seeds: (string | Uint8Array)[]): Promise { + const [address] = await getProgramDerivedAddress({ + programAddress: STAKE_POOL_PROGRAM as Address, + seeds, + }) + return address +} + +function addressBytes(address: string): Uint8Array { + return Uint8Array.from(getAddressEncoder().encode(address as Address)) +} + +export function poolWithdrawAuthority(pool: string): Promise { + return poolProgramAddress([addressBytes(pool), 'withdraw']) +} + +export function validatorStakeAccount(pool: string, validator: PoolValidator): Promise { + const seeds: (string | Uint8Array)[] = [addressBytes(validator.vote), addressBytes(pool)] + if (validator.seedSuffix !== 0) { + seeds.push(Uint8Array.from(getU32Encoder().encode(validator.seedSuffix))) + } + return poolProgramAddress(seeds) +} + +/** The address `create_account_with_seed` makes: a hash of base, seed and owning program. */ +export function addressWithSeed(base: string, seed: string, owner: string): string { + const hash = createHash('sha256') + hash.update(addressBytes(base)) + hash.update(seed) + hash.update(addressBytes(owner)) + return getAddressDecoder().decode(hash.digest()) +} + +const readonly = (address: string): { address: Address; role: AccountRole } => ({ + address: address as Address, + role: AccountRole.READONLY, +}) +const writable = (address: string): { address: Address; role: AccountRole } => ({ + address: address as Address, + role: AccountRole.WRITABLE, +}) +const signer = (address: string): { address: Address; role: AccountRole } => ({ + address: address as Address, + role: AccountRole.WRITABLE_SIGNER, +}) + +export const accountRoles = { readonly, writable, signer } + +const u64Bytes = (value: bigint): number[] => [...getU64Encoder().encode(value)] + +/** An Anchor instruction whose only argument is an amount: an eight-byte discriminator, then a u64. */ +export function anchorAmountData(discriminator: string, amount: bigint): Uint8Array { + const prefix = Array.from({ length: 8 }, (_, i) => + Number.parseInt(discriminator.slice(i * 2, i * 2 + 2), 16) + ) + return Uint8Array.from([...prefix, ...u64Bytes(amount)]) +} + +export function computeBudget(units: number): Instruction[] { + return [ + { + programAddress: COMPUTE_BUDGET_PROGRAM as Address, + data: Uint8Array.from([2, ...getU32Encoder().encode(units)]), + }, + { + programAddress: COMPUTE_BUDGET_PROGRAM as Address, + data: Uint8Array.from([3, ...u64Bytes(COMPUTE_UNIT_PRICE)]), + }, + ] +} + +export function createTokenAccount(account: string, mint: string): Instruction { + return { + programAddress: ASSOCIATED_TOKEN_PROGRAM as Address, + accounts: [ + signer(account), + writable(associatedTokenAccount(account, mint)), + readonly(account), + readonly(mint), + readonly(SYSTEM_PROGRAM), + readonly(TOKEN_PROGRAM), + ], + data: Uint8Array.of(CREATE_IDEMPOTENT), + } +} + +export interface PoolInstructionBase { + pool: string + state: StakePoolState + withdrawAuthority: string + account: string +} + +/** The person is their own referrer, so a referral fee, if the pool ever sets one, stays theirs. */ +export function depositSol( + base: PoolInstructionBase & { lamports: bigint; minimumPoolTokens: bigint } +): Instruction { + const { pool, state, withdrawAuthority, account, lamports, minimumPoolTokens } = base + const own = associatedTokenAccount(account, state.poolMint) + return { + programAddress: STAKE_POOL_PROGRAM as Address, + accounts: [ + writable(pool), + readonly(withdrawAuthority), + writable(state.reserveStake), + signer(account), + writable(own), + writable(state.managerFeeAccount), + writable(own), + writable(state.poolMint), + readonly(SYSTEM_PROGRAM), + readonly(TOKEN_PROGRAM), + ], + data: Uint8Array.from([ + DEPOSIT_SOL_WITH_SLIPPAGE, + ...u64Bytes(lamports), + ...u64Bytes(minimumPoolTokens), + ]), + } +} + +export function withdrawSol( + base: PoolInstructionBase & { poolTokens: bigint; minimumLamports: bigint } +): Instruction { + const { pool, state, withdrawAuthority, account, poolTokens, minimumLamports } = base + return { + programAddress: STAKE_POOL_PROGRAM as Address, + accounts: [ + writable(pool), + readonly(withdrawAuthority), + signer(account), + writable(associatedTokenAccount(account, state.poolMint)), + writable(state.reserveStake), + writable(account), + writable(state.managerFeeAccount), + writable(state.poolMint), + readonly(CLOCK_SYSVAR), + readonly(STAKE_HISTORY_SYSVAR), + readonly(STAKE_PROGRAM), + readonly(TOKEN_PROGRAM), + ], + data: Uint8Array.from([ + WITHDRAW_SOL_WITH_SLIPPAGE, + ...u64Bytes(poolTokens), + ...u64Bytes(minimumLamports), + ]), + } +} + +/** + * A new stake account derived from the person's own address and a seed. The usual way needs a fresh + * keypair to sign too, and a venue here must need no signature but the person's. + */ +export function createStakeAccountWithSeed( + account: string, + seed: string, + lamports: bigint +): Instruction { + const seedBytes = new TextEncoder().encode(seed) + return { + programAddress: SYSTEM_PROGRAM as Address, + accounts: [signer(account), writable(addressWithSeed(account, seed, STAKE_PROGRAM))], + data: Uint8Array.from([ + ...getU32Encoder().encode(CREATE_ACCOUNT_WITH_SEED), + ...addressBytes(account), + ...u64Bytes(BigInt(seedBytes.length)), + ...seedBytes, + ...u64Bytes(lamports), + ...u64Bytes(STAKE_ACCOUNT_SPACE), + ...addressBytes(STAKE_PROGRAM), + ]), + } +} + +export function withdrawStake( + base: PoolInstructionBase & { + validatorStake: string + stakeAccount: string + poolTokens: bigint + minimumLamports: bigint + } +): Instruction { + const { pool, state, withdrawAuthority, account, validatorStake, stakeAccount } = base + return { + programAddress: STAKE_POOL_PROGRAM as Address, + accounts: [ + writable(pool), + writable(state.validatorList), + readonly(withdrawAuthority), + writable(validatorStake), + writable(stakeAccount), + readonly(account), + signer(account), + writable(associatedTokenAccount(account, state.poolMint)), + writable(state.managerFeeAccount), + writable(state.poolMint), + readonly(CLOCK_SYSVAR), + readonly(TOKEN_PROGRAM), + readonly(STAKE_PROGRAM), + ], + data: Uint8Array.from([ + WITHDRAW_STAKE_WITH_SLIPPAGE, + ...u64Bytes(base.poolTokens), + ...u64Bytes(base.minimumLamports), + ]), + } +} + +export function deactivateStake(account: string, stakeAccount: string): Instruction { + return { + programAddress: STAKE_PROGRAM as Address, + accounts: [writable(stakeAccount), readonly(CLOCK_SYSVAR), signer(account)], + data: Uint8Array.from(getU32Encoder().encode(STAKE_DEACTIVATE)), + } +} + +/** Legacy on purpose: with no lookup tables, every account is there for `assertSafe` to read. */ +export async function compileForSigning( + rpc: SolanaRpc, + account: string, + instructions: readonly Instruction[] +): Promise { + const reply = BlockhashReply.safeParse( + await rpc('getLatestBlockhash', [{ commitment: 'confirmed' }]) + ) + if (!reply.success) return unreadable() + const { blockhash, lastValidBlockHeight } = reply.data.value + const message = pipe( + createTransactionMessage({ version: 'legacy' }), + (m) => setTransactionMessageFeePayer(account as Address, m), + (m) => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: blockhash as Blockhash, + lastValidBlockHeight: BigInt(lastValidBlockHeight), + }, + m + ), + (m) => appendTransactionMessageInstructions(instructions, m) + ) + return getBase64EncodedWireTransaction(compileTransaction(message)) +} + +export interface ReadInstruction { + program: string + data: Uint8Array + /** Every account by position. A lookup-table account is refused before a rule sees one. */ + accounts: readonly string[] +} + +export interface InstructionRule { + program: string + /** The first bytes of the data, in hex. */ + prefix: string + /** The whole data's length. Absent only where a rule's `also` measures it. */ + length?: number + accountCount: number + /** + * Addresses by position. A position left out is one the program itself checks against its own + * state, such as a reserve or a fee account, so naming it wrongly fails and diverts nothing. + */ + accounts: Readonly> + also?: (instruction: ReadInstruction) => boolean +} + +export interface StakeShape { + rules: readonly InstructionRule[] + /** Checks across instructions. Returns what is wrong, or nothing. */ + also?: (instructions: readonly ReadInstruction[]) => string | undefined +} + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export const createTokenAccountRule = (account: string, mint: string): InstructionRule => ({ + program: ASSOCIATED_TOKEN_PROGRAM, + prefix: '01', + length: 1, + accountCount: 6, + accounts: { + 0: account, + 1: associatedTokenAccount(account, mint), + 2: account, + 3: mint, + 4: SYSTEM_PROGRAM, + 5: TOKEN_PROGRAM, + }, +}) + +export const depositSolRule = (account: string, pool: string, mint: string): InstructionRule => { + const own = associatedTokenAccount(account, mint) + return { + program: STAKE_POOL_PROGRAM, + prefix: DEPOSIT_SOL_WITH_SLIPPAGE.toString(16).padStart(2, '0'), + length: 17, + accountCount: 10, + accounts: { 0: pool, 3: account, 4: own, 6: own, 7: mint, 8: SYSTEM_PROGRAM, 9: TOKEN_PROGRAM }, + also: ({ data }) => readU64(data, 1) > BigInt(0) && readU64(data, 9) > BigInt(0), + } +} + +export const withdrawSolRule = (account: string, pool: string, mint: string): InstructionRule => ({ + program: STAKE_POOL_PROGRAM, + prefix: WITHDRAW_SOL_WITH_SLIPPAGE.toString(16).padStart(2, '0'), + length: 17, + accountCount: 12, + accounts: { + 0: pool, + 2: account, + 3: associatedTokenAccount(account, mint), + 5: account, + 7: mint, + 8: CLOCK_SYSVAR, + 9: STAKE_HISTORY_SYSVAR, + 10: STAKE_PROGRAM, + 11: TOKEN_PROGRAM, + }, + also: ({ data }) => readU64(data, 1) > BigInt(0) && readU64(data, 9) > BigInt(0), +}) + +export const withdrawStakeRule = ( + account: string, + pool: string, + mint: string +): InstructionRule => ({ + program: STAKE_POOL_PROGRAM, + prefix: WITHDRAW_STAKE_WITH_SLIPPAGE.toString(16).padStart(2, '0'), + length: 17, + accountCount: 13, + accounts: { + 0: pool, + 5: account, + 6: account, + 7: associatedTokenAccount(account, mint), + 9: mint, + 10: CLOCK_SYSVAR, + 11: TOKEN_PROGRAM, + 12: STAKE_PROGRAM, + }, + also: ({ data }) => readU64(data, 1) > BigInt(0) && readU64(data, 9) > BigInt(0), +}) + +/** Only `base = the person`, a stake account, a bounded amount of their own SOL, and a plain seed. */ +export const createStakeAccountRule = (account: string): InstructionRule => { + const prefix = hex( + Uint8Array.from([...getU32Encoder().encode(CREATE_ACCOUNT_WITH_SEED), ...addressBytes(account)]) + ) + return { + program: SYSTEM_PROGRAM, + prefix, + accountCount: 2, + accounts: { 0: account }, + also: ({ data, accounts }) => { + const seedLength = Number(readU64(data, 36)) + if (seedLength < 1 || seedLength > 32 || data.length !== 44 + seedLength + 48) return false + const seed = new TextDecoder().decode(data.subarray(44, 44 + seedLength)) + const end = 44 + seedLength + return ( + /^[A-Za-z0-9-]+$/.test(seed) && + accounts[1] === addressWithSeed(account, seed, STAKE_PROGRAM) && + readU64(data, end) <= MAX_STAKE_ACCOUNT_LAMPORTS && + readU64(data, end + 8) === STAKE_ACCOUNT_SPACE && + readAddress(data, end + 16) === STAKE_PROGRAM + ) + }, + } +} + +export const deactivateStakeRule = (account: string): InstructionRule => ({ + program: STAKE_PROGRAM, + prefix: hex(Uint8Array.from(getU32Encoder().encode(STAKE_DEACTIVATE))), + length: 4, + accountCount: 3, + accounts: { 1: CLOCK_SYSVAR, 2: account }, +}) + +function refuse(sentence: string): never { + throw new ChainActionRefused(`${sentence} Nothing was signed or sent.`) +} + +function checkComputeBudget(instructions: readonly ReadInstruction[]): void { + const [limit, price] = instructions + const valid = + instructions.length === 2 && + limit?.data.length === 5 && + limit.data[0] === 2 && + price?.data.length === 9 && + price.data[0] === 3 + if (!valid) return refuse('This transaction sets a compute budget I didn’t build.') + const units = BigInt(readU32(limit.data, 1)) + if ((readU64(price.data, 1) * units) / BigInt(1_000_000) > MAX_PRIORITY_FEE_LAMPORTS) { + refuse('This transaction would pay an unusually large priority fee from your SOL.') + } +} + +function unexpectedProgram(venueName: string, program: string): string { + if (program === SYSTEM_PROGRAM) { + return `This transaction includes a SOL transfer or account change ${venueName} doesn’t need.` + } + if (program === TOKEN_PROGRAM) { + return `This transaction moves or approves tokens outside what ${venueName} needs.` + } + return `This transaction calls a program ${venueName} should not need.` +} + +function ruleFailure( + venueName: string, + rule: InstructionRule, + instruction: ReadInstruction +): string | undefined { + if (instruction.program !== rule.program) return unexpectedProgram(venueName, instruction.program) + const prefix = hex(instruction.data.subarray(0, rule.prefix.length / 2)) + const lengthOk = rule.length === undefined || instruction.data.length === rule.length + if (prefix !== rule.prefix || !lengthOk) { + return `This transaction asks ${venueName} for an instruction I didn’t expect.` + } + const accountsOk = + instruction.accounts.length === rule.accountCount && + Object.entries(rule.accounts).every( + ([position, address]) => instruction.accounts[Number(position)] === address + ) + if (!accountsOk || (rule.also !== undefined && !rule.also(instruction))) { + return `This transaction names an account or amount that isn’t what ${venueName} should get from you.` + } + return undefined +} + +/** How far a transaction gets through a shape, so a refusal names the nearest thing wrong. */ +function shapeFailure( + venueName: string, + shape: StakeShape, + instructions: readonly ReadInstruction[] +): { matched: number; reason: string } | undefined { + const { rules } = shape + for (let i = 0; i < Math.max(rules.length, instructions.length); i += 1) { + const rule = rules[i] + const instruction = instructions[i] + if (instruction === undefined) { + return { + matched: i, + reason: `This transaction is missing a step of the ${venueName} action.`, + } + } + if (rule === undefined) { + return { matched: i, reason: unexpectedProgram(venueName, instruction.program) } + } + const reason = ruleFailure(venueName, rule, instruction) + if (reason !== undefined) { + return { matched: i + (instruction.program === rule.program ? 0.5 : 0), reason } + } + } + const across = shape.also?.(instructions) + return across === undefined ? undefined : { matched: rules.length, reason: across } +} + +/** + * Refuses a transaction unless it is exactly one of the shapes a venue builds: the person alone + * signs and pays, every program, instruction and step is expected and in order, each carries the + * exact data length, and every account that decides whose funds these are is the person's. + * + * What this can't see is what an allowed program does inside. Amounts stay the person's choice: + * the build fixes them, and a change to one still pays only the person. + */ +export function assertStakeShape( + payload: SigningPayload, + account: string, + venueName: string, + shapes: readonly StakeShape[] +): void { + if (payload.family !== 'solana') return refuse('That is not a Solana transaction.') + if (!isAddress(account)) return refuse('That is not a Solana wallet address.') + + const transaction = decodeSolanaTransaction(payload.transaction) + if (transaction.feePayer !== account) { + refuse(`This transaction’s network fee would not be paid by your wallet, ${account}.`) + } + if (transaction.signerCount !== 1 || transaction.readonlySignerCount !== 0) { + refuse('This transaction needs a signature from someone other than you.') + } + + const budget: ReadInstruction[] = [] + const rest: ReadInstruction[] = [] + for (const instruction of transaction.instructions) { + const program = transaction.staticAccounts[instruction.programIndex] + const accounts = instruction.accounts.map((index) => transaction.staticAccounts[index]) + if (program === undefined || accounts.some((address) => address === undefined)) { + return refuse('This transaction hides an account in a lookup table, so I can’t check it.') + } + const read = { program, data: instruction.data, accounts: accounts as string[] } + if (program === COMPUTE_BUDGET_PROGRAM) budget.push(read) + else rest.push(read) + } + checkComputeBudget(budget) + + let nearest: { matched: number; reason: string } | undefined + for (const shape of shapes) { + const failure = shapeFailure(venueName, shape, rest) + if (failure === undefined) return + if (nearest === undefined || failure.matched > nearest.matched) nearest = failure + } + refuse(nearest?.reason ?? `This transaction isn’t a ${venueName} action I build.`) +} + +/** Refuses anything but a stake of SOL or an unstake of the venue's own token. */ +export function stakedTokenOrRefuse( + venueName: string, + kind: string, + tokenIn: string, + expected: string, + example: string +): void { + if (tokenIn.trim().toUpperCase() !== expected.toUpperCase()) { + throw new ChainActionRefused( + `${venueName} takes ${expected} to ${kind} here, not ${tokenIn}. Try "${example}". Nothing was signed or sent.` + ) + } +} diff --git a/apps/dapp/lib/chains/solana/streamflow-listing.ts b/apps/dapp/lib/chains/solana/streamflow-listing.ts new file mode 100644 index 00000000..f8e4a839 --- /dev/null +++ b/apps/dapp/lib/chains/solana/streamflow-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const streamflowVenue: Venue = { + id: 'streamflow', + family: 'solana', + name: 'Streamflow', + category: 'payments', + chains: ['Solana'], + url: 'https://app.streamflow.finance', + // Listed, as 1Click is: whether this runs depends on a secret this client-safe file can't read. + bestFor: + 'Paying someone hour by hour over a set number of days, funded up front. Costs a fee in SOL, shown before you sign', +} diff --git a/apps/dapp/lib/chains/solana/streamflow.ts b/apps/dapp/lib/chains/solana/streamflow.ts new file mode 100644 index 00000000..493b6f05 --- /dev/null +++ b/apps/dapp/lib/chains/solana/streamflow.ts @@ -0,0 +1,580 @@ +import { createHash, createHmac, randomBytes } from 'node:crypto' + +import { + AccountRole, + appendTransactionMessageInstructions, + compileTransaction, + createTransactionMessage, + getAddressDecoder, + getAddressEncoder, + getBase64EncodedWireTransaction, + getCompiledTransactionMessageDecoder, + getTransactionDecoder, + isAddress, + isBlockhash, + isOffCurveAddress, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + type Address, + type Blockhash, +} from '@solana/kit' +import { z } from 'zod' + +import { bytesFromBase64 } from '../base64' +import { sameProof, secret } from '../oneclick-origin' +import { sendRefusal } from '../recipient' +import { + ChainActionRefused, + toBaseUnits, + tokenBySymbol, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, +} from '../types' +import { + ASSOCIATED_TOKEN_PROGRAM, + associatedTokenAccount, + decodeSolanaTransaction, + solanaAssertSafe, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, + type SolanaVenuePolicy, +} from './assert-safe' +import { venueFetch } from './http' +import { fromBaseUnits, SOLANA_TOKENS } from './tokens' + +/** + * Payment streams on Streamflow's main program: the whole amount is funded when the stream is + * created and unlocks hour by hour over the days the person typed. + * + * Streamflow also lists an immutable program, strmdbLr6w7QNmsiEXyFwWg3VSfg1GiELgU27P8aCGw, with no + * upgrade key and no SOL fee. It isn't used because its only single-signer path needs an account + * made by seed, and Streamflow's app hides that program behind a flag that is off by default, so a + * recipient would have nowhere ordinary to withdraw and the person nowhere to cancel. + */ + +// https://developers.streamflow.finance/docs/resources/program-ids, "Main Program (mainnet)". +// Executable by getAccountInfo, and it takes create_v2 in a simulation: streamflow.live.test.ts. +export const STREAMFLOW_PROGRAM = 'strmRqUCoQUgGUan5YhzUZa6KqdzwX5L6FpUxfmKg5m' + +// `STREAMFLOW_TREASURY_PUBLIC_KEY` in @streamflow/stream 13.4.0's solana/constants.ts. The program +// refuses any other, and writes this one into each stream, as a simulation shows. The token fee +// reaches it only as the stream unlocks. +export const STREAMFLOW_TREASURY = '5SEpbdjFK5FxwTvfsGMXVQTD2v4M2c5tyRTxhdsPkgDw' + +// `WITHDRAWOR` in the same file. The program refuses any other. It takes no SOL here, because +// automatic withdrawals are off: the creation fee sits on the stream's own account. +export const STREAMFLOW_WITHDRAWOR = 'wdrwhnCv4pzW8beKsbPa4S2UDZrXenjg16KJdKSpb5u' + +// `FEE_ORACLE_PUBLIC_KEY` in the same file, and owned on chain by Streamflow's partner oracle +// program, pardpVtPjC8nLj1Dwncew62mUzfChdCX1EaoZe8oCAa. Whoever runs it sets what a stream costs. +export const STREAMFLOW_FEE_ORACLE = 'B743wFVk2pCYhV91cn287e1xY7f1vt4gdY48hhNiuQmT' + +const RENT_SYSVAR = 'SysvarRent111111111111111111111111111111111' +const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111' + +/** Anchor's discriminator for `create_v2`, whose stream address needs no second signer. */ +const CREATE_V2 = 'd6904cec5f8b31b4' + +export const STREAMFLOW_POLICY: SolanaVenuePolicy = { + venueName: 'Streamflow', + programs: { [STREAMFLOW_PROGRAM]: { discriminators: [CREATE_V2] } }, + mayWrapSol: false, +} + +const VENUE_ID = 'streamflow' +const STREAMFLOW_APP = 'app.streamflow.finance' +const NOTHING_MOVED = 'Nothing was signed or sent.' + +const STREAMABLE = ['USDC', 'USDT'] +const WHOLE_DAYS = /^[1-9]\d{0,3}$/ +const MAX_DAYS = 3650 +const MAX_U64 = BigInt('18446744073709551615') + +const PERIOD_SECONDS = 3600 +const PERIODS_PER_DAY = 24 +/** Division leaves a remainder that unlocks at the start, and this keeps it from mattering. */ +const MAX_START_SHARE = BigInt(100) + +const STREAM_NAME = 'Intent stream' +const NAME_BYTES = 64 +const INSTRUCTION_BYTES = 150 +const NONCE_OFFSET = 136 + +/** A fresh recipient measured about 92,000 units in simulation. */ +const COMPUTE_UNIT_LIMIT = 200_000 +/** About 0.00002 SOL at the limit above. */ +const COMPUTE_UNIT_PRICE = BigInt(100_000) +const SET_COMPUTE_UNIT_LIMIT = 2 +const SET_COMPUTE_UNIT_PRICE = 3 + +const DEFAULT_RPC_URL = 'https://solana-rpc.publicnode.com' + +/** Offsets into a stream's account: `streamLayout` in @streamflow/stream, and a simulation of one. */ +const STREAM = { + sender: 49, + recipient: 113, + mint: 177, + treasury: 241, + tokenFee: 305, + partner: 325, + partnerFee: 389, + deposited: 417, + creationFee: 704, + minimumBytes: 708, +} + +function refuse(sentence: string): never { + throw new ChainActionRefused(`${sentence} ${NOTHING_MOVED}`) +} + +const notTheStream = (): never => refuse('This is not the stream you asked for.') +const unreadable = (): never => refuse('Solana’s network could not be read just now.') + +export type SolanaRpc = (method: string, params: unknown[]) => Promise + +const defaultRpc: SolanaRpc = async (method, params) => { + const url = process.env['SOLANA_RPC_URL']?.trim() || DEFAULT_RPC_URL + const { status, body } = await venueFetch(url, { + label: 'Solana', + method: 'POST', + body: { jsonrpc: '2.0', id: 1, method, params }, + }) + return status === 200 ? body : undefined +} + +const RpcAccount = z.object({ + result: z.object({ value: z.object({ owner: z.string() }).nullable() }), +}) + +const RpcBlockhash = z.object({ + result: z.object({ + value: z.object({ + blockhash: z.string().refine(isBlockhash), + lastValidBlockHeight: z.number().int().nonnegative(), + }), + }), +}) + +const RpcSimulation = z.object({ + result: z.object({ + value: z.object({ + err: z.unknown(), + accounts: z + .array(z.object({ data: z.tuple([z.string(), z.string()]) }).nullable()) + .nullable() + .optional(), + }), + }), +}) + +/** + * The address a program derives from these seeds. Written out as `associatedTokenAccount` is, + * because `assertSafe` is synchronous by contract and the kit's derivation is not. + */ +function programAddress(seeds: readonly Uint8Array[], program: string): string { + const encoded = getAddressEncoder().encode(program as Address) + for (let bump = 255; bump >= 0; bump -= 1) { + const hash = createHash('sha256') + for (const seed of seeds) hash.update(seed) + hash.update(Uint8Array.of(bump)) + hash.update(Uint8Array.from(encoded)) + hash.update('ProgramDerivedAddress') + const candidate = getAddressDecoder().decode(hash.digest()) + if (isOffCurveAddress(candidate)) return candidate + } + throw new Error('No program address for these seeds.') +} + +function addressBytes(address: string): Uint8Array { + return Uint8Array.from(getAddressEncoder().encode(address as Address)) +} + +function streamAddress(account: string, mint: string, nonce: number): string { + const big = Buffer.alloc(4) + big.writeUInt32BE(nonce) + return programAddress( + [Buffer.from('strm-met'), addressBytes(mint), addressBytes(account), big], + STREAMFLOW_PROGRAM + ) +} + +function escrowAddress(stream: string): string { + return programAddress([Buffer.from('strm'), addressBytes(stream)], STREAMFLOW_PROGRAM) +} + +interface AskedStream { + recipient: string + token: ChainToken + units: bigint + days: number +} + +/** Even hours, with what division leaves unlocking at the start, so it ends when it was asked to. */ +function schedule(units: bigint, days: number): { perPeriod: bigint; atStart: bigint } { + const periods = BigInt(days * PERIODS_PER_DAY) + const perPeriod = units / periods + const atStart = units - perPeriod * periods + if (perPeriod === BigInt(0) || atStart * MAX_START_SHARE > units) { + refuse(`That amount is too small to spread evenly over ${days} ${days === 1 ? 'day' : 'days'}.`) + } + return { perPeriod, atStart } +} + +function asked(request: ChainActionRequest, account: string): AskedStream { + if (!isAddress(account)) refuse('That is not a Solana wallet address.') + if (request.kind !== 'stream') refuse('Streamflow only streams here.') + const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn) + if (token === undefined || !STREAMABLE.includes(token.symbol)) { + return refuse(`Streamflow streams ${STREAMABLE.join(' or ')} here, not ${request.tokenIn}.`) + } + const { recipient } = request + if (recipient === undefined) return refuse('A stream needs an address to go to.') + const refusal = sendRefusal('solana', token.symbol, recipient, account) + if (refusal !== undefined) throw new ChainActionRefused(refusal) + if (isOffCurveAddress(recipient as Address)) { + refuse('No one can sign for that address, and a stream’s recipient has to sign to withdraw.') + } + const durationDays = request.durationDays ?? '' + if (!WHOLE_DAYS.test(durationDays) || Number(durationDays) > MAX_DAYS) { + refuse(`A stream needs a length in whole days, from 1 to ${MAX_DAYS} days.`) + } + const units = BigInt(toBaseUnits(request.amount, token.decimals)) + if (units > MAX_U64) refuse('That amount is too large for a stream.') + const days = Number(durationDays) + schedule(units, days) + return { recipient, token, units, days } +} + +/** A transfer can land anywhere, but a stream pays only an address that can sign to withdraw. */ +async function assertRecipientIsWallet(rpc: SolanaRpc, recipient: string): Promise { + const reply = RpcAccount.safeParse( + await rpc('getAccountInfo', [ + recipient, + { encoding: 'base64', commitment: 'confirmed', dataSlice: { offset: 0, length: 0 } }, + ]) + ) + if (!reply.success) unreadable() + else if (reply.data.result.value !== null && reply.data.result.value.owner !== SYSTEM_PROGRAM) { + refuse('That address isn’t a plain wallet, and a stream’s recipient has to sign to withdraw.') + } +} + +function createData(stream: AskedStream, nonce: number): Uint8Array { + const { perPeriod, atStart } = schedule(stream.units, stream.days) + const data = Buffer.alloc(INSTRUCTION_BYTES) + let at = data.write(CREATE_V2, 0, 'hex') + // A start and cliff of zero mean the moment this lands, so a slow signature can't fail it. + for (const value of [ + BigInt(0), + stream.units, + BigInt(PERIOD_SECONDS), + perPeriod, + BigInt(0), + atStart, + ]) { + at = data.writeBigUInt64LE(value, at) + } + // Cancelable by the sender, and none of: cancel by recipient, automatic withdrawal, transfer by + // sender, transfer by recipient, top up. + at = data.writeUInt8(1, at) + 5 + at += data.write(STREAM_NAME, at) + NAME_BYTES - Buffer.byteLength(STREAM_NAME) + at = data.writeBigUInt64LE(BigInt(PERIOD_SECONDS), at) + // Not pausable, and no rate change, which would also let the cancel and transfer rules change. + at += 2 + data.writeUInt32LE(nonce, at) + return data +} + +function createAccounts(account: string, stream: AskedStream, nonce: number) { + const mint = stream.token.id + const metadata = streamAddress(account, mint, nonce) + const tokens = associatedTokenAccount(account, mint) + const writable: readonly [string, AccountRole][] = [ + [account, AccountRole.WRITABLE_SIGNER], + [tokens, AccountRole.WRITABLE], + [stream.recipient, AccountRole.WRITABLE], + [metadata, AccountRole.WRITABLE], + [escrowAddress(metadata), AccountRole.WRITABLE], + [associatedTokenAccount(stream.recipient, mint), AccountRole.WRITABLE], + [STREAMFLOW_TREASURY, AccountRole.WRITABLE], + [associatedTokenAccount(STREAMFLOW_TREASURY, mint), AccountRole.WRITABLE], + [STREAMFLOW_WITHDRAWOR, AccountRole.WRITABLE], + // The partner's fee would go here. Naming the person keeps it from reaching anyone else. + [account, AccountRole.WRITABLE], + [tokens, AccountRole.WRITABLE], + [mint, AccountRole.READONLY], + [STREAMFLOW_FEE_ORACLE, AccountRole.READONLY], + [RENT_SYSVAR, AccountRole.READONLY], + [STREAMFLOW_PROGRAM, AccountRole.READONLY], + [TOKEN_PROGRAM, AccountRole.READONLY], + [ASSOCIATED_TOKEN_PROGRAM, AccountRole.READONLY], + [SYSTEM_PROGRAM, AccountRole.READONLY], + ] + return writable +} + +function compile( + account: string, + stream: AskedStream, + nonce: number, + lifetime: { blockhash: string; lastValidBlockHeight: number } +): string { + const limit = Buffer.alloc(5) + limit.writeUInt8(SET_COMPUTE_UNIT_LIMIT) + limit.writeUInt32LE(COMPUTE_UNIT_LIMIT, 1) + const price = Buffer.alloc(9) + price.writeUInt8(SET_COMPUTE_UNIT_PRICE) + price.writeBigUInt64LE(COMPUTE_UNIT_PRICE, 1) + const message = pipe( + createTransactionMessage({ version: 'legacy' }), + (m) => setTransactionMessageFeePayer(account as Address, m), + (m) => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: lifetime.blockhash as Blockhash, + lastValidBlockHeight: BigInt(lifetime.lastValidBlockHeight), + }, + m + ), + (m) => + appendTransactionMessageInstructions( + [ + { programAddress: COMPUTE_BUDGET_PROGRAM as Address, data: Uint8Array.from(limit) }, + { programAddress: COMPUTE_BUDGET_PROGRAM as Address, data: Uint8Array.from(price) }, + { + programAddress: STREAMFLOW_PROGRAM as Address, + accounts: createAccounts(account, stream, nonce).map(([address, role]) => ({ + address: address as Address, + role, + })), + data: Uint8Array.from(createData(stream, nonce)), + }, + ], + m + ) + ) + return getBase64EncodedWireTransaction(compileTransaction(message)) +} + +interface Issued { + recipient: string + /** The token's id. */ + token: string + /** Base units. */ + amount: string + days: string +} + +/** + * Proves a stream came from this server. The browser hands `context` back at submit and could + * change it along with the bytes, so every field that decides where funds go is signed, for this + * venue and account. The prefix keeps this from passing as any other signature on the secret. + */ +function proofOf(account: string, issued: Issued, key: string): string { + const message = JSON.stringify([ + account, + issued.token, + issued.amount, + issued.recipient, + issued.days, + ]) + return createHmac('sha256', key).update(`streamflow-stream:${message}`).digest('base64url') +} + +function readIssued(account: string, context: Record | undefined): AskedStream { + const notIssued = (): never => refuse('This stream’s details are not what this app issued.') + const key = secret() + const recipient = context?.['recipient'] + const tokenId = context?.['token'] + const amount = context?.['amount'] + const days = context?.['days'] + const proof = context?.['proof'] + if ( + key === undefined || + recipient === undefined || + tokenId === undefined || + amount === undefined || + days === undefined || + proof === undefined || + !sameProof(proof, proofOf(account, { recipient, token: tokenId, amount, days }, key)) + ) { + return notIssued() + } + const token = SOLANA_TOKENS.find((t) => t.id === tokenId) + return token === undefined + ? notIssued() + : { recipient, token, units: BigInt(amount), days: Number(days) } +} + +/** The one call this transaction makes after its compute budget, seen in full. */ +function onlyCall(base64: string): { accounts: (string | undefined)[]; data: Uint8Array } { + const { messageBytes } = getTransactionDecoder().decode(bytesFromBase64(base64)) + const message = getCompiledTransactionMessageDecoder().decode(messageBytes) + if ('addressTableLookups' in message && (message.addressTableLookups?.length ?? 0) > 0) { + refuse('This transaction uses a lookup table, which hides accounts from this check.') + } + const transaction = decodeSolanaTransaction(base64) + const programs = transaction.instructions.map( + (instruction) => transaction.staticAccounts[instruction.programIndex] + ) + const call = transaction.instructions[programs.length - 1] + const onlyBudgetFirst = programs + .slice(0, -1) + .every((program) => program === COMPUTE_BUDGET_PROGRAM) + if (call === undefined || !onlyBudgetFirst || programs.at(-1) !== STREAMFLOW_PROGRAM) { + return notTheStream() + } + return { + accounts: call.accounts.map((index) => transaction.staticAccounts[index]), + data: call.data, + } +} + +function assertSafe( + payload: SigningPayload, + account: string, + context?: Record +): void { + const stream = readIssued(account, context) + solanaAssertSafe(STREAMFLOW_POLICY)(payload, account) + if (payload.family !== 'solana') return refuse('That is not a Solana transaction.') + const { accounts, data } = onlyCall(payload.transaction) + if (data.length !== INSTRUCTION_BYTES) notTheStream() + const nonce = Buffer.from(data).readUInt32LE(NONCE_OFFSET) + // Rebuilt from what was issued, so anything else in this call, however small, doesn't match. + const expected = createAccounts(account, stream, nonce) + const sameAccounts = + accounts.length === expected.length && + expected.every(([address], position) => accounts[position] === address) + if (!sameAccounts || !Buffer.from(createData(stream, nonce)).equals(data)) notTheStream() +} + +interface WrittenFees { + /** Base units of the token, on top of the stream. */ + token: string + lamports: string +} + +/** + * The stream the program would write, from a dry run. What it costs is read here rather than from + * the fee oracle, since the program's own arithmetic is what the person pays. Whoever runs the + * oracle can change it, so a build that names anyone but who was asked for is refused too. + */ +function readWritten(data: string, account: string, stream: AskedStream): WrittenFees { + const bytes = Buffer.from(data, 'base64') + if (bytes.length < STREAM.minimumBytes) unreadable() + const addressAt = (offset: number): string => + getAddressDecoder().decode(bytes.subarray(offset, offset + 32)) + if ( + addressAt(STREAM.sender) !== account || + addressAt(STREAM.recipient) !== stream.recipient || + addressAt(STREAM.mint) !== stream.token.id || + addressAt(STREAM.treasury) !== STREAMFLOW_TREASURY || + addressAt(STREAM.partner) !== account || + bytes.readBigUInt64LE(STREAM.partnerFee) !== BigInt(0) || + bytes.readBigUInt64LE(STREAM.deposited) !== stream.units + ) { + notTheStream() + } + return { + token: bytes.readBigUInt64LE(STREAM.tokenFee).toString(), + lamports: String(bytes.readUInt32LE(STREAM.creationFee)), + } +} + +async function dryRun( + rpc: SolanaRpc, + transaction: string, + account: string, + stream: AskedStream, + nonce: number +): Promise { + const metadata = streamAddress(account, stream.token.id, nonce) + const reply = RpcSimulation.safeParse( + await rpc('simulateTransaction', [ + transaction, + { + encoding: 'base64', + sigVerify: false, + replaceRecentBlockhash: true, + commitment: 'confirmed', + accounts: { encoding: 'base64', addresses: [metadata] }, + }, + ]) + ) + if (!reply.success) return unreadable() + const { err, accounts } = reply.data.result.value + if (err !== null && err !== undefined) { + refuse( + `This stream would fail on the chain right now. Check your ${stream.token.symbol} balance, and that you have enough SOL for Streamflow’s fee and the rent.` + ) + } + const written = accounts?.[0]?.data[0] + return written === undefined ? unreadable() : readWritten(written, account, stream) +} + +function streamNotes(stream: AskedStream, fees: WrittenFees): string[] { + const { recipient, token, days } = stream + const length = `${days} ${days === 1 ? 'day' : 'days'}` + return [ + `The full amount leaves your wallet now and sits in Streamflow's program. It unlocks evenly, hour by hour, for ${length}, starting when this lands.`, + `On top of that, Streamflow takes ${fromBaseUnits(fees.token, token.decimals)} ${token.symbol} and ${fromBaseUnits(fees.lamports, 9)} SOL. Your wallet also pays about 0.008 SOL in rent, and about 0.0015 SOL more if that address has no ${token.symbol} account yet. You don't get the SOL back.`, + `The money doesn't arrive by itself. ${recipient} has to withdraw what has streamed, at ${STREAMFLOW_APP} or by calling the program, and needs a little SOL for the network fee.`, + `You can cancel until it ends, and only you can. What has streamed goes to ${recipient} and the rest comes back to you, so they can't count on it. This app can't cancel for you yet: use ${STREAMFLOW_APP} or call the program from this wallet.`, + `Check that address is a wallet they can sign from. The stream stays with it and can't be moved to another address, and an exchange deposit address may not be able to claim it.`, + `Whoever holds Streamflow's upgrade key can change this program, including how it treats funds already in it, and Streamflow can change its fees at any time. Neodyme's audit of May 2024 found no critical, high or medium issues and described a 3-of-5 multisig holding that key, but the program was last upgraded on 21 September 2026, so what runs now is not what they reviewed. DefiLlama's hack list shows no exploit.`, + ] +} + +export interface StreamflowOptions { + /** For tests. */ + rpc?: SolanaRpc +} + +export function createStreamflowVenue(options: StreamflowOptions = {}): ChainVenue { + const rpc = options.rpc ?? defaultRpc + + async function prepare(request: ChainActionRequest, account: string): Promise { + const key = secret() + if (key === undefined) refuse('Streaming on Streamflow isn’t available here right now.') + const stream = asked(request, account) + await assertRecipientIsWallet(rpc, stream.recipient) + + const reply = RpcBlockhash.safeParse( + await rpc('getLatestBlockhash', [{ commitment: 'confirmed' }]) + ) + if (!reply.success) return unreadable() + const nonce = randomBytes(4).readUInt32LE() + const transaction = compile(account, stream, nonce, reply.data.result.value) + + const issued: Issued = { + recipient: stream.recipient, + token: stream.token.id, + amount: stream.units.toString(), + days: String(stream.days), + } + const context = { ...issued, proof: proofOf(account, issued, key as string) } + const signing: SigningPayload = { family: 'solana', transaction } + assertSafe(signing, account, context) + const fees = await dryRun(rpc, transaction, account, stream, nonce) + + const amount = request.amount.trim() + const { days, token, recipient } = stream + return { + venueId: VENUE_ID, + venueName: 'Streamflow', + kind: 'stream', + summary: `Streams ${amount} ${token.symbol} to ${recipient} over ${days} ${days === 1 ? 'day' : 'days'}.`, + pay: { amount, symbol: token.symbol }, + notes: streamNotes(stream, fees), + signing, + context, + } + } + + return { id: VENUE_ID, name: 'Streamflow', kinds: ['stream'], prepare, assertSafe } +} diff --git a/apps/dapp/lib/chains/solana/tokens.ts b/apps/dapp/lib/chains/solana/tokens.ts index 9e1724c8..e56b4468 100644 --- a/apps/dapp/lib/chains/solana/tokens.ts +++ b/apps/dapp/lib/chains/solana/tokens.ts @@ -9,6 +9,8 @@ import type { ChainToken } from '../types' * - USDT: https://tether.to/en/supported-protocols * - JitoSOL: https://go.jito.network/docs/jitosol/jitosol-liquid-staking/for-developers/staking-integration/ * - ONyc: https://docs.onre.finance/technical-resources/token-configuration-and-reference + * - mSOL: https://docs.marinade.finance/developers/contract-addresses (5 November 2026) + * - sSOL: https://docs.solayer.org/resources/solayer-programs.md (5 November 2026) * - SOL: the wrapped SOL mint, the Token Program's native mint. */ export const SOLANA_TOKENS: readonly ChainToken[] = [ @@ -42,6 +44,18 @@ export const SOLANA_TOKENS: readonly ChainToken[] = [ decimals: 9, name: 'OnRe Tokenized Reinsurance', }, + { + symbol: 'mSOL', + id: 'mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So', + decimals: 9, + name: 'Marinade Staked SOL', + }, + { + symbol: 'sSOL', + id: 'sSo14endRuUbvQaJS3dq36Q829a3A6BEfoeeRGJywEh', + decimals: 9, + name: 'Solayer Restaked SOL', + }, ] /** Display units for a base-unit integer, without floating point. */ diff --git a/apps/dapp/lib/chains/solana/venues.ts b/apps/dapp/lib/chains/solana/venues.ts index 82a45394..06ad1e75 100644 --- a/apps/dapp/lib/chains/solana/venues.ts +++ b/apps/dapp/lib/chains/solana/venues.ts @@ -1,5 +1,10 @@ import type { Venue } from '@intent/types' +import { jitoListing } from './jito-listing' +import { marinadeListing } from './marinade-listing' +import { solayerListing } from './solayer-listing' +import { streamflowVenue } from './streamflow-listing' + /** * The Solana apps the Apps screen shows, from the ranked table in the integration * report. `executes` means the app builds, checks, signs and submits here with no @@ -16,7 +21,7 @@ export const solanaVenues: Venue[] = [ url: 'https://jup.ag', integration: 'executes', capability: - 'Swaps USDC, USDT, SOL, JitoSOL and ONyc through Jupiter’s on-chain router. Refuses thin liquidity, and checks every transaction before you sign.', + 'Swaps USDC, USDT, SOL, JitoSOL, mSOL, sSOL and ONyc through Jupiter’s on-chain router. Refuses thin liquidity, and checks every transaction before you sign.', }, { // Listed, as Lulo is: whether this runs depends on a secret this client-safe file can't read. @@ -154,4 +159,8 @@ export const solanaVenues: Venue[] = [ bestFor: 'Oracle-priced perpetuals against a pool. Leverage can lose your whole position', url: 'https://jup.ag/perps', }, + jitoListing, + marinadeListing, + solayerListing, + streamflowVenue, ] diff --git a/apps/dapp/lib/chains/sui/aftermath-listing.ts b/apps/dapp/lib/chains/sui/aftermath-listing.ts new file mode 100644 index 00000000..11632201 --- /dev/null +++ b/apps/dapp/lib/chains/sui/aftermath-listing.ts @@ -0,0 +1,14 @@ +import type { Venue } from '@intent/types' + +export const aftermathListing: Venue = { + id: 'aftermath', + family: 'sui', + name: 'Aftermath', + category: 'staking', + chains: ['Sui'], + url: 'https://aftermath.finance', + bestFor: + 'Liquid staking with afSUI, with SUI back at the next epoch. Its perps lost about $1.14M in April 2026; those are separate and not used here', + integration: 'executes', + capability: 'Stakes SUI for afSUI, and unstakes afSUI by the next epoch', +} diff --git a/apps/dapp/lib/chains/sui/aftermath.ts b/apps/dapp/lib/chains/sui/aftermath.ts new file mode 100644 index 00000000..df22a0b1 --- /dev/null +++ b/apps/dapp/lib/chains/sui/aftermath.ts @@ -0,0 +1,310 @@ +import { coinWithBalance, Transaction } from '@mysten/sui/transactions' + +import { + ChainActionRefused, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type VenueQuote, +} from '../types' +import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' +import { + assertPinnedObjects, + creditedAmount, + dryRun, + percentText, + readAddress, + readObjectJson, + readU64, + stakesAfterSwap, + SUI_SYSTEM_STATE, + u64Field, +} from './haedal' +import { assertPtbSafe, moveTarget, type ParsedCall, type PtbPolicy, type ReadPure } from './safety' +import { displayAmount } from './tokens' + +/** + * All from aftermath.finance/api/addresses (the `staking` section), which aftermath-ts-sdk 6.4.0 + * reads. Its docs' Contracts page lists the same objects, but shifts the package rows by one, so + * this package reads as `afsui` there. On chain: this is the upgraded LSD package at version 2, + * its `staked_sui_vault` takes these object types, and live stakes call it. + */ +export const AFTERMATH_PACKAGE = + '0x1575034d2729907aefca1ac757d6ccfcd3fc7e9e77927523c06007d8353ad836' +export const AFTERMATH_VAULT = '0x2f8f6d5da7f13ea37daa397724280483ed062769813b6f31e9788e59cc88994d' +export const AFTERMATH_VAULT_STATE = + '0x55486449e41d89cfbdb20e005c1c5c1007858ad5b4d5d7c047d2b3b592fe8791' +export const AFTERMATH_SAFE = '0xeb685899830dd5837b47007809c76d91a098d52aabbf61e8ac467c59e5cc4610' +export const AFTERMATH_REFERRAL_VAULT = + '0x4ce9a19b594599536c53edb25d22532f82f18038dc8ef618afd00fbbfb9845ef' +/** `aftermathValidator` in the same API. On chain it is active, named Aftermath, with a fee of 0 here. */ +export const AFTERMATH_VALIDATOR = + '0xd30018ec3f5ff1a3c75656abf927a87d7f0529e6dc89c7ddd1bd27ecb05e3db2' +/** On chain, `Safe` holds this coin's TreasuryCap, and its metadata says afSUI, 9 decimals. */ +export const AFSUI_TYPE = + '0xf325ce1300e8dac124071d3152c5c5ee6174914f8bc2161e88329cf579246efc::afsui::AFSUI' + +export const AFTERMATH_TOKENS: ChainToken[] = [ + { symbol: 'afSUI', id: AFSUI_TYPE, decimals: 9, name: 'Aftermath Staked SUI' }, +] + +const SUI_DECIMALS = 9 +const AFSUI_DECIMALS = 9 +/** The vault's fees are fixed point with 18 decimals. */ +const FEE_ONE = BigInt('1000000000000000000') +const SYSTEM_READ_TIMEOUT_MS = 10_000 +const MS_PER_HOUR = 3_600_000 + +const REQUEST_STAKE = moveTarget(AFTERMATH_PACKAGE, 'staked_sui_vault', 'request_stake') +const REQUEST_UNSTAKE = moveTarget(AFTERMATH_PACKAGE, 'staked_sui_vault', 'request_unstake') + +function fail(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +interface VaultReading { + minimum: bigint + /** The whole fee a delayed unstake pays on its amount, as a fraction with 18 decimals. */ + feeOnAmount: bigint + rewardShare: { rewards: bigint; total: bigint } +} + +async function readVault(): Promise { + const state = await readObjectJson(AFTERMATH_VAULT_STATE) + const config = state?.['protocol_config'] as Record | undefined + const unstakeFee = config?.['default_unstake_protocol_fee'] as Record | undefined + const minimum = u64Field(config?.['min_staking_threshold']) + const totalFee = u64Field(unstakeFee?.['total_fee']) + const rewards = u64Field(state?.['total_rewards_amount']) + const total = u64Field(state?.['total_sui_amount']) + if ( + minimum === undefined || + totalFee === undefined || + rewards === undefined || + total === undefined || + total === BigInt(0) + ) { + fail("I couldn't read Aftermath's fees, so I stopped. Try again shortly.") + } + const share = rewards > total ? total : rewards + return { + minimum, + feeOnAmount: (totalFee * share) / total, + rewardShare: { rewards, total }, + } +} + +function feePercent(vault: VaultReading): number { + return (Number(vault.feeOnAmount) / Number(FEE_ONE)) * 100 +} + +/** Hours until the next epoch, rounded up, from the chain's own clock for the current one. */ +async function hoursToNextEpoch(now: number = Date.now()): Promise { + const { systemState } = await withTimeout( + suiClient().core.getCurrentSystemState(), + SYSTEM_READ_TIMEOUT_MS, + 'Reading the epoch' + ) + const started = Number(systemState.epochStartTimestampMs) + const length = Number(systemState.parameters.epochDurationMs) + if (!Number.isFinite(started) || !Number.isFinite(length) || length <= 0) { + fail("I couldn't read when this Sui epoch ends, so I stopped. Try again shortly.") + } + return Math.max(1, Math.ceil((started + length - now) / MS_PER_HOUR)) +} + +async function checkedStake(request: ChainActionRequest, account: string) { + assertSuiAccount(account) + if (request.tokenIn.trim().toUpperCase() !== 'SUI') { + throw new ChainActionRefused( + `Aftermath stakes SUI, not ${request.tokenIn}. Try "stake 1 SUI with Aftermath".` + ) + } + const amount = toBaseUnits(request.amount, SUI_DECIMALS) + const vault = await readVault() + if (BigInt(amount) < vault.minimum) { + fail(`Aftermath stakes at least ${displayAmount(String(vault.minimum), SUI_DECIMALS)} SUI.`) + } + + const tx = new Transaction() + tx.setSender(account) + const [sui] = tx.splitCoins(tx.gas, [BigInt(amount)]) + if (sui === undefined) throw new Error('Splitting a coin returned nothing.') + const [minted] = tx.moveCall({ + target: REQUEST_STAKE, + arguments: [ + tx.object(AFTERMATH_VAULT), + tx.object(AFTERMATH_SAFE), + tx.object(SUI_SYSTEM_STATE), + tx.object(AFTERMATH_REFERRAL_VAULT), + sui, + tx.pure.address(AFTERMATH_VALIDATOR), + ], + }) + if (minted === undefined) throw new Error('Staking returned nothing.') + tx.transferObjects([minted], account) + + const transaction = await buildUnsigned(tx, suiClient(), 'SUI') + const simulated = await dryRun(transaction, 'Checking the stake') + const received = creditedAmount(simulated.Transaction?.balanceChanges, AFSUI_TYPE, account) + if (received === undefined) { + fail( + "I couldn't confirm how much afSUI this stake would return, so I stopped. Try again shortly." + ) + } + return { transaction, receive: displayAmount(received, AFSUI_DECIMALS), vault } +} + +/** + * `request_unstake` pays nothing in this transaction: the contract holds the afSUI, and pays the + * sender when the next epoch is processed. So the amount is worked out from the same two readings + * the contract uses then, and can't be read from the dry run. + */ +async function checkedUnstake(request: ChainActionRequest, account: string) { + assertSuiAccount(account) + if (request.tokenIn.trim().toUpperCase() !== 'AFSUI') { + throw new ChainActionRefused( + `Aftermath unstakes afSUI, not ${request.tokenIn}. Try "unstake 1 afSUI with Aftermath".` + ) + } + const amount = toBaseUnits(request.amount, AFSUI_DECIMALS) + const vault = await readVault() + const value = await readU64( + account, + moveTarget(AFTERMATH_PACKAGE, 'staked_sui_vault', 'afsui_to_sui'), + (tx) => [tx.object(AFTERMATH_VAULT), tx.object(AFTERMATH_SAFE), tx.pure.u64(BigInt(amount))] + ) + if (value === undefined) { + fail("I couldn't read what this afSUI is worth, so I stopped. Try again shortly.") + } + if (value < vault.minimum) { + fail( + `Aftermath unstakes at least ${displayAmount(String(vault.minimum), SUI_DECIMALS)} SUI worth of afSUI.` + ) + } + + const tx = new Transaction() + tx.setSender(account) + const coin = tx.add( + coinWithBalance({ type: AFSUI_TYPE, balance: BigInt(amount), useGasCoin: false }) + ) + tx.moveCall({ + target: REQUEST_UNSTAKE, + arguments: [tx.object(AFTERMATH_VAULT), tx.object(AFTERMATH_SAFE), coin], + }) + + const transaction = await buildUnsigned(tx, suiClient(), 'afSUI') + const simulated = await dryRun(transaction, 'Checking the unstake') + if (simulated.Transaction === undefined) { + fail('This unstake would fail if sent right now, so I stopped. Try again shortly.') + } + const fee = (value * vault.feeOnAmount) / FEE_ONE + return { + transaction, + receive: displayAmount(String(value - fee), SUI_DECIMALS), + vault, + } +} + +const SHARED_RISKS = [ + "Your SUI sits in contracts Aftermath can upgrade through a vote of its signers. Aftermath's admin key can change the fees alone: on chain it is a single signer, not a multisig.", + 'Aftermath Perps, a separate product, lost about $1.14M in April 2026 through a flaw in its withdrawals. Aftermath is reported to have said it was confined to perps. This uses staking, not perps.', +] + +/** No apyPercent: no rate can be read from Aftermath here, and a rate is never invented. */ +async function quote(request: ChainActionRequest, account: string): Promise { + const { receive } = await checkedStake(request, account) + return { receive: { amount: receive, symbol: 'afSUI' } } +} + +async function prepareStake(request: ChainActionRequest, account: string): Promise { + const { transaction, receive, vault } = await checkedStake(request, account) + return { + venueId: 'aftermath', + venueName: 'Aftermath', + kind: 'stake', + summary: `Stake ${request.amount} SUI with Aftermath for about ${receive} afSUI.`, + pay: { amount: request.amount, symbol: 'SUI' }, + receive: { amount: receive, symbol: 'afSUI', approximate: true }, + notes: [ + 'Staking rewards are not guaranteed. They depend on the validators, and I am not showing a rate because I could not read one from Aftermath.', + "afSUI is worth more than SUI over time, so you get fewer of them, and it can trade below its value on the market. This stake goes to Aftermath's own validator.", + `To get out, unstake here. The SUI comes at the start of the next Sui epoch, within about 24 hours. The fee is about ${percentText(feePercent(vault))}% of the whole amount right now. The contract works it out from the rewards the whole pool has earned, not yours, so it is charged even if you unstake soon after staking.`, + ...SHARED_RISKS, + ], + signing: { family: 'sui', transaction }, + } +} + +async function prepareUnstake( + request: ChainActionRequest, + account: string +): Promise { + const { transaction, receive, vault } = await checkedUnstake(request, account) + const hours = await hoursToNextEpoch() + return { + venueId: 'aftermath', + venueName: 'Aftermath', + kind: 'unstake', + summary: `Unstake ${request.amount} afSUI with Aftermath. About ${receive} SUI comes to this wallet at the next Sui epoch.`, + pay: { amount: request.amount, symbol: 'afSUI' }, + receive: { amount: receive, symbol: 'SUI', approximate: true }, + notes: [ + `The SUI does not come in this transaction. It comes at the start of the next Sui epoch, in about ${hours} ${hours === 1 ? 'hour' : 'hours'}, and can't be cancelled once sent.`, + `You sign nothing more. The contract pays this wallet when that epoch is processed. Aftermath runs a bot for that, and anyone else can run it too.`, + `The fee is about ${percentText(feePercent(vault))}% of the whole amount, already taken off the amount shown. The contract works it out from the rewards the whole pool has earned, not yours. The final amount can move slightly with the next epoch's rate.`, + 'Aftermath also has an instant unstake, with a different fee that depends on its reserves. This app does not use it.', + ...SHARED_RISKS, + ], + signing: { family: 'sui', transaction }, + } +} + +const PINS: ReadonlyMap = new Map([ + [ + REQUEST_STAKE, + [ + AFTERMATH_VAULT, + AFTERMATH_SAFE, + SUI_SYSTEM_STATE, + AFTERMATH_REFERRAL_VAULT, + undefined, + undefined, + ], + ], + [REQUEST_UNSTAKE, [AFTERMATH_VAULT, AFTERMATH_SAFE, undefined]], +]) + +function checkAftermathCall(call: ParsedCall, read: ReadPure): void { + if (call.target !== REQUEST_STAKE && call.target !== REQUEST_UNSTAKE) return + if (call.typeArguments.length > 0) fail('This Aftermath call names a token it should not.') + if ( + call.target === REQUEST_STAKE && + readAddress(read, call.arguments[5]) !== AFTERMATH_VALIDATOR + ) { + fail('This stake names a validator I did not choose.') + } +} + +export const AFTERMATH_POLICY: PtbPolicy = { + venueName: 'Aftermath', + allowedCalls: new Set([REQUEST_STAKE, REQUEST_UNSTAKE]), + checkCall: checkAftermathCall, +} + +export const aftermathVenue: ChainVenue = { + id: 'aftermath', + name: 'Aftermath', + kinds: ['stake', 'unstake'], + assertCanFollow: stakesAfterSwap('Aftermath'), + quote, + prepare: (request, account) => + request.kind === 'unstake' ? prepareUnstake(request, account) : prepareStake(request, account), + assertSafe: (payload: SigningPayload, account: string) => { + assertPtbSafe(payload, account, AFTERMATH_POLICY) + assertPinnedObjects(payload, PINS, 'Aftermath') + }, +} diff --git a/apps/dapp/lib/chains/sui/capabilities.ts b/apps/dapp/lib/chains/sui/capabilities.ts index 86446f58..0f8b230a 100644 --- a/apps/dapp/lib/chains/sui/capabilities.ts +++ b/apps/dapp/lib/chains/sui/capabilities.ts @@ -4,5 +4,7 @@ import type { ChainCapability } from '../explain' export const suiCapabilities: ChainCapability[] = [ { kind: 'swap', venue: 'Cetus', symbol: 'SUI', then: ['cashout', 'supply', 'send'] }, { kind: 'supply', venue: 'NAVI', symbol: 'USDC' }, + { kind: 'withdraw', venue: 'NAVI', symbol: 'USDC' }, { kind: 'stake', venue: 'SpringSui', symbol: 'SUI' }, + { kind: 'unstake', venue: 'SpringSui', symbol: 'sSUI' }, ] diff --git a/apps/dapp/lib/chains/sui/deepbook-listing.ts b/apps/dapp/lib/chains/sui/deepbook-listing.ts new file mode 100644 index 00000000..e5a47564 --- /dev/null +++ b/apps/dapp/lib/chains/sui/deepbook-listing.ts @@ -0,0 +1,14 @@ +import type { Venue } from '@intent/types' + +export const deepbookListing: Venue = { + id: 'deepbook', + family: 'sui', + name: 'DeepBook', + category: 'orderbook', + chains: ['Sui'], + url: 'https://www.deepbook.tech', + bestFor: "Swaps straight against Sui's on-chain order book", + integration: 'executes', + capability: + 'Swaps SUI, USDC and USDsui at market, and USDT when its thin book can fill. Limit orders are not connected yet', +} diff --git a/apps/dapp/lib/chains/sui/deepbook.ts b/apps/dapp/lib/chains/sui/deepbook.ts new file mode 100644 index 00000000..8dbae8ec --- /dev/null +++ b/apps/dapp/lib/chains/sui/deepbook.ts @@ -0,0 +1,283 @@ +import { Transaction } from '@mysten/sui/transactions' +import { normalizeSuiAddress } from '@mysten/sui/utils' + +import type { + ChainActionRequest, + ChainVenue, + PreparedAction, + SigningPayload, + VenueQuote, +} from '../types' +import { assertSuiAccount } from './client' +import { + bridgeNote, + CLOCK, + CLOCK_SHARED_VERSION, + dataOf, + judgeFill, + LIVE_SWAP_IO, + literalU64, + minimumOut, + moveCallsNamed, + pairTokens, + readOrRefuse, + refuse, + resultOf, + sameType, + sharedObjectId, + slippageNote, + tokenType, + u64At, + unspentNote, + type Fill, + type SwapIo, +} from './direct-swap' +import { assertPtbSafe, moveTarget, type ParsedCall, type PtbPolicy } from './safety' +import { displayAmount } from './tokens' + +/** + * DeepBook core, version 9. `DEEPBOOK_PACKAGE_ID` in @mysten/deepbook-v3 2.10.1 + * (utils/constants.ts), and the Move Registry's @deepbook/core resolves to it. On chain, the + * UpgradeCap 0xdadf253c...4483 names this package at version 9, and the book takes calls from it. + */ +export const DEEPBOOK_PACKAGE = '0xbca0cefe720330e7bb4f2283438b68de6a0e656688db4ac90d61e71fe6151fe8' + +/** `DEEP.type` in the same file, and what `swap_exact_base_for_quote`'s signature asks for on chain. */ +export const DEEP_TYPE = + '0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP' + +export interface DeepbookPool { + id: string + initialSharedVersion: number + base: string + quote: string +} + +/** + * `mainnetPools` in the same file. On chain each object is a `pool::Pool` of these + * coins, and `pool::get_pool_id_by_asset` on the registry 0xaf16199a...549d returns this id. + */ +export const DEEPBOOK_POOLS: readonly DeepbookPool[] = [ + { + id: '0xe05dafb5133bcffb8d59f4e12465dc0e9faeaa05e3e342a08fe135800e3e4407', + initialSharedVersion: 389750322, + base: 'SUI', + quote: 'USDC', + }, + { + id: '0xfc28a2fb22579c16d672a1152039cbf671e5f4b9f103feddff4ea06ef3c2bc25', + initialSharedVersion: 646905478, + base: 'USDT', + quote: 'USDC', + }, + { + id: '0x826eeacb2799726334aa580396338891205a41cf9344655e526aae6ddd5dc03f', + initialSharedVersion: 804805725, + base: 'SUI', + quote: 'USDsui', + }, + { + id: '0xa374264d43e6baa5aa8b35ff18ff24fdba7443b4bcb884cb4c2f568d32cdac36', + initialSharedVersion: 804805725, + base: 'USDsui', + quote: 'USDC', + }, +] + +const VENUE_NAME = 'DeepBook' +const SELL_BASE = moveTarget(DEEPBOOK_PACKAGE, 'pool', 'swap_exact_base_for_quote') +const BUY_BASE = moveTarget(DEEPBOOK_PACKAGE, 'pool', 'swap_exact_quote_for_base') +const ZERO_COIN = moveTarget('0x2', 'coin', 'zero') +/** DeepBook scales its fee rates and prices by this. */ +const FLOAT_SCALING = 1e9 +/** `FEE_PENALTY_MULTIPLIER` in DeepBook's constants.move: paying in the input coin costs 25% more. */ +const INPUT_FEE_MULTIPLIER = 1.25 + +function requirePair(swap: ParsedCall['typeArguments'], id: string | undefined): void { + const pool = DEEPBOOK_POOLS.find((p) => normalizeSuiAddress(p.id) === id) + if ( + pool === undefined || + !sameType(swap[0], tokenType(pool.base)) || + !sameType(swap[1], tokenType(pool.quote)) + ) { + refuse('This swap uses a DeepBook pool I have not confirmed for these tokens.') + } +} + +export const DEEPBOOK_POLICY: PtbPolicy = { + venueName: VENUE_NAME, + allowedCalls: new Set([SELL_BASE, BUY_BASE, ZERO_COIN]), + checkCall: (call) => { + if (call.target === ZERO_COIN && !sameType(call.typeArguments[0], DEEP_TYPE)) { + refuse('This swap makes an empty coin of something other than DEEP.') + } + }, +} + +/** + * Exactly one swap, on a pinned pool of the coins it names, with a stated minimum and an empty DEEP + * coin, so the fee comes out of the input and nothing else in the wallet is touched. + */ +function assertOneMarketSwap(payload: SigningPayload): void { + const data = dataOf(payload) + const swaps = [...moveCallsNamed(data, SELL_BASE), ...moveCallsNamed(data, BUY_BASE)] + const [swap] = swaps + if (swap === undefined || swaps.length !== 1) { + return refuse('This transaction should make exactly one swap.') + } + const { call } = swap + requirePair(call.typeArguments, sharedObjectId(data, call.arguments[0])) + + const [, , deep, minimum, clock, ...extra] = call.arguments + if (extra.length > 0 || sharedObjectId(data, clock) !== normalizeSuiAddress(CLOCK)) { + refuse('This swap does not match what DeepBook takes.') + } + const fromCommand = resultOf(deep)?.[0] + const zero = moveCallsNamed(data, ZERO_COIN).find((z) => z.at === fromCommand) + if (zero === undefined) refuse('This swap would spend DEEP from your wallet.') + + const floor = literalU64(data, minimum) + if (floor === undefined || floor === BigInt(0)) { + refuse('This swap has no minimum amount out.') + } +} + +/** The book's own dry run, with the fee paid from the input, as the swap below pays it. */ +async function readFill(request: ChainActionRequest, account: string, io: SwapIo) { + assertSuiAccount(account) + const pairs = DEEPBOOK_POOLS.map((p) => [p.base, p.quote] as const) + const { pairIndex, ...tokens } = pairTokens(VENUE_NAME, pairs, request) + const pool = DEEPBOOK_POOLS[pairIndex] + if (pool === undefined) return refuse('DeepBook has no pool for this pair.') + const sellsBase = tokens.inIsFirst + const typeArguments = [tokenType(pool.base), tokenType(pool.quote)] + + const tx = new Transaction() + tx.setSender(account) + const object = tx.sharedObjectRef({ + objectId: pool.id, + initialSharedVersion: pool.initialSharedVersion, + mutable: false, + }) + const clock = tx.sharedObjectRef({ + objectId: CLOCK, + initialSharedVersion: CLOCK_SHARED_VERSION, + mutable: false, + }) + tx.moveCall({ + target: `${DEEPBOOK_PACKAGE}::pool::${sellsBase ? 'get_quote_quantity_out_input_fee' : 'get_base_quantity_out_input_fee'}`, + typeArguments, + arguments: [object, tx.pure.u64(tokens.amountIn), clock], + }) + tx.moveCall({ + target: `${DEEPBOOK_PACKAGE}::pool::mid_price`, + typeArguments, + arguments: [object, clock], + }) + tx.moveCall({ + target: `${DEEPBOOK_PACKAGE}::pool::pool_trade_params`, + typeArguments, + arguments: [object], + }) + const views = await readOrRefuse(io, tx, VENUE_NAME) + + // Both directions return (base, quote, DEEP required): one is what's left, the other what's bought. + const unspent = u64At(views, 0, sellsBase ? 0 : 1) + const out = u64At(views, 0, sellsBase ? 1 : 0) + const spent = BigInt(tokens.amountIn) - BigInt(unspent) + const mid = Number(u64At(views, 1, 0)) / FLOAT_SCALING + const paid = sellsBase ? Number(out) / Number(spent) : Number(spent) / Number(out) + const fill: Fill = { + venueName: VENUE_NAME, + tokens, + unspent, + out, + impact: paid / mid - 1, + } + await judgeFill(io, fill) + const feeRate = (Number(u64At(views, 2, 0)) / FLOAT_SCALING) * INPUT_FEE_MULTIPLIER + return { fill, pool, sellsBase, typeArguments, feeRate } +} + +export function createDeepbookVenue(io: SwapIo = LIVE_SWAP_IO): ChainVenue { + async function quote(request: ChainActionRequest, account: string): Promise { + const { fill } = await readFill(request, account, io) + const { tokenOut } = fill.tokens + return { + receive: { amount: displayAmount(fill.out, tokenOut.decimals), symbol: tokenOut.symbol }, + } + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { fill, pool, sellsBase, typeArguments, feeRate } = await readFill(request, account, io) + const { tokens } = fill + const { tokenIn, tokenOut } = tokens + + const tx = new Transaction() + tx.setSender(account) + const input = io.spend(tx, tokenIn, tokens.amountIn) + const [emptyDeep] = tx.moveCall({ target: ZERO_COIN, typeArguments: [DEEP_TYPE] }) + if (emptyDeep === undefined) throw new Error('An empty coin returned nothing.') + const [base, quoteCoin, deep] = tx.moveCall({ + target: sellsBase ? SELL_BASE : BUY_BASE, + typeArguments, + arguments: [ + tx.sharedObjectRef({ + objectId: pool.id, + initialSharedVersion: pool.initialSharedVersion, + mutable: true, + }), + input, + emptyDeep, + tx.pure.u64(minimumOut(fill.out)), + tx.sharedObjectRef({ + objectId: CLOCK, + initialSharedVersion: CLOCK_SHARED_VERSION, + mutable: false, + }), + ], + }) + if (base === undefined || quoteCoin === undefined || deep === undefined) { + throw new Error('The swap returned nothing.') + } + tx.moveCall({ + target: '0x2::coin::destroy_zero', + typeArguments: [DEEP_TYPE], + arguments: [deep], + }) + tx.transferObjects([base, quoteCoin], account) + const transaction = await io.build(tx, tokenIn.symbol) + + const receive = displayAmount(fill.out, tokenOut.decimals) + return { + venueId: 'deepbook', + venueName: VENUE_NAME, + kind: 'swap', + summary: `Swap ${request.amount} ${tokenIn.symbol} for about ${receive} ${tokenOut.symbol} on DeepBook's order book.`, + pay: { amount: request.amount, symbol: tokenIn.symbol }, + receive: { amount: receive, symbol: tokenOut.symbol, approximate: true }, + notes: [ + slippageNote(), + `This trades at the market on an order book. The fee is about ${Number((feeRate * 100).toFixed(4))}%, taken from what you pay, which costs 25% more than paying it in DEEP.`, + ...unspentNote(fill), + "A multisig account can upgrade DeepBook's contracts, and contracts can fail. DefiLlama lists no exploit of DeepBook as of November 2026.", + ...bridgeNote(tokens), + ], + signing: { family: 'sui', transaction }, + } + } + + return { + id: 'deepbook', + name: VENUE_NAME, + kinds: ['swap'], + quote, + prepare, + assertSafe: (payload: SigningPayload, account: string) => { + assertPtbSafe(payload, account, DEEPBOOK_POLICY) + assertOneMarketSwap(payload) + }, + } +} + +export const deepbookVenue = createDeepbookVenue() diff --git a/apps/dapp/lib/chains/sui/direct-swap.ts b/apps/dapp/lib/chains/sui/direct-swap.ts new file mode 100644 index 00000000..851e970c --- /dev/null +++ b/apps/dapp/lib/chains/sui/direct-swap.ts @@ -0,0 +1,263 @@ +import { bcs } from '@mysten/sui/bcs' +import { + coinWithBalance, + Transaction, + type TransactionObjectArgument, +} from '@mysten/sui/transactions' +import { fromBase64, normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils' + +import { + ChainActionRefused, + tokenBySymbol, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type SigningPayload, +} from '../types' +import { judgeRoute, SLIPPAGE_BPS } from './cetus-rules' +import { buildUnsigned, suiClient, withTimeout } from './client' +import { priceFromLlama } from './price' +import { displayAmount, SUI_TOKENS } from './tokens' + +/** + * What DeepBook and Momentum share: swapping straight against one pinned pool, with the quote read + * on chain by dry run. Each venue checks its own transaction's shape on top of `assertPtbSafe`, + * because `PtbPolicy.checkCall` can read literal arguments but not which object or result an + * argument names, and a swap's pool and its minimum depend on those. + */ + +const VIEW_TIMEOUT_MS = 10_000 +const BPS = 10_000 +/** A pool's fill steps can leave some of the amount unspent. Past this, it isn't the trade asked for. */ +const MAX_UNSPENT_PERCENT = 1 + +export const CLOCK = '0x6' +export const CLOCK_SHARED_VERSION = 1 + +export interface SwapIo { + /** Dry-runs a read-only transaction and returns each command's return values. */ + view: (tx: Transaction) => Promise + build: (tx: Transaction, symbol: string) => Promise + price: (token: ChainToken) => Promise + spend: (tx: Transaction, token: ChainToken, amount: string) => TransactionObjectArgument +} + +export const LIVE_SWAP_IO: SwapIo = { + async view(tx) { + // Checks off, because these calls return values no transaction could keep, and no coin is spent. + const result = await withTimeout( + suiClient().core.simulateTransaction({ + transaction: tx, + checksEnabled: false, + include: { commandResults: true }, + }), + VIEW_TIMEOUT_MS, + 'Reading the price' + ) + if (result.$kind !== 'Transaction') throw new Error('The read did not run.') + return result.commandResults.map((c) => c.returnValues.map((v) => v.bcs)) + }, + build: (tx, symbol) => buildUnsigned(tx, suiClient(), symbol), + price: (token) => priceFromLlama(token), + spend: (tx, token, amount) => + tx.add(coinWithBalance({ type: token.id, balance: BigInt(amount) })), +} + +export function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +export interface PairedTokens { + tokenIn: ChainToken + tokenOut: ChainToken + /** Whether the coin going in is the pool's first type argument. */ + inIsFirst: boolean + amountIn: string +} + +export function pairTokens( + venueName: string, + pairs: readonly (readonly [string, string])[], + request: ChainActionRequest +): PairedTokens & { pairIndex: number } { + const tokenIn = tokenBySymbol(SUI_TOKENS, request.tokenIn) + const tokenOut = tokenBySymbol(SUI_TOKENS, request.tokenOut ?? '') + if (tokenIn === undefined || tokenOut === undefined) { + throw new ChainActionRefused("I can't confirm those as verified tokens on Sui.") + } + const pairIndex = pairs.findIndex( + ([a, b]) => + (a === tokenIn.symbol && b === tokenOut.symbol) || + (a === tokenOut.symbol && b === tokenIn.symbol) + ) + if (pairIndex < 0) { + return refuse( + `${venueName} has no direct pool here for ${tokenIn.symbol} and ${tokenOut.symbol}.` + ) + } + return { + tokenIn, + tokenOut, + inIsFirst: pairs[pairIndex]?.[0] === tokenIn.symbol, + amountIn: toBaseUnits(request.amount, tokenIn.decimals), + pairIndex, + } +} + +export function tokenType(symbol: string): string { + const token = tokenBySymbol(SUI_TOKENS, symbol) + if (token === undefined) throw new Error(`${symbol} is not in the token table.`) + return normalizeStructTag(token.id) +} + +/** Reads the chain or refuses: a venue with no readable price offers no swap. */ +export async function readOrRefuse( + io: SwapIo, + tx: Transaction, + venueName: string +): Promise { + try { + return await io.view(tx) + } catch { + return refuse(`I couldn't read a price from ${venueName} right now, so I stopped.`) + } +} + +export function u64At(views: Uint8Array[][], command: number, value: number): string { + const bytes = views[command]?.[value] + if (bytes === undefined) throw new ChainActionRefused('The chain returned less than I expected.') + return bcs.u64().parse(bytes) +} + +export function u128At(views: Uint8Array[][], command: number, value: number): string { + const bytes = views[command]?.[value] + if (bytes === undefined) throw new ChainActionRefused('The chain returned less than I expected.') + return bcs.u128().parse(bytes) +} + +export interface Fill { + venueName: string + tokens: PairedTokens + /** How much of the amount in the pool would take, and how much it would give. */ + unspent: string + out: string + /** How far the price moves or fills from its mark, as a fraction. */ + impact: number +} + +/** Holds a fill to the same limits as a routed swap: the whole amount, a fair value, a real output. */ +export async function judgeFill(io: SwapIo, fill: Fill): Promise { + const { tokenIn, tokenOut, amountIn } = fill.tokens + const spent = BigInt(amountIn) - BigInt(fill.unspent) + if (spent <= BigInt(0) || BigInt(fill.out) === BigInt(0)) { + refuse( + `${fill.venueName} can't fill ${displayAmount(amountIn, tokenIn.decimals)} ${tokenIn.symbol} for ${tokenOut.symbol} right now. Try a larger amount, or another venue.` + ) + } + if (BigInt(fill.unspent) * BigInt(100) > BigInt(amountIn) * BigInt(MAX_UNSPENT_PERCENT)) { + refuse( + `${fill.venueName} could only take part of this amount, and about ${displayAmount(fill.unspent, tokenIn.decimals)} ${tokenIn.symbol} would stay unspent. Try a different amount.` + ) + } + const [priceIn, priceOut] = await Promise.all([io.price(tokenIn), io.price(tokenOut)]) + judgeRoute({ + requestedIn: spent.toString(), + amountIn: spent.toString(), + amountOut: fill.out, + deviationRatio: fill.impact, + tokenIn, + tokenOut, + priceIn, + priceOut, + }) +} + +export function minimumOut(out: string): string { + return ((BigInt(out) * BigInt(BPS - SLIPPAGE_BPS)) / BigInt(BPS)).toString() +} + +export function slippageNote(): string { + return `The amount you get can move before this lands. If it drops by more than ${SLIPPAGE_BPS / 100}%, the swap cancels and you only pay the network fee.` +} + +export function unspentNote(fill: Fill): string[] { + if (BigInt(fill.unspent) === BigInt(0)) return [] + const { tokenIn } = fill.tokens + return [ + `Only part of this can trade, in the steps the pool allows. About ${displayAmount(fill.unspent, tokenIn.decimals)} ${tokenIn.symbol} stays in your wallet.`, + ] +} + +export function bridgeNote(tokens: PairedTokens): string[] { + return tokens.tokenIn.symbol === 'USDT' || tokens.tokenOut.symbol === 'USDT' + ? ['USDT on Sui is a bridged token, so it also depends on the Sui Bridge.'] + : [] +} + +type TxData = ReturnType +type TxCommand = TxData['commands'][number] +export type MoveCall = Extract['MoveCall'] +export type TxArgument = MoveCall['arguments'][number] + +/** The bytes `assertPtbSafe` has already accepted, for a venue's own check of their shape. */ +export function dataOf(payload: SigningPayload): TxData { + if (payload.family !== 'sui') return refuse('That is not a Sui transaction.') + return Transaction.from(fromBase64(payload.transaction)).getData() +} + +export function moveCallsNamed(data: TxData, target: string): { at: number; call: MoveCall }[] { + return data.commands.flatMap((command, at) => { + if (command.$kind !== 'MoveCall') return [] + const { package: pkg, module, function: fn } = command.MoveCall + return `${normalizeSuiAddress(pkg)}::${module}::${fn}` === target + ? [{ at, call: command.MoveCall }] + : [] + }) +} + +/** The shared object an argument names, or nothing if it names anything else. */ +export function sharedObjectId(data: TxData, argument: TxArgument | undefined): string | undefined { + if (argument?.$kind !== 'Input') return undefined + const object = data.inputs[argument.Input] + if (object?.$kind !== 'Object' || object.Object.$kind !== 'SharedObject') return undefined + return normalizeSuiAddress(object.Object.SharedObject.objectId) +} + +/** A literal u64 argument. Anything computed in the transaction reads as nothing. */ +export function literalU64(data: TxData, argument: TxArgument | undefined): bigint | undefined { + if (argument?.$kind !== 'Input') return undefined + const input = data.inputs[argument.Input] + if (input?.$kind !== 'Pure') return undefined + try { + return BigInt(bcs.u64().parse(fromBase64(input.Pure.bytes))) + } catch { + return undefined + } +} + +export function literalBool(data: TxData, argument: TxArgument | undefined): boolean | undefined { + if (argument?.$kind !== 'Input') return undefined + const input = data.inputs[argument.Input] + if (input?.$kind !== 'Pure') return undefined + try { + return bcs.bool().parse(fromBase64(input.Pure.bytes)) + } catch { + return undefined + } +} + +/** Which command, and which of its return values, an argument takes. */ +export function resultOf(argument: TxArgument | undefined): [number, number] | undefined { + if (argument?.$kind === 'Result') return [argument.Result, 0] + if (argument?.$kind === 'NestedResult') return argument.NestedResult + return undefined +} + +export function sameType(a: string | undefined, b: string): boolean { + if (a === undefined) return false + try { + return normalizeStructTag(a) === normalizeStructTag(b) + } catch { + return false + } +} diff --git a/apps/dapp/lib/chains/sui/haedal-listing.ts b/apps/dapp/lib/chains/sui/haedal-listing.ts new file mode 100644 index 00000000..15cedae0 --- /dev/null +++ b/apps/dapp/lib/chains/sui/haedal-listing.ts @@ -0,0 +1,14 @@ +import type { Venue } from '@intent/types' + +export const haedalListing: Venue = { + id: 'haedal', + family: 'sui', + name: 'Haedal', + category: 'staking', + chains: ['Sui'], + url: 'https://www.haedal.xyz', + bestFor: + 'Liquid staking with haSUI and an instant way out. Its vaults reportedly lost about $915K in June 2026; those are separate and not used here', + integration: 'executes', + capability: 'Stakes SUI for haSUI, and unstakes haSUI right away for a small fee', +} diff --git a/apps/dapp/lib/chains/sui/haedal.ts b/apps/dapp/lib/chains/sui/haedal.ts new file mode 100644 index 00000000..b5648a13 --- /dev/null +++ b/apps/dapp/lib/chains/sui/haedal.ts @@ -0,0 +1,399 @@ +import { bcs } from '@mysten/sui/bcs' +import { coinWithBalance, Transaction, type TransactionArgument } from '@mysten/sui/transactions' +import { fromBase64, normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils' + +import { + ChainActionRefused, + takesOnly, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type VenueQuote, +} from '../types' +import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' +import { assertPtbSafe, moveTarget, type ParsedCall, type PtbPolicy, type ReadPure } from './safety' +import { displayAmount } from './tokens' + +/** + * Latest package and the Staking object: github.com/haedallsd/haedal-protocol-interface README. + * On chain, UpgradeCap 0xc1c6690f...2f25 names this package at version 8, Staking is a shared + * `0xbde4ba4c...::staking::Staking`, and live unstakes call this package. An upgrade fails closed: + * the old package aborts on `assert_version`, and a new id isn't allowed here. + */ +export const HAEDAL_PACKAGE = '0x126e4cfb051cad744706df590ec399e8c02b6feae195c35b8b496280d5442a62' +export const HAEDAL_STAKING = '0x47b224762220393057ebf4f70501b6e657c3e56684737568439a04f80849b2ca' +/** Same README's original package. On chain, coin metadata 0x84f24e39...57f3 says haSUI, 9 decimals. */ +export const HASUI_TYPE = + '0xbde4ba4c2e274a60ce15c1cfff9e5c42e41654ac8b6d906a57efa4bd3c29f47d::hasui::HASUI' + +export const HAEDAL_TOKENS: ChainToken[] = [ + { symbol: 'haSUI', id: HASUI_TYPE, decimals: 9, name: 'Haedal Staked SUI' }, +] + +export const SUI_SYSTEM_STATE = '0x5' +const SUI_DECIMALS = 9 +const HASUI_DECIMALS = 9 +const DRY_RUN_TIMEOUT_MS = 15_000 +const READ_TIMEOUT_MS = 10_000 +/** The contract's own floor, for a stake and for the SUI value of an unstake: bytecode constant 0. */ +const MIN_SUI = BigInt('1000000000') +/** `request_unstake_instant_coin` divides `service_fee` by this: bytecode constant 4. */ +const FEE_SCALE = 10_000_000 +/** The README: 0x0 leaves the validator to Haedal. */ +const NO_VALIDATOR = normalizeSuiAddress('0x0') + +const REQUEST_STAKE = moveTarget(HAEDAL_PACKAGE, 'staking', 'request_stake_coin') +const REQUEST_UNSTAKE = moveTarget(HAEDAL_PACKAGE, 'staking', 'request_unstake_instant_coin') + +function fail(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +type TxInput = ReturnType['inputs'][number] + +function objectIdOf(input: TxInput | undefined): string | undefined { + if (input?.$kind === 'UnresolvedObject') return input.UnresolvedObject.objectId + if (input?.$kind !== 'Object') return undefined + const object = input.Object + if (object.$kind === 'SharedObject') return object.SharedObject.objectId + if (object.$kind === 'ImmOrOwnedObject') return object.ImmOrOwnedObject.objectId + return undefined +} + +/** + * A policy's `checkCall` reads only literals, so the objects a call names are checked here. Per + * argument, the object it must be, or undefined for one that isn't pinned, like the person's coin. + * Run after `assertPtbSafe`, which has already refused bytes that don't parse. + */ +export function assertPinnedObjects( + payload: SigningPayload, + pins: ReadonlyMap, + venueName: string +): void { + if (payload.family !== 'sui') fail('That is not a Sui transaction.') + const data = Transaction.from(fromBase64(payload.transaction)).getData() + for (const command of data.commands) { + if (command.$kind !== 'MoveCall') continue + const { package: pkg, module, function: fn, arguments: args } = command.MoveCall + const pinned = pins.get(moveTarget(pkg, module, fn)) + if (pinned === undefined) continue + if (args.length !== pinned.length) { + fail(`This transaction calls ${module}::${fn} in a way ${venueName} doesn't.`) + } + pinned.forEach((expected, index) => { + if (expected === undefined) return + const argument = args[index] + const id = argument?.$kind === 'Input' ? objectIdOf(data.inputs[argument.Input]) : undefined + if (id === undefined || normalizeSuiAddress(id) !== normalizeSuiAddress(expected)) { + fail(`This transaction uses an object I haven't confirmed for ${venueName}.`) + } + }) + } +} + +/** Reads an address literal, or nothing when it isn't one. */ +export function readAddress(read: ReadPure, argument: Parameters[0]): string | undefined { + const bytes = read(argument) + if (bytes === undefined) return undefined + try { + return normalizeSuiAddress(bcs.Address.parse(bytes)) + } catch { + return undefined + } +} + +/** Dry-runs built bytes, which moves nothing. */ +export function dryRun(transaction: string, what: string) { + return withTimeout( + suiClient().core.simulateTransaction({ + transaction: fromBase64(transaction), + include: { balanceChanges: true, commandResults: true }, + }), + DRY_RUN_TIMEOUT_MS, + what + ) +} + +/** How much of a coin a dry run paid this account, in base units. */ +export function creditedAmount( + changes: readonly { coinType: string; address: string; amount: string }[] | undefined, + coinType: string, + account: string +): string | undefined { + const paid = changes?.find( + (c) => + normalizeStructTag(c.coinType) === normalizeStructTag(coinType) && + normalizeSuiAddress(c.address) === normalizeSuiAddress(account) + ) + return paid !== undefined && /^\d+$/.test(paid.amount) && BigInt(paid.amount) > BigInt(0) + ? paid.amount + : undefined +} + +const COIN = bcs.struct('Coin', { id: bcs.Address, value: bcs.u64() }) + +/** + * The value of the coin a call returned in a dry run, in base units. A balance change won't do for + * SUI: it is mixed with the gas paid and with storage rebates, which can be larger than either. + */ +export function returnedCoinAmount( + results: readonly { returnValues: readonly { bcs: Uint8Array | null }[] }[] | undefined, + transaction: string, + target: string +): string | undefined { + const { commands } = Transaction.from(fromBase64(transaction)).getData() + const index = commands.findIndex( + (c) => + c.$kind === 'MoveCall' && + moveTarget(c.MoveCall.package, c.MoveCall.module, c.MoveCall.function) === target + ) + const bytes = index < 0 ? undefined : results?.[index]?.returnValues[0]?.bcs + if (bytes === undefined || bytes === null) return undefined + try { + const { value } = COIN.parse(bytes) + return BigInt(value) > BigInt(0) ? value : undefined + } catch { + return undefined + } +} + +/** The u64 a public read function returns, from a dry run with no gas or signature, or nothing. */ +export async function readU64( + account: string, + target: string, + args: (tx: Transaction) => TransactionArgument[] +): Promise { + const tx = new Transaction() + tx.setSender(account) + tx.moveCall({ target, arguments: args(tx) }) + const simulated = await withTimeout( + suiClient().core.simulateTransaction({ + transaction: tx, + include: { commandResults: true }, + checksEnabled: false, + }), + DRY_RUN_TIMEOUT_MS, + 'Reading the contract' + ) + const bytes = simulated.commandResults?.[0]?.returnValues[0]?.bcs + if (bytes === undefined || bytes === null) return undefined + try { + return BigInt(bcs.u64().parse(bytes)) + } catch { + return undefined + } +} + +/** An object's fields as the chain reports them, or nothing. */ +export async function readObjectJson(id: string): Promise | undefined> { + const { objects } = await withTimeout( + suiClient().core.getObjects({ objectIds: [id], include: { json: true } }), + READ_TIMEOUT_MS, + 'Reading the contract' + ) + const object = objects[0] + if (object === undefined || object instanceof Error) return undefined + return object.json ?? undefined +} + +/** A u64 from an object's fields, which the chain gives as digits. */ +export function u64Field(value: unknown): bigint | undefined { + return typeof value === 'string' && /^\d+$/.test(value) ? BigInt(value) : undefined +} + +/** A rate for a card: 0.02, 0.34. Rounded to what a person can use, never to zero. */ +export function percentText(percent: number): string { + const text = percent.toFixed(2).replace(/\.?0+$/, '') + return text === '0' ? 'under 0.01' : text +} + +/** `assertCanFollow` for a venue that stakes SUI and can also unstake, which never follows a swap. */ +export function stakesAfterSwap(venueName: string): NonNullable { + const takesSui = takesOnly(venueName, ['SUI']) + return async (step, account) => { + if (step.kind !== 'stake') fail(`${venueName} can only stake after a swap.`) + await takesSui(step, account) + } +} + +async function readFees(): Promise<{ serviceFee: number; rewardFee: number }> { + const staking = await readObjectJson(HAEDAL_STAKING) + const config = staking?.['config'] as Record | undefined + const serviceFee = u64Field(config?.['service_fee']) + const rewardFee = u64Field(config?.['reward_fee']) + if (serviceFee === undefined || rewardFee === undefined) { + fail("I couldn't read Haedal's fees, so I stopped. Try again shortly.") + } + return { + serviceFee: (Number(serviceFee) / FEE_SCALE) * 100, + rewardFee: (Number(rewardFee) / FEE_SCALE) * 100, + } +} + +async function checkedStake(request: ChainActionRequest, account: string) { + assertSuiAccount(account) + if (request.tokenIn.trim().toUpperCase() !== 'SUI') { + throw new ChainActionRefused( + `Haedal stakes SUI, not ${request.tokenIn}. Try "stake 1 SUI with Haedal".` + ) + } + const amount = toBaseUnits(request.amount, SUI_DECIMALS) + if (BigInt(amount) < MIN_SUI) fail('Haedal stakes at least 1 SUI.') + + const tx = new Transaction() + tx.setSender(account) + const [sui] = tx.splitCoins(tx.gas, [BigInt(amount)]) + if (sui === undefined) throw new Error('Splitting a coin returned nothing.') + const [minted] = tx.moveCall({ + target: REQUEST_STAKE, + arguments: [ + tx.object(SUI_SYSTEM_STATE), + tx.object(HAEDAL_STAKING), + sui, + tx.pure.address(NO_VALIDATOR), + ], + }) + if (minted === undefined) throw new Error('Staking returned nothing.') + tx.transferObjects([minted], account) + + const transaction = await buildUnsigned(tx, suiClient(), 'SUI') + const simulated = await dryRun(transaction, 'Checking the stake') + const received = creditedAmount(simulated.Transaction?.balanceChanges, HASUI_TYPE, account) + if (received === undefined) { + fail( + "I couldn't confirm how much haSUI this stake would return, so I stopped. Try again shortly." + ) + } + return { transaction, receive: displayAmount(received, HASUI_DECIMALS) } +} + +async function checkedUnstake(request: ChainActionRequest, account: string) { + assertSuiAccount(account) + if (request.tokenIn.trim().toUpperCase() !== 'HASUI') { + throw new ChainActionRefused( + `Haedal unstakes haSUI, not ${request.tokenIn}. Try "unstake 1 haSUI with Haedal".` + ) + } + const amount = toBaseUnits(request.amount, HASUI_DECIMALS) + const value = await readU64( + account, + moveTarget(HAEDAL_PACKAGE, 'staking', 'get_sui_by_stsui'), + (tx) => [tx.object(HAEDAL_STAKING), tx.pure.u64(BigInt(amount))] + ) + if (value === undefined) { + fail("I couldn't read what this haSUI is worth, so I stopped. Try again shortly.") + } + if (value < MIN_SUI) fail('Haedal unstakes at least 1 SUI worth of haSUI.') + + const tx = new Transaction() + tx.setSender(account) + const coin = tx.add( + coinWithBalance({ type: HASUI_TYPE, balance: BigInt(amount), useGasCoin: false }) + ) + const [sui] = tx.moveCall({ + target: REQUEST_UNSTAKE, + arguments: [tx.object(SUI_SYSTEM_STATE), tx.object(HAEDAL_STAKING), coin], + }) + if (sui === undefined) throw new Error('Unstaking returned nothing.') + tx.transferObjects([sui], account) + + const transaction = await buildUnsigned(tx, suiClient(), 'haSUI') + const simulated = await dryRun(transaction, 'Checking the unstake') + const received = returnedCoinAmount(simulated.commandResults, transaction, REQUEST_UNSTAKE) + if (received === undefined) { + fail( + "I couldn't confirm how much SUI this unstake would return, so I stopped. Try again shortly." + ) + } + return { transaction, receive: displayAmount(received, SUI_DECIMALS) } +} + +const SHARED_RISKS = [ + 'Your SUI sits in contracts a Haedal admin multisig can upgrade. That admin can also change the fees and pause staking, unstaking and claims.', + "Haedal's vaults are reported to have lost about $915K in June 2026, with staking reportedly not affected. I could not confirm this from a primary source. This uses staking, not the vaults.", +] + +/** No apyPercent: no rate can be read from Haedal here, and a rate is never invented. */ +async function quote(request: ChainActionRequest, account: string): Promise { + const { receive } = await checkedStake(request, account) + return { receive: { amount: receive, symbol: 'haSUI' } } +} + +async function prepareStake(request: ChainActionRequest, account: string): Promise { + const { transaction, receive } = await checkedStake(request, account) + const { serviceFee, rewardFee } = await readFees() + return { + venueId: 'haedal', + venueName: 'Haedal', + kind: 'stake', + summary: `Stake ${request.amount} SUI with Haedal for about ${receive} haSUI.`, + pay: { amount: request.amount, symbol: 'SUI' }, + receive: { amount: receive, symbol: 'haSUI', approximate: true }, + notes: [ + `Staking rewards are not guaranteed. They depend on the validators, and Haedal keeps ${percentText(rewardFee)}% of them. I am not showing a rate because I could not read one from Haedal.`, + 'haSUI is worth more than SUI over time, so you get fewer of them, and it can trade below its value on the market.', + `To get out, unstake here. The SUI comes back in the same transaction for a fee of ${percentText(serviceFee)}%, if Haedal can free that much SUI at the time. The minimum is 1 SUI worth.`, + ...SHARED_RISKS, + ], + signing: { family: 'sui', transaction }, + } +} + +async function prepareUnstake( + request: ChainActionRequest, + account: string +): Promise { + const { transaction, receive } = await checkedUnstake(request, account) + const { serviceFee } = await readFees() + return { + venueId: 'haedal', + venueName: 'Haedal', + kind: 'unstake', + summary: `Unstake ${request.amount} haSUI with Haedal for about ${receive} SUI, right away.`, + pay: { amount: request.amount, symbol: 'haSUI' }, + receive: { amount: receive, symbol: 'SUI', approximate: true }, + notes: [ + `This is Haedal's instant unstake: there is no wait, and the SUI is in your wallet once this lands. The fee is ${percentText(serviceFee)}%, already taken off the amount shown.`, + "Haedal also has a free unstake that waits one or two epochs, then needs a separate claim. This app doesn't build that one, so it stays on Haedal's own site.", + ...SHARED_RISKS, + ], + signing: { family: 'sui', transaction }, + } +} + +const PINS: ReadonlyMap = new Map([ + [REQUEST_STAKE, [SUI_SYSTEM_STATE, HAEDAL_STAKING, undefined, undefined]], + [REQUEST_UNSTAKE, [SUI_SYSTEM_STATE, HAEDAL_STAKING, undefined]], +]) + +function checkHaedalCall(call: ParsedCall, read: ReadPure): void { + if (call.target !== REQUEST_STAKE && call.target !== REQUEST_UNSTAKE) return + if (call.typeArguments.length > 0) fail('This Haedal call names a token it should not.') + if (call.target === REQUEST_STAKE && readAddress(read, call.arguments[3]) !== NO_VALIDATOR) { + fail('This stake names a validator I did not choose.') + } +} + +export const HAEDAL_POLICY: PtbPolicy = { + venueName: 'Haedal', + allowedCalls: new Set([REQUEST_STAKE, REQUEST_UNSTAKE]), + checkCall: checkHaedalCall, +} + +/** On hold: built and tested, but not registered in the Sui module or listed. */ +export const haedalVenue: ChainVenue = { + id: 'haedal', + name: 'Haedal', + kinds: ['stake', 'unstake'], + assertCanFollow: stakesAfterSwap('Haedal'), + quote, + prepare: (request, account) => + request.kind === 'unstake' ? prepareUnstake(request, account) : prepareStake(request, account), + assertSafe: (payload: SigningPayload, account: string) => { + assertPtbSafe(payload, account, HAEDAL_POLICY) + assertPinnedObjects(payload, PINS, 'Haedal') + }, +} diff --git a/apps/dapp/lib/chains/sui/lending-calls.ts b/apps/dapp/lib/chains/sui/lending-calls.ts new file mode 100644 index 00000000..fec429ff --- /dev/null +++ b/apps/dapp/lib/chains/sui/lending-calls.ts @@ -0,0 +1,128 @@ +import { Transaction } from '@mysten/sui/transactions' +import { fromBase64, normalizeStructTag, normalizeSuiAddress, toBase64 } from '@mysten/sui/utils' + +import { ChainActionRefused, type SigningPayload } from '../types' +import { suiClient, withTimeout } from './client' +import type { PtbPolicy } from './safety' +import { moveTarget } from './safety' + +/** + * What the Sui lending venues share. `assertPtbSafe` reads targets, signer and + * recipients. It can't see object inputs, so this adds what an allowed call may be + * pointed at: the token, the market and the literals. + */ +export interface PinnedCall { + typeArguments: readonly string[] + argumentCount: number + /** The object that must sit at an argument position. */ + objects?: Readonly> | undefined + /** The literal bytes that must sit at an argument position, as base64. */ + pure?: Readonly> | undefined +} + +export type PinnedCalls = ReadonlyMap + +export function u64Bytes(value: number): string { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), true) + return toBase64(bytes) +} + +export function pinnedPolicy(venueName: string, pins: PinnedCalls): PtbPolicy { + return { venueName, allowedCalls: new Set(pins.keys()) } +} + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +/** Run after `assertPtbSafe`, which has already refused anything not allowed. */ +export function assertCallsPinned( + payload: SigningPayload, + pins: PinnedCalls, + venueName: string +): void { + if (payload.family !== 'sui') refuse('That is not a Sui transaction.') + const data = Transaction.from(fromBase64(payload.transaction)).getData() + + for (const command of data.commands) { + if (command.$kind !== 'MoveCall') continue + const { package: pkg, module, function: fn, typeArguments, arguments: args } = command.MoveCall + const pin = pins.get(moveTarget(pkg, module, fn)) + if (pin === undefined) continue + + const label = `${module}::${fn}` + if (args.length !== pin.argumentCount) { + refuse(`This transaction passes ${label} the wrong arguments for ${venueName}.`) + } + const sameTypes = + typeArguments.length === pin.typeArguments.length && + typeArguments.every( + (type, i) => normalizeStructTag(type) === normalizeStructTag(pin.typeArguments[i] ?? '') + ) + if (!sameTypes) refuse(`This transaction uses a token ${venueName} isn't confirmed for.`) + + for (const [index, wanted] of Object.entries(pin.objects ?? {})) { + const argument = args[Number(index)] + const input = argument?.$kind === 'Input' ? data.inputs[argument.Input] : undefined + const object = input?.$kind === 'Object' ? input.Object : undefined + const id = + object?.$kind === 'SharedObject' + ? object.SharedObject.objectId + : object?.$kind === 'ImmOrOwnedObject' + ? object.ImmOrOwnedObject.objectId + : undefined + if (id === undefined || normalizeSuiAddress(id) !== normalizeSuiAddress(wanted)) { + refuse(`This transaction points ${label} at an object ${venueName} doesn't use.`) + } + } + for (const [index, wanted] of Object.entries(pin.pure ?? {})) { + const argument = args[Number(index)] + const input = argument?.$kind === 'Input' ? data.inputs[argument.Input] : undefined + if (input?.$kind !== 'Pure' || input.Pure.bytes !== wanted) { + refuse(`This transaction passes ${label} a value ${venueName} doesn't use.`) + } + } + } +} + +const DRY_RUN_TIMEOUT_MS = 15_000 + +/** + * What a dry run, which moves nothing, says this account's balance of a coin would + * change by. Nothing when the dry run failed or couldn't be read. + */ +export async function dryRunChange( + transaction: string, + coinType: string, + account: string, + what: string +): Promise { + const simulated = await withTimeout( + suiClient().core.simulateTransaction({ + transaction: fromBase64(transaction), + include: { effects: true, balanceChanges: true }, + }), + DRY_RUN_TIMEOUT_MS, + what + ) + const result = simulated.Transaction + if (result?.effects?.status.success !== true || result.balanceChanges === undefined) { + return undefined + } + const me = normalizeSuiAddress(account) + const coin = normalizeStructTag(coinType) + const change = result.balanceChanges.find( + (c) => normalizeStructTag(c.coinType) === coin && normalizeSuiAddress(c.address) === me + ) + return change === undefined ? BigInt(0) : BigInt(change.amount) +} + +/** A position's share price rounds, so a hundredth of a percent under what was asked still counts. */ +export function gaveWhatWasAsked(received: bigint, asked: bigint): boolean { + return received * BigInt(10_000) >= asked * BigInt(9_999) +} + +export function coinTypeName(coinType: string): string { + return normalizeStructTag(coinType).replace(/^0x/, '') +} diff --git a/apps/dapp/lib/chains/sui/module.ts b/apps/dapp/lib/chains/sui/module.ts index 67111346..90863d34 100644 --- a/apps/dapp/lib/chains/sui/module.ts +++ b/apps/dapp/lib/chains/sui/module.ts @@ -1,22 +1,34 @@ import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' import { createSendVenue, SEND_VENUE_ID } from '../send' import type { ChainModule, ChainVenue } from '../types' +import { aftermathVenue } from './aftermath' import { readSuiBalances } from './balances' import { cetusVenue } from './cetus' +import { deepbookVenue } from './deepbook' +import { momentumVenue } from './momentum' import { naviVenue } from './navi' import { priceFromLlama } from './price' +import { scallopVenue } from './scallop' import { springsuiVenue } from './springsui' import { submitSui } from './submit' +import { suilendVenue } from './suilend' import { SUI_TOKENS } from './tokens' +import { walrusVenue } from './walrus' export function buildSuiVenues( env: Record = process.env ): ChainVenue[] { return [ cetusVenue, + deepbookVenue, + momentumVenue, ...(canIssueOneClick(env) ? [oneClickOriginVenue('sui')] : []), naviVenue, + suilendVenue, + scallopVenue, springsuiVenue, + aftermathVenue, + walrusVenue, ...(canIssueOneClick(env) ? [createSendVenue('sui')] : []), ] } @@ -25,7 +37,18 @@ export const suiModule: ChainModule = { chain: 'sui', tokens: SUI_TOKENS, venues: buildSuiVenues(), - defaultVenue: { swap: 'cetus', supply: 'navi', stake: 'springsui', send: SEND_VENUE_ID }, + defaultVenue: { + swap: 'cetus', + supply: 'navi', + withdraw: 'navi', + stake: 'springsui', + unstake: 'springsui', + send: SEND_VENUE_ID, + }, + venueForToken: { + stake: { WAL: 'walrus' }, + unstake: { WAL: 'walrus', SSUI: 'springsui', AFSUI: 'aftermath' }, + }, priceUsd: (token) => priceFromLlama(token), submit: (input) => submitSui(input), balances: readSuiBalances, diff --git a/apps/dapp/lib/chains/sui/momentum-listing.ts b/apps/dapp/lib/chains/sui/momentum-listing.ts new file mode 100644 index 00000000..62188d29 --- /dev/null +++ b/apps/dapp/lib/chains/sui/momentum-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const momentumListing: Venue = { + id: 'momentum', + family: 'sui', + name: 'Momentum', + category: 'swap', + chains: ['Sui'], + url: 'https://app.mmt.finance/trade', + bestFor: 'Direct swaps in concentrated-liquidity pools', + integration: 'executes', + capability: 'Swaps SUI, USDC and USDT in its SUI/USDC and USDT/USDC pools', +} diff --git a/apps/dapp/lib/chains/sui/momentum.ts b/apps/dapp/lib/chains/sui/momentum.ts new file mode 100644 index 00000000..f74986bc --- /dev/null +++ b/apps/dapp/lib/chains/sui/momentum.ts @@ -0,0 +1,361 @@ +import { Transaction } from '@mysten/sui/transactions' +import { normalizeSuiAddress } from '@mysten/sui/utils' + +import type { + ChainActionRequest, + ChainVenue, + PreparedAction, + SigningPayload, + VenueQuote, +} from '../types' +import { assertSuiAccount } from './client' +import { + bridgeNote, + CLOCK, + CLOCK_SHARED_VERSION, + dataOf, + judgeFill, + LIVE_SWAP_IO, + literalBool, + literalU64, + minimumOut, + moveCallsNamed, + pairTokens, + readOrRefuse, + refuse, + resultOf, + sameType, + sharedObjectId, + slippageNote, + tokenType, + u128At, + u64At, + unspentNote, + type Fill, + type SwapIo, +} from './direct-swap' +import { assertPtbSafe, moveTarget, type PtbPolicy } from './safety' +import { displayAmount } from './tokens' + +/** + * Momentum's latest CLMM package, version 5. Its docs (mmt-finance/docs, + * clmm-smart-contracts/deployments) and @mmt-finance/clmm-sdk 1.3.25 both list it, the Move Registry's + * @mmt/clmm-core resolves to it, and on chain the UpgradeCap 0x59d16188...dcb4 names it. The docs + * label it `ClmmPublishedAt` and the SDK `packageId`. The other id they list, 0x70285592...b860, is + * version 1, where the types were defined, so it is not the one to call. + */ +export const MOMENTUM_PACKAGE = '0xcf60a40f45d46fc1e828871a647c1e25a0915dec860d2662eb10fdb382c3c1d1' + +/** `Version` in the same table and `versionId` in the SDK. On chain, a `version::Version`. */ +export const MOMENTUM_VERSION = '0x2375a0b1ec12010aaea3b2545acfa2ad34cfbba03ce4b59f4c39e1e25eed1b2a' +const VERSION_SHARED_VERSION = 499761252 + +export interface MomentumPool { + id: string + initialSharedVersion: number + x: string + y: string +} + +/** + * "SUI-suiUSDC" and "USDT-USDC" in the docs' pool table. On chain each is a `pool::Pool` of + * these coins. USDT-USDC is the deeper and cheaper of its two listed tiers. SUI-suiUSDT is left out: + * a 10 SUI read found about $0.01 of depth. There is no USDsui pool. + */ +export const MOMENTUM_POOLS: readonly MomentumPool[] = [ + { + id: '0x455cf8d2ac91e7cb883f515874af750ed3cd18195c970b7a2d46235ac2b0c388', + initialSharedVersion: 499761256, + x: 'SUI', + y: 'USDC', + }, + { + id: '0x737ec6a4d3ed0c7e6cc18d8ba04e7ffd4806b726c97efd89867597368c4d06a9', + initialSharedVersion: 609810806, + x: 'USDT', + y: 'USDC', + }, +] + +/** + * The widest limits `trade::flash_swap` takes, from the SDK's `swapV2`. Price protection is the + * minimum below, not the limit: at a price limit the pool would stop part way and fill less. + */ +const LOWEST_SQRT_PRICE = BigInt('4295048017') +const HIGHEST_SQRT_PRICE = BigInt('79226673515401279992447579050') +/** `pool::swap_fee_rate` counts in millionths. */ +const FEE_RATE_SCALE = 1e6 + +const VENUE_NAME = 'Momentum' +const FLASH_SWAP = moveTarget(MOMENTUM_PACKAGE, 'trade', 'flash_swap') +const RECEIPT_DEBTS = moveTarget(MOMENTUM_PACKAGE, 'trade', 'swap_receipt_debts') +const REPAY = moveTarget(MOMENTUM_PACKAGE, 'trade', 'repay_flash_swap') +const DESTROY_ZERO_BALANCE = moveTarget('0x2', 'balance', 'destroy_zero') +const ZERO_BALANCE = moveTarget('0x2', 'balance', 'zero') +const INTO_BALANCE = moveTarget('0x2', 'coin', 'into_balance') +const FROM_BALANCE = moveTarget('0x2', 'coin', 'from_balance') +const SPLIT_COIN = moveTarget('0x2', 'coin', 'split') + +/** + * `trade::flash_swap` takes no minimum, so the transaction states one: `coin::split` of the output + * aborts when the output holds less. It is a framework call rather than a SplitCoins command so + * that a miss reads as a failed swap, not as a wallet short of coins. + */ +export const MOMENTUM_POLICY: PtbPolicy = { + venueName: VENUE_NAME, + allowedCalls: new Set([ + FLASH_SWAP, + RECEIPT_DEBTS, + REPAY, + DESTROY_ZERO_BALANCE, + ZERO_BALANCE, + INTO_BALANCE, + FROM_BALANCE, + SPLIT_COIN, + ]), +} + +/** + * Exactly one exact-in swap on a pinned pool, repaid to the same pool, whose whole output goes + * through one stated minimum. These balance and coin calls are only safe in this shape. + */ +function assertOneGuardedSwap(payload: SigningPayload): void { + const data = dataOf(payload) + const flashes = moveCallsNamed(data, FLASH_SWAP) + const repays = moveCallsNamed(data, REPAY) + const [flash] = flashes + const [repay] = repays + if (flash === undefined || repay === undefined || flashes.length !== 1 || repays.length !== 1) { + return refuse('This transaction should make exactly one swap.') + } + + const { call } = flash + const poolId = sharedObjectId(data, call.arguments[0]) + const pool = MOMENTUM_POOLS.find((p) => normalizeSuiAddress(p.id) === poolId) + if ( + pool === undefined || + !sameType(call.typeArguments[0], tokenType(pool.x)) || + !sameType(call.typeArguments[1], tokenType(pool.y)) || + sharedObjectId(data, repay.call.arguments[0]) !== poolId || + !sameType(repay.call.typeArguments[0], tokenType(pool.x)) || + !sameType(repay.call.typeArguments[1], tokenType(pool.y)) + ) { + return refuse('This swap uses a Momentum pool I have not confirmed for these tokens.') + } + + const version = normalizeSuiAddress(MOMENTUM_VERSION) + const xToY = literalBool(data, call.arguments[1]) + if ( + call.arguments.length !== 7 || + repay.call.arguments.length !== 5 || + xToY === undefined || + literalBool(data, call.arguments[2]) !== true || + sharedObjectId(data, call.arguments[5]) !== normalizeSuiAddress(CLOCK) || + sharedObjectId(data, call.arguments[6]) !== version || + sharedObjectId(data, repay.call.arguments[4]) !== version + ) { + refuse('This swap does not match what Momentum takes.') + } + + const outSide = xToY ? 1 : 0 + const outType = tokenType(xToY ? pool.y : pool.x) + const outputs = moveCallsNamed(data, FROM_BALANCE) + const [output] = outputs + const taken = resultOf(output?.call.arguments[0]) + const splits = moveCallsNamed(data, SPLIT_COIN) + const [split] = splits + const splitFrom = resultOf(split?.call.arguments[0]) + const floor = literalU64(data, split?.call.arguments[1]) + if ( + output === undefined || + outputs.length !== 1 || + splits.length !== 1 || + taken?.[0] !== flash.at || + taken[1] !== outSide || + !sameType(output.call.typeArguments[0], outType) || + splitFrom?.[0] !== output.at || + floor === undefined || + floor === BigInt(0) + ) { + refuse('This swap has no minimum amount out on what it pays you.') + } +} + +/** The pool's own calculation of this swap, with no coin needed to run it. */ +async function readFill(request: ChainActionRequest, account: string, io: SwapIo) { + assertSuiAccount(account) + const pairs = MOMENTUM_POOLS.map((p) => [p.x, p.y] as const) + const { pairIndex, ...tokens } = pairTokens(VENUE_NAME, pairs, request) + const pool = MOMENTUM_POOLS[pairIndex] + if (pool === undefined) return refuse('Momentum has no pool for this pair.') + const xToY = tokens.inIsFirst + const typeArguments = [tokenType(pool.x), tokenType(pool.y)] + const limit = xToY ? LOWEST_SQRT_PRICE : HIGHEST_SQRT_PRICE + + const tx = new Transaction() + tx.setSender(account) + const object = tx.sharedObjectRef({ + objectId: pool.id, + initialSharedVersion: pool.initialSharedVersion, + mutable: false, + }) + const [state] = tx.moveCall({ + target: `${MOMENTUM_PACKAGE}::trade::compute_swap_result`, + typeArguments, + arguments: [ + object, + tx.pure.bool(xToY), + tx.pure.bool(true), + tx.pure.u128(limit), + tx.pure.u64(tokens.amountIn), + ], + }) + if (state === undefined) throw new Error('Reading the swap returned nothing.') + for (const field of [ + 'get_state_amount_calculated', + 'get_state_amount_specified', + 'get_state_sqrt_price', + ]) { + tx.moveCall({ target: `${MOMENTUM_PACKAGE}::trade::${field}`, arguments: [state] }) + } + tx.moveCall({ + target: `${MOMENTUM_PACKAGE}::pool::sqrt_price`, + typeArguments, + arguments: [object], + }) + tx.moveCall({ + target: `${MOMENTUM_PACKAGE}::pool::swap_fee_rate`, + typeArguments, + arguments: [object], + }) + const views = await readOrRefuse(io, tx, VENUE_NAME) + + const out = u64At(views, 1, 0) + const unspent = u64At(views, 2, 0) + const ratio = Number(u128At(views, 3, 0)) / Number(u128At(views, 4, 0)) + const fill: Fill = { venueName: VENUE_NAME, tokens, unspent, out, impact: ratio * ratio - 1 } + await judgeFill(io, fill) + const feeRate = Number(u64At(views, 5, 0)) / FEE_RATE_SCALE + return { fill, pool, xToY, typeArguments, limit, feeRate } +} + +export function createMomentumVenue(io: SwapIo = LIVE_SWAP_IO): ChainVenue { + async function quote(request: ChainActionRequest, account: string): Promise { + const { fill } = await readFill(request, account, io) + const { tokenOut } = fill.tokens + return { + receive: { amount: displayAmount(fill.out, tokenOut.decimals), symbol: tokenOut.symbol }, + } + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { fill, pool, xToY, typeArguments, limit, feeRate } = await readFill(request, account, io) + const { tokens } = fill + const { tokenIn, tokenOut } = tokens + const inType = tokenType(tokenIn.symbol) + const outType = tokenType(tokenOut.symbol) + + const tx = new Transaction() + tx.setSender(account) + const input = io.spend(tx, tokenIn, tokens.amountIn) + const object = tx.sharedObjectRef({ + objectId: pool.id, + initialSharedVersion: pool.initialSharedVersion, + mutable: true, + }) + const version = tx.sharedObjectRef({ + objectId: MOMENTUM_VERSION, + initialSharedVersion: VERSION_SHARED_VERSION, + mutable: false, + }) + const [balanceX, balanceY, receipt] = tx.moveCall({ + target: FLASH_SWAP, + typeArguments, + arguments: [ + object, + tx.pure.bool(xToY), + tx.pure.bool(true), + tx.pure.u64(tokens.amountIn), + tx.pure.u128(limit), + tx.sharedObjectRef({ + objectId: CLOCK, + initialSharedVersion: CLOCK_SHARED_VERSION, + mutable: false, + }), + version, + ], + }) + if (balanceX === undefined || balanceY === undefined || receipt === undefined) { + throw new Error('The swap returned nothing.') + } + tx.moveCall({ + target: DESTROY_ZERO_BALANCE, + typeArguments: [inType], + arguments: [xToY ? balanceX : balanceY], + }) + const [debtX, debtY] = tx.moveCall({ target: RECEIPT_DEBTS, arguments: [receipt] }) + const debt = xToY ? debtX : debtY + if (debt === undefined) throw new Error('The swap reported no debt.') + const [payment] = tx.splitCoins(input, [debt]) + if (payment === undefined) throw new Error('Splitting a coin returned nothing.') + const [paid] = tx.moveCall({ + target: INTO_BALANCE, + typeArguments: [inType], + arguments: [payment], + }) + const [nothing] = tx.moveCall({ target: ZERO_BALANCE, typeArguments: [outType] }) + if (paid === undefined || nothing === undefined) throw new Error('A balance returned nothing.') + tx.moveCall({ + target: REPAY, + typeArguments, + arguments: [object, receipt, xToY ? paid : nothing, xToY ? nothing : paid, version], + }) + const [output] = tx.moveCall({ + target: FROM_BALANCE, + typeArguments: [outType], + arguments: [xToY ? balanceY : balanceX], + }) + if (output === undefined) throw new Error('The output returned nothing.') + const [minimum] = tx.moveCall({ + target: SPLIT_COIN, + typeArguments: [outType], + arguments: [output, tx.pure.u64(minimumOut(fill.out))], + }) + if (minimum === undefined) throw new Error('Splitting a coin returned nothing.') + tx.mergeCoins(output, [minimum]) + tx.transferObjects([input, output], account) + const transaction = await io.build(tx, tokenIn.symbol) + + const receive = displayAmount(fill.out, tokenOut.decimals) + return { + venueId: 'momentum', + venueName: VENUE_NAME, + kind: 'swap', + summary: `Swap ${request.amount} ${tokenIn.symbol} for about ${receive} ${tokenOut.symbol} through Momentum.`, + pay: { amount: request.amount, symbol: tokenIn.symbol }, + receive: { amount: receive, symbol: tokenOut.symbol, approximate: true }, + notes: [ + slippageNote(), + `This pool's fee is ${Number((feeRate * 100).toFixed(4))}%, and the amount shown already includes it.`, + ...unspentNote(fill), + "Momentum's admins, through a multisig account, can upgrade these contracts and pause trading, and contracts can fail. DefiLlama lists no exploit of Momentum as of November 2026.", + ...bridgeNote(tokens), + ], + signing: { family: 'sui', transaction }, + } + } + + return { + id: 'momentum', + name: VENUE_NAME, + kinds: ['swap'], + quote, + prepare, + assertSafe: (payload: SigningPayload, account: string) => { + assertPtbSafe(payload, account, MOMENTUM_POLICY) + assertOneGuardedSwap(payload) + }, + } +} + +export const momentumVenue = createMomentumVenue() diff --git a/apps/dapp/lib/chains/sui/navi-withdraw.ts b/apps/dapp/lib/chains/sui/navi-withdraw.ts new file mode 100644 index 00000000..1c560b4e --- /dev/null +++ b/apps/dapp/lib/chains/sui/navi-withdraw.ts @@ -0,0 +1,231 @@ +import { bcs } from '@mysten/sui/bcs' +import { ObjectError } from '@mysten/sui/client' +import { Transaction } from '@mysten/sui/transactions' +import { fromHex, normalizeSuiAddress, toBase64 } from '@mysten/sui/utils' + +import { + ChainActionRefused, + tokenBySymbol, + toBaseUnits, + type ChainActionRequest, + type PreparedAction, + type SigningPayload, +} from '../types' +import { assertSuiAccount, buildUnsigned, suiClient } from './client' +import { + assertCallsPinned, + dryRunChange, + gaveWhatWasAsked, + pinnedPolicy, + type PinnedCalls, +} from './lending-calls' +import { NAVI_PACKAGE } from './policies' +import { assertPtbSafe, moveTarget } from './safety' +import { displayAmount, SUI_TOKENS } from './tokens' + +/** + * NAVI's `/navi/config` and `/navi/pools`, what @naviprotocol/lending's `getConfig` and + * `getPools` return. Pinned rather than read each time, so getting money out doesn't + * depend on NAVI's server. On chain each is a shared object of the type + * `incentive_v3::entry_withdraw` and `oracle_pro::update_single_price_v3` declare, and + * the live test fails when NAVI's config moves away from any of them. + */ +export const NAVI_STORAGE = '0xbb4e2f4b6205c2e2a2db47aeb4f830796ec7c005f88537ee775986639bc442fe' +export const NAVI_INCENTIVE_V2 = + '0xf87a8acb8b81d14307894d12595541a73f19933f88e1326d5be349c7a6f7559c' +export const NAVI_INCENTIVE_V3 = + '0x62982dad27fb10bb314b3384d5de8d2ac2d72ab2dbeae5d801dbdb9efa816c80' +export const NAVI_PRICE_ORACLE = + '0x1568865ed9a0b5ec414220e8f79b3d04c77acc82358f6e5ae4635687392ffbef' +export const NAVI_USDC_POOL = '0xa3582097b4c57630046c0c49a88bfc6b202a3ec0a9db5597c31765f7563755a8' +export const NAVI_USDC_ASSET_ID = 10 +export const NAVI_ORACLE_PACKAGE = + '0x4837ae94425107554c8847721cf9954c1ad8e10520433b9e37dc11c507148bea' +export const NAVI_ORACLE_CONFIG = + '0x1afe1cb83634f581606cc73c4487ddd8cc39a944b951283af23f7d69d5589478' +export const NAVI_SUPRA_HOLDER = + '0xaa0315f0748c1f24ddb2b45f7939cff40f7a8104af5ccbc4a1d32f870c0b4105' +export const NAVI_SWITCHBOARD = '0x1fa7566f40f93cdbafd5a029a231e06664219444debb59beec2fe3f19ca08b7e' +export const NAVI_USDC_PYTH_OBJECT = + '0x6ddfc6f9921e55998cbc2e68f78eba897981d79ac17f66c5277bc38954a2a3f8' +export const NAVI_USDC_FEED = '0xe120611435395f144b4bcc4466a00b6b26d7a27318f96e148648852a9dd6b31c' +/** The `user_info` table of the storage object above: address to the assets a person holds. */ +export const NAVI_USER_INFO = '0xabc6c3fbc89b96e3351fdbeb5730bcc5398648367260c6a4e201779e34694e04' + +const USDC = SUI_TOKENS.find((t) => t.symbol === 'USDC') +if (USDC === undefined) throw new Error('USDC is missing from the Sui token table.') +const USDC_TYPE = USDC.id + +const CLOCK = '0x0000000000000000000000000000000000000000000000000000000000000006' +const ENTRY_DEPOSIT = moveTarget(NAVI_PACKAGE, 'incentive_v3', 'entry_deposit') +const ENTRY_WITHDRAW = moveTarget(NAVI_PACKAGE, 'incentive_v3', 'entry_withdraw') +const UPDATE_PRICE = moveTarget(NAVI_ORACLE_PACKAGE, 'oracle_pro', 'update_single_price_v3') +const ASSET_ID_BYTES = toBase64(bcs.u8().serialize(NAVI_USDC_ASSET_ID).toBytes()) + +/** + * `entry_withdraw` names no recipient: the contract pays the sender. Only the USDC + * pool is allowed, so the type and the asset id can't point at another reserve. + * `update_single_price_v3` moves no money. It only copies a price into NAVI's oracle. + */ +const PINS: PinnedCalls = new Map([ + [ + ENTRY_DEPOSIT, + { + typeArguments: [USDC_TYPE], + argumentCount: 8, + objects: { 1: NAVI_STORAGE, 2: NAVI_USDC_POOL, 6: NAVI_INCENTIVE_V2, 7: NAVI_INCENTIVE_V3 }, + pure: { 3: ASSET_ID_BYTES }, + }, + ], + [ + ENTRY_WITHDRAW, + { + typeArguments: [USDC_TYPE], + argumentCount: 8, + objects: { + 1: NAVI_PRICE_ORACLE, + 2: NAVI_STORAGE, + 3: NAVI_USDC_POOL, + 6: NAVI_INCENTIVE_V2, + 7: NAVI_INCENTIVE_V3, + }, + pure: { 4: ASSET_ID_BYTES }, + }, + ], + [ + UPDATE_PRICE, + { + typeArguments: [], + argumentCount: 7, + objects: { + 1: NAVI_ORACLE_CONFIG, + 2: NAVI_PRICE_ORACLE, + 3: NAVI_SUPRA_HOLDER, + 4: NAVI_USDC_PYTH_OBJECT, + 5: NAVI_SWITCHBOARD, + }, + pure: { 6: toBase64(bcs.Address.serialize(NAVI_USDC_FEED).toBytes()) }, + }, + ], +]) + +export const NAVI_EXIT_POLICY = pinnedPolicy('NAVI', PINS) + +export function assertNaviSafe(payload: SigningPayload, account: string): void { + assertPtbSafe(payload, account, NAVI_EXIT_POLICY) + assertCallsPinned(payload, PINS, 'NAVI') +} + +export interface NaviHoldings { + collaterals: readonly number[] + loans: readonly number[] +} + +const UserInfo = bcs.struct('UserInfo', { + collaterals: bcs.vector(bcs.u8()), + loans: bcs.vector(bcs.u8()), +}) + +/** The assets a wallet holds on NAVI, read from the chain. Nothing when it has never used NAVI. */ +export async function readNaviHoldings(account: string): Promise { + try { + const { dynamicField } = await suiClient().core.getDynamicField({ + parentId: NAVI_USER_INFO, + name: { type: 'address', bcs: fromHex(normalizeSuiAddress(account)) }, + }) + return UserInfo.parse(dynamicField.value.bcs) + } catch (e) { + if (e instanceof ObjectError && e.code === 'notExists') return undefined + throw e + } +} + +/** + * Refuses what this can't withdraw. NAVI aborts a withdrawal unless every asset the + * person holds has a price set in the same transaction, and only USDC's feed is pinned + * here, so anyone holding more is sent to NAVI's own app. + */ +export function assertWithdrawableHoldings(holdings: NaviHoldings | undefined): void { + if (holdings === undefined || !holdings.collaterals.includes(NAVI_USDC_ASSET_ID)) { + throw new ChainActionRefused('This wallet has no USDC in NAVI. Nothing was signed or sent.') + } + if (holdings.loans.length > 0 || holdings.collaterals.length > 1) { + throw new ChainActionRefused( + 'You hold other assets or a loan on NAVI, and I can only withdraw from a USDC-only position. Withdraw at app.naviprotocol.io. Nothing was signed or sent.' + ) + } +} + +export async function prepareNaviWithdraw( + request: ChainActionRequest, + account: string, + readHoldings: typeof readNaviHoldings = readNaviHoldings +): Promise { + assertSuiAccount(account) + const token = tokenBySymbol(SUI_TOKENS, request.tokenIn) + if (token?.symbol !== 'USDC') { + throw new ChainActionRefused( + `NAVI takes USDC here, not ${request.tokenIn}. Nothing was signed or sent. Try "withdraw 10 USDC from NAVI".` + ) + } + const amount = toBaseUnits(request.amount, token.decimals) + assertWithdrawableHoldings(await readHoldings(account)) + + const tx = new Transaction() + tx.setSender(account) + tx.moveCall({ + target: UPDATE_PRICE, + arguments: [ + tx.object(CLOCK), + tx.object(NAVI_ORACLE_CONFIG), + tx.object(NAVI_PRICE_ORACLE), + tx.object(NAVI_SUPRA_HOLDER), + tx.object(NAVI_USDC_PYTH_OBJECT), + tx.object(NAVI_SWITCHBOARD), + tx.pure.address(NAVI_USDC_FEED), + ], + }) + tx.moveCall({ + target: ENTRY_WITHDRAW, + typeArguments: [USDC_TYPE], + arguments: [ + tx.object(CLOCK), + tx.object(NAVI_PRICE_ORACLE), + tx.object(NAVI_STORAGE), + tx.object(NAVI_USDC_POOL), + tx.pure.u8(NAVI_USDC_ASSET_ID), + tx.pure.u64(amount), + tx.object(NAVI_INCENTIVE_V2), + tx.object(NAVI_INCENTIVE_V3), + ], + }) + + const transaction = await buildUnsigned(tx, suiClient(), token.symbol) + const received = await dryRunChange(transaction, token.id, account, 'Checking the withdrawal') + if (received === undefined || received <= BigInt(0)) { + throw new ChainActionRefused( + "I couldn't confirm this withdrawal would return anything, so I stopped. Nothing was signed or sent. Try again shortly." + ) + } + const receive = displayAmount(received.toString(), token.decimals) + // The contract pays the smaller of the amount and what the person holds. + if (!gaveWhatWasAsked(received, BigInt(amount))) { + throw new ChainActionRefused( + `You have about ${receive} USDC in NAVI, which is less than ${request.amount}. Nothing was signed or sent. Try ${receive}.` + ) + } + + return { + venueId: 'navi', + venueName: 'NAVI', + kind: 'withdraw', + summary: `Withdraw about ${receive} USDC from NAVI's main lending market to your wallet.`, + pay: { amount: request.amount, symbol: token.symbol }, + receive: { amount: receive, symbol: token.symbol, approximate: true }, + notes: [ + 'If most of the pool is lent out, this can fail until borrowers repay.', + "NAVI needs a current USDC price to let money out, and this reads one that others publish on chain. If that price has gone stale, this stops until it's fresh again.", + ], + signing: { family: 'sui', transaction }, + } +} diff --git a/apps/dapp/lib/chains/sui/navi.ts b/apps/dapp/lib/chains/sui/navi.ts index 22010e36..acceea88 100644 --- a/apps/dapp/lib/chains/sui/navi.ts +++ b/apps/dapp/lib/chains/sui/navi.ts @@ -16,8 +16,7 @@ import { } from '../types' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' import { naviApy, poolNotes } from './navi-rules' -import { NAVI_POLICY } from './policies' -import { assertPtbSafe } from './safety' +import { assertNaviSafe, prepareNaviWithdraw } from './navi-withdraw' import { SUI_TOKENS } from './tokens' const REQUEST_TIMEOUT_MS = 10_000 @@ -62,6 +61,7 @@ async function quote(request: ChainActionRequest, account: string): Promise { + if (request.kind === 'withdraw') return prepareNaviWithdraw(request, account) const { token, amount, pool } = await checkedPool(request, account) const tx = new Transaction() @@ -93,10 +93,9 @@ async function prepare(request: ChainActionRequest, account: string): Promise - assertPtbSafe(payload, account, NAVI_POLICY), + assertSafe: (payload: SigningPayload, account: string) => assertNaviSafe(payload, account), } diff --git a/apps/dapp/lib/chains/sui/scallop-listing.ts b/apps/dapp/lib/chains/sui/scallop-listing.ts new file mode 100644 index 00000000..c04f9033 --- /dev/null +++ b/apps/dapp/lib/chains/sui/scallop-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const scallopListing: Venue = { + id: 'scallop', + family: 'sui', + name: 'Scallop', + category: 'lending', + chains: ['Sui'], + url: 'https://scallop.io', + bestFor: 'Lending with a small USDC pool. A retired contract lost about $142K in April 2026', + integration: 'executes', + capability: 'Supplies and withdraws USDC and shows the base rate. Borrowing is not connected', +} diff --git a/apps/dapp/lib/chains/sui/scallop.ts b/apps/dapp/lib/chains/sui/scallop.ts new file mode 100644 index 00000000..b0738f92 --- /dev/null +++ b/apps/dapp/lib/chains/sui/scallop.ts @@ -0,0 +1,350 @@ +import { bcs } from '@mysten/sui/bcs' +import { coinWithBalance, Transaction } from '@mysten/sui/transactions' + +import { + ChainActionRefused, + quotedApy, + takesOnly, + tokenBySymbol, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type VenueQuote, +} from '../types' +import { assertSuiAccount, buildUnsigned, suiClient } from './client' +import { + assertCallsPinned, + coinTypeName, + dryRunChange, + gaveWhatWasAsked, + pinnedPolicy, + type PinnedCalls, +} from './lending-calls' +import { poolNotes } from './navi-rules' +import { assertPtbSafe, moveTarget } from './safety' +import { displayAmount, SUI_TOKENS } from './tokens' + +/** + * Scallop's latest protocol package, version 19: the `package` field of the UpgradeCap + * in `core.packages.protocol` of Scallop's address registry, the same cap id as + * @scallop-io/sui-scallop-sdk 5.4.3's `constants/testAddress.ts`. `mint` and `redeem` + * here match the signatures on Scallop's docs (integrations/contract-integration/ + * lending-function), which still name an older package. + */ +export const SCALLOP_PACKAGE = '0xde5c09ad171544aa3724dc67216668c80e754860f419136a68d78504eb2e2805' +export const SCALLOP_ORIGINAL_PACKAGE = + '0xefe8b36d5b2e43728cc323298626b83177803521d195cfb11e15b910e892fddf' +export const SCALLOP_UPGRADE_CAP = + '0x38527d154618d1fd5a644b90717fe07cf0e9f26b46b63e9568e611a3f86d5c1a' +/** Both on the docs page above and in the registry. On chain, shared `version::Version` and `market::Market`. */ +export const SCALLOP_VERSION = '0x07871c4b3c847a0f674510d4978d5cf6f960452795e8ff6f189fd2088a3f6ac7' +export const SCALLOP_MARKET = '0xa757975255146dc9686aa823b7838b507f315d704f428cbadad2f4ea061939d9' +/** Table ids read from the market object. They hold the pool's numbers, keyed by coin type. */ +export const SCALLOP_BALANCE_SHEETS = + '0x8708eb23153bdc4b345c9f536fe05b62206f3f55629b26389d4fe5f129bd8368' +export const SCALLOP_BORROW_DYNAMICS = + '0x2d878e129dec2d83f3e240fa403cd588bc5101dd9b60040c27007e24ef242d8d' +export const SCALLOP_INTEREST_MODELS = + '0x1e8419e665b8b796723c97747c504f4a37a527d4f944f27ae9467ae68e8b50f9' + +const USDC = SUI_TOKENS.find((t) => t.symbol === 'USDC') +if (USDC === undefined) throw new Error('USDC is missing from the Sui token table.') +const USDC_TYPE = USDC.id +export const SCALLOP_RECEIPT_TYPE = `${SCALLOP_ORIGINAL_PACKAGE}::reserve::MarketCoin<${USDC_TYPE}>` +const RECEIPT_SYMBOL = 'Scallop USDC' +const SECONDS_PER_YEAR = 31_536_000 +const FIXED_POINT_32 = 2 ** 32 + +const versionAndMarket = { objects: { 0: SCALLOP_VERSION, 1: SCALLOP_MARKET } } +const MINT = moveTarget(SCALLOP_PACKAGE, 'mint', 'mint') +const REDEEM = moveTarget(SCALLOP_PACKAGE, 'redeem', 'redeem') + +/** Neither call names a recipient. Each returns a coin, and the transaction's transfers must pay the signer. */ +const PINS: PinnedCalls = new Map([ + [MINT, { typeArguments: [USDC_TYPE], argumentCount: 4, ...versionAndMarket }], + [REDEEM, { typeArguments: [USDC_TYPE], argumentCount: 4, ...versionAndMarket }], +]) + +export const SCALLOP_POLICY = pinnedPolicy('Scallop', PINS) + +export interface ScallopPool { + cash: bigint + debt: bigint + revenue: bigint + receiptSupply: bigint + /** Fixed point over 2^32, per `borrowRateScale` seconds. */ + borrowRate: bigint + borrowRateScale: bigint + /** Fixed point over 2^32. */ + revenueFactor: bigint +} + +/** Chain reads, kept apart so the refusals can be tested without a network. */ +export interface ScallopReader { + pool: () => Promise + /** The USDC receipts this wallet holds, in base units. */ + receipts: (account: string) => Promise +} + +function unreadable(): never { + throw new ChainActionRefused( + "I couldn't read Scallop's USDC pool, so I stopped. Nothing was signed or sent. Try again shortly." + ) +} + +/** Value in USDC base units behind all receipts, as the SDK counts it. */ +function poolValue(pool: ScallopPool): bigint { + return pool.debt + (pool.cash > pool.revenue ? pool.cash - pool.revenue : BigInt(0)) +} + +/** Rounded up: interest only moves the share price up, so this never pays out less than asked. */ +export function receiptsForAmount(amount: bigint, pool: ScallopPool): bigint { + const value = poolValue(pool) + if (value <= BigInt(0) || pool.receiptSupply <= BigInt(0)) unreadable() + return (amount * pool.receiptSupply + value - BigInt(1)) / value +} + +export function amountForReceipts(receipts: bigint, pool: ScallopPool): bigint { + if (pool.receiptSupply <= BigInt(0)) unreadable() + return (receipts * poolValue(pool)) / pool.receiptSupply +} + +/** + * The SDK's `supplyApr`: the borrow rate the contract last set, at the pool's current + * use, less Scallop's revenue share. Rewards are not counted. + */ +export function scallopSupplyApr(pool: ScallopPool): number | undefined { + const value = Number(poolValue(pool)) + const scale = Number(pool.borrowRateScale) + if (value <= 0 || scale <= 0) return undefined + const borrowApr = ((Number(pool.borrowRate) / FIXED_POINT_32) * SECONDS_PER_YEAR) / scale + const used = Number(pool.debt) / value + const keptBySuppliers = 1 - Number(pool.revenueFactor) / FIXED_POINT_32 + const percent = borrowApr * used * keptBySuppliers * 100 + return Number.isFinite(percent) ? Math.round(percent * 100) / 100 : undefined +} + +function scallopApy(pool: ScallopPool): PreparedAction['apy'] { + const basePercent = scallopSupplyApr(pool) + return basePercent === undefined ? undefined : { basePercent } +} + +const BalanceSheet = bcs.struct('BalanceSheet', { + cash: bcs.u64(), + debt: bcs.u64(), + revenue: bcs.u64(), + market_coin_supply: bcs.u64(), +}) +const BorrowDynamic = bcs.struct('BorrowDynamic', { + interest_rate: bcs.u64(), + interest_rate_scale: bcs.u64(), +}) +const InterestModel = bcs.struct('InterestModel', { + type: bcs.string(), + base_borrow_rate_per_sec: bcs.u64(), + interest_rate_scale: bcs.u64(), + borrow_rate_on_mid_kink: bcs.u64(), + mid_kink: bcs.u64(), + borrow_rate_on_high_kink: bcs.u64(), + high_kink: bcs.u64(), + max_borrow_rate: bcs.u64(), + revenue_factor: bcs.u64(), +}) + +async function usdcField(parentId: string): Promise { + const { dynamicField } = await suiClient().core.getDynamicField({ + parentId, + name: { + type: '0x1::type_name::TypeName', + bcs: bcs.string().serialize(coinTypeName(USDC_TYPE)).toBytes(), + }, + }) + return dynamicField.value.bcs +} + +const chainReader: ScallopReader = { + pool: async () => { + const [sheet, dynamics, model] = await Promise.all([ + usdcField(SCALLOP_BALANCE_SHEETS), + usdcField(SCALLOP_BORROW_DYNAMICS), + usdcField(SCALLOP_INTEREST_MODELS), + ]) + const { cash, debt, revenue, market_coin_supply: receiptSupply } = BalanceSheet.parse(sheet) + const { interest_rate: borrowRate, interest_rate_scale: borrowRateScale } = + BorrowDynamic.parse(dynamics) + const { type, revenue_factor: revenueFactor } = InterestModel.parse(model) + if (type !== coinTypeName(USDC_TYPE)) unreadable() + return { + cash: BigInt(cash), + debt: BigInt(debt), + revenue: BigInt(revenue), + receiptSupply: BigInt(receiptSupply), + borrowRate: BigInt(borrowRate), + borrowRateScale: BigInt(borrowRateScale), + revenueFactor: BigInt(revenueFactor), + } + }, + receipts: async (account) => { + const { balance } = await suiClient().core.getBalance({ + owner: account, + coinType: SCALLOP_RECEIPT_TYPE, + }) + return BigInt(balance.balance) + }, +} + +function checkedRequest(request: ChainActionRequest, account: string): ChainToken { + assertSuiAccount(account) + const token = tokenBySymbol(SUI_TOKENS, request.tokenIn) + if (token?.symbol !== 'USDC') { + throw new ChainActionRefused( + `Scallop takes USDC here, not ${request.tokenIn}. Nothing was signed or sent. Try "supply 10 USDC on Scallop".` + ) + } + return token +} + +async function quote( + reader: ScallopReader, + request: ChainActionRequest, + account: string +): Promise { + checkedRequest(request, account) + return quotedApy(scallopApy(await reader.pool())) +} + +async function prepareSupply( + reader: ScallopReader, + request: ChainActionRequest, + account: string +): Promise { + const token = checkedRequest(request, account) + const amount = toBaseUnits(request.amount, token.decimals) + const pool = await reader.pool() + + const tx = new Transaction() + tx.setSender(account) + const coin = tx.add( + coinWithBalance({ type: token.id, balance: BigInt(amount), useGasCoin: false }) + ) + const [receipt] = tx.moveCall({ + target: MINT, + typeArguments: [USDC_TYPE], + arguments: [tx.object(SCALLOP_VERSION), tx.object(SCALLOP_MARKET), coin, tx.object.clock()], + }) + if (receipt === undefined) throw new Error('Minting returned nothing.') + tx.transferObjects([receipt], account) + + const transaction = await buildUnsigned(tx, suiClient(), token.symbol) + const minted = await dryRunChange( + transaction, + SCALLOP_RECEIPT_TYPE, + account, + 'Checking the deposit' + ) + if (minted === undefined || minted <= BigInt(0)) { + throw new ChainActionRefused( + "I couldn't confirm how much this deposit would return, so I stopped. Nothing was signed or sent. Try again shortly." + ) + } + const receive = displayAmount(minted.toString(), token.decimals) + + return { + venueId: 'scallop', + venueName: 'Scallop', + kind: 'supply', + summary: `Supply ${request.amount} USDC to Scallop for about ${receive} ${RECEIPT_SYMBOL} receipts.`, + pay: { amount: request.amount, symbol: token.symbol }, + receive: { amount: receive, symbol: RECEIPT_SYMBOL, approximate: true }, + apy: scallopApy(pool), + notes: [ + ...poolNotes({ availableUsdc: Number(pool.cash) / 1e6 }), + 'Your wallet gets receipts that grow in value, so you get fewer of them than the USDC you put in. You withdraw by handing them back, here or on Scallop. If you send them away, your deposit goes with them.', + "A retired Scallop rewards contract lost about $142K (about 150,000 SUI) on April 26, 2026. Reports say deposits in Scallop's main contracts were not affected. I could not confirm that those who lost money were repaid.", + "Scallop's team can upgrade these contracts through a multisig, which can change how they work.", + ], + signing: { family: 'sui', transaction }, + } +} + +async function prepareWithdraw( + reader: ScallopReader, + request: ChainActionRequest, + account: string +): Promise { + const token = checkedRequest(request, account) + const amount = BigInt(toBaseUnits(request.amount, token.decimals)) + + const held = await reader.receipts(account) + if (held <= BigInt(0)) { + throw new ChainActionRefused( + 'This wallet has no Scallop USDC receipts I can redeem. A deposit made on Scallop itself may be held as sUSDC or staked, and needs withdrawing at scallop.io. Nothing was signed or sent.' + ) + } + const pool = await reader.pool() + const receipts = receiptsForAmount(amount, pool) + if (receipts > held) { + const worth = displayAmount(amountForReceipts(held, pool).toString(), token.decimals) + throw new ChainActionRefused( + `Your Scallop receipts are worth about ${worth} USDC, which is less than ${request.amount}. Nothing was signed or sent. Try ${worth}.` + ) + } + + const tx = new Transaction() + tx.setSender(account) + const coin = tx.add( + coinWithBalance({ type: SCALLOP_RECEIPT_TYPE, balance: receipts, useGasCoin: false }) + ) + const [usdc] = tx.moveCall({ + target: REDEEM, + typeArguments: [USDC_TYPE], + arguments: [tx.object(SCALLOP_VERSION), tx.object(SCALLOP_MARKET), coin, tx.object.clock()], + }) + if (usdc === undefined) throw new Error('Redeeming returned nothing.') + tx.transferObjects([usdc], account) + + const transaction = await buildUnsigned(tx, suiClient(), token.symbol) + const received = await dryRunChange(transaction, token.id, account, 'Checking the withdrawal') + if (received === undefined || !gaveWhatWasAsked(received, amount)) { + throw new ChainActionRefused( + "I couldn't confirm this withdrawal would return what you asked, so I stopped. Nothing was signed or sent. Try again shortly." + ) + } + const receive = displayAmount(received.toString(), token.decimals) + + return { + venueId: 'scallop', + venueName: 'Scallop', + kind: 'withdraw', + summary: `Withdraw about ${receive} USDC from Scallop to your wallet.`, + pay: { amount: request.amount, symbol: token.symbol }, + receive: { amount: receive, symbol: token.symbol, approximate: true }, + notes: [ + 'This is a small pool. If most of it is lent out, this can fail until borrowers repay.', + ], + signing: { family: 'sui', transaction }, + } +} + +export function createScallopVenue(reader: ScallopReader = chainReader): ChainVenue { + return { + id: 'scallop', + name: 'Scallop', + kinds: ['supply', 'withdraw'], + assertCanFollow: takesOnly('Scallop', ['USDC']), + quote: (request, account) => quote(reader, request, account), + prepare: (request, account) => + request.kind === 'withdraw' + ? prepareWithdraw(reader, request, account) + : prepareSupply(reader, request, account), + assertSafe: (payload: SigningPayload, account: string) => { + assertPtbSafe(payload, account, SCALLOP_POLICY) + assertCallsPinned(payload, PINS, 'Scallop') + }, + } +} + +export const scallopVenue = createScallopVenue() diff --git a/apps/dapp/lib/chains/sui/springsui-unstake.ts b/apps/dapp/lib/chains/sui/springsui-unstake.ts new file mode 100644 index 00000000..2bea2c57 --- /dev/null +++ b/apps/dapp/lib/chains/sui/springsui-unstake.ts @@ -0,0 +1,147 @@ +import { coinWithBalance, Transaction } from '@mysten/sui/transactions' +import { normalizeStructTag } from '@mysten/sui/utils' + +import { + ChainActionRefused, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type PreparedAction, + type SigningPayload, +} from '../types' +import { assertSuiAccount, buildUnsigned, suiClient } from './client' +import { + assertPinnedObjects, + dryRun, + percentText, + readObjectJson, + returnedCoinAmount, + SUI_SYSTEM_STATE, + u64Field, +} from './haedal' +import { SPRINGSUI_PACKAGE, SSUI_INFO, SSUI_TYPE, SSUI_WEIGHT_HOOK } from './policies' +import { assertPtbSafe, moveTarget, type ParsedCall, type PtbPolicy } from './safety' +import { displayAmount } from './tokens' + +/** On chain, its coin metadata 0x06e4724a...7d9a says sSUI, 9 decimals. */ +export const SPRINGSUI_TOKENS: ChainToken[] = [ + { symbol: 'sSUI', id: SSUI_TYPE, decimals: 9, name: 'Spring SUI' }, +] + +const SUI_DECIMALS = 9 +const SSUI_DECIMALS = 9 +const BPS_PER_PERCENT = 100 + +/** springsui-sdk 4.1.0's `redeemAmountAndRebalanceAndSendToUser` makes these two calls, in this order. */ +const REDEEM = moveTarget(SPRINGSUI_PACKAGE, 'liquid_staking', 'redeem') +const REBALANCE = moveTarget(SPRINGSUI_PACKAGE, 'weight', 'rebalance') + +function fail(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +async function readRedeemFeeBps(): Promise { + const info = await readObjectJson(SSUI_INFO) + const cell = info?.['fee_config'] as Record | undefined + const fees = cell?.['element'] as Record | undefined + const bps = u64Field(fees?.['redeem_fee_bps']) + if (bps === undefined) fail("I couldn't read SpringSui's fee, so I stopped. Try again shortly.") + return bps +} + +export async function prepareSpringsuiUnstake( + request: ChainActionRequest, + account: string +): Promise { + assertSuiAccount(account) + if (request.tokenIn.trim().toUpperCase() !== 'SSUI') { + throw new ChainActionRefused( + `SpringSui unstakes sSUI, not ${request.tokenIn}. If this came from another app, name it, like "unstake 1 afSUI with Aftermath".` + ) + } + const amount = toBaseUnits(request.amount, SSUI_DECIMALS) + + const tx = new Transaction() + tx.setSender(account) + const coin = tx.add( + coinWithBalance({ type: SSUI_TYPE, balance: BigInt(amount), useGasCoin: false }) + ) + const [sui] = tx.moveCall({ + target: REDEEM, + typeArguments: [SSUI_TYPE], + arguments: [tx.object(SSUI_INFO), coin, tx.object(SUI_SYSTEM_STATE)], + }) + if (sui === undefined) throw new Error('Redeeming returned nothing.') + tx.moveCall({ + target: REBALANCE, + typeArguments: [SSUI_TYPE], + arguments: [tx.object(SSUI_WEIGHT_HOOK), tx.object(SUI_SYSTEM_STATE), tx.object(SSUI_INFO)], + }) + tx.transferObjects([sui], account) + + const transaction = await buildUnsigned(tx, suiClient(), 'sSUI') + const simulated = await dryRun(transaction, 'Checking the unstake') + const received = returnedCoinAmount(simulated.commandResults, transaction, REDEEM) + if (received === undefined) { + fail( + "I couldn't confirm how much SUI this unstake would return, so I stopped. Try again shortly." + ) + } + const receive = displayAmount(received, SUI_DECIMALS) + const feeBps = await readRedeemFeeBps() + + return { + venueId: 'springsui', + venueName: 'SpringSui', + kind: 'unstake', + summary: `Unstake ${request.amount} sSUI with SpringSui for about ${receive} SUI, right away.`, + pay: { amount: request.amount, symbol: 'sSUI' }, + receive: { amount: receive, symbol: 'SUI', approximate: true }, + notes: [ + `There is no wait: the SUI is in your wallet once this lands. The fee is ${percentText(Number(feeBps) / BPS_PER_PERCENT)}%, already taken off the amount shown.`, + 'Your remaining sSUI stays in third-party contracts that can fail. SpringSui shares an operator group with Suilend, Bluefin and Ember.', + ], + signing: { family: 'sui', transaction }, + } +} + +/** Anyone can create a token in this package, so the allowed call says nothing about the coin. */ +function checkBurnsSsui(call: ParsedCall): void { + for (const coin of call.typeArguments) { + if (normalizeStructTag(coin) !== normalizeStructTag(SSUI_TYPE)) { + fail('This unstake burns a token other than sSUI.') + } + } +} + +export const SPRINGSUI_UNSTAKE_POLICY: PtbPolicy = { + venueName: 'SpringSui', + allowedCalls: new Set([REDEEM, REBALANCE]), + checkCall: checkBurnsSsui, +} + +const PINS: ReadonlyMap = new Map([ + [REDEEM, [SSUI_INFO, undefined, SUI_SYSTEM_STATE]], + [REBALANCE, [SSUI_WEIGHT_HOOK, SUI_SYSTEM_STATE, SSUI_INFO]], +]) + +/** Whether these bytes redeem. Bytes that can't be read aren't, and the stake policy refuses them. */ +export function isSpringsuiRedeem(payload: SigningPayload): boolean { + if (payload.family !== 'sui') return false + try { + return Transaction.from(payload.transaction) + .getData() + .commands.some( + (c) => + c.$kind === 'MoveCall' && + moveTarget(c.MoveCall.package, c.MoveCall.module, c.MoveCall.function) === REDEEM + ) + } catch { + return false + } +} + +export function assertSpringsuiUnstakeSafe(payload: SigningPayload, account: string): void { + assertPtbSafe(payload, account, SPRINGSUI_UNSTAKE_POLICY) + assertPinnedObjects(payload, PINS, 'SpringSui') +} diff --git a/apps/dapp/lib/chains/sui/springsui.ts b/apps/dapp/lib/chains/sui/springsui.ts index 09070be0..cfaa8831 100644 --- a/apps/dapp/lib/chains/sui/springsui.ts +++ b/apps/dapp/lib/chains/sui/springsui.ts @@ -9,9 +9,9 @@ import { type PreparedAction, type SigningPayload, type VenueQuote, - takesOnly, } from '../types' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' +import { stakesAfterSwap } from './haedal' import { mintedAmount, SPRINGSUI_PACKAGE, @@ -21,6 +21,11 @@ import { SSUI_WEIGHT_HOOK, } from './policies' import { assertPtbSafe } from './safety' +import { + assertSpringsuiUnstakeSafe, + isSpringsuiRedeem, + prepareSpringsuiUnstake, +} from './springsui-unstake' import { displayAmount } from './tokens' const SUI_SYSTEM_STATE = '0x5' @@ -82,6 +87,7 @@ async function quote(request: ChainActionRequest, account: string): Promise { + if (request.kind === 'unstake') return prepareSpringsuiUnstake(request, account) const { transaction, receive } = await checkedStake(request, account) return { venueId: 'springsui', @@ -102,10 +108,13 @@ async function prepare(request: ChainActionRequest, account: string): Promise - assertPtbSafe(payload, account, SPRINGSUI_POLICY), + isSpringsuiRedeem(payload) + ? assertSpringsuiUnstakeSafe(payload, account) + : assertPtbSafe(payload, account, SPRINGSUI_POLICY), } diff --git a/apps/dapp/lib/chains/sui/suilend-listing.ts b/apps/dapp/lib/chains/sui/suilend-listing.ts new file mode 100644 index 00000000..125214c4 --- /dev/null +++ b/apps/dapp/lib/chains/sui/suilend-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const suilendListing: Venue = { + id: 'suilend', + family: 'sui', + name: 'Suilend', + category: 'lending', + chains: ['Sui'], + url: 'https://suilend.fi', + bestFor: 'Lending in the largest market on Sui, under new ownership since June 2026', + integration: 'executes', + capability: 'Supplies and withdraws USDC and shows the base rate. Borrowing is not connected', +} diff --git a/apps/dapp/lib/chains/sui/suilend.ts b/apps/dapp/lib/chains/sui/suilend.ts new file mode 100644 index 00000000..b047f57e --- /dev/null +++ b/apps/dapp/lib/chains/sui/suilend.ts @@ -0,0 +1,508 @@ +import { coinWithBalance, Transaction } from '@mysten/sui/transactions' +import { fromBase64 } from '@mysten/sui/utils' + +import { + ChainActionRefused, + quotedApy, + takesOnly, + tokenBySymbol, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type VenueQuote, +} from '../types' +import { assertSuiAccount, buildUnsigned, suiClient } from './client' +import { + assertCallsPinned, + coinTypeName, + dryRunChange, + gaveWhatWasAsked, + pinnedPolicy, + u64Bytes, + type PinnedCalls, +} from './lending-calls' +import { poolNotes } from './navi-rules' +import { assertPtbSafe, moveTarget } from './safety' +import { displayAmount, SUI_TOKENS } from './tokens' + +/** + * `_generated/_envs/mainnet.js` and `client.js` of @suilend/sdk 14.2.1. On chain, the + * UpgradeCap below names this package at version 25, and `lending_market`'s signatures + * were read from it. The market's `version` field is 7, the version this package was + * built for. The SDK says market version 8 will follow: then every call here aborts, + * so the dry run refuses until someone checks the new package. + */ +export const SUILEND_PACKAGE = '0x59672975e15c58ffc9450f5236b6aa9fa2407d94b8081e634778aa01957d8c6f' +export const SUILEND_ORIGINAL_PACKAGE = + '0xf95b06141ed4a174f239417323bde3f209b972f5930d8521ea38a52aff3a6ddf' +export const SUILEND_UPGRADE_CAP = + '0x3d4ef1859c3ee9fc72858f588b56a09da5466e64f8cc4e90a7b3b909fba8a7ae' +/** `LENDING_MARKET_ID` in the SDK. On chain, a shared `LendingMarket`. */ +export const SUILEND_MARKET = '0x84030d26d85eaa7035084a057f2f11f701b7e2e4eda87551becbc7c97505ece1' +export const SUILEND_MAIN_POOL = `${SUILEND_ORIGINAL_PACKAGE}::suilend::MAIN_POOL` +/** Read from the market's `reserves`: the entry whose `coin_type` is native USDC. Reserves only append. */ +export const SUILEND_USDC_RESERVE = 7 + +const CAP_TYPE = `${SUILEND_ORIGINAL_PACKAGE}::lending_market::ObligationOwnerCap<${SUILEND_MAIN_POOL}>` +const WAD = BigInt('1000000000000000000') +const MAX_CAP_PAGES = 5 + +const USDC = SUI_TOKENS.find((t) => t.symbol === 'USDC') +if (USDC === undefined) throw new Error('USDC is missing from the Sui token table.') +const USDC_TYPE = USDC.id +const USDC_NAME = coinTypeName(USDC_TYPE) +const USDC_TYPES = [SUILEND_MAIN_POOL, USDC_TYPE] +const USDC_RESERVE_BYTES = u64Bytes(SUILEND_USDC_RESERVE) +const EXEMPTION_TYPE = `${SUILEND_ORIGINAL_PACKAGE}::lending_market::RateLimiterExemption<${SUILEND_MAIN_POOL}, ${USDC_TYPE}>` + +function fn(name: string): string { + return moveTarget(SUILEND_PACKAGE, 'lending_market', name) +} + +const marketAndReserve = { + objects: { 0: SUILEND_MARKET }, + pure: { 1: USDC_RESERVE_BYTES }, +} + +/** + * Nothing here can credit anyone but the signer: every call that touches a position + * takes the owner cap, which only its holder can pass. `repay` is left out because it + * takes a bare obligation id, so it could pay down a stranger's loan. + */ +const PINS: PinnedCalls = new Map([ + [ + fn('create_obligation'), + { typeArguments: [SUILEND_MAIN_POOL], argumentCount: 1, objects: { 0: SUILEND_MARKET } }, + ], + [ + fn('deposit_liquidity_and_mint_ctokens'), + { typeArguments: USDC_TYPES, argumentCount: 4, ...marketAndReserve }, + ], + [ + fn('deposit_ctokens_into_obligation'), + { typeArguments: USDC_TYPES, argumentCount: 5, ...marketAndReserve }, + ], + [fn('withdraw_ctokens'), { typeArguments: USDC_TYPES, argumentCount: 5, ...marketAndReserve }], + [ + fn('redeem_ctokens_and_withdraw_liquidity_request'), + { typeArguments: USDC_TYPES, argumentCount: 5, ...marketAndReserve }, + ], + [ + fn('fulfill_liquidity_request'), + { typeArguments: USDC_TYPES, argumentCount: 3, ...marketAndReserve }, + ], + // The redeem's rate-limit exemption, as none. A real one only comes from a liquidation. + [moveTarget('0x1', 'option', 'none'), { typeArguments: [EXEMPTION_TYPE], argumentCount: 0 }], +]) + +export const SUILEND_POLICY = pinnedPolicy('Suilend', PINS) + +export interface SuilendReserve { + available: bigint + ctokenSupply: bigint + borrowedWad: bigint + spreadFeesWad: bigint + depositLimit: bigint + utilsPercent: readonly number[] + aprsBps: readonly number[] + spreadFeeBps: number +} + +export interface SuilendPosition { + capId: string + obligationId: string + /** cTokens of USDC deposited. Zero when the position holds none. */ + ctokens: bigint + hasBorrows: boolean +} + +/** Chain reads, kept apart so the refusals can be tested without a network. */ +export interface SuilendReader { + reserve: () => Promise + positions: (account: string) => Promise +} + +function unreadable(): never { + throw new ChainActionRefused( + "I couldn't read Suilend's USDC market, so I stopped. Nothing was signed or sent. Try again shortly." + ) +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null + ? (value as Record) + : unreadable() +} + +function whole(value: unknown): bigint { + return typeof value === 'string' && /^\d+$/.test(value) ? BigInt(value) : unreadable() +} + +function decimalValue(value: unknown): bigint { + return whole(record(value)['value']) +} + +export function parseSuilendReserve(market: unknown): SuilendReserve { + const reserves = record(market)['reserves'] + const raw = Array.isArray(reserves) ? record(reserves[SUILEND_USDC_RESERVE]) : unreadable() + if (raw['coin_type'] !== USDC_NAME) unreadable() + const config = record(record(raw['config'])['element']) + const utilsRaw = config['interest_rate_utils'] + const aprsRaw = config['interest_rate_aprs'] + if (typeof utilsRaw !== 'string' || !Array.isArray(aprsRaw)) unreadable() + return { + available: whole(raw['available_amount']), + ctokenSupply: whole(raw['ctoken_supply']), + borrowedWad: decimalValue(raw['borrowed_amount']), + spreadFeesWad: decimalValue(raw['unclaimed_spread_fees']), + depositLimit: whole(config['deposit_limit']), + utilsPercent: [...fromBase64(utilsRaw)], + aprsBps: aprsRaw.map((apr) => Number(whole(apr))), + spreadFeeBps: Number(whole(config['spread_fee_bps'])), + } +} + +/** Reserve value in base units, as the contract counts it before this block's interest. */ +function totalLiquidity(reserve: SuilendReserve): bigint { + return reserve.available + (reserve.borrowedWad - reserve.spreadFeesWad) / WAD +} + +/** Rounded up: interest only moves the share price up, so this never pays out less than asked. */ +export function ctokensForAmount(amount: bigint, reserve: SuilendReserve): bigint { + const total = totalLiquidity(reserve) + if (total <= BigInt(0) || reserve.ctokenSupply <= BigInt(0)) unreadable() + return (amount * reserve.ctokenSupply + total - BigInt(1)) / total +} + +export function amountForCtokens(ctokens: bigint, reserve: SuilendReserve): bigint { + if (reserve.ctokenSupply <= BigInt(0)) unreadable() + return (ctokens * totalLiquidity(reserve)) / reserve.ctokenSupply +} + +/** + * The base rate from the contract's own curve: borrowers' rate at the current use of + * the pool, shared across all deposits, less Suilend's spread. This is the SDK's + * `calculateDepositAprPercent`. Rewards are not counted, and no rate is shown if the + * curve doesn't read. + */ +export function suilendSupplyApr(reserve: SuilendReserve): number | undefined { + const borrowed = Number(reserve.borrowedWad / WAD) + const available = Number(reserve.available) + const { utilsPercent: utils, aprsBps: aprs } = reserve + if (borrowed + available <= 0 || utils.length !== aprs.length || utils.length < 2) { + return undefined + } + const usedPercent = (borrowed / (borrowed + available)) * 100 + const upper = utils.findIndex((util) => util >= usedPercent) + if (usedPercent === 0) return 0 + if (upper < 1) return undefined + const [lowUtil, highUtil] = [utils[upper - 1], utils[upper]] + const [lowApr, highApr] = [aprs[upper - 1], aprs[upper]] + if ( + lowUtil === undefined || + highUtil === undefined || + lowApr === undefined || + highApr === undefined + ) { + return undefined + } + const borrowBps = lowApr + ((usedPercent - lowUtil) / (highUtil - lowUtil)) * (highApr - lowApr) + const percent = (usedPercent / 100) * (borrowBps / 100) * (1 - reserve.spreadFeeBps / 10_000) + return Number.isFinite(percent) ? Math.round(percent * 100) / 100 : undefined +} + +function suilendApy(reserve: SuilendReserve): PreparedAction['apy'] { + const basePercent = suilendSupplyApr(reserve) + return basePercent === undefined ? undefined : { basePercent } +} + +const chainReader: SuilendReader = { + reserve: async () => { + const { object } = await suiClient().core.getObject({ + objectId: SUILEND_MARKET, + include: { json: true }, + }) + return parseSuilendReserve(object.json) + }, + positions: async (account) => { + const client = suiClient() + const caps: { capId: string; obligationId: string }[] = [] + let cursor: string | null | undefined + for (let page = 0; page < MAX_CAP_PAGES; page++) { + const owned = await client.core.listOwnedObjects({ + owner: account, + type: CAP_TYPE, + include: { json: true }, + ...(cursor ? { cursor } : {}), + }) + for (const { objectId, json } of owned.objects) { + const obligationId = record(json)['obligation_id'] + if (typeof obligationId !== 'string') unreadable() + caps.push({ capId: objectId, obligationId }) + } + if (!owned.hasNextPage) break + cursor = owned.cursor + } + return Promise.all( + caps.map(async ({ capId, obligationId }) => { + const { object } = await client.core.getObject({ + objectId: obligationId, + include: { json: true }, + }) + const obligation = record(object.json) + const { deposits, borrows } = obligation + if (!Array.isArray(deposits) || !Array.isArray(borrows)) unreadable() + const usdc = deposits.map(record).find((d) => d['coin_type'] === USDC_NAME) + return { + capId, + obligationId, + ctokens: usdc === undefined ? BigInt(0) : whole(usdc['deposited_ctoken_amount']), + hasBorrows: borrows.length > 0, + } + }) + ) + }, +} + +function checkedRequest(request: ChainActionRequest, account: string): ChainToken { + assertSuiAccount(account) + const token = tokenBySymbol(SUI_TOKENS, request.tokenIn) + if (token?.symbol !== 'USDC') { + throw new ChainActionRefused( + `Suilend takes USDC here, not ${request.tokenIn}. Nothing was signed or sent. Try "supply 10 USDC on Suilend".` + ) + } + return token +} + +function dollars(base: bigint): number { + return Number(base) / 1e6 +} + +function sharedNotes(reserve: SuilendReserve): string[] { + return [ + ...poolNotes({ availableUsdc: dollars(reserve.available) }), + "Suilend's contract upgrades are controlled by a single key, not a multisig, going by that address's own recent transactions. If that key were stolen, deposits could be at risk. Suilend's team can also change the market's settings.", + 'Bluewater bought Suilend in June 2026, so it now has new owners.', + ] +} + +/** + * Only a position with no loan is reused, so a deposit never becomes collateral by + * accident and can always leave without a fresh price. + */ +export function positionForSupply( + positions: readonly SuilendPosition[] +): SuilendPosition | undefined { + const free = positions.filter((p) => !p.hasBorrows) + return free.find((p) => p.ctokens > BigInt(0)) ?? free[0] +} + +async function quote( + reader: SuilendReader, + request: ChainActionRequest, + account: string +): Promise { + checkedRequest(request, account) + return quotedApy(suilendApy(await reader.reserve())) +} + +async function prepareSupply( + reader: SuilendReader, + request: ChainActionRequest, + account: string +): Promise { + const token = checkedRequest(request, account) + const amount = toBaseUnits(request.amount, token.decimals) + const reserve = await reader.reserve() + if (totalLiquidity(reserve) + BigInt(amount) > reserve.depositLimit) { + throw new ChainActionRefused( + "Suilend's USDC market is at its deposit limit. Nothing was signed or sent. Try a smaller amount or another venue." + ) + } + const existing = positionForSupply(await reader.positions(account)) + + const tx = new Transaction() + tx.setSender(account) + const [newCap] = + existing === undefined + ? tx.moveCall({ + target: fn('create_obligation'), + typeArguments: [SUILEND_MAIN_POOL], + arguments: [tx.object(SUILEND_MARKET)], + }) + : [undefined] + const cap = existing === undefined ? newCap : tx.object(existing.capId) + if (cap === undefined) throw new Error('Opening a position returned nothing.') + + const coin = tx.add( + coinWithBalance({ type: token.id, balance: BigInt(amount), useGasCoin: false }) + ) + const [ctokens] = tx.moveCall({ + target: fn('deposit_liquidity_and_mint_ctokens'), + typeArguments: USDC_TYPES, + arguments: [ + tx.object(SUILEND_MARKET), + tx.pure.u64(SUILEND_USDC_RESERVE), + tx.object.clock(), + coin, + ], + }) + if (ctokens === undefined) throw new Error('Minting returned nothing.') + tx.moveCall({ + target: fn('deposit_ctokens_into_obligation'), + typeArguments: USDC_TYPES, + arguments: [ + tx.object(SUILEND_MARKET), + tx.pure.u64(SUILEND_USDC_RESERVE), + cap, + tx.object.clock(), + ctokens, + ], + }) + if (existing === undefined) tx.transferObjects([cap], account) + + const transaction = await buildUnsigned(tx, suiClient(), token.symbol) + const change = await dryRunChange(transaction, token.id, account, 'Checking the deposit') + if (change !== -BigInt(amount)) { + throw new ChainActionRefused( + "I couldn't confirm what this deposit would do, so I stopped. Nothing was signed or sent. Try again shortly." + ) + } + + return { + venueId: 'suilend', + venueName: 'Suilend', + kind: 'supply', + summary: `Supply ${request.amount} USDC to Suilend's main market.`, + pay: { amount: request.amount, symbol: token.symbol }, + apy: suilendApy(reserve), + notes: [ + ...sharedNotes(reserve), + existing === undefined + ? 'This opens a Suilend position, held by a position ticket sent to your wallet. Whoever holds that ticket controls the deposit, so keep it.' + : 'This adds to your existing Suilend position. Whoever holds its position ticket controls the deposit, so keep it.', + 'You can withdraw here at any time while this position has no loan, as long as the pool has the cash.', + ], + signing: { family: 'sui', transaction }, + } +} + +async function prepareWithdraw( + reader: SuilendReader, + request: ChainActionRequest, + account: string +): Promise { + const token = checkedRequest(request, account) + const amount = BigInt(toBaseUnits(request.amount, token.decimals)) + + const holding = (await reader.positions(account)).filter((p) => p.ctokens > BigInt(0)) + if (holding.length === 0) { + throw new ChainActionRefused('This wallet has no USDC in Suilend. Nothing was signed or sent.') + } + const free = holding.filter((p) => !p.hasBorrows) + if (free.length === 0) { + // With a loan the contract wants fresh prices, which public Pyth access no longer gives without a key. + throw new ChainActionRefused( + 'Your USDC in Suilend backs a loan, and I only withdraw from positions with no loan. Repay or withdraw at suilend.fi. Nothing was signed or sent.' + ) + } + const position = free.reduce((best, p) => (p.ctokens > best.ctokens ? p : best)) + + const reserve = await reader.reserve() + const ctokens = ctokensForAmount(amount, reserve) + if (ctokens > position.ctokens) { + const held = displayAmount( + amountForCtokens(position.ctokens, reserve).toString(), + token.decimals + ) + throw new ChainActionRefused( + `You have about ${held} USDC in Suilend, which is less than ${request.amount}. Nothing was signed or sent. Try ${held}.` + ) + } + + const tx = new Transaction() + tx.setSender(account) + const [out] = tx.moveCall({ + target: fn('withdraw_ctokens'), + typeArguments: USDC_TYPES, + arguments: [ + tx.object(SUILEND_MARKET), + tx.pure.u64(SUILEND_USDC_RESERVE), + tx.object(position.capId), + tx.object.clock(), + tx.pure.u64(ctokens), + ], + }) + const [exemption] = tx.moveCall({ + target: moveTarget('0x1', 'option', 'none'), + typeArguments: [EXEMPTION_TYPE], + }) + if (out === undefined || exemption === undefined) throw new Error('A step returned nothing.') + const [liquidityRequest] = tx.moveCall({ + target: fn('redeem_ctokens_and_withdraw_liquidity_request'), + typeArguments: USDC_TYPES, + arguments: [ + tx.object(SUILEND_MARKET), + tx.pure.u64(SUILEND_USDC_RESERVE), + tx.object.clock(), + out, + exemption, + ], + }) + if (liquidityRequest === undefined) throw new Error('Redeeming returned nothing.') + const [usdc] = tx.moveCall({ + target: fn('fulfill_liquidity_request'), + typeArguments: USDC_TYPES, + arguments: [tx.object(SUILEND_MARKET), tx.pure.u64(SUILEND_USDC_RESERVE), liquidityRequest], + }) + if (usdc === undefined) throw new Error('Withdrawing returned nothing.') + tx.transferObjects([usdc], account) + + const transaction = await buildUnsigned(tx, suiClient(), token.symbol) + const received = await dryRunChange(transaction, token.id, account, 'Checking the withdrawal') + if (received === undefined || !gaveWhatWasAsked(received, amount)) { + throw new ChainActionRefused( + "I couldn't confirm this withdrawal would return what you asked, so I stopped. Nothing was signed or sent. Try again shortly." + ) + } + const receive = displayAmount(received.toString(), token.decimals) + + return { + venueId: 'suilend', + venueName: 'Suilend', + kind: 'withdraw', + summary: `Withdraw about ${receive} USDC from Suilend's main market to your wallet.`, + pay: { amount: request.amount, symbol: token.symbol }, + receive: { amount: receive, symbol: token.symbol, approximate: true }, + notes: [ + 'Suilend limits how much can leave the whole market each hour, so a large withdrawal may need to wait.', + 'If the pool has little cash because most of it is lent out, this can fail until borrowers repay.', + 'This does not claim reward tokens. Any you have earned stay claimable at suilend.fi.', + ], + signing: { family: 'sui', transaction }, + } +} + +export function createSuilendVenue(reader: SuilendReader = chainReader): ChainVenue { + return { + id: 'suilend', + name: 'Suilend', + kinds: ['supply', 'withdraw'], + assertCanFollow: takesOnly('Suilend', ['USDC']), + quote: (request, account) => quote(reader, request, account), + prepare: (request, account) => + request.kind === 'withdraw' + ? prepareWithdraw(reader, request, account) + : prepareSupply(reader, request, account), + assertSafe: (payload: SigningPayload, account: string) => { + assertPtbSafe(payload, account, SUILEND_POLICY) + assertCallsPinned(payload, PINS, 'Suilend') + }, + } +} + +export const suilendVenue = createSuilendVenue() diff --git a/apps/dapp/lib/chains/sui/tokens.ts b/apps/dapp/lib/chains/sui/tokens.ts index 1368491c..cd285861 100644 --- a/apps/dapp/lib/chains/sui/tokens.ts +++ b/apps/dapp/lib/chains/sui/tokens.ts @@ -26,6 +26,24 @@ export const SUI_TOKENS: readonly ChainToken[] = [ decimals: 6, name: 'Tether (Sui Bridge)', }, + { + symbol: 'WAL', + id: '0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL', + decimals: 9, + name: 'Walrus', + }, + { + symbol: 'sSUI', + id: '0x83556891f4a0f233ce7b05cfe7f957d4020492a34f5405b2cb9377d060bef4bf::spring_sui::SPRING_SUI', + decimals: 9, + name: 'Spring SUI', + }, + { + symbol: 'afSUI', + id: '0xf325ce1300e8dac124071d3152c5c5ee6174914f8bc2161e88329cf579246efc::afsui::AFSUI', + decimals: 9, + name: 'Aftermath Staked SUI', + }, ] export const STABLECOIN_SYMBOLS: ReadonlySet = new Set(['USDC', 'USDsui', 'USDT']) diff --git a/apps/dapp/lib/chains/sui/venues.ts b/apps/dapp/lib/chains/sui/venues.ts index 0982fc41..2761f64b 100644 --- a/apps/dapp/lib/chains/sui/venues.ts +++ b/apps/dapp/lib/chains/sui/venues.ts @@ -1,5 +1,12 @@ import type { Venue } from '@intent/types' +import { aftermathListing } from './aftermath-listing' +import { deepbookListing } from './deepbook-listing' +import { momentumListing } from './momentum-listing' +import { scallopListing } from './scallop-listing' +import { suilendListing } from './suilend-listing' +import { walrusListing } from './walrus-listing' + function listed( id: string, name: string, @@ -27,7 +34,7 @@ export const suiVenues: Venue[] = [ url: 'https://app.cetus.zone', bestFor: "Swaps at the best route across Sui's main pools", integration: 'executes', - capability: 'Swaps SUI, USDC, USDsui and USDT', + capability: 'Swaps SUI, USDC, USDsui, USDT, WAL and the liquid staking tokens sSUI and afSUI', }, // Listed, as Lulo is on Solana: whether this runs depends on a secret this client-safe file can't read. listed( @@ -46,7 +53,7 @@ export const suiVenues: Venue[] = [ url: 'https://app.naviprotocol.io', bestFor: 'Earning on USDC, at a variable rate that can move quickly', integration: 'executes', - capability: 'Supplies USDC and shows the current rate', + capability: 'Supplies and withdraws USDC and shows the current rate', }, listed( 'bluefin-aggregator', @@ -55,13 +62,7 @@ export const suiVenues: Venue[] = [ 'https://trade.bluefin.io/swap', 'Swaps with quotes from market makers, formerly 7K' ), - listed( - 'suilend', - 'Suilend', - 'lending', - 'https://suilend.fi', - 'Lending in the largest market on Sui, under new ownership since June 2026' - ), + suilendListing, listed( 'current-finance', 'Current Finance', @@ -90,13 +91,9 @@ export const suiVenues: Venue[] = [ 'https://www.bucketprotocol.io', 'USDB savings, though the current rate may not compete' ), - listed( - 'deepbook', - 'DeepBook', - 'orderbook', - 'https://www.deepbook.tech', - "Sui's on-chain order book, for limit orders" - ), + deepbookListing, + momentumListing, + walrusListing, { id: 'springsui', family: 'sui', @@ -106,27 +103,8 @@ export const suiVenues: Venue[] = [ url: 'https://www.springsui.com', bestFor: 'Liquid staking with the largest pool on Sui', integration: 'executes', - capability: 'Stakes SUI for sSUI', + capability: 'Stakes SUI for sSUI, and unstakes sSUI right away for a small fee', }, - listed( - 'haedal', - 'Haedal', - 'staking', - 'https://www.haedal.xyz', - 'Liquid staking with haSUI. Its vaults lost about $915K in June 2026; staking reportedly was not affected' - ), - listed( - 'aftermath', - 'Aftermath', - 'aggregator', - 'https://aftermath.finance', - 'Swaps and afSUI staking. Its perps lost about $1.14M in April 2026' - ), - listed( - 'scallop', - 'Scallop', - 'lending', - 'https://scallop.io', - 'Lending with a small USDC pool. A retired contract lost about $142K in April 2026' - ), + aftermathListing, + scallopListing, ] diff --git a/apps/dapp/lib/chains/sui/walrus-listing.ts b/apps/dapp/lib/chains/sui/walrus-listing.ts new file mode 100644 index 00000000..28c6f887 --- /dev/null +++ b/apps/dapp/lib/chains/sui/walrus-listing.ts @@ -0,0 +1,13 @@ +import type { Venue } from '@intent/types' + +export const walrusListing: Venue = { + id: 'walrus', + family: 'sui', + name: 'Walrus', + category: 'staking', + chains: ['Sui'], + url: 'https://docs.wal.app', + bestFor: 'Earning on WAL with a storage node. Getting out takes one to two 14-day epochs', + integration: 'executes', + capability: 'Stakes WAL with a storage node and unstakes it', +} diff --git a/apps/dapp/lib/chains/sui/walrus.ts b/apps/dapp/lib/chains/sui/walrus.ts new file mode 100644 index 00000000..9f0b2d5a --- /dev/null +++ b/apps/dapp/lib/chains/sui/walrus.ts @@ -0,0 +1,760 @@ +import { bcs } from '@mysten/sui/bcs' +import { coinWithBalance, Transaction } from '@mysten/sui/transactions' +import { + deriveDynamicFieldID, + fromBase64, + normalizeStructTag, + normalizeSuiAddress, +} from '@mysten/sui/utils' + +import { + ChainActionRefused, + takesOnly, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type VenueQuote, +} from '../types' +import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' +import { assertPtbSafe, moveTarget, type ParsedCall, type PtbPolicy, type ReadPure } from './safety' +import { displayAmount } from './tokens' + +/** + * The shared staking object, as `staking_object` in MystenLabs/walrus + * setup/client_config_mainnet.yaml and on docs.wal.app/docs/usage/networks. On chain it is + * shared, with this initial version and the type below. + */ +export const WALRUS_STAKING = '0x10b9d30c28448939ce6c4d6c6e0ffce4a7f8a4ada8248bdad09ef8b70e4a3904' +const WALRUS_STAKING_SHARED_VERSION = '317862159' + +/** + * The live package: `published-at` in MystenLabs/walrus mainnet-contracts/walrus/Published.toml, + * version 4. The staking object's own `package_id` names the same one. Types keep the original id + * from that file. `prepare` reads the staking object first, so an upgrade blocks this venue. + */ +export const WALRUS_PACKAGE = '0xa9ac4281c72b0ed3277c08e821ab77eccea7b187f2b402ffa3b33f548d4bbf1e' +export const WALRUS_ORIGINAL_PACKAGE = + '0xfdc88f7d7cf30afab2f82e8380d11ee8f70efb90e863d1de8616fae1bb09ea77' +export const WALRUS_VERSION = '4' + +/** + * The WAL package on docs.wal.app/docs/usage/networks. Its coin metadata on chain reads symbol WAL, + * name "WAL Token", 9 decimals, and `stake_with_pool` takes a `Coin` of exactly this type. + */ +export const WAL_TYPE = + '0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL' +const WAL_DECIMALS = 9 + +export const WALRUS_TOKENS: ChainToken[] = [ + { symbol: 'WAL', id: WAL_TYPE, decimals: WAL_DECIMALS, name: 'Walrus' }, +] + +export const STAKED_WAL_TYPE = `${WALRUS_ORIGINAL_PACKAGE}::staked_wal::StakedWal` + +const STAKE = moveTarget(WALRUS_PACKAGE, 'staking', 'stake_with_pool') +const REQUEST_WITHDRAW = moveTarget(WALRUS_PACKAGE, 'staking', 'request_withdraw_stake') +const WITHDRAW = moveTarget(WALRUS_PACKAGE, 'staking', 'withdraw_stake') +const SPLIT = moveTarget(WALRUS_PACKAGE, 'staked_wal', 'split') + +/** Arguments each call takes in a transaction, signatures read on chain. `TxContext` isn't one. */ +const ARGUMENT_COUNTS: ReadonlyMap = new Map([ + [STAKE, 3], + [REQUEST_WITHDRAW, 2], + [WITHDRAW, 2], + [SPLIT, 2], +]) + +/** `MIN_STAKING_THRESHOLD` in staked_wal.move. It also binds both sides of a split. */ +const MIN_STAKE = BigInt('1000000000') + +/** 0.5% of the network's 1,000 shards, so the pick isn't a node about to leave the committee. */ +const MIN_NODE_SHARDS = 5 +const BPS_PER_PERCENT = 100 +/** A node's name is whatever its operator typed, and it goes on the card. */ +const SAFE_NODE_NAME = /^[\p{L}\p{N} .,|()&'_-]{1,64}$/u +const DAY_MS = 86_400_000 +const READ_TIMEOUT_MS = 10_000 +const DRY_RUN_TIMEOUT_MS = 15_000 +const OWNED_PAGE_SIZE = 50 +const OWNED_MAX_PAGES = 10 + +const NOTHING_SENT = 'Nothing was signed or sent.' + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} ${NOTHING_SENT}`) +} + +function unreadable(): never { + return refuse("I couldn't read Walrus's staking state, so I stopped. Try again shortly.") +} + +function checkCall(call: ParsedCall, read: ReadPure): void { + const expected = ARGUMENT_COUNTS.get(call.target) + if (expected === undefined) return + if (call.typeArguments.length > 0 || call.arguments.length !== expected) { + refuse('This transaction does not match the Walrus call it names.') + } + try { + if (call.target === STAKE) { + const node = read(call.arguments[2]) + if (node?.length !== 32) refuse('This stake does not name a storage node.') + } + if (call.target === SPLIT) { + const amount = read(call.arguments[1]) + if (amount?.length !== 8 || BigInt(bcs.u64().parse(amount)) === BigInt(0)) { + refuse('This split does not name an amount.') + } + } + } catch (e) { + if (e instanceof ChainActionRefused) throw e + refuse('This transaction does not match the Walrus call it names.') + } +} + +/** Only the exact staking calls. `join`, the node operator calls and anything else are refused. */ +export const WALRUS_POLICY: PtbPolicy = { + venueName: 'Walrus', + allowedCalls: new Set(ARGUMENT_COUNTS.keys()), + checkCall, +} + +/** + * `checkCall` sees only literal arguments, so the staking object is checked here, on the bytes. + * Everything else that can move funds, a StakedWal included, has to pass `assertPtbSafe`: a + * transfer to anyone but the account is refused and `public_transfer` isn't an allowed call. + */ +export function assertWalrusSafe(payload: SigningPayload, account: string): void { + assertPtbSafe(payload, account, WALRUS_POLICY) + if (payload.family !== 'sui') return + const data = Transaction.from(fromBase64(payload.transaction)).getData() + const staking = normalizeSuiAddress(WALRUS_STAKING) + for (const command of data.commands) { + if (command.$kind !== 'MoveCall' || command.MoveCall.module !== 'staking') continue + const first = command.MoveCall.arguments[0] + const input = first?.$kind === 'Input' ? data.inputs[first.Input] : undefined + const object = input?.$kind === 'Object' ? input.Object : undefined + const id = object?.$kind === 'SharedObject' ? object.SharedObject.objectId : undefined + if (id === undefined || normalizeSuiAddress(id) !== staking) { + refuse('This transaction uses a staking object other than the Walrus one.') + } + } +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null + ? (value as Record) + : undefined +} + +function wholeNumber(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isSafeInteger(value) ? value : undefined + return typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : undefined +} + +function variantOf(value: unknown): string | undefined { + const variant = record(value)?.['@variant'] + return typeof variant === 'string' ? variant : undefined +} + +/** A `VecMap` as Sui's JSON gives it, wrapped in the single field of a Move tuple struct. */ +function entriesOf(value: unknown): Record[] | undefined { + const contents = record(record(value)?.['pos0'])?.['contents'] + if (!Array.isArray(contents)) return undefined + const entries = contents.map(record) + return entries.every((e) => e !== undefined) ? (entries as Record[]) : undefined +} + +function committeeFrom(value: unknown): ReadonlyMap | undefined { + const entries = entriesOf(value) + if (entries === undefined) return undefined + const committee = new Map() + for (const entry of entries) { + const shards = entry['value'] + if (typeof entry['key'] !== 'string' || !Array.isArray(shards)) return undefined + committee.set(normalizeSuiAddress(entry['key']), shards.length) + } + return committee +} + +export interface StakingState { + epoch: number + epochDurationMs: number + firstEpochStartMs: number + shardCount: number + /** Node id to shards held. */ + committee: ReadonlyMap + /** Chosen halfway through an epoch, and absent until then. */ + nextCommittee: ReadonlyMap | undefined +} + +export function stakingStateFrom(json: unknown): StakingState { + const value = record(record(json)?.['value']) + const epoch = wholeNumber(value?.['epoch']) + const epochDurationMs = wholeNumber(value?.['epoch_duration']) + const firstEpochStartMs = wholeNumber(value?.['first_epoch_start']) + const shardCount = wholeNumber(value?.['n_shards']) + const committee = committeeFrom(value?.['committee']) + const next = value?.['next_committee'] + const nextCommittee = next === null ? undefined : committeeFrom(next) + if ( + epoch === undefined || + epochDurationMs === undefined || + epochDurationMs <= 0 || + firstEpochStartMs === undefined || + shardCount === undefined || + committee === undefined || + (next !== null && nextCommittee === undefined) + ) { + return unreadable() + } + return { epoch, epochDurationMs, firstEpochStartMs, shardCount, committee, nextCommittee } +} + +function epochDate(state: StakingState, epoch: number): string { + const startsAt = state.firstEpochStartMs + (epoch - 1) * state.epochDurationMs + return new Date(startsAt).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }) +} + +export interface StorageNode { + id: string + name: string + shards: number + commissionBps: number +} + +/** What the pool's own object says, or nothing if any of it can't be trusted. */ +function nodeFrom( + json: unknown, + state: StakingState +): (StorageNode & { worstCommissionBps: number }) | undefined { + const pool = record(json) + const rawId = pool?.['id'] + if (typeof rawId !== 'string' || variantOf(pool?.['state']) !== 'Active') return undefined + const id = normalizeSuiAddress(rawId) + const name = record(pool?.['node_info'])?.['name'] + const shards = state.committee.get(id) + const commissionBps = wholeNumber(pool?.['commission_rate']) + const pending = entriesOf(pool?.['pending_commission_rate']) + if (typeof name !== 'string' || !SAFE_NODE_NAME.test(name) || shards === undefined) + return undefined + if (commissionBps === undefined || commissionBps > 100 * BPS_PER_PERCENT) return undefined + if (pending === undefined) return undefined + const rates = pending.map((p) => wholeNumber(p['value'])) + if (rates.some((r) => r === undefined)) return undefined + const worstCommissionBps = Math.max(commissionBps, ...(rates as number[])) + return { id, name, shards, commissionBps, worstCommissionBps } +} + +/** + * The rule: among active nodes in this epoch's committee, and the next one once chosen, holding at + * least 5 shards, the lowest commission counting any rise already announced. Then the most shards, + * then the lowest id, so the same chain state always gives the same node. + */ +export function pickNode(pools: readonly unknown[], state: StakingState): StorageNode { + const candidates = pools + .map((pool) => nodeFrom(pool, state)) + .filter((n) => n !== undefined) + .filter( + (n) => + n.shards >= MIN_NODE_SHARDS && + (state.nextCommittee === undefined || state.nextCommittee.has(n.id)) + ) + .sort( + (a, b) => + a.worstCommissionBps - b.worstCommissionBps || + b.shards - a.shards || + a.id.localeCompare(b.id) + ) + const best = candidates[0] + if (best === undefined) { + return refuse('No Walrus storage node passed my checks right now. Try again shortly.') + } + return { id: best.id, name: best.name, shards: best.shards, commissionBps: best.commissionBps } +} + +export interface Position { + objectId: string + version: string + digest: string + nodeId: string + principal: bigint + activationEpoch: number + /** Set once a withdrawal has started. */ + withdrawEpoch: number | undefined +} + +function positionFrom(object: { + objectId: string + version: string + digest: string + type: string + json?: unknown +}): Position { + const json = record(object.json) + const nodeId = json?.['node_id'] + const principal = json?.['principal'] + const activationEpoch = wholeNumber(json?.['activation_epoch']) + const stateName = variantOf(json?.['state']) + const withdrawEpoch = wholeNumber(record(json?.['state'])?.['withdraw_epoch']) + if ( + normalizeStructTag(object.type) !== normalizeStructTag(STAKED_WAL_TYPE) || + typeof nodeId !== 'string' || + typeof principal !== 'string' || + !/^\d+$/.test(principal) || + activationEpoch === undefined || + (stateName !== 'Staked' && stateName !== 'Withdrawing') || + (stateName === 'Withdrawing' && withdrawEpoch === undefined) + ) { + return unreadable() + } + return { + objectId: object.objectId, + version: object.version, + digest: object.digest, + nodeId: normalizeSuiAddress(nodeId), + principal: BigInt(principal), + activationEpoch, + withdrawEpoch: stateName === 'Withdrawing' ? withdrawEpoch : undefined, + } +} + +type Phase = + | { name: 'ready' } + | { name: 'waiting'; epoch: number } + | { name: 'active'; withdrawEpoch: number } + +/** `can_withdraw_early`, `request_withdraw_stake` and `withdraw_stake`, from the Move source. */ +export function phaseOf(position: Position, state: StakingState): Phase { + if (position.withdrawEpoch !== undefined) { + return position.withdrawEpoch <= state.epoch + ? { name: 'ready' } + : { name: 'waiting', epoch: position.withdrawEpoch } + } + const inNext = state.nextCommittee?.has(position.nodeId) === true + const inCurrent = state.committee.has(position.nodeId) + const activation = position.activationEpoch + const early = activation === state.epoch + 2 || (activation === state.epoch + 1 && !inNext) + if (early || (!inCurrent && !inNext)) return { name: 'ready' } + if (activation === state.epoch + 1) return { name: 'active', withdrawEpoch: activation + 1 } + return { name: 'active', withdrawEpoch: inNext ? state.epoch + 2 : state.epoch + 1 } +} + +function wal(base: bigint): string { + return displayAmount(base.toString(), WAL_DECIMALS, WAL_DECIMALS) +} + +interface Piece { + position: Position + /** Set when only part of the position is taken. */ + split: bigint | undefined +} + +function sum(positions: readonly Position[]): bigint { + return positions.reduce((total, p) => total + p.principal, BigInt(0)) +} + +/** Largest first, so the fewest objects move and the last one is split as little as possible. */ +function takePieces(positions: readonly Position[], target: bigint): Piece[] { + const pieces: Piece[] = [] + let remaining = target + const ordered = [...positions].sort((a, b) => + a.principal === b.principal + ? a.objectId.localeCompare(b.objectId) + : a.principal > b.principal + ? -1 + : 1 + ) + for (const position of ordered) { + if (remaining === BigInt(0)) break + if (position.principal <= remaining) { + pieces.push({ position, split: undefined }) + remaining -= position.principal + continue + } + if (remaining < MIN_STAKE || position.principal - remaining < MIN_STAKE) { + refuse( + `Walrus can't split a stake so that either side is under 1 WAL, and ${wal(target)} WAL would need that. Try a different amount.` + ) + } + pieces.push({ position, split: remaining }) + remaining = BigInt(0) + } + return pieces +} + +export type UnstakePlan = + | { step: 'collect'; pieces: Piece[]; total: bigint } + | { step: 'start'; pieces: Piece[]; total: bigint; waiting: bigint; collectEpoch: number } + +/** + * An unstake takes two transactions, a wait apart. So what a request means depends on what the + * person already holds: asking again after the wait must collect, and asking again during it must + * not start a second withdrawal of the same money. + */ +export function planUnstake( + positions: readonly Position[], + state: StakingState, + amount: bigint +): UnstakePlan { + const ready: Position[] = [] + const waiting: Position[] = [] + const active: Position[] = [] + const collectEpochs = new Map() + for (const position of positions) { + const phase = phaseOf(position, state) + if (phase.name === 'ready') ready.push(position) + if (phase.name === 'waiting') { + waiting.push(position) + collectEpochs.set(position, phase.epoch) + } + if (phase.name === 'active') { + active.push(position) + collectEpochs.set(position, phase.withdrawEpoch) + } + } + const latestEpoch = (held: readonly Position[]): number => + Math.max(...held.map((p) => collectEpochs.get(p) ?? state.epoch)) + + const readyTotal = sum(ready) + const waitingTotal = sum(waiting) + const activeTotal = sum(active) + + if (readyTotal + waitingTotal + activeTotal === BigInt(0)) { + return refuse('This wallet has no WAL staked with Walrus.') + } + if (amount <= readyTotal) { + return { step: 'collect', pieces: takePieces(ready, amount), total: amount } + } + if (readyTotal > BigInt(0)) { + return refuse( + `${wal(readyTotal)} WAL is ready to collect now, which is less than you asked for. Try "unstake ${wal(readyTotal)} WAL" first.` + ) + } + if (amount <= waitingTotal) { + const epoch = latestEpoch(waiting) + return refuse( + `${wal(waitingTotal)} WAL is already on its way out. You can collect it from epoch ${epoch}, about ${epochDate(state, epoch)}.` + ) + } + const toStart = amount - waitingTotal + if (toStart > activeTotal) { + return refuse( + `You have ${wal(activeTotal + waitingTotal)} WAL staked with Walrus, which is less than ${wal(amount)}.` + ) + } + const pieces = takePieces(active, toStart) + return { + step: 'start', + pieces, + total: toStart, + waiting: waitingTotal, + collectEpoch: latestEpoch(pieces.map((p) => p.position)), + } +} + +/** How much WAL a dry run paid this account, in base units. */ +export function walPaid( + changes: readonly { coinType: string; address: string; amount: string }[] | undefined, + account: string +): string | undefined { + const paid = changes?.find( + (c) => + normalizeStructTag(c.coinType) === normalizeStructTag(WAL_TYPE) && + normalizeSuiAddress(c.address) === normalizeSuiAddress(account) + ) + return paid !== undefined && /^\d+$/.test(paid.amount) && BigInt(paid.amount) > BigInt(0) + ? paid.amount + : undefined +} + +export interface WalrusDeps { + client: () => ReturnType + build: typeof buildUnsigned +} + +type Client = ReturnType + +/** The upgrade check comes first, so a new package refuses in plain words, not a failed dry run. */ +async function readStakingState(client: Client): Promise { + const { object } = await withTimeout( + client.core.getObject({ objectId: WALRUS_STAKING, include: { json: true } }), + READ_TIMEOUT_MS, + "Reading Walrus's staking object" + ) + const staking = record(object.json) + const packageId = staking?.['package_id'] + if ( + typeof packageId !== 'string' || + normalizeSuiAddress(packageId) !== normalizeSuiAddress(WALRUS_PACKAGE) || + staking?.['version'] !== WALRUS_VERSION || + staking?.['new_package_id'] !== null + ) { + return refuse( + 'Walrus has upgraded its contracts, or is about to. This stays blocked until the new ones are checked.' + ) + } + const inner = await withTimeout( + client.core.getObject({ + objectId: deriveDynamicFieldID( + WALRUS_STAKING, + 'u64', + bcs.u64().serialize(WALRUS_VERSION).toBytes() + ), + include: { json: true }, + }), + READ_TIMEOUT_MS, + "Reading Walrus's epoch" + ) + return stakingStateFrom(inner.object.json) +} + +async function readNode(client: Client, state: StakingState): Promise { + const { objects } = await withTimeout( + client.core.getObjects({ objectIds: [...state.committee.keys()], include: { json: true } }), + READ_TIMEOUT_MS, + "Reading Walrus's storage nodes" + ) + return pickNode( + objects.flatMap((o) => (o instanceof Error ? [] : [o.json])), + state + ) +} + +async function readPositions(client: Client, account: string): Promise { + const positions: Position[] = [] + let cursor: string | null = null + for (let page = 0; page < OWNED_MAX_PAGES; page += 1) { + const listed: Awaited> = await withTimeout( + client.core.listOwnedObjects({ + owner: account, + type: STAKED_WAL_TYPE, + limit: OWNED_PAGE_SIZE, + cursor, + include: { json: true }, + }), + READ_TIMEOUT_MS, + 'Reading your staked WAL' + ) + positions.push(...listed.objects.map(positionFrom)) + if (!listed.hasNextPage) return positions + cursor = listed.cursor + } + return refuse('This wallet holds more staked WAL positions than I can read at once.') +} + +function assertTakesWal(tokenIn: string): void { + if (tokenIn.trim().toUpperCase() !== 'WAL') { + refuse(`Walrus takes WAL, not ${tokenIn}. Try "stake 10 WAL with Walrus".`) + } +} + +function stakingArgument(tx: Transaction) { + return tx.sharedObjectRef({ + objectId: WALRUS_STAKING, + initialSharedVersion: WALRUS_STAKING_SHARED_VERSION, + mutable: true, + }) +} + +function positionArgument(tx: Transaction, position: Position) { + return tx.objectRef({ + objectId: position.objectId, + version: position.version, + digest: position.digest, + }) +} + +function splitOff(tx: Transaction, position: Position, amount: bigint) { + const [part] = tx.moveCall({ + target: SPLIT, + arguments: [positionArgument(tx, position), tx.pure.u64(amount)], + }) + if (part === undefined) throw new Error('Splitting a stake returned nothing.') + return part +} + +function buildStake(amount: string, nodeId: string, account: string): Transaction { + const tx = new Transaction() + tx.setSender(account) + const coin = tx.add( + coinWithBalance({ type: WAL_TYPE, balance: BigInt(amount), useGasCoin: false }) + ) + const [staked] = tx.moveCall({ + target: STAKE, + arguments: [stakingArgument(tx), coin, tx.pure.id(nodeId)], + }) + if (staked === undefined) throw new Error('Staking returned nothing.') + tx.transferObjects([staked], account) + return tx +} + +function buildCollect(pieces: readonly Piece[], account: string): Transaction { + const tx = new Transaction() + tx.setSender(account) + const coins = pieces.map(({ position, split }) => { + const stake = + split === undefined ? positionArgument(tx, position) : splitOff(tx, position, split) + const [coin] = tx.moveCall({ target: WITHDRAW, arguments: [stakingArgument(tx), stake] }) + if (coin === undefined) throw new Error('Withdrawing returned nothing.') + return coin + }) + tx.transferObjects(coins, account) + return tx +} + +function buildStart(pieces: readonly Piece[], account: string): Transaction { + const tx = new Transaction() + tx.setSender(account) + for (const { position, split } of pieces) { + const stake = + split === undefined ? positionArgument(tx, position) : splitOff(tx, position, split) + tx.moveCall({ target: REQUEST_WITHDRAW, arguments: [stakingArgument(tx), stake] }) + // A whole position stays where it is. A part split off is new, so it must go to its owner. + if (split !== undefined) tx.transferObjects([stake], account) + } + return tx +} + +const UPGRADE_NOTE = + "Your WAL sits in Walrus's contracts, which can fail. They can be upgraded by a quorum of storage nodes or by one emergency upgrade key, and an upgrade can pause this route until the new contracts are checked." + +export function createWalrusVenue( + deps: WalrusDeps = { client: () => suiClient(), build: buildUnsigned } +): ChainVenue { + async function checkedStake(request: ChainActionRequest, account: string) { + assertSuiAccount(account) + assertTakesWal(request.tokenIn) + const amount = toBaseUnits(request.amount, WAL_DECIMALS) + if (BigInt(amount) < MIN_STAKE) refuse('Walrus takes a stake of at least 1 WAL.') + const client = deps.client() + const state = await readStakingState(client) + const node = await readNode(client, state) + return { amount, client, state, node } + } + + async function prepareStake( + request: ChainActionRequest, + account: string + ): Promise { + const { amount, client, state, node } = await checkedStake(request, account) + const transaction = await deps.build(buildStake(amount, node.id, account), client, 'WAL') + const activation = state.nextCommittee === undefined ? state.epoch + 1 : state.epoch + 2 + const epochDays = state.epochDurationMs / DAY_MS + return { + venueId: 'walrus', + venueName: 'Walrus', + kind: 'stake', + summary: `Stake ${request.amount} WAL with ${node.name}, a Walrus storage node.`, + pay: { amount: request.amount, symbol: 'WAL' }, + receive: { amount: request.amount, symbol: 'StakedWAL', approximate: false }, + notes: [ + `Your stake goes to ${node.name}, a node in the current Walrus committee holding ${node.shards} of ${state.shardCount} shards. Its operator chose that name, and its id is ${node.id}. I pick the committee node with at least ${MIN_NODE_SHARDS} shards that keeps the lowest commission.`, + `${node.name} keeps ${node.commissionBps / BPS_PER_PERCENT}% of your rewards as commission today. A node can raise that with at least one epoch's notice.`, + 'Rewards are not guaranteed. I am not showing a rate because I do not read one from Walrus here.', + `It starts earning at epoch ${activation}, about ${epochDate(state, activation)}. An epoch lasts ${epochDays} days.`, + `You get a StakedWAL object in your wallet, not a coin, so it can't be swapped. Whoever holds it can collect the WAL, so don't send it to anyone.`, + `To get out, ask to unstake. That starts a withdrawal, which takes one to two epochs (up to ${epochDays * 2} days) and can't be cancelled. Then ask again to collect your WAL and rewards.`, + UPGRADE_NOTE, + ], + signing: { family: 'sui', transaction }, + } + } + + async function prepareUnstake( + request: ChainActionRequest, + account: string + ): Promise { + assertSuiAccount(account) + assertTakesWal(request.tokenIn) + const amount = BigInt(toBaseUnits(request.amount, WAL_DECIMALS)) + const client = deps.client() + const state = await readStakingState(client) + const plan = planUnstake(await readPositions(client, account), state, amount) + + if (plan.step === 'collect') { + const transaction = await deps.build(buildCollect(plan.pieces, account), client, 'WAL') + const simulated = await withTimeout( + client.core.simulateTransaction({ + transaction: fromBase64(transaction), + include: { balanceChanges: true }, + }), + DRY_RUN_TIMEOUT_MS, + 'Checking the withdrawal' + ) + const received = walPaid(simulated.Transaction?.balanceChanges, account) + if (received === undefined) { + return refuse( + "I couldn't confirm how much WAL this would return, so I stopped. Try again shortly." + ) + } + return { + venueId: 'walrus', + venueName: 'Walrus', + kind: 'unstake', + summary: `Collect ${wal(plan.total)} WAL from Walrus, with any rewards it earned.`, + pay: { amount: wal(plan.total), symbol: 'WAL' }, + receive: { + amount: displayAmount(received, WAL_DECIMALS), + symbol: 'WAL', + approximate: true, + }, + notes: [ + 'This takes your WAL out now, with no further wait.', + 'Stake taken out before it started earning comes back with no rewards.', + UPGRADE_NOTE, + ], + signing: { family: 'sui', transaction }, + } + } + + const transaction = await deps.build(buildStart(plan.pieces, account), client, 'WAL') + const date = epochDate(state, plan.collectEpoch) + return { + venueId: 'walrus', + venueName: 'Walrus', + kind: 'unstake', + summary: `Start unstaking ${wal(plan.total)} WAL from Walrus. You can collect it from epoch ${plan.collectEpoch}, about ${date}.`, + pay: { amount: wal(plan.total), symbol: 'WAL' }, + notes: [ + `This only starts the withdrawal. No WAL arrives yet, and it can't be cancelled. It earns until epoch ${plan.collectEpoch}.`, + `From epoch ${plan.collectEpoch}, about ${date}, ask to unstake ${wal(plan.total)} WAL again to collect it with its rewards. An epoch lasts ${state.epochDurationMs / DAY_MS} days. Dates can slip a little, since an epoch ends when the network moves on.`, + ...(plan.waiting > BigInt(0) + ? [ + `${wal(plan.waiting)} WAL is already on its way out, so this starts only the other ${wal(plan.total)}.`, + ] + : []), + UPGRADE_NOTE, + ], + signing: { family: 'sui', transaction }, + } + } + + /** No apyPercent: no rate is read from Walrus here, and a rate is never invented. */ + async function quote(request: ChainActionRequest, account: string): Promise { + const { amount } = await checkedStake(request, account) + return { receive: { amount: displayAmount(amount, WAL_DECIMALS), symbol: 'StakedWAL' } } + } + + return { + id: 'walrus', + name: 'Walrus', + kinds: ['stake', 'unstake'], + assertCanFollow: takesOnly('Walrus', ['WAL']), + quote, + prepare: (request, account) => + request.kind === 'unstake' + ? prepareUnstake(request, account) + : prepareStake(request, account), + assertSafe: (payload: SigningPayload, account: string) => assertWalrusSafe(payload, account), + } +} + +export const walrusVenue: ChainVenue = createWalrusVenue() diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index ce50abee..29745ac2 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -8,7 +8,18 @@ import type { ChainSlug } from '@intent/types' export type ChainWithActions = Extract -export type ChainActionKind = 'swap' | 'supply' | 'stake' | 'send' +export type ChainActionKind = + | 'swap' + | 'supply' + | 'stake' + | 'send' + | 'withdraw' + | 'unstake' + | 'borrow' + | 'repay' + | 'stream' + | 'invoice' + | 'pay_invoice' /** What the user asked for. Amounts are decimal text in display units. */ export interface ChainActionRequest { @@ -24,8 +35,17 @@ export interface ChainActionRequest { tokenOut?: string | undefined /** A venue the user named. Absent means the chain's default for this kind. */ venue?: string | undefined - /** Sends only, exactly as typed. Where the money goes, so it is checked and never changed. */ + /** + * Who gets paid or billed, exactly as typed: sends, streams and invoices. It decides where the + * money goes, so it is checked and never changed. + */ recipient?: string | undefined + /** Streams only: whole days the amount is paid over. */ + durationDays?: string | undefined + /** Invoices only: when it is due, as YYYY-MM-DD. */ + dueDate?: string | undefined + /** Paying an invoice only: the invoice's id on chain, as digits. */ + invoiceId?: string | undefined } export interface NearTransaction { @@ -41,7 +61,15 @@ export type SigningPayload = | { family: 'solana'; transaction: string } | { family: 'sui'; transaction: string } | { family: 'near'; transactions: NearTransaction[] } - | { family: 'evm'; chainId: number; to: string; data: string; value: string } + | { + family: 'evm' + chainId: number + to: string + data: string + value: string + /** An ERC-20 `approve` the wallet sends and sees confirmed first, when the call pulls a token. */ + approval?: { to: string; data: string } | undefined + } | { family: 'stellar'; xdr: string; account: string } /** @@ -205,6 +233,11 @@ export interface ChainModule { tokens: readonly ChainToken[] venues: readonly ChainVenue[] defaultVenue: Partial> + /** + * Where a token only one venue here can take goes when no venue is named, by upper-case symbol. + * Comes before the default, which would only refuse it. + */ + venueForToken?: Partial>> | undefined /** Dollars per display unit, or nothing if no live price, in which case the trade is refused. */ priceUsd: (token: ChainToken) => Promise /** Sends signed bytes, or records what a wallet already sent. */ diff --git a/apps/dapp/lib/lend/cap.ts b/apps/dapp/lib/lend/cap.ts deleted file mode 100644 index 46d22259..00000000 --- a/apps/dapp/lib/lend/cap.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { assertTradeWithinCap, type TradeCapOptions } from '../server/trade-cap' -import { fromBaseUnits } from '../swap/assets' -import { blendXlm } from './reserves' - -/** - * The mainnet trade cap for an amount of a Blend reserve. - * - * A reserve is named by contract id and the cap prices by symbol. XLM is the - * one reserve this app can price; any other is passed through by id, finds no - * price, and is refused on mainnet rather than estimated. - */ -export async function assertReserveWithinCap( - asset: string, - baseAmount: string, - options: TradeCapOptions = {} -): Promise { - await assertTradeWithinCap( - asset === blendXlm() ? 'XLM' : asset, - fromBaseUnits(baseAmount), - options - ) -} diff --git a/apps/dapp/lib/orchestrate/capabilities.ts b/apps/dapp/lib/orchestrate/capabilities.ts index f06bbb4e..820f6671 100644 --- a/apps/dapp/lib/orchestrate/capabilities.ts +++ b/apps/dapp/lib/orchestrate/capabilities.ts @@ -34,7 +34,6 @@ const STELLAR_ONLY = [ 'Borrow and repay: done from your position in the History tab, where you can see what it can support and the price at which it would be liquidated. Not run from the chat.', 'Cash out: send USDC or XLM to a Nigerian bank account, paid in naira, through an anchor. A form collects the bank details. Mainnet only.', 'Leveraged positions: open a long or short on an asset with leverage, for example "long XLM 10x with 50 USDC".', - 'Each trade is capped at $500 on mainnet.', ] const NATIVE_NOT_SENT: Record = { @@ -91,6 +90,31 @@ export function capabilitySheet(chain: string): string { `Stake: stake an asset for a staking token. ${raced('stake') ? 'Agents compete to propose the best venue; you' : 'You'} review it and sign in your wallet.` ) } + if (offered('withdraw')) { + lines.push( + 'Withdraw: take an asset back out of a lending or earn venue, naming the venue, for example "withdraw 25 USDC from Aave". You review it and sign in your wallet.' + ) + } + if (offered('unstake')) { + lines.push( + 'Unstake: turn a staking token back into the asset, for example "unstake 1 sSUI". Some venues pay later or charge a fee, and the card says which. You review it and sign in your wallet.' + ) + } + if (offered('borrow')) { + lines.push( + 'Borrow and repay: borrow against collateral already supplied, for example "borrow 25 USDC on Aave", and repay it with "repay 25 USDC on Aave". A loan can be liquidated. You review it and sign in your wallet.' + ) + } + if (offered('stream')) { + lines.push( + 'Stream: pay someone over time, for example "stream 100 USDC to an address over 30 days". You review it and sign in your wallet, and the person paid withdraws what has streamed.' + ) + } + if (offered('invoice')) { + lines.push( + 'Invoice: bill an address on chain, for example "invoice an address 100 USDC due 2027-01-31", and pay one with "pay invoice 12 with 100 USDC". You review it and sign in your wallet.' + ) + } const sendable = SENDABLE[chain as ChainWithActions] if (offered('send') && sendable !== undefined) { lines.push( @@ -123,7 +147,7 @@ export function capabilitySheet(chain: string): string { "Cash out does not run from NEAR: Weave's NEAR deposits aren't working yet. Stake after a swap is not available on NEAR, since a swap to NEAR can arrive as NEAR or wNEAR, so stake what arrives as a second request." ) } else if (chain === 'monad') { - lines.push('Cash out and deposit after a swap do not run on Monad.') + lines.push('Cash out after a swap does not run on Monad.') } } lines.push('Limit orders are not available off Stellar.', balanceLine(chain)) diff --git a/apps/dapp/lib/other-stellar-network.ts b/apps/dapp/lib/other-stellar-network.ts index 4f7c9738..a5114bc7 100644 --- a/apps/dapp/lib/other-stellar-network.ts +++ b/apps/dapp/lib/other-stellar-network.ts @@ -10,8 +10,8 @@ export interface OtherStellarNetwork { /** * The Stellar network a deployment does not serve, and where to find it. * - * A deployment is fixed to one network, because the trade cap, the venue - * lists, the sponsor and the session gate are all decided for that network. + * A deployment is fixed to one network, because the venue lists, the + * sponsor and the session gate are all decided for that network. * So the other network is a separate deployment, reached by a link. The * address comes from configuration and is used only if it is http or https, * so a bad value cannot become a `javascript:` link in the menu. A mainnet diff --git a/apps/dapp/lib/server/execution-flow.ts b/apps/dapp/lib/server/execution-flow.ts index 8617984f..d3637ca4 100644 --- a/apps/dapp/lib/server/execution-flow.ts +++ b/apps/dapp/lib/server/execution-flow.ts @@ -14,7 +14,7 @@ import type { MarketPrice } from '../swap/price-types' import { fetchMarketPrices } from '../swap/prices' import { soroswapRouter } from '../swap/contract-registry' import { readContractCall } from '../swap/plan-validator' -import { usdValue } from './trade-cap' +import { usdValue } from './usd-value' /** * What a signed transaction moved, read from its own bytes. diff --git a/apps/dapp/lib/server/trade-cap.ts b/apps/dapp/lib/server/trade-cap.ts deleted file mode 100644 index 3f27b86b..00000000 --- a/apps/dapp/lib/server/trade-cap.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { activeNetwork, type StellarNetworkName } from '@intent/config' - -import { blendXlm } from '../lend/reserves' -import { fromBaseUnits } from '../swap/assets' -import type { PlanAction } from '../swap/build-plan' -import { fetchPools, type Pool } from '../swap/liquidity-pools' -import type { MarketPrice } from '../swap/price-types' -import { fetchMarketPrices } from '../swap/prices' - -/** - * A small per-trade cap on mainnet, and none on testnet. - * - * Real money on new rails: until the app has been watched keeping its - * promises with real volume, no single trade may be worth more than a few - * tens of dollars. The cap is in dollars rather than units so it means the - * same thing for XLM and for USDC, and it is checked against the same price - * table the agents reason from. - * - * A trade that cannot be valued is refused rather than waved through. A - * fallback price is a guess, and a cap checked against a guess is no cap; - * the price sources are Reflector and the mainnet book, and both being - * unreachable is the moment to stop, not the moment to estimate. - */ - -type Env = Record - -export const TRADE_CAP_ENV = 'MAINNET_MAX_TRADE_USD' - -export const DEFAULT_MAINNET_MAX_TRADE_USD = 500 - -/** The cap in dollars, or nothing on testnet, where there is none. */ -export function tradeCapUsd( - env: Env = process.env, - network: StellarNetworkName = activeNetwork() -): number | undefined { - if (network !== 'mainnet') return undefined - const raw = env[TRADE_CAP_ENV]?.trim() - if (raw === undefined || raw === '') return DEFAULT_MAINNET_MAX_TRADE_USD - const cap = Number(raw) - if (!Number.isFinite(cap) || cap <= 0) { - throw new Error(`${TRADE_CAP_ENV} must be a positive number of dollars, not "${raw}"`) - } - return cap -} - -/** Thrown for a trade over the cap, or one the cap cannot be checked against. */ -export class TradeCapExceeded extends Error {} - -/** Refuses a dollar value above the cap. A no-op on testnet. */ -export function assertWithinCap( - usd: number, - env: Env = process.env, - network: StellarNetworkName = activeNetwork() -): void { - const cap = tradeCapUsd(env, network) - if (cap === undefined) return - if (!Number.isFinite(usd)) { - throw new TradeCapExceeded( - `this trade could not be valued in dollars, so the mainnet cap of $${cap} cannot be checked` - ) - } - if (usd > cap) { - throw new TradeCapExceeded( - `this deployment caps each trade at $${cap} on mainnet, and this one is about ` + - `$${usd.toFixed(2)}. Set ${TRADE_CAP_ENV} to change it.` - ) - } -} - -/** - * Dollars for an amount of an asset in display units, from a price table; - * `NaN` when the table has no real price for it. A fallback entry counts as - * no price, for the reason given above. - */ -export function usdValue( - symbol: string, - amount: string, - prices: Record -): number { - const price = prices[symbol] - if (price === undefined || price.source === 'fallback') return Number.NaN - return Number(amount) * price.usd -} - -export interface TradeCapOptions { - env?: Env - network?: StellarNetworkName - /** Injected in tests. The live table otherwise. */ - prices?: () => Promise> -} - -/** - * Checks one trade against the cap: what leaves the account, as a symbol - * and a display-unit amount. On testnet this returns before pricing anything, - * so nothing about a testnet build changes. - */ -export async function assertTradeWithinCap( - symbol: string, - amount: string, - options: TradeCapOptions = {} -): Promise { - const env = options.env ?? process.env - const network = options.network ?? activeNetwork() - if (tradeCapUsd(env, network) === undefined) return - const table = await (options.prices ?? fetchMarketPrices)() - assertWithinCap(usdValue(symbol, amount, table), env, network) -} - -/** - * Checks a plan against the cap: each step on its own, and then every step - * together per asset. One signature over ten steps could otherwise move ten - * times the cap out of an account in independent outflows. A pool deposit - * spends both sides, so both are valued; a pool that cannot be found cannot - * be valued, and is refused for that. On testnet nothing is read. - */ -export async function assertPlanWithinCap( - actions: PlanAction[], - options: TradeCapOptions & { pools?: () => Promise } = {} -): Promise { - const env = options.env ?? process.env - const network = options.network ?? activeNetwork() - const cap = tradeCapUsd(env, network) - if (cap === undefined) return - const table = await (options.prices ?? fetchMarketPrices)() - - const spentBySymbol = new Map() - const spend = (symbol: string, amount: string): void => { - const usd = usdValue(symbol, amount, table) - assertWithinCap(usd, env, network) - spentBySymbol.set(symbol, (spentBySymbol.get(symbol) ?? 0) + usd) - } - - let pools: Pool[] | undefined - for (const action of actions) { - switch (action.kind) { - case 'swap': - spend(action.from.code, fromBaseUnits(action.sendAmount)) - break - case 'rest': - spend(action.selling.code, action.amount) - break - case 'lend': - // Named by contract. XLM is the one reserve this app supplies, and - // anything else has no price here to cap by. - spend(action.asset === blendXlm() ? 'XLM' : action.asset, fromBaseUnits(action.amount)) - break - case 'pool': { - pools ??= await (options.pools ?? fetchPools)() - const [a, b] = pools.find((p) => p.id === action.poolId)?.assets ?? [] - if (a === undefined || b === undefined) assertWithinCap(Number.NaN, env, network) - else { - // Both sides leave together, so the deposit is one trade of their sum. - assertWithinCap( - usdValue(a.code, action.maxAmountA, table) + usdValue(b.code, action.maxAmountB, table), - env, - network - ) - spend(a.code, action.maxAmountA) - spend(b.code, action.maxAmountB) - } - break - } - case 'trust': - break - } - } - - for (const [symbol, usd] of spentBySymbol) { - if (usd > cap) { - throw new TradeCapExceeded( - `this deployment caps each trade at $${cap} on mainnet, and these steps together spend ` + - `about $${usd.toFixed(2)} in ${symbol} across this plan. Set ${TRADE_CAP_ENV} to change it.` - ) - } - } -} diff --git a/apps/dapp/lib/server/usd-value.ts b/apps/dapp/lib/server/usd-value.ts new file mode 100644 index 00000000..5bc8fcd9 --- /dev/null +++ b/apps/dapp/lib/server/usd-value.ts @@ -0,0 +1,16 @@ +import type { MarketPrice } from '../swap/price-types' + +/** + * Dollars for an amount of an asset in display units, from a price table; + * `NaN` when the table has no real price for it. A fallback entry is a guess, + * so it counts as no price. + */ +export function usdValue( + symbol: string, + amount: string, + prices: Record +): number { + const price = prices[symbol] + if (price === undefined || price.source === 'fallback') return Number.NaN + return Number(amount) * price.usd +} diff --git a/apps/dapp/lib/trade-cap-copy.ts b/apps/dapp/lib/trade-cap-copy.ts deleted file mode 100644 index a006e5eb..00000000 --- a/apps/dapp/lib/trade-cap-copy.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ChainSlug } from '@intent/types' - -/** - * The mainnet per-trade cap as one plain sentence, or nothing where there is - * no cap. The cap is a Stellar one: on Arc it does not apply, so saying it - * there would promise a limit nothing enforces. - */ -export function tradeCapNote(capUsd: number | undefined, chain: ChainSlug): string | undefined { - if (capUsd === undefined || chain !== 'stellar') return undefined - return `Each trade is capped at $${capUsd.toLocaleString('en-US')} on mainnet.` -} diff --git a/apps/dapp/public/images/venues/aave-avalanche.webp b/apps/dapp/public/images/venues/aave-avalanche.webp new file mode 100644 index 0000000000000000000000000000000000000000..221f968dfa40fe65dbd25044ae24e227165f1be7 GIT binary patch literal 2142 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 literal 0 HcmV?d00001 diff --git a/apps/dapp/public/images/venues/curvance.webp b/apps/dapp/public/images/venues/curvance.webp new file mode 100644 index 0000000000000000000000000000000000000000..272645a53f8a1b7aa40e5d4b1cc11ac6dd5b7c5c GIT binary patch literal 1150 zcmV-^1cCcfNk&F?1ONb6MM6+kP&iC#1ONapf50CAO)zL1$&u#z|Aar`>xUqs{}Z4G zGc#HjFdYyEA%y1`gm5lMfv_`NvS%1cG7K6FBfe3MXWFnX_U@93%jOmiP+kETsRkxeA zZmcXm7=UL#e;-_z9Z^ep)%sZvT@)aOF}?Kr^=aq3tL>VK#6W3dOee$orqefmy*Uq8 z4hsN)CG^wBKM(AIl(Ga{KIq5(kio`+WKz4`?tXgueZF^-1j0aZW5Cme<;L|Hfw{1C z4Gw|Y!pF^llUiPMw1^-urWzQF)wHvZ#hJ4R6e$KQnNv=#ARsVM-Z(4}6$6WyFr3C> zptu185(_HB!7M@wRSH8|7+7%u#|BvtVT%3#SfrjX3j=~iS_~p6vMA^-8cH#;`@1)i zB_Z&G^Na6?$VH(5$Qe*T=1B6*@9$pVoMJ3MBCXY2)PX7mJ|DIStCoc{(61=v7b{*wwp;F^612}KmyRqy5i#p zkDu!`SdW5ttY7GaS)j`&mW|JAIB@LT;_BhRD!X`KJ~8l*ux9ms|BLfj09l(q90i~& z_uCg;Jph(WCOHzB%qoTKdBWSz6Tocd?*DYeVi19zFo0YVWbT^Be+EwWbb;TdVgSM7 z13e)H6d5Vh0tN;YsETSg44D}niE*I503krwmS}8d2+9;=7zo=bNt7%Ai6|#PMI(9H zsh`oth5&#|C>_Fz)N-1!ygny4M=aoYy>q{Xc8md0O9&BYX);+J{Qcklza35(ivs}S z&A+$TokT5&nJkZg|C0iM#+SGg)6VhNuD07}IwpY90z@DtwYfge-*b0wuR9(l#{d9q zoqpZ@B$irmecsypW{U*~62!5wT)V!0{qXyzGSTUXS^$9vf{BnG5z{d+t3Y+L1prtK zW6WMB?{J`F0wHQa&=8HF5s1K;nNfvEj7d5j(*Xn3gGSJ3hyVnP1tKw>PSQyds4avL zK_El`5R8_9NfMJ7Qw#(MAp#+UNW@SeATf}b1nLU_AwUQKFyja#1b|6|5d#1S00BmT Q5aSpC00CnQ5bz&20VA^O5C8xG literal 0 HcmV?d00001 diff --git a/apps/dapp/public/images/venues/euler-avalanche.webp b/apps/dapp/public/images/venues/euler-avalanche.webp new file mode 100644 index 0000000000000000000000000000000000000000..d3dae70b6e146f47f0f986079656c05112c07cac GIT binary patch literal 3074 zcmV+d4E^&`Nk&Hc3jhFDMM6+kP&iEP3jhEwf50CQRaw#hU%4cw7t&q#|G)P(Gcz+Y zcNYwVnVFfHnTMHSb9L*UbN=UyKhG1Fr(~Gjs6feZHi0YnhZ*d+*Z~{GSpfDkDsaOb z%b;dlxYYR20>-^h`!Ht#g9{n|&bV-VHjo9-;0nfvsy7iu9+$wifH0|y43vzGWNtMp z2*?H(8ONM1}24J*C@jjDARc7R)8MSfHmU5^f~AdYvyOtp$I;{xJhB|ws7 z+Sa#i`#;+@zir#LZQHhOThF#}y65WIU$!LKrfto&<`|=EtbPC9wr$(C?|roGyXKM` z2mk>%R`s22Z(vr4?WD79+qP}nwz+_9-vn4avsEjBJ}s#}q1>mYGlH=oxd0?LBRK}- zJV@S5PEP*p^uPUi=HVxwIBG{5qdt{#QRs?b8G zD;`urf!SxhtTj^KGW%)-vBpJH5&I#`>!F$NJvW=zu5wf$z!!?qDP6%k zuEyH>f%-wR^wVyXHN8<+SBY10$kYuMbqvDwGt z*}^*33zDUsHKVMlJc;vuVAL@P8>GC|r59AvhE{&Scyd>>w<5rPg-Qu&2%a$Ye3>}I zAloZXcFJW4OjJCmgo+^IVfTi(q^heNzb($dEZccY|y<9?8xNyWxtY3eyGV>$If&O0;3+r4q1!JK;DU z$ze8CKP{dtOd6RjlG>K4Dp6SU755vDIn-t|?+2z> zim;}@yc8nCl@|9QT+}1Ey4|MVw;`RibrG|eL?33^bA0YRlFK`8n*9u~W}N}qArX)r zvZhKQaO%ZgHsiA9zqQszQ)TVbQ$Fm*-cO4sgS7|YrM1?lnv=(pt3(A)^#Hrg=ksR> zo6t{XS&O8btl7L4Maw_tn*Ru|M#7ATw-%wLvgUiwsYqGsIa4DXY|Il`yLnmjdT8Vg zyRrIdiDY4e0=ZzbRVzX3l@wWjhArIiH*FbTKq*w9RK=Qa6+4r8m-f4sQFAxzP z%zwVpr9Wwdp)=KOaZJR!^e5Z{fl9%nUg*Hz` z>JF)b1#Jim1MdHdlNag#!7L{@dKPa*tIa$`4Vh^cQU&x_xzJN`IPf z?=uGWTPQ;CDlE5PL#ggoH5p&DnmuVgv8nCTDc;(lKnVyNf3B)VjrSI3y3gQ!=7~@| z*90>BDwOJCnopS|Y#oJeF>R8+wx~dA?grH4*ADGx|GjhW3xVp((CGr5-VR99FSiHqv z5rR*eW2b8h7yQTKg&Vs1gF5k>8b|md@*W1cnWCaWy_X{DSM0i0e5?C%YbqA@3re(i zO~w?C%=j&%JY^y+yDAf+!2G1(T*{VcxkvrFFYSSagZZdvTD4g;aU_@RZDd_2YNwRf z_1kI&^`Gx-6)L`B@jRuWs(r7+>c){?&9Nuzv(|Q&ck_tK0elvlqDb*SEMBO-tJk*^ zziH$stnS>A1@`6}qg~q4^`HV&T7*>R_5pEF85JOe$FbI#~-U<;dH^w`DEK zYPSY;U-&Bv2lJ%7zG*$Taa0$m9b{d~+9)tzOXEeF93o|X3pYBV%KuMTJa?(=u*2%c zQGQMoNJ8p`wbqU&uWp^!B&^nZsX2GPqDbL?EdH$e{fn(Q>f<+$iZpX9`M`;K$>T+v z9wf3N*L6C!cqos$$HIRt*}*iOSU4mrS{RnSBeQE!k?9hV`EN zD+>p8Qo*=tvtZ(oj9okWAs|V5b6e5Ee^|U= zy-?e~6SraHkZixa*N+DON zI0|Q}?bpj+BQWugTj8{g5C~|{Q(A}R%?@)F3sVQ-;bX-tNS3rF9wAFJ!R zCUuQ4Ub@wI7a;jSKVUvXP;K>oY;RzG)@$a>G^_oQXWE`V+XKmkIb?{pMx1!eJ?Vku z`%K@n#!TNaFkjO%Ci-MmJzy1P;A2tf|uhbo}jA zRU!4_z%T7$4ezJ24i9AUS+eA0bv@BG0#v3Qtqf+BL?k6A24VE8K-h@Xk!>X>Cns;~ Q{qCzyc;bg;S7n0L6k+lLR{#J2 literal 0 HcmV?d00001 diff --git a/apps/dapp/public/images/venues/euler-monad.webp b/apps/dapp/public/images/venues/euler-monad.webp new file mode 100644 index 0000000000000000000000000000000000000000..d3dae70b6e146f47f0f986079656c05112c07cac GIT binary patch literal 3074 zcmV+d4E^&`Nk&Hc3jhFDMM6+kP&iEP3jhEwf50CQRaw#hU%4cw7t&q#|G)P(Gcz+Y zcNYwVnVFfHnTMHSb9L*UbN=UyKhG1Fr(~Gjs6feZHi0YnhZ*d+*Z~{GSpfDkDsaOb z%b;dlxYYR20>-^h`!Ht#g9{n|&bV-VHjo9-;0nfvsy7iu9+$wifH0|y43vzGWNtMp z2*?H(8ONM1}24J*C@jjDARc7R)8MSfHmU5^f~AdYvyOtp$I;{xJhB|ws7 z+Sa#i`#;+@zir#LZQHhOThF#}y65WIU$!LKrfto&<`|=EtbPC9wr$(C?|roGyXKM` z2mk>%R`s22Z(vr4?WD79+qP}nwz+_9-vn4avsEjBJ}s#}q1>mYGlH=oxd0?LBRK}- zJV@S5PEP*p^uPUi=HVxwIBG{5qdt{#QRs?b8G zD;`urf!SxhtTj^KGW%)-vBpJH5&I#`>!F$NJvW=zu5wf$z!!?qDP6%k zuEyH>f%-wR^wVyXHN8<+SBY10$kYuMbqvDwGt z*}^*33zDUsHKVMlJc;vuVAL@P8>GC|r59AvhE{&Scyd>>w<5rPg-Qu&2%a$Ye3>}I zAloZXcFJW4OjJCmgo+^IVfTi(q^heNzb($dEZccY|y<9?8xNyWxtY3eyGV>$If&O0;3+r4q1!JK;DU z$ze8CKP{dtOd6RjlG>K4Dp6SU755vDIn-t|?+2z> zim;}@yc8nCl@|9QT+}1Ey4|MVw;`RibrG|eL?33^bA0YRlFK`8n*9u~W}N}qArX)r zvZhKQaO%ZgHsiA9zqQszQ)TVbQ$Fm*-cO4sgS7|YrM1?lnv=(pt3(A)^#Hrg=ksR> zo6t{XS&O8btl7L4Maw_tn*Ru|M#7ATw-%wLvgUiwsYqGsIa4DXY|Il`yLnmjdT8Vg zyRrIdiDY4e0=ZzbRVzX3l@wWjhArIiH*FbTKq*w9RK=Qa6+4r8m-f4sQFAxzP z%zwVpr9Wwdp)=KOaZJR!^e5Z{fl9%nUg*Hz` z>JF)b1#Jim1MdHdlNag#!7L{@dKPa*tIa$`4Vh^cQU&x_xzJN`IPf z?=uGWTPQ;CDlE5PL#ggoH5p&DnmuVgv8nCTDc;(lKnVyNf3B)VjrSI3y3gQ!=7~@| z*90>BDwOJCnopS|Y#oJeF>R8+wx~dA?grH4*ADGx|GjhW3xVp((CGr5-VR99FSiHqv z5rR*eW2b8h7yQTKg&Vs1gF5k>8b|md@*W1cnWCaWy_X{DSM0i0e5?C%YbqA@3re(i zO~w?C%=j&%JY^y+yDAf+!2G1(T*{VcxkvrFFYSSagZZdvTD4g;aU_@RZDd_2YNwRf z_1kI&^`Gx-6)L`B@jRuWs(r7+>c){?&9Nuzv(|Q&ck_tK0elvlqDb*SEMBO-tJk*^ zziH$stnS>A1@`6}qg~q4^`HV&T7*>R_5pEF85JOe$FbI#~-U<;dH^w`DEK zYPSY;U-&Bv2lJ%7zG*$Taa0$m9b{d~+9)tzOXEeF93o|X3pYBV%KuMTJa?(=u*2%c zQGQMoNJ8p`wbqU&uWp^!B&^nZsX2GPqDbL?EdH$e{fn(Q>f<+$iZpX9`M`;K$>T+v z9wf3N*L6C!cqos$$HIRt*}*iOSU4mrS{RnSBeQE!k?9hV`EN zD+>p8Qo*=tvtZ(oj9okWAs|V5b6e5Ee^|U= zy-?e~6SraHkZixa*N+DON zI0|Q}?bpj+BQWugTj8{g5C~|{Q(A}R%?@)F3sVQ-;bX-tNS3rF9wAFJ!R zCUuQ4Ub@wI7a;jSKVUvXP;K>oY;RzG)@$a>G^_oQXWE`V+XKmkIb?{pMx1!eJ?Vku z`%K@n#!TNaFkjO%Ci-MmJzy1P;A2tf|uhbo}jA zRU!4_z%T7$4ezJ24i9AUS+eA0bv@BG0#v3Qtqf+BL?k6A24VE8K-h@Xk!>X>Cns;~ Q{qCzyc;bg;S7n0L6k+lLR{#J2 literal 0 HcmV?d00001 diff --git a/apps/dapp/public/images/venues/fastlane.webp b/apps/dapp/public/images/venues/fastlane.webp new file mode 100644 index 0000000000000000000000000000000000000000..6aff2f13c80e8864e94c8d065eac7d4140f235c6 GIT binary patch literal 4098 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 literal 0 HcmV?d00001 diff --git a/apps/dapp/public/images/venues/momentum.webp b/apps/dapp/public/images/venues/momentum.webp new file mode 100644 index 0000000000000000000000000000000000000000..f480e16f12c2cf98d62f29818fc9c6e4f6c35e50 GIT binary patch literal 4588 zcmVp^L^a6K$fM?)86gR$GVkAk5BnY#yqz6ttg3|mJ8;{^k>uU$ z#hERh-2LE(`+pzDIo!1U(udjm^=?92|2N-6!pyStx z|2u)ywTW((%J4)GC72Y{eo!LHIm13FfrQ9{7Y$(Gde88d-#S@ueqVh9k%(1`; zDd(I=DRY!ET%@iA#$^})hJy;Yw1YO{`~9x*MbfDeR5d~%n`{KF(s(24fZ8#SMaZOK zN-!uEtt2Lb6A|s>yjtF1-Pnqa65$j;t|ByMavH+EC zV@;W=!H7`U6xp<59%W&a5<^x}BsK~ZWNlz1S{7OqV_cRPqwKJ0ZiFo`L82*;W*suB zwih@ng@XO29_^{^POUxn_PRexd+Mc> z@+e~;FW;ZHZ^z5qi^!B1XSMwdHv`+x`}uIR=QfVAJGZ4huJ+sg{N-#mHmp|z+f`E> z(SDpSU+>bM=fP3T_b=n+(_3$$PV1{?lbT=^1EgXJzP_fb_~1o@Nn=& z4!U#L9m95>3_g!RdmkJRyW_A!gZ6y*UH*1)ucX(`!9!|gc;4CRXY?_L6XtQ-%b*<( zzKVO#WxepJkkm`-sDKyOIW`C9=$kcY@3xm=J4auwt$}=Pg`+}9lmQA&fVk3#9Qa`8 zmjo1~Q%W?`idw>3}aAD_MO z)<9KV~Ln5 zHum1|98m*Gs9W-E5|JRf|KY8o9Js+y0zP@FJ+&LII}LJNw)@cI`3*RE}Y` z=hmBp^rQh)Gapgqs)K`=Ma!She;3d!ZiNKekC)8NIY+#**}d9--tE8czc=@E|NXW4 z;aq!f-hZ?GYxwYq3GPWD8aM|MSWLPls7YSDcV(DbO$9@&0`bva3wg6C7~5W8Cb{+L z>MzstCDos%O1yw(HPly>;eWXS-y!Z&>)(2YdV-e^!wZQ@RxzwxKz($Di!~_7EBoVrXFI zo-+>&GGbnlZsV$S=C-$zVQ^W8Mdaw;pLCHxQqvzyi8NGc^}`*yh6u%{2~@BjsNhIE zG(x1*r63Ojavp%nv-B0f;P@=APxkr245A|>L6fEiq!LV|M_+Jp@uLd8FSfV^T3M`Z ze6>h>>5*!W1u6FVqdm_8w|RwVjg`%E-+3j{Q~^Va6VD|5(|J#hWmOue0+IS380Na- zx}%^7L!d-Tpo#ZnA`_axoNDjjY>S_Rk2h&Aghk^e69ap8RL4AMx_E!<&GO4UOk_?u z=7+o7oLVSJ4VY%I7@1JGfrT6GSYG_U$SP_rIhEkcYknX8zM1U`k_~7g;Tg!9PTX&8 z(sS(Er-338r~jE7Xrci27dy5m3=*EojihG8B6Mo*I@wd7?%8@6<{W&5Dhsr8x3=U; z7@8$Q!?R;aa-F?r6lfMzMb!Zd7~dWL(8`Noq_F`rY2}T`eQ#i|=DNjaE%-Mo+Gmzz z$if`+qwT(v89>^eDw=4XLA5KqlO%^9ac7LvVy zWq!2F%n_ju6$WA1bzgsW97QeHY5^_p6FGkTcRjJKV2Eh)`wqy4O<=N! YF(lI5J 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 literal 0 HcmV?d00001 diff --git a/apps/dapp/public/images/venues/streamflow.webp b/apps/dapp/public/images/venues/streamflow.webp new file mode 100644 index 0000000000000000000000000000000000000000..212f82d68cc45be2a2ce7877691db46191fd76cb GIT binary patch literal 5070 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 Mk&u7>B4-{H0cB?a761SM literal 0 HcmV?d00001 diff --git a/apps/dapp/public/images/venues/uniswap-monad.webp b/apps/dapp/public/images/venues/uniswap-monad.webp new file mode 100644 index 0000000000000000000000000000000000000000..e3b562cfd580f059cd519beb5c9d632b7440b1e6 GIT binary patch literal 4162 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 Mk&u7>B4-{H0cB?a761SM literal 0 HcmV?d00001 diff --git a/apps/dapp/public/images/venues/walrus.webp b/apps/dapp/public/images/venues/walrus.webp new file mode 100644 index 0000000000000000000000000000000000000000..abaf5903ec53a9ff7fdd74c2ad72d4a3ad02ecba GIT binary patch literal 1160 zcmV;31b6#VNk&G11ONb6MM6+kP&goT1ONcg8~~jGDu4ih06sAoi9;eGp%%(yI3NQA zw18_IRNsI)`2I8b9nU0?zoCCT{FMG%s3ZJi{8m`6uK$o6zcDhFrc|hGquX$8VkJX#I@yi)enbut@Y_?o zY-JN{OELfY%jUStL4;~R@yI}^u}l!EB4{|;7oezJ(!G}U5-3JK=u>4&A;A!)h|baF zJ_U4$X@CI!{(O`G0Waq}pV8r*{PVpJ5HKC1xzJd)L0!43GOV(8IzDaRq*MY%5+3Ty zAnsQp-v-gI79dTI(NTyE|M5;_Kt)TTq`W2Lj3_dHbyrZ}u)|YMgDC_0BvPVMD zKkb`uu((fs5ICpjd9c#k{OT>qYloz)inB@Oo{jZ5Z_o^rJX?hjta5n++5b0^x$l#D?Kk0HGlP~H|tBZz*|_2ho>COTdqsZRNp z;MAVZDJm*YMA1LXyPCz@its|7it9!aP~Hx1$@t<^uNi%;r67?1zj85osWyhJ6GlFT zH=qH^q{JNlbV+#EnX@@nymii8g-w2A{Lh4!-`Ek{n*3@|2X`HpNKyx=S_Wg^%!{ek z;z*f&FqbfyHT7n=e!Nnflz-p2vByWCZzMkbNpf)4TbsFIW{Pr#tlx8Po=eapq+W^O zM}qO%4*p`wHKi-Du^Qj{6sFu`8)X})3W}}57Y@93hc_mCdbn!vBZ~p=4i=rTs79sO z_pI=?Znquy!UL_3i@Y17_EF<{N?QiGgoR0vg;lP%-F6Tc9N=eVvW~rU-_4%wr8{59 zn3GpM5i82X{7huF2xlzO>(qCUo_E)vS`T*4!uz}T3-Lg$m$u%cy?u;Pxe{#*#u0?K zn|&kh4OYj@QyqUc6#pi&DSkz;e@?tmCyVVPp;B=Si?l-coT93WB%j z(v(|{l@O}PvuE{pK}{xfbpoj1gVSyAwvKg(2}goW2|5#ek9NgHCGT4$yr zKq=m5{BfcoOZ^OS{IXFx|FX;T_SGEaQsJU+j}6FRbT=;t_VMR60o884Ox&Q<+x!yB zyK^KA(GfX?EszWoZVmtBXR;vl literal 0 HcmV?d00001 From 231611beabe7d77be1258d041f165e5bb4d899cb Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:46:11 +0100 Subject: [PATCH 2/4] fix(dapp): keep the word boundaries on the action verbs --- apps/dapp/lib/chains/parse-action.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dapp/lib/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index 2126b43b..3f05063a 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -66,7 +66,7 @@ const DAYS_IN_A_MONTH = 30 /** Sentences that ask to do something, so a miss gets told what works rather than a guess. */ const ACTION_VERB = - /(swap|convert|exchange|trade|change|buy|sell|supply|lend|deposit|save|earn|stake|unstake|withdraw|redeem|borrow|repay|stream|invoice|cash out|send|bridge|long|short)/i + /\b(swap|convert|exchange|trade|change|buy|sell|supply|lend|deposit|save|earn|stake|unstake|withdraw|redeem|borrow|repay|stream|invoice|cash out|send|bridge|long|short)\b/i /** People say the chain for its coin. `sui` and `near` are already their symbols. */ const TOKEN_OF_CHAIN_NAME: ReadonlyMap = new Map([ From 0a915168cf303034bcbf94840d89514d7e539d3f Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:40:46 +0100 Subject: [PATCH 3/4] feat(dapp): let people take their money back out, on every chain Every venue a person can deposit into now has a way out in the app. No real wallet has signed any of this. Adds withdraw, unstake and repay where they were missing, a claim step for exits that take time, and cancelling a stream. An exit with no venue named goes to wherever the money is, read from the chain, and "all" empties a position exactly where the contract allows. "where is my money" answers in the chat. Adds borrowing on Solana through Kamino Lend, on Sui through NAVI, Suilend and Scallop, and on NEAR through Rhea Lend. Rhea DEX becomes NEAR's default swap while 1Click refuses every route with an on-chain NEAR leg. Fixes on Stellar: "supply 5 USDC" signed 5 XLM, a swap then lend supplied XLM, and "repay all" failed on mainnet. Adds the missing collateral, non-XLM, liquidity pool and order exits. Removes NEAR's 1,000 NEAR and 10,000 token ceilings, and stops reading a leading "tell" as a typo of "sell". --- apps/dapp/.env.example | 6 +- apps/dapp/app/api/cashout/orders/route.ts | 5 +- .../app/api/chain/[chain]/positions/route.ts | 81 + .../app/api/chain/[chain]/prepare/route.ts | 23 +- apps/dapp/app/api/lend/collateral/route.ts | 2 +- apps/dapp/app/api/lend/repay/route.ts | 84 +- apps/dapp/app/api/lend/withdraw/route.ts | 102 +- apps/dapp/app/api/offers/build/route.ts | 67 +- apps/dapp/app/api/pools/route.ts | 27 + apps/dapp/app/api/pools/submit/route.ts | 53 + apps/dapp/app/api/pools/withdraw/route.ts | 50 + .../components/intents/borrow-confirm.tsx | 13 + apps/dapp/components/intents/cashout-card.tsx | 2 +- .../intents/chain-action-confirm.tsx | 4 + apps/dapp/components/intents/intent-chat.tsx | 150 +- apps/dapp/components/intents/open-orders.tsx | 153 +- .../components/intents/open-positions.tsx | 320 ++-- .../components/intents/pool-positions.tsx | 144 ++ .../components/intents/withdraw-confirm.tsx | 8 +- apps/dapp/hooks/use-borrow.ts | 25 +- apps/dapp/hooks/use-collateral.ts | 2 +- apps/dapp/hooks/use-limit-order.ts | 24 +- apps/dapp/hooks/use-pool-withdraw.ts | 153 ++ apps/dapp/hooks/use-withdraw.ts | 22 +- apps/dapp/lib/__tests__/aave.test.ts | 230 ++- .../dapp/lib/__tests__/aftermath.live.test.ts | 62 +- apps/dapp/lib/__tests__/aftermath.test.ts | 170 +- .../__tests__/aquarius-quoter.live.test.ts | 36 +- apps/dapp/lib/__tests__/beefy.test.ts | 135 +- .../lib/__tests__/blend-borrow.live.test.ts | 15 +- apps/dapp/lib/__tests__/blend-exit.test.ts | 370 ++++ .../lib/__tests__/build-phoenix.live.test.ts | 44 +- .../dapp/lib/__tests__/chain-sequence.test.ts | 9 +- apps/dapp/lib/__tests__/curvance.test.ts | 112 +- apps/dapp/lib/__tests__/erc4626-refusals.ts | 33 +- apps/dapp/lib/__tests__/euler.test.ts | 53 +- .../dapp/lib/__tests__/evm-exits.live.test.ts | 543 ++++++ apps/dapp/lib/__tests__/exit-routing.test.ts | 256 +++ apps/dapp/lib/__tests__/fastlane.live.test.ts | 4 +- apps/dapp/lib/__tests__/fastlane.test.ts | 45 + apps/dapp/lib/__tests__/jito.test.ts | 15 + .../lib/__tests__/kamino-lend.live.test.ts | 409 +++++ apps/dapp/lib/__tests__/kamino-lend.test.ts | 972 +++++++++++ apps/dapp/lib/__tests__/marinade.test.ts | 15 + apps/dapp/lib/__tests__/morpho.test.ts | 72 +- .../lib/__tests__/navi-withdraw.live.test.ts | 179 +- apps/dapp/lib/__tests__/navi-withdraw.test.ts | 330 +++- .../lib/__tests__/near-exits.live.test.ts | 236 +++ apps/dapp/lib/__tests__/near-exits.test.ts | 740 ++++++++ apps/dapp/lib/__tests__/near-safety.test.ts | 13 +- apps/dapp/lib/__tests__/near-stake.test.ts | 5 +- apps/dapp/lib/__tests__/near-swap.test.ts | 5 +- apps/dapp/lib/__tests__/near-venues.test.ts | 8 +- .../orchestrate-capabilities-chain.test.ts | 6 +- apps/dapp/lib/__tests__/parse-exit.test.ts | 75 + .../lib/__tests__/positions-reply.test.ts | 412 +++++ .../lib/__tests__/positions-route.test.ts | 218 +++ apps/dapp/lib/__tests__/rhea.live.test.ts | 290 ++++ apps/dapp/lib/__tests__/rhea.test.ts | 1539 +++++++++++++++++ .../lib/__tests__/route-rate-limit.test.ts | 2 + apps/dapp/lib/__tests__/sablier.test.ts | 238 ++- apps/dapp/lib/__tests__/scallop.live.test.ts | 182 ++ apps/dapp/lib/__tests__/scallop.test.ts | 366 +++- .../lib/__tests__/solana-exits.live.test.ts | 246 +++ apps/dapp/lib/__tests__/solana-exits.test.ts | 1051 +++++++++++ .../lib/__tests__/solana-stake-fixtures.ts | 94 +- apps/dapp/lib/__tests__/solana-venues.test.ts | 13 +- apps/dapp/lib/__tests__/solayer.test.ts | 24 +- .../__tests__/springsui-unstake.live.test.ts | 36 +- .../lib/__tests__/springsui-unstake.test.ts | 57 +- .../lib/__tests__/stake-claim.live.test.ts | 302 ++++ apps/dapp/lib/__tests__/stake-claim.test.ts | 568 ++++++ .../lib/__tests__/staked-positions.test.ts | 76 + .../stellar-exit-offers-pools.test.ts | 248 +++ .../lib/__tests__/stellar-exit.live.test.ts | 269 +++ .../lib/__tests__/streamflow-cancel.test.ts | 677 ++++++++ .../lib/__tests__/streamflow.live.test.ts | 157 +- .../lib/__tests__/sui-lending-captured.ts | 30 + .../lib/__tests__/sui-lending-loans.test.ts | 225 +++ apps/dapp/lib/__tests__/sui-module.test.ts | 13 +- apps/dapp/lib/__tests__/suilend.live.test.ts | 141 ++ apps/dapp/lib/__tests__/suilend.test.ts | 364 +++- .../lib/__tests__/venue-integration.test.ts | 4 +- apps/dapp/lib/__tests__/walrus.live.test.ts | 112 ++ apps/dapp/lib/__tests__/walrus.test.ts | 495 +++++- .../__tests__/weave-cashout-gaps.live.test.ts | 79 + .../lib/__tests__/weave-cashout-view.test.ts | 8 + .../lib/__tests__/weave-corridors.test.ts | 17 +- .../lib/__tests__/weave-orders-route.test.ts | 23 +- .../lib/__tests__/weave-parse-cashout.test.ts | 56 +- apps/dapp/lib/chains/evm/aave.ts | 250 ++- apps/dapp/lib/chains/evm/beefy.ts | 95 +- apps/dapp/lib/chains/evm/curvance.ts | 117 +- apps/dapp/lib/chains/evm/erc4626.ts | 115 +- apps/dapp/lib/chains/evm/euler.ts | 18 +- apps/dapp/lib/chains/evm/fastlane.ts | 40 +- apps/dapp/lib/chains/evm/module.ts | 2 + apps/dapp/lib/chains/evm/morpho.ts | 30 + apps/dapp/lib/chains/evm/sablier.ts | 212 ++- apps/dapp/lib/chains/explain.ts | 2 + apps/dapp/lib/chains/near/capabilities.ts | 10 +- apps/dapp/lib/chains/near/earn.ts | 379 +++- apps/dapp/lib/chains/near/module.ts | 19 +- apps/dapp/lib/chains/near/oneclick.ts | 12 +- apps/dapp/lib/chains/near/rhea-lend.ts | 1021 +++++++++++ apps/dapp/lib/chains/near/rhea-listing.ts | 27 + apps/dapp/lib/chains/near/rhea-read.ts | 162 ++ apps/dapp/lib/chains/near/rhea-safety.ts | 510 ++++++ apps/dapp/lib/chains/near/rhea-swap.ts | 735 ++++++++ apps/dapp/lib/chains/near/safety.ts | 173 +- apps/dapp/lib/chains/near/stake.ts | 359 +++- apps/dapp/lib/chains/near/swap.ts | 25 +- apps/dapp/lib/chains/near/tokens.ts | 2 + apps/dapp/lib/chains/near/venues.ts | 31 +- apps/dapp/lib/chains/parse-action.ts | 58 +- apps/dapp/lib/chains/positions-reply.ts | 280 +++ apps/dapp/lib/chains/prepare.ts | 186 +- apps/dapp/lib/chains/sequence.ts | 6 +- .../solana/__fixtures__/captured-exits.ts | 23 + apps/dapp/lib/chains/solana/accounts.ts | 67 + apps/dapp/lib/chains/solana/assert-safe.ts | 130 +- apps/dapp/lib/chains/solana/capabilities.ts | 5 + apps/dapp/lib/chains/solana/jito.ts | 3 + apps/dapp/lib/chains/solana/jupiter-lend.ts | 271 ++- .../lib/chains/solana/kamino-lend-listing.ts | 15 + .../lib/chains/solana/kamino-lend-rules.ts | 914 ++++++++++ apps/dapp/lib/chains/solana/kamino-lend.ts | 929 ++++++++++ apps/dapp/lib/chains/solana/kamino.ts | 389 ++++- apps/dapp/lib/chains/solana/lulo.ts | 139 +- apps/dapp/lib/chains/solana/marinade.ts | 2 + apps/dapp/lib/chains/solana/module.ts | 8 + apps/dapp/lib/chains/solana/programs.ts | 101 +- .../dapp/lib/chains/solana/solayer-listing.ts | 2 +- apps/dapp/lib/chains/solana/solayer.ts | 65 +- apps/dapp/lib/chains/solana/stake-claim.ts | 401 +++++ apps/dapp/lib/chains/solana/stake-pool.ts | 107 +- apps/dapp/lib/chains/solana/streamflow.ts | 476 ++++- apps/dapp/lib/chains/solana/venues.ts | 6 +- apps/dapp/lib/chains/sui/aftermath.ts | 119 +- apps/dapp/lib/chains/sui/capabilities.ts | 3 + apps/dapp/lib/chains/sui/lending-calls.ts | 361 +++- apps/dapp/lib/chains/sui/module.ts | 2 + apps/dapp/lib/chains/sui/navi-withdraw.ts | 897 +++++++++- apps/dapp/lib/chains/sui/navi.ts | 43 +- apps/dapp/lib/chains/sui/scallop-listing.ts | 3 +- apps/dapp/lib/chains/sui/scallop.ts | 979 ++++++++++- apps/dapp/lib/chains/sui/springsui-unstake.ts | 27 +- apps/dapp/lib/chains/sui/springsui.ts | 2 + apps/dapp/lib/chains/sui/suilend-listing.ts | 3 +- apps/dapp/lib/chains/sui/suilend.ts | 902 ++++++++-- apps/dapp/lib/chains/sui/venues.ts | 3 +- apps/dapp/lib/chains/sui/walrus.ts | 439 ++++- apps/dapp/lib/chains/types.ts | 41 + apps/dapp/lib/chat/typos.ts | 14 +- apps/dapp/lib/lend/blend-client.ts | 171 +- apps/dapp/lib/lend/exit.ts | 258 +++ apps/dapp/lib/lend/parse-exit.ts | 130 ++ apps/dapp/lib/lend/pool-errors.ts | 34 +- apps/dapp/lib/lend/position.ts | 23 +- apps/dapp/lib/lend/reserves.ts | 68 +- apps/dapp/lib/orchestrate/capabilities.ts | 25 +- apps/dapp/lib/swap/build-pool.ts | 139 +- apps/dapp/lib/swap/liquidity-pools.ts | 37 + apps/dapp/lib/swap/offers.ts | 21 + apps/dapp/lib/venues.ts | 12 +- apps/dapp/lib/weave/cashout-view.ts | 23 +- apps/dapp/lib/weave/client.ts | 11 +- apps/dapp/lib/weave/corridors.ts | 15 +- apps/dapp/lib/weave/parse-cashout.ts | 10 +- apps/dapp/lib/weave/types.ts | 36 +- .../public/images/venues/kamino-lend.webp | Bin 0 -> 560 bytes 171 files changed, 27764 insertions(+), 1365 deletions(-) create mode 100644 apps/dapp/app/api/chain/[chain]/positions/route.ts create mode 100644 apps/dapp/app/api/pools/route.ts create mode 100644 apps/dapp/app/api/pools/submit/route.ts create mode 100644 apps/dapp/app/api/pools/withdraw/route.ts create mode 100644 apps/dapp/components/intents/pool-positions.tsx create mode 100644 apps/dapp/hooks/use-pool-withdraw.ts create mode 100644 apps/dapp/lib/__tests__/blend-exit.test.ts create mode 100644 apps/dapp/lib/__tests__/evm-exits.live.test.ts create mode 100644 apps/dapp/lib/__tests__/exit-routing.test.ts create mode 100644 apps/dapp/lib/__tests__/kamino-lend.live.test.ts create mode 100644 apps/dapp/lib/__tests__/kamino-lend.test.ts create mode 100644 apps/dapp/lib/__tests__/near-exits.live.test.ts create mode 100644 apps/dapp/lib/__tests__/near-exits.test.ts create mode 100644 apps/dapp/lib/__tests__/parse-exit.test.ts create mode 100644 apps/dapp/lib/__tests__/positions-reply.test.ts create mode 100644 apps/dapp/lib/__tests__/positions-route.test.ts create mode 100644 apps/dapp/lib/__tests__/rhea.live.test.ts create mode 100644 apps/dapp/lib/__tests__/rhea.test.ts create mode 100644 apps/dapp/lib/__tests__/solana-exits.live.test.ts create mode 100644 apps/dapp/lib/__tests__/solana-exits.test.ts create mode 100644 apps/dapp/lib/__tests__/stake-claim.live.test.ts create mode 100644 apps/dapp/lib/__tests__/stake-claim.test.ts create mode 100644 apps/dapp/lib/__tests__/staked-positions.test.ts create mode 100644 apps/dapp/lib/__tests__/stellar-exit-offers-pools.test.ts create mode 100644 apps/dapp/lib/__tests__/stellar-exit.live.test.ts create mode 100644 apps/dapp/lib/__tests__/streamflow-cancel.test.ts create mode 100644 apps/dapp/lib/__tests__/sui-lending-loans.test.ts create mode 100644 apps/dapp/lib/__tests__/weave-cashout-gaps.live.test.ts create mode 100644 apps/dapp/lib/chains/near/rhea-lend.ts create mode 100644 apps/dapp/lib/chains/near/rhea-listing.ts create mode 100644 apps/dapp/lib/chains/near/rhea-read.ts create mode 100644 apps/dapp/lib/chains/near/rhea-safety.ts create mode 100644 apps/dapp/lib/chains/near/rhea-swap.ts create mode 100644 apps/dapp/lib/chains/positions-reply.ts create mode 100644 apps/dapp/lib/chains/solana/__fixtures__/captured-exits.ts create mode 100644 apps/dapp/lib/chains/solana/accounts.ts create mode 100644 apps/dapp/lib/chains/solana/kamino-lend-listing.ts create mode 100644 apps/dapp/lib/chains/solana/kamino-lend-rules.ts create mode 100644 apps/dapp/lib/chains/solana/kamino-lend.ts create mode 100644 apps/dapp/lib/chains/solana/stake-claim.ts create mode 100644 apps/dapp/lib/lend/exit.ts create mode 100644 apps/dapp/lib/lend/parse-exit.ts create mode 100644 apps/dapp/public/images/venues/kamino-lend.webp diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index 5cbfe749..931b60bd 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -178,7 +178,9 @@ DEFINDEX_API_KEY= # Noether perps gateway. The SDK default api.noether.exchange does not # resolve; this Azure host was taken from the production bundle and answers. -# Testnet only; the gateway reports version 0.0.0-dev and is unaudited. +# Testnet only; the gateway reports version 0.0.0-dev. Noether's mainnet market +# was deployed on 2026-10-03 and read as paused with no funds on 2026-11-05: its +# own docs say trading opens after an independent audit. # NOETHER_API_URL=https://noether-api.proudmeadow-533cf0d8.germanywestcentral.azurecontainerapps.io NOETHER_API_URL= @@ -206,7 +208,7 @@ SPONSOR_ALERT_XLM=20 WEAVE_SECRET_KEY= # Signs Weave's webhooks to /api/cashout/webhook. Unset, every webhook is refused with a 401. WEAVE_WEBHOOK_SECRET= -# Comma list from NGN,KES,UGX,TZS. Unset means NGN only, because no real payout has been tested for the others. +# Comma list from NGN,KES,UGX,TZS. Unset means NGN only. No real payout has been tested through this app for any of them. WEAVE_CASHOUT_CURRENCIES= # Avalanche C-Chain RPC used to build and confirm USDC deposits, and to confirm 1Click swaps there. AVALANCHE_RPC_URL= diff --git a/apps/dapp/app/api/cashout/orders/route.ts b/apps/dapp/app/api/cashout/orders/route.ts index f5da4c8e..44c28425 100644 --- a/apps/dapp/app/api/cashout/orders/route.ts +++ b/apps/dapp/app/api/cashout/orders/route.ts @@ -21,7 +21,7 @@ import { toPublic, } from '../../../../lib/weave/orders' import { nextStatus } from '../../../../lib/weave/status' -import { isCashoutChain } from '../../../../lib/weave/types' +import { closedCashoutReply, isCashoutChain } from '../../../../lib/weave/types' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -94,6 +94,9 @@ export async function POST(request: Request): Promise { if (!isCashoutChain(chain)) { return refusal('unknown_chain', 'Cash-outs are not available on this chain.') } + // The chat refuses this before any form opens. A page left open from before it did can still post. + const closed = closedCashoutReply(chain) + if (closed !== undefined) return refusal('chain_closed', closed, 422) const onThisNetwork = chainRefusal(chain) if (onThisNetwork !== undefined) return onThisNetwork diff --git a/apps/dapp/app/api/chain/[chain]/positions/route.ts b/apps/dapp/app/api/chain/[chain]/positions/route.ts new file mode 100644 index 00000000..e0130775 --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/positions/route.ts @@ -0,0 +1,81 @@ +import { isAddress as isSolanaAddress } from '@solana/kit' +import { NextResponse } from 'next/server' + +import { NEAR_ACCOUNT } from '../../../../../lib/chains/near/account' +import type { PositionsReport, VenueHoldings } from '../../../../../lib/chains/positions-reply' +import { readPositionsBriefly } from '../../../../../lib/chains/prepare' +import { chainModule } from '../../../../../lib/chains/registry' +import type { ChainActionKind, ChainWithActions } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 20 + +const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/ +const SUI_ADDRESS = /^0x[0-9a-fA-F]{64}$/ + +/** The same shapes the balance readers hold to. Every read swallows its error, so a bad address has to stop here. */ +function isAccountOn(chain: ChainWithActions, account: string): boolean { + switch (chain) { + case 'solana': + return isSolanaAddress(account) + case 'sui': + return SUI_ADDRESS.test(account) + case 'near': + return NEAR_ACCOUNT.test(account) + case 'avalanche': + case 'monad': + return EVM_ADDRESS.test(account) + } +} + +/** A venue that only swaps or sends keeps nothing, so one with no reader is left out, not called unread. */ +const HOLDS_MONEY: ReadonlySet = new Set([ + 'supply', + 'stake', + 'withdraw', + 'unstake', + 'borrow', + 'repay', + 'stream', + 'claim', + 'cancel', +]) + +/** What the account holds at each venue on this chain, read when asked and never kept. */ +export async function GET( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'read') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const account = new URL(request.url).searchParams.get('account') ?? '' + if (!isAccountOn(chainApi.chain, account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + + const asked = chainApi.venues.filter( + (venue) => venue.positions !== undefined || venue.kinds.some((kind) => HOLDS_MONEY.has(kind)) + ) + const readings = await Promise.all(asked.map((venue) => readPositionsBriefly(venue, account))) + + const venues: VenueHoldings[] = [] + const unread: PositionsReport['unread'] = [] + asked.forEach(({ id, name, kinds }, i) => { + const positions = readings[i] + // A figure that isn't a number says nothing about what is there, so it isn't read as nothing. + if (positions === undefined || positions.some((p) => Number.isNaN(Number(p.amount)))) { + unread.push({ id, name }) + return + } + venues.push({ id, name, kinds, positions: positions.filter((p) => Number(p.amount) > 0) }) + }) + + const report: PositionsReport = { venues, unread } + return NextResponse.json(report, { headers: { 'cache-control': 'no-store' } }) +} diff --git a/apps/dapp/app/api/chain/[chain]/prepare/route.ts b/apps/dapp/app/api/chain/[chain]/prepare/route.ts index 0ea829e5..8020930f 100644 --- a/apps/dapp/app/api/chain/[chain]/prepare/route.ts +++ b/apps/dapp/app/api/chain/[chain]/prepare/route.ts @@ -26,10 +26,12 @@ const Body = z.object({ 'stream', 'invoice', 'pay_invoice', + 'claim', + 'cancel', ]), amount: z .string() - .regex(/^\d+(\.\d+)?$/) + .regex(/^(\d+(\.\d+)?|all)$/) .max(40), tokenIn: z.string().min(1).max(16), tokenOut: z.string().min(1).max(16).optional(), @@ -47,6 +49,10 @@ const Body = z.object({ .string() .regex(/^\d{1,78}$/) .optional(), + streamId: z + .string() + .regex(/^[A-Za-z0-9]{1,64}$/) + .optional(), }), }) @@ -68,8 +74,18 @@ export async function POST( } try { - const { kind, amount, tokenIn, tokenOut, venue, recipient, durationDays, dueDate, invoiceId } = - parsed.data.request + const { + kind, + amount, + tokenIn, + tokenOut, + venue, + recipient, + durationDays, + dueDate, + invoiceId, + streamId, + } = parsed.data.request const prepared = await prepareChainAction( chainApi, { @@ -82,6 +98,7 @@ export async function POST( ...(durationDays !== undefined ? { durationDays } : {}), ...(dueDate !== undefined ? { dueDate } : {}), ...(invoiceId !== undefined ? { invoiceId } : {}), + ...(streamId !== undefined ? { streamId } : {}), }, parsed.data.account ) diff --git a/apps/dapp/app/api/lend/collateral/route.ts b/apps/dapp/app/api/lend/collateral/route.ts index 4b4f916f..1138c0a5 100644 --- a/apps/dapp/app/api/lend/collateral/route.ts +++ b/apps/dapp/app/api/lend/collateral/route.ts @@ -31,7 +31,7 @@ interface CollateralBody { asset?: string /** Base units. Required to post; omit when reclaiming everything. */ amount?: string - /** 'post' adds collateral, 'reclaim' returns it to a plain supply balance. */ + /** 'post' moves it from the wallet into the pool, 'reclaim' pays it back to the wallet. */ direction?: 'post' | 'reclaim' } diff --git a/apps/dapp/app/api/lend/repay/route.ts b/apps/dapp/app/api/lend/repay/route.ts index 1298f65f..62a9863f 100644 --- a/apps/dapp/app/api/lend/repay/route.ts +++ b/apps/dapp/app/api/lend/repay/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server' import { buildBlendRepay, prepareBlendWithdraw } from '../../../../lib/lend/blend-client' -import { explainPoolError } from '../../../../lib/lend/pool-errors' -import { readReserveList } from '../../../../lib/lend/reserves' +import { findDebt, planRepayAll, readSpendable, worthOf } from '../../../../lib/lend/exit' +import { explainExitError } from '../../../../lib/lend/pool-errors' +import { readBlendPositions, type BlendPositions } from '../../../../lib/lend/position' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { reportError } from '../../../../lib/server/report' @@ -14,17 +15,19 @@ import { reportError } from '../../../../lib/server/report' * user dependent on somebody else's interface. For a debt that is worse than * for a supply, because the balance grows while they look for one. * - * Omitting the amount repays everything, and that is the path worth - * encouraging. A debt read a moment ago is already larger, so repaying an - * exact figure leaves a remainder that keeps accruing — and keeps the position - * liquidatable for a sum the user believed they had cleared. + * Omitting the amount repays everything the wallet can cover. The figure is + * worked out here from the debt read now, and the pool refunds what is over: see + * `repayAllAmount`. */ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' interface RepayBody { account?: string + /** The reserve's asset, as a contract id. */ asset?: string + /** A ticker, when a person typed one instead. */ + symbol?: string /** Base units, or omitted to clear the whole liability. */ amount?: string } @@ -43,44 +46,81 @@ export async function POST(request: Request): Promise { if (typeof body.account !== 'string' || body.account === '') { return NextResponse.json({ error: 'account_required' }, { status: 400 }) } - if (typeof body.asset !== 'string' || body.asset === '') { + const asset = typeof body.asset === 'string' && body.asset !== '' ? body.asset : undefined + const symbol = typeof body.symbol === 'string' && body.symbol !== '' ? body.symbol : undefined + if (asset === undefined && symbol === undefined) { return NextResponse.json({ error: 'asset_required' }, { status: 400 }) } - if (body.amount !== undefined && (typeof body.amount !== 'string' || body.amount === '')) { + if ( + body.amount !== undefined && + (typeof body.amount !== 'string' || !/^[1-9]\d*$/.test(body.amount)) + ) { return NextResponse.json({ error: 'amount_invalid' }, { status: 400 }) } + let positions: BlendPositions try { - const accepted = await readReserveList() - if (!accepted.includes(body.asset)) { + positions = await readBlendPositions(body.account) + } catch (e) { + reportError('lend/repay', e, { account: body.account, asset }) + return NextResponse.json( + { error: 'Could not read what you owe in Blend. Nothing was signed or sent.' }, + { status: 502 } + ) + } + + const debt = findDebt(positions, { + ...(asset !== undefined ? { assetId: asset } : {}), + ...(symbol !== undefined ? { symbol } : {}), + }) + if ('refusal' in debt) { + return NextResponse.json({ error: debt.refusal }, { status: 400 }) + } + + let amount = body.amount + if (amount === undefined) { + let spendable: bigint + try { + spendable = await readSpendable(body.account, debt.assetId) + } catch (e) { + reportError('lend/repay', e, { account: body.account, asset: debt.assetId }) return NextResponse.json( - { error: 'Blend has no reserve for this asset, so nothing can be owed on it.' }, - { status: 400 } + { error: 'Could not read your wallet balance. Nothing was signed or sent.' }, + { status: 502 } ) } - } catch (e) { - reportError('lend/repay', e, { account: body.account, asset: body.asset }) - return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) + const plan = planRepayAll(BigInt(debt.amount), spendable, debt.symbol) + if ('refusal' in plan) { + return NextResponse.json({ error: plan.refusal }, { status: 400 }) + } + amount = plan.amount } try { - const built = await buildBlendRepay({ - account: body.account, - asset: body.asset, - ...(body.amount !== undefined ? { amount: body.amount } : {}), - }) + const built = await buildBlendRepay({ account: body.account, asset: debt.assetId, amount }) const prepared = await prepareBlendWithdraw(built.xdr) if (!prepared.ok) { - return NextResponse.json({ error: explainPoolError(prepared.reason) }, { status: 400 }) + return NextResponse.json( + { error: explainExitError(prepared.reason, 'repay') }, + { status: 400 } + ) } + // Decided by what the pool says would remain, not by what was asked for: a wallet that holds + // less than the debt, asked to "repay all", repays part of it and must be told so. + const remaining = + prepared.after !== undefined ? worthOf(debt, prepared.after.liabilities) : undefined + return NextResponse.json({ xdr: prepared.xdr, asset: built.asset, + symbol: debt.symbol, amount: built.amount, - everything: built.everything, + owed: debt.amount, + everything: remaining === '0', recipient: built.recipient, + ...(remaining !== undefined ? { remaining } : {}), }) } catch (e) { return NextResponse.json( diff --git a/apps/dapp/app/api/lend/withdraw/route.ts b/apps/dapp/app/api/lend/withdraw/route.ts index 5b780797..41474ae9 100644 --- a/apps/dapp/app/api/lend/withdraw/route.ts +++ b/apps/dapp/app/api/lend/withdraw/route.ts @@ -1,23 +1,28 @@ import { NextResponse } from 'next/server' -import { buildBlendWithdraw, prepareBlendWithdraw } from '../../../../lib/lend/blend-client' -import { explainPoolError } from '../../../../lib/lend/pool-errors' -import { readReserveList } from '../../../../lib/lend/reserves' +import { + buildBlendCollateralWithdraw, + buildBlendWithdraw, + buildBlendWithdrawEverywhere, + prepareBlendWithdraw, +} from '../../../../lib/lend/blend-client' +import { planWithdraw, worthOf } from '../../../../lib/lend/exit' +import { explainExitError } from '../../../../lib/lend/pool-errors' +import { readBlendPositions, type BlendPositions } from '../../../../lib/lend/position' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { reportError } from '../../../../lib/server/report' /** * Builds a withdrawal from Blend, ready for signature. * - * The mirror of the supply builder, and simulated for the same reason: Soroban - * needs the simulation to compute a resource footprint, and it doubles as the - * feasibility check. + * Read from the position first, because a person names an asset and the pool + * holds it in one of two forms: a plain supply and collateral are different + * request types, and asking for the wrong one is refused as "nothing supplied" + * over money that is really there. Both forms pay the wallet. * - * What it catches here is different, though. A supply can fail on a cap or a - * balance; a withdrawal fails when the reserve has lent out too much of what it - * holds. Blend refuses a withdrawal that would push utilisation past `max_util` - * — 95% in this pool, which sits around 90% — so a perfectly real position can - * be temporarily larger than the reserve can pay out. Surfacing the pool's own + * The simulation is the mirror of the supply builder's, and what it catches + * differs: a withdrawal fails when the reserve has lent out too much of what it + * holds, or when collateral is still backing a loan. Surfacing the pool's own * revert reason says which of those happened. */ export const runtime = 'nodejs' @@ -27,6 +32,8 @@ interface WithdrawBody { account?: string /** The reserve's asset, as a contract id. */ asset?: string + /** A ticker, when a person typed one instead. */ + symbol?: string /** * Base units, or omitted to empty the position. * @@ -35,6 +42,8 @@ interface WithdrawBody { * the pool clamps to whatever is actually there. */ amount?: string + /** Which form to take from. Omitted, the position decides. */ + from?: 'supply' | 'collateral' } // A withdrawal returns the account's own position to the account @@ -54,50 +63,81 @@ export async function POST(request: Request): Promise { if (typeof body.account !== 'string' || body.account === '') { return NextResponse.json({ error: 'account_required' }, { status: 400 }) } - if (typeof body.asset !== 'string' || body.asset === '') { + const asset = typeof body.asset === 'string' && body.asset !== '' ? body.asset : undefined + const symbol = typeof body.symbol === 'string' && body.symbol !== '' ? body.symbol : undefined + if (asset === undefined && symbol === undefined) { return NextResponse.json({ error: 'asset_required' }, { status: 400 }) } // Absent means "everything". An empty string is a client bug rather than an // intention, so it is refused instead of being read as the sentinel. - if (body.amount !== undefined && (typeof body.amount !== 'string' || body.amount === '')) { + if ( + body.amount !== undefined && + (typeof body.amount !== 'string' || !/^[1-9]\d*$/.test(body.amount)) + ) { return NextResponse.json({ error: 'amount_invalid' }, { status: 400 }) } + if (body.from !== undefined && body.from !== 'supply' && body.from !== 'collateral') { + return NextResponse.json({ error: 'from_invalid' }, { status: 400 }) + } + let positions: BlendPositions try { - const accepted = await readReserveList() - if (!accepted.includes(body.asset)) { - return NextResponse.json( - { error: 'Blend has no reserve for this asset, so there is nothing to withdraw.' }, - { status: 400 } - ) - } + positions = await readBlendPositions(body.account) } catch (e) { - reportError('lend/withdraw', e, { account: body.account, asset: body.asset }) - return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) + reportError('lend/withdraw', e, { account: body.account, asset }) + return NextResponse.json( + { error: 'Could not read your Blend position. Nothing was signed or sent.' }, + { status: 502 } + ) + } + + const plan = planWithdraw(positions, { + ...(asset !== undefined ? { assetId: asset } : {}), + ...(symbol !== undefined ? { symbol } : {}), + ...(body.amount !== undefined ? { amount: body.amount } : {}), + ...(body.from !== undefined ? { from: body.from } : {}), + }) + if ('refusal' in plan) { + return NextResponse.json({ error: plan.refusal }, { status: 400 }) } try { - const built = await buildBlendWithdraw({ + const options = { account: body.account, - asset: body.asset, - ...(body.amount !== undefined ? { amount: body.amount } : {}), - }) + asset: plan.assetId, + ...(plan.amount !== undefined ? { amount: plan.amount } : {}), + } + const built = + plan.from === 'both' + ? await buildBlendWithdrawEverywhere(options) + : plan.from === 'collateral' + ? await buildBlendCollateralWithdraw(options) + : await buildBlendWithdraw(options) const prepared = await prepareBlendWithdraw(built.xdr) if (!prepared.ok) { - return NextResponse.json({ error: explainPoolError(prepared.reason) }, { status: 400 }) + return NextResponse.json( + { error: explainExitError(prepared.reason, 'withdraw') }, + { status: 400 } + ) } + const supplyLeft = worthOf(plan.supplied, prepared.after?.supply) + const collateralLeft = worthOf(plan.collateral, prepared.after?.collateral) + return NextResponse.json({ xdr: prepared.xdr, asset: built.asset, + symbol: plan.symbol, + from: plan.from, amount: built.amount, everything: built.everything, recipient: built.recipient, - // What the position still holds afterwards — not the sum withdrawn. On a - // full withdrawal this is zero, which is the clearest confirmation the - // simulation can give that the position will actually be emptied. - ...(prepared.remaining !== undefined ? { remaining: prepared.remaining } : {}), + // What stays behind in the form this took from: not the sum withdrawn. Zero is the + // clearest confirmation the simulation can give that it will actually be emptied. + ...(prepared.after !== undefined + ? { remaining: plan.from === 'collateral' ? collateralLeft : supplyLeft } + : {}), }) } catch (e) { return NextResponse.json( diff --git a/apps/dapp/app/api/offers/build/route.ts b/apps/dapp/app/api/offers/build/route.ts index eff77eb5..997a0f03 100644 --- a/apps/dapp/app/api/offers/build/route.ts +++ b/apps/dapp/app/api/offers/build/route.ts @@ -1,10 +1,11 @@ import { NextResponse } from 'next/server' import { reportError } from '../../../../lib/server/report' -import { resolveAsset } from '../../../../lib/swap/assets' +import { resolveAsset, type ClassicAsset } from '../../../../lib/swap/assets' import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { buildOfferTransaction } from '../../../../lib/swap/build-offer' import { fetchOrderBookTop, offerPriceFromUsd } from '../../../../lib/swap/limit-price' +import { fetchOffer, type HorizonOfferRecord } from '../../../../lib/swap/offers' import { enforceRateLimit } from '../../../../lib/server/rate-limit' /** @@ -38,6 +39,11 @@ interface BuildOfferBody { offerId?: string } +function ledgerAsset(asset: HorizonOfferRecord['selling']): ClassicAsset { + if (asset.asset_type === 'native') return { kind: 'classic', code: 'XLM' } + return { kind: 'classic', code: asset.asset_code ?? '', issuer: asset.asset_issuer ?? '' } +} + export async function POST(req: Request): Promise { const limited = await enforceRateLimit(req, 'build') if (limited !== undefined) return limited @@ -54,33 +60,41 @@ export async function POST(req: Request): Promise { if (account === undefined || account.trim() === '') { return NextResponse.json({ error: 'account_required' }, { status: 400 }) } - if (sellSymbol === undefined || buySymbol === undefined || amount === undefined) { + if (amount === undefined) { return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) } - await ensureLiveAssets([sellSymbol, buySymbol]) - const selling = resolveAsset(sellSymbol) - const buying = resolveAsset(buySymbol) - if (selling === undefined || buying === undefined) { - // The allowlist is deliberately small: an intent is free text read by a - // model, and an unknown symbol must fail here rather than resolve to - // whatever issuer answers to the name. - return NextResponse.json({ error: 'unknown_asset' }, { status: 400 }) - } - - // Cancelling needs no price: an offer at zero size is removed whatever it - // was priced at, and requiring a price would mean re-deriving one just to - // throw it away. - const cancelling = Number(amount) === 0 - if (cancelling) { + // Cancelling is read from the ledger, not from the names a client sends. Getting an order back + // must not depend on this app's token list: one placed with a token that later left the list, + // or from another wallet app, would otherwise be listed here with no way to withdraw it. + if (Number(amount) === 0) { if (offerId === undefined || offerId === '0') { return NextResponse.json({ error: 'offer_id_required' }, { status: 400 }) } try { + const offer = await fetchOffer(offerId) + if (offer === undefined) { + return NextResponse.json( + { + error: 'offer_gone', + detail: 'This order is no longer open, so there is nothing to withdraw.', + }, + { status: 404 } + ) + } + if (offer.seller !== account) { + return NextResponse.json( + { + error: 'not_your_offer', + detail: 'This order belongs to another account. Nothing was signed or sent.', + }, + { status: 403 } + ) + } const built = await buildOfferTransaction({ account, - selling, - buying, + selling: ledgerAsset(offer.selling), + buying: ledgerAsset(offer.buying), amount: '0', // Any positive ratio serves; the operation is removing the order. price: { n: 1, d: 1 }, @@ -88,6 +102,7 @@ export async function POST(req: Request): Promise { }) return NextResponse.json({ xdr: built.xdr, offerId: built.offerId, cancelling: true }) } catch (e) { + reportError('offers/build', e, { offerId }) return NextResponse.json( { error: 'build_failed', detail: e instanceof Error ? e.message : undefined }, { status: 400 } @@ -95,6 +110,20 @@ export async function POST(req: Request): Promise { } } + if (sellSymbol === undefined || buySymbol === undefined) { + return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + } + + await ensureLiveAssets([sellSymbol, buySymbol]) + const selling = resolveAsset(sellSymbol) + const buying = resolveAsset(buySymbol) + if (selling === undefined || buying === undefined) { + // The allowlist is deliberately small: an intent is free text read by a + // model, and an unknown symbol must fail here rather than resolve to + // whatever issuer answers to the name. + return NextResponse.json({ error: 'unknown_asset' }, { status: 400 }) + } + if (typeof limitPriceUsd !== 'number' || !Number.isFinite(limitPriceUsd)) { return NextResponse.json({ error: 'limit_price_required' }, { status: 400 }) } diff --git a/apps/dapp/app/api/pools/route.ts b/apps/dapp/app/api/pools/route.ts new file mode 100644 index 00000000..6d66b8f3 --- /dev/null +++ b/apps/dapp/app/api/pools/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' + +import { enforceRateLimit } from '../../../lib/server/rate-limit' +import { reportError } from '../../../lib/server/report' +import { readPoolShares } from '../../../lib/swap/liquidity-pools' + +/** An account's shares in Stellar's built-in liquidity pools, valued against live reserves. */ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(req: Request): Promise { + const limited = await enforceRateLimit(req, 'resolve') + if (limited !== undefined) return limited + + const account = new URL(req.url).searchParams.get('account') + if (account === null || account.trim() === '') { + return NextResponse.json({ error: 'account_required' }, { status: 400 }) + } + + try { + return NextResponse.json({ shares: await readPoolShares(account) }) + } catch (e) { + // Never an empty list: "you hold no shares" must not stand in for "could not find out". + reportError('pools', e, { account }) + return NextResponse.json({ error: 'horizon_unreachable' }, { status: 502 }) + } +} diff --git a/apps/dapp/app/api/pools/submit/route.ts b/apps/dapp/app/api/pools/submit/route.ts new file mode 100644 index 00000000..0e823c3b --- /dev/null +++ b/apps/dapp/app/api/pools/submit/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server' + +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { logExecution } from '../../../../lib/server/analytics' +import { sponsorForRequest } from '../../../../lib/sponsor/sponsor-request' +import { assertSelfPoolWithdraw } from '../../../../lib/swap/build-pool' +import { submitSignedSwap } from '../../../../lib/swap/submit' + +/** + * Submits a signed pool withdrawal, re-checked because between build and here it + * passed through the client and a browser extension. + */ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(request: Request): Promise { + const limited = await enforceRateLimit(request, 'submit') + if (limited !== undefined) return limited + + let body: { signedXdr?: unknown; account?: unknown } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + } + + if (typeof body.signedXdr !== 'string' || body.signedXdr === '') { + return NextResponse.json({ error: 'signedXdr is required' }, { status: 400 }) + } + if (typeof body.account !== 'string' || body.account === '') { + return NextResponse.json({ error: 'account is required' }, { status: 400 }) + } + + try { + assertSelfPoolWithdraw(body.signedXdr, body.account) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'refusing to submit' }, + { status: 400 } + ) + } + + const sent = await sponsorForRequest(request, body.signedXdr, body.account) + const result = await submitSignedSwap(sent.xdr) + await logExecution({ + kind: 'plan', + account: body.account, + feeSponsored: sent.sponsored, + result, + signedXdr: sent.xdr, + }) + return NextResponse.json({ ...result, feeSponsored: sent.sponsored }) +} diff --git a/apps/dapp/app/api/pools/withdraw/route.ts b/apps/dapp/app/api/pools/withdraw/route.ts new file mode 100644 index 00000000..f55d21c1 --- /dev/null +++ b/apps/dapp/app/api/pools/withdraw/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server' + +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { buildPoolExit } from '../../../../lib/swap/build-pool' + +/** + * Builds a withdrawal of every share an account holds in one pool, ready to sign. + * + * The operation has no recipient field: both assets can only return to the + * account that signs it, and the network requires that account to hold a + * trustline for each for as long as it holds the shares. + */ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const POOL_ID = /^[0-9a-f]{64}$/ + +export async function POST(request: Request): Promise { + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + let body: { account?: unknown; poolId?: unknown } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + } + + if (typeof body.account !== 'string' || body.account === '') { + return NextResponse.json({ error: 'account_required' }, { status: 400 }) + } + if (typeof body.poolId !== 'string' || !POOL_ID.test(body.poolId)) { + return NextResponse.json({ error: 'pool_required' }, { status: 400 }) + } + + try { + const built = await buildPoolExit({ account: body.account, poolId: body.poolId }) + return NextResponse.json({ + xdr: built.xdr, + poolId: built.poolId, + shares: built.shares, + floors: built.floors, + }) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'This withdrawal could not be built.' }, + { status: 400 } + ) + } +} diff --git a/apps/dapp/components/intents/borrow-confirm.tsx b/apps/dapp/components/intents/borrow-confirm.tsx index cb2f6e6b..98e89631 100644 --- a/apps/dapp/components/intents/borrow-confirm.tsx +++ b/apps/dapp/components/intents/borrow-confirm.tsx @@ -160,6 +160,19 @@ export function BorrowConfirm({ ? 'Clearing a debt returns the collateral behind it to your control.' : 'Borrowed against the collateral you have posted. Interest accrues from the moment it settles.'}

+ {direction === 'repay' && borrow.everything === true ? ( +

+ You owe about {display(borrow.owed) ?? ''} {borrow.asset ?? ''}. Your wallet may show{' '} + {display(borrow.amount) ?? ''}: interest keeps growing until this settles, so it asks + for slightly more, and the pool returns the difference in the same transaction. +

+ ) : null} + {direction === 'repay' && borrow.everything !== true && borrow.remaining !== undefined ? ( +

+ About {display(borrow.remaining) ?? ''} {borrow.asset ?? ''} stays owed and keeps + growing. +

+ ) : null} {/* The risk panel. Only meaningful for a borrow: a repayment can only diff --git a/apps/dapp/components/intents/cashout-card.tsx b/apps/dapp/components/intents/cashout-card.tsx index 5cded506..7f37437e 100644 --- a/apps/dapp/components/intents/cashout-card.tsx +++ b/apps/dapp/components/intents/cashout-card.tsx @@ -298,7 +298,7 @@ function BeforeYouSign({ {expired ? 'This payout will be cancelled. Use Edit to start a new one.' - : 'Unpaid orders are cancelled. Nothing is taken.'} + : 'Unpaid orders are cancelled. Nothing is taken. Weave has not yet paid out a real order in this app.'} { @@ -990,6 +1000,16 @@ export function IntentChat({ recordSettled('The supply settled.', supplyHash, supply.explorerUrl) // eslint-disable-next-line react-hooks/exhaustive-deps }, [supplyHash]) + const exitWithdrawHash = exitWithdraw.phase === 'settled' ? exitWithdraw.hash : undefined + useEffect(() => { + recordSettled('The withdrawal settled.', exitWithdrawHash, exitWithdraw.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exitWithdrawHash]) + const exitRepayHash = exitRepay.phase === 'settled' ? exitRepay.hash : undefined + useEffect(() => { + recordSettled('The repayment settled.', exitRepayHash, exitRepay.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exitRepayHash]) const perpHash = perp.phase === 'settled' ? perp.hash : undefined useEffect(() => { recordSettled('The position opened.', perpHash, perp.explorerUrl) @@ -1053,6 +1073,10 @@ export function IntentChat({ swap.phase === 'submitting' || sequence.phase === 'signing' || sequence.phase === 'submitting' || + exitWithdraw.phase === 'signing' || + exitWithdraw.phase === 'submitting' || + exitRepay.phase === 'signing' || + exitRepay.phase === 'submitting' || chainSigning(chainAction.phase) // Declared after the effects that record, so a transaction settling in the same @@ -1084,6 +1108,8 @@ export function IntentChat({ dialogue.current = [] setPending(null) supply.reset() + exitWithdraw.reset() + exitRepay.reset() perp.reset() planExec.reset() limit.reset() @@ -1192,6 +1218,8 @@ export function IntentChat({ sequence.reset() swap.reset() supply.reset() + exitWithdraw.reset() + exitRepay.reset() perp.reset() setExecutingKey(null) setPlacedId(null) @@ -1495,15 +1523,23 @@ export function IntentChat({ return } + // The asset named, by its own contract. This always sent XLM's, so "supply 5 USDC" signed 5 XLM + // under a card that said USDC. Refused when the pool does not lend the token held here. + const reserve = suppliableReserve(supplyOnly.asset) + if (reserve === undefined) { + setAffordError(`Blend does not take ${supplyOnly.asset} here. Nothing was signed or sent.`) + return + } + const units = supplyOnly.amount === undefined - ? (balances?.xlm ?? '0') + ? heldUnits(supplyOnly.asset) : supplyOnly.amountIsUsd === true ? (Number(supplyOnly.amount) / (price as number)).toFixed(7) : supplyOnly.amount supply.prepare({ - assetId: blendXlm(), + assetId: reserve, symbol: supplyOnly.asset, amount: toBaseUnits(units), }) @@ -1665,15 +1701,23 @@ export function IntentChat({ return } + const reserve = suppliableReserve(symbol) + if (reserve === undefined) { + setParsed(null) + setFollowOn(null) + setAffordError(`Blend does not take ${symbol} here. Nothing was signed or sent.`) + return + } + const units = !stated - ? (balances?.xlm ?? '0') + ? heldUnits(symbol) : body.amountIsUsd === true ? ((body.amountUsd ?? 0) / (price as number)).toFixed(7) : String(body.amountUsd ?? 0) setParsed(null) setFollowOn(null) - supply.prepare({ assetId: blendXlm(), symbol, amount: toBaseUnits(units) }) + supply.prepare({ assetId: reserve, symbol, amount: toBaseUnits(units) }) return } // A token the app cannot vouch for: ask rather than guess. Nothing is run. @@ -1749,6 +1793,59 @@ export function IntentChat({ })() } + /** All of an asset the wallet holds, as a display figure. */ + function heldUnits(symbol: string): string { + return (symbol === 'XLM' ? balances?.xlm : balances?.trustlines?.[symbol]?.balance) ?? '0' + } + + /** + * "Withdraw 25 XLM" and "repay 5 USDC" on Stellar. Straight to a card to sign, with no model + * in the way: see `parse-exit.ts`. The server reads the position and says if it can't be done. + */ + function startStellarExit(exit: StellarExit, text: string): void { + if (executionInFlight) { + setAffordError(FINISH_SIGNATURE_NOTICE) + return + } + archiveOpenTurn() + resetTurnState() + setClarifying(null) + setParsed(null) + setMessage(text) + setTurnId(crypto.randomUUID()) + + if (exit.kind === 'where') { + if (!isConnected || address === undefined) { + setAffordError('Connect a wallet first, so there is an account to look at.') + return + } + void fetch(`/api/lend/position?account=${encodeURIComponent(address)}`) + .then(async (res) => { + if (!res.ok) throw new Error('unread') + const body = (await res.json()) as { positions?: BlendPositions | null } + if (body.positions == null) throw new Error('unread') + setAffordError(whereReply(body.positions)) + }) + .catch(() => { + setAffordError('Could not read your Blend position. Nothing was signed or sent.') + }) + return + } + + let amount: string | undefined + try { + amount = exit.amount === undefined ? undefined : toBaseUnits(exit.amount) + } catch { + setAffordError( + 'That amount has more decimal places than Stellar keeps. Nothing was signed or sent.' + ) + return + } + const request = { symbol: exit.symbol, ...(amount !== undefined ? { amount } : {}) } + if (exit.kind === 'withdraw') exitWithdraw.prepare(request) + else exitRepay.prepare('repay', request) + } + /** Ends a chain sequence where it stands. Its USDC stays the person's, and they are told so. */ function leaveSequence(): void { const run = chainSequenceNow.current @@ -2087,8 +2184,9 @@ export function IntentChat({ */ function handleSubmit(typed: string): void { latest.current.turn += 1 - // A misspelled verb is corrected before anything reads the sentence. - const text = fixVerbTypo(typed) + // A misspelled verb is corrected before anything reads the sentence. "Tell me my positions" + // has none, and would become "sell me". + const text = asksForPositions(typed) ? typed : fixVerbTypo(typed) // A chain signature is with the wallet: a new message must not close its card. if (chainSigning(chainAction.phase)) { @@ -2131,6 +2229,12 @@ export function IntentChat({ return } + const stellarExit = slug === 'stellar' ? parseStellarExit(text) : null + if (stellarExit !== null) { + startStellarExit(stellarExit, text) + return + } + // The answer to "which currency?". Anything that isn't one closes the question // and is read as what it is, so nobody is held in it. if (cashoutQuestion !== null && cashoutQuestion.answer === undefined) { @@ -2261,10 +2365,18 @@ export function IntentChat({ const earlier = raceContext(thread, []) // The conversation so far, replies included. Without it every message is // read cold, and "explain that better" has nothing to improve on. - const answer = await fetchOrchestrate(turns, earlier, slug, fetch, dialogue.current) + // A question about positions needs no model, and takes the balance path from here on. + const positions = asksForPositions(text) + const answer = positions + ? { outcome: 'balance' as const } + : await fetchOrchestrate(turns, earlier, slug, fetch, dialogue.current) // The busy indicator stays up until the holdings are read. A reply that arrives after the // person moved to another wallet, chain or message is not theirs any more, so it is dropped. - const balanceText = answer.outcome === 'balance' ? await walletBalanceReply() : undefined + const balanceText = positions + ? await readPositionsReply(slug, descriptor.name, isConnected ? address : undefined) + : answer.outcome === 'balance' + ? await walletBalanceReply() + : undefined clarifyRuns.current -= 1 if (clarifyRuns.current === 0) setClarifyBusy(false) // The last wallet's question, answered after it left. A balance has its own rule below. @@ -2701,12 +2813,22 @@ export function IntentChat({ const venue = followOn.venueNamed === true ? followOn.venue : (agentsVenue ?? followOn.venue) + // The token the swap delivers, by its own contract. This always named XLM's, so a swap into + // USDC went on to supply that many XLM. Said before the swap, not after it has run. + const lendAsset = suppliableReserve(parsed.input.tokenOut) + if (venue === 'blend' && lendAsset === undefined) { + setAffordError( + `Blend does not take ${parsed.input.tokenOut} here, so this cannot end in a supply. Nothing was signed or sent.` + ) + return + } + sequence.prepare({ kind: 'swap-then-lend', ...slippageEdit, quote: route, receiveSymbol: parsed.input.tokenOut, - lendAsset: blendXlm(), + lendAsset: lendAsset ?? '', venue, swapLabel: `Swap ${parsed.input.amountIn} ${parsed.input.tokenIn} for ${parsed.input.tokenOut}`, }) @@ -3408,6 +3530,8 @@ export function IntentChat({ {/* Supplying what the account already holds. One transaction, one signature — no sequence to explain. */} {supply.phase !== 'idle' ? : null} + {exitWithdraw.phase !== 'idle' ? : null} + {exitRepay.phase !== 'idle' ? : null} {restored === null ? ( @@ -3492,11 +3616,7 @@ export function IntentChat({ {/* Everything currently resting, read from the ledger rather than remembered here, so a fill that happened elsewhere still shows. */} - limit.cancel(offer.id, offer.sellingAsset, offer.buyingAsset)} - /> + {/* Appears only once an agent has won with an executable route, so the race is never interrupted by a confirmation prompt. diff --git a/apps/dapp/components/intents/open-orders.tsx b/apps/dapp/components/intents/open-orders.tsx index 622a615b..eb723f05 100644 --- a/apps/dapp/components/intents/open-orders.tsx +++ b/apps/dapp/components/intents/open-orders.tsx @@ -1,9 +1,11 @@ 'use client' import { Button, Card } from '@intent/ui' -import { useQuery } from '@tanstack/react-query' -import { Loader2 } from 'lucide-react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { ExternalLink, Loader2 } from 'lucide-react' +import { useEffect } from 'react' +import { useLimitOrder, type LimitOrder } from '../../hooks/use-limit-order' import type { OpenOffer } from '../../lib/swap/offers' import { TokenIcon } from '../ui/token-icon' @@ -17,6 +19,10 @@ import { TokenIcon } from '../ui/token-icon' * * A shrinking amount is a partial fill, visible without any bookkeeping of our * own: Stellar decrements the offer as it trades. + * + * Withdrawing runs on its own hook, separate from whatever order is being placed + * beside it. Sharing one showed a withdrawal as "order placed", and with no order + * being placed, showed nothing at all, even when it failed. */ async function loadOffers(account: string): Promise { @@ -26,16 +32,48 @@ async function loadOffers(account: string): Promise { return body.offers ?? [] } -export function OpenOrders({ - account, - onCancel, - cancelling, -}: { - account: string | undefined - onCancel: (offer: OpenOffer) => void - /** True while a cancellation is in flight, so the buttons can settle. */ - cancelling?: boolean -}): JSX.Element | null { +function WithdrawStatus({ cancel }: { cancel: LimitOrder }): JSX.Element | null { + const { phase } = cancel + if (phase === 'checking' || phase === 'signing' || phase === 'submitting') { + return ( +

+ + {phase === 'signing' ? 'Waiting for your wallet…' : 'Withdrawing the order…'} +

+ ) + } + if (phase === 'cancelled') { + return ( +

+ Order withdrawn. What had not traded is back in your wallet. + {cancel.explorerUrl !== undefined ? ( + + View transaction + + + ) : null} +

+ ) + } + if (phase === 'failed') { + return ( +

+ {cancel.error ?? 'The order was not withdrawn.'} It is still resting, and your funds have + not moved. +

+ ) + } + return null +} + +export function OpenOrders({ account }: { account: string | undefined }): JSX.Element | null { + const cancel = useLimitOrder() + const queryClient = useQueryClient() const { data, isLoading, error } = useQuery({ queryKey: ['open-offers', account], queryFn: () => loadOffers(account as string), @@ -44,6 +82,11 @@ export function OpenOrders({ refetchInterval: 15_000, }) + useEffect(() => { + if (cancel.phase !== 'cancelled') return + void queryClient.invalidateQueries({ queryKey: ['open-offers', account] }) + }, [cancel.phase, queryClient, account]) + if (account === undefined) return null if (isLoading) { @@ -66,48 +109,58 @@ export function OpenOrders({ } const offers = data ?? [] - if (offers.length === 0) return null + const busy = + cancel.phase === 'checking' || cancel.phase === 'signing' || cancel.phase === 'submitting' + + // The last order just withdrawn still owes its confirmation. + if (offers.length === 0 && cancel.phase === 'idle') return null return ( - - Resting {offers.length === 1 ? 'order' : 'orders'} - - -
    - {offers.map((offer) => ( -
  • -
    - - - {offer.remaining} {offer.sellingAsset} - for - - {offer.buyingAsset} - - - at {offer.price} {offer.buyingAsset} each - -
    - - -
  • - ))} -
+ {offers.length > 0 ? ( + <> + + Resting {offers.length === 1 ? 'order' : 'orders'} + -

- These sit on Stellar and fill on their own. Withdrawing returns whatever has not traded. -

+
    + {offers.map((offer) => ( +
  • +
    + + + {offer.remaining} {offer.sellingAsset} + for + + {offer.buyingAsset} + + + at {offer.price} {offer.buyingAsset} each + +
    + + +
  • + ))} +
+ +

+ These sit on Stellar and fill on their own. Withdrawing returns whatever has not traded. +

+ + ) : null} + +
) } diff --git a/apps/dapp/components/intents/open-positions.tsx b/apps/dapp/components/intents/open-positions.tsx index 0b7fc055..c219fb3c 100644 --- a/apps/dapp/components/intents/open-positions.tsx +++ b/apps/dapp/components/intents/open-positions.tsx @@ -3,7 +3,7 @@ import { Button, Card } from '@intent/ui' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' -import { ArrowRight, ExternalLink } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import { useWallet } from '../../hooks/use-wallet' import { useBlendPosition } from '../../hooks/use-blend-position' @@ -13,6 +13,7 @@ import { useCollateral } from '../../hooks/use-collateral' import { useWithdrawalStatus } from '../../hooks/use-withdrawal-status' import { BorrowConfirm } from './borrow-confirm' import { LIQUIDATION_HF, MINIMUM_SAFE_HF } from '../../lib/lend/health' +import { blendReserves } from '../../lib/lend/reserves' import { loadTurns, onTurnsChanged } from '../../lib/chat-history' import { lookupAnchor } from '../../lib/offramp/anchors' import { pendingWithdrawals } from '../../lib/offramp/pending-withdrawals' @@ -21,6 +22,8 @@ import { blendPositionUrl } from '../../lib/swap/contract-registry' import { toBaseUnits } from '../../lib/swap/assets' import { useChain } from '../../providers/chain-provider' import { TokenIcon } from '../ui/token-icon' +import { OpenOrders } from './open-orders' +import { PoolPositions, loadPoolShares } from './pool-positions' import { WithdrawConfirm } from './withdraw-confirm' /** @@ -81,20 +84,73 @@ function typedBaseUnits(draft: string, heldBase: string): string | undefined { return base } -/** - * What this pool lends. - * - * Read from the pool's reserve list rather than a ticker table: Blend's USDC - * is a different issuer from Circle's, so an asset that looks familiar can be - * one this pool has never heard of. These four are its whole reserve list, - * confirmed by reading each contract's own `symbol` rather than inferring it. - */ -const BORROWABLE = [ - { id: 'CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU', symbol: 'USDC' }, - { id: 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC', symbol: 'XLM' }, - { id: 'CAP5AMC2OHNVREO66DFIN6DHJMPOBAJ2KCDDIMFBR7WWJH5RZBFM3UEI', symbol: 'wBTC' }, - { id: 'CAZAQB3D7KSLSNOSQKYD2V4JP5V2Y3B4RDJZRLBFCCIXDCTE3WHSY3UE', symbol: 'wETH' }, -] +/** A typed amount, a button for it, and a button for all of it. "All" is the only way to empty one. */ +function AmountAction({ + label, + symbol, + held, + busy, + onSome, + onAll, + secondary, +}: { + label: string + symbol: string + /** Base units. A typed figure above this is refused rather than quietly changed. */ + held: string + busy: boolean + onSome: (baseUnits: string) => void + onAll: () => void + /** A second use for the typed amount. */ + secondary?: { label: string; disabled: boolean; onSome: (baseUnits: string) => void } +}): JSX.Element { + // Held as text rather than a number: a half-written "5." is a valid thing to + // be typing and not a valid number. + const [draft, setDraft] = useState('') + const typed = typedBaseUnits(draft, held) + + return ( +
+ setDraft(e.target.value)} + inputMode="decimal" + placeholder="Amount" + aria-label={`Amount of ${symbol} to ${label.toLowerCase()}`} + disabled={busy} + className="border-border bg-background focus:border-foreground/40 w-24 rounded-md border px-2 py-1.5 text-sm tabular-nums outline-none transition-colors disabled:opacity-50" + /> + + {/* Distinct from typing the balance shown. That figure is stale the moment + it renders, as interest moves it every ledger, so only this path can + actually empty it. */} + + {secondary !== undefined ? ( + + ) : null} +
+ ) +} /** How a health factor reads to somebody who has never seen one. */ function healthTone(hf: number | undefined): string { @@ -106,15 +162,15 @@ function healthTone(hf: number | undefined): string { export function OpenPositions(): JSX.Element | null { const { address, isConnected } = useWallet() - const { position, positions } = useBlendPosition() + const { positions, error: blendUnread } = useBlendPosition() const withdraw = useWithdraw() const borrow = useBorrow() const queryClient = useQueryClient() - // What the user has typed, held as text rather than a number: a half-written - // "5." is a valid thing to be typing and not a valid number. - const [draft, setDraft] = useState('') + // What this network's pool lends: a list for the wrong network offers a loan + // the pool has never heard of. + const borrowable = blendReserves() const [borrowDraft, setBorrowDraft] = useState('') - const [borrowAsset, setBorrowAsset] = useState(BORROWABLE[0]?.id ?? '') + const [borrowAsset, setBorrowAsset] = useState(borrowable[0]?.id ?? '') // Posting collateral is a `submit` like any other, so it reuses the same // build-review-sign machinery rather than a bespoke path. const collateral$ = useCollateral() @@ -134,30 +190,36 @@ export function OpenPositions(): JSX.Element | null { // just been paid looked like it had not been recorded at all. useEffect(() => onTurnsChanged(() => setPending(pendingWithdrawals(loadTurns(slug)))), [slug]) - const { data: offers } = useQuery({ + const { data: offers, isError: offersUnread } = useQuery({ queryKey: ['open-offers', address], queryFn: () => loadOffers(address as string), enabled: isConnected && address !== undefined, refetchInterval: 15_000, }) + const { data: poolShares, isError: poolsUnread } = useQuery({ + queryKey: ['pool-shares', address], + queryFn: () => loadPoolShares(address as string), + enabled: isConnected && address !== undefined, + refetchInterval: 60_000, + }) /** - * Converts part of a plain supply into collateral. + * Posts collateral, from the wallet. * - * Confirmed first, because this is the step that spends the guarantee: a - * plain supply cannot be liquidated and collateral can, and no other action - * in this app changes that. The wallet prompt that follows says nothing - * about seizure, so the app has to. + * Confirmed first, because this is the step that takes on the risk: a plain + * supply cannot be liquidated and collateral can. It does not convert a supply: + * Blend's `apply_supply_collateral` takes the amount from the spender. The wallet + * prompt that follows says nothing about seizure, so the app has to. */ function postCollateral(baseUnits: string, assetId: string, symbol: string): void { const display = (Number(baseUnits) / 1e7).toLocaleString(undefined, { maximumFractionDigits: 7, }) const agreed = window.confirm( - `Post ${display} ${symbol} as collateral?\n\n` + - 'It keeps earning, and it can back a loan. It can also be taken from you if a loan ' + + `Post ${display} ${symbol} from your wallet as collateral?\n\n` + + 'It earns, and it can back a loan. It can also be taken from you if a loan ' + 'you open against it falls below its liquidation threshold.\n\n' + - 'A plain supply carries no such risk. This one will.' + 'What you already have supplied stays as it is, and carries no such risk.' ) if (!agreed) return @@ -172,19 +234,23 @@ export function OpenPositions(): JSX.Element | null { if (withdraw.phase !== 'settled') return void queryClient.invalidateQueries({ queryKey: ['blend-position', address] }) }, [withdraw.phase, queryClient, address]) + useEffect(() => { + if (borrow.phase !== 'settled') return + void queryClient.invalidateQueries({ queryKey: ['blend-position', address] }) + }, [borrow.phase, queryClient, address]) - const resting = offers ?? [] + const supplied = positions?.supplied ?? [] const collateral = positions?.collateral ?? [] const borrowed = positions?.borrowed ?? [] const count = - resting.length + - (position != null ? 1 : 0) + + (offers?.length ?? 0) + + (poolShares?.length ?? 0) + + supplied.length + collateral.length + borrowed.length + pending.length const busy = withdraw.phase !== 'idle' - const typed = position == null ? undefined : typedBaseUnits(draft, position.amount) // A borrow is not bounded by a balance the way a withdrawal is, so this // only checks the figure is well formed. The pool decides whether the @@ -203,7 +269,8 @@ export function OpenPositions(): JSX.Element | null { // Nothing open is a perfectly good state and says so once, rather than // rendering an empty titled section that reads as something failing to load. - if (!isConnected || count === 0) return null + // A read that failed is not "nothing open", which is the one thing this must not imply. + if (!isConnected || (count === 0 && !blendUnread && !offersUnread && !poolsUnread)) return null return (
@@ -212,11 +279,19 @@ export function OpenPositions(): JSX.Element | null { {count}
+ {blendUnread ? ( + + Could not read your Blend position from the network, so this list may be missing money you + hold there. It will try again shortly. + + ) : null} + {/* Lending first: it earns continuously and has no expiry, so it is the - position most easily forgotten. */} - {position != null ? ( - - + position most easily forgotten. Every asset, not only XLM: a supply of + anything else used to be invisible here, and so had no way out. */} + {supplied.map((held) => ( + +
@@ -224,7 +299,7 @@ export function OpenPositions(): JSX.Element | null { earning
- {amount(position.display)} {position.symbol} + {amount(held.display)} {held.symbol}
@@ -233,58 +308,28 @@ export function OpenPositions(): JSX.Element | null { offer a withdrawal for a plain (non-collateral) supply, which is how a real position became visible here and retrievable nowhere. */} - setDraft(e.target.value)} - inputMode="decimal" - placeholder="Amount" - aria-label={`Amount of ${position.symbol} to withdraw`} - disabled={busy} - className="border-border bg-background focus:border-foreground/40 w-24 rounded-md border px-2 py-1.5 text-sm tabular-nums outline-none transition-colors disabled:opacity-50" - /> - - {/* Distinct from typing the balance shown. That figure is stale the - moment it renders -- the position earns every ledger -- so only - this path can actually empty it. */} - - {/* The one action here that takes a guarantee away. A plain supply - cannot be liquidated; collateral can. Typing an amount and - pressing this converts that much of it, and the confirmation - says so plainly rather than treating it as a transfer. */} - + />
- ) : null} + ))} {/* Sits directly under the position it acts on, so the amount being withdrawn and the amount held are readable together. */} @@ -314,7 +359,7 @@ export function OpenPositions(): JSX.Element | null { {collateral$.error ?? 'Something went wrong.'}

- Nothing moved, and your supply is unchanged. + Nothing moved, and your wallet is unchanged.