Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,15 @@ MERIDIAN_MIGRATION_MIN_IMPROVEMENT_BPS=50
# Deliberately not a fixed list, a new protocol is a new env var, never a
# code change (migrate_adapter itself has no notion of protocol either, it
# just takes an address). Leave unset to exclude a protocol from
# consideration. Real rate comparison for either protocol is not implemented
# yet, and the live testnet vault doesn't have migrate_adapter deployed yet
# either (see apps/docs/operations/migration-keeper.md), so the keeper never
# actually migrates anything regardless of these.
# consideration. Real rate comparison is implemented for both protocols
# (packages/stellar-sdk-helpers/src/rate-sources.ts): Blend prices
# immediately; DeFindex needs a second, time-separated share-price sample
# per pool before it can report a rate, persisted via UPSTASH_REDIS_REST_URL
# / UPSTASH_REDIS_REST_TOKEN above (falls back to an in-memory, per-process
# store — not durable across serverless invocations — when those aren't
# set). The live testnet vault still doesn't have migrate_adapter deployed
# (see apps/docs/operations/migration-keeper.md, #514), so the keeper can't
# actually migrate anything on testnet yet regardless of these.
MERIDIAN_ADAPTER_BLEND_ID=
MERIDIAN_ADAPTER_DEFINDEX_ID=

Expand Down
80 changes: 51 additions & 29 deletions apps/docs/operations/migration-keeper.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,60 @@ consent, delegation, or signature is needed.

## Current status: not yet functional against the live testnet vault

Two independent gaps, tracked separately, both must close before this keeper
actually migrates anything in practice:
One remaining gap blocks this keeper from actually migrating anything in
practice:

- The live testnet vault (`CONTRACT_ADDRESSES.testnet.vault`) predates
`migrate_adapter` being added to `vault/src/lib.rs` and was never
redeployed since; it doesn't have the function at all. Confirmed directly
via `stellar contract invoke -- --help` against the live contract. See
#514.
- Rate comparison isn't implemented (below). See #511.

Everything else described in this document, the discovery, retry, deadline
budget, and structured-failure-reporting mechanism, is built and tested; it
has nothing real to act on yet.

## Rate comparison is not implemented yet

Neither adapter contract exposes a ready-made, comparable rate:

- `BlendAdapter` exposes `total_assets()` (a point-in-time USDC value) and
the underlying pool's raw reserve data (utilization, the kinked-curve
parameters `r_base`/`r_one`/`r_two`/`r_three`). Turning that into a current
interest rate means reimplementing Blend's three-slope interest rate
formula off-chain. Nothing in this codebase does that today.
- `DefindexAdapter` exposes `get_asset_amounts_per_shares()`, a share-price
snapshot. Deriving a rate from that needs a second sample over time; no
history is stored anywhere for it either.

Rate comparison is deliberately pluggable (`RateSourceFn` in
`packages/stellar-sdk-helpers/src/migration-keeper.ts`) rather than guessed
at. The default implementation always returns `null` ("rate unknown"), so
**the keeper never migrates anything until a real rate source is injected**.
Implementing either protocol's rate formula is separate, dedicated follow-up
work, not rushed into the mechanism this PR ships.
Rate comparison (below) is now implemented (#511). Everything else described
in this document — the discovery, retry, deadline budget, and
structured-failure-reporting mechanism — is built and tested. Once #514
closes, this keeper is functionally complete end to end; #514 is the only
remaining blocker to a real testnet migration.

## Rate comparison

Neither adapter contract exposes a ready-made, comparable rate, so
`packages/stellar-sdk-helpers/src/rate-sources.ts` derives one for each
protocol from what's actually available on-chain:

- **Blend**: `BlendAdapter` exposes `total_assets()` (a point-in-time USDC
value) and, via `get_pool()`, the underlying pool. Rather than
reimplementing Blend's three-slope interest rate curve off-chain from the
pool's raw reserve fields, `createBlendRateSource` loads the pool with
`@blend-capital/blend-sdk` (already a dependency, used elsewhere in this
package for position reads) and reads the reserve's own `estSupplyApy` —
the same weekly-compounded rate estimate Blend's own indexer and UI
compute, via `Reserve.setRates()`. This avoids a second, hand-rolled copy
of that formula that could silently drift from Blend's actual deployed
behavior.
- **DeFindex**: `DefindexAdapter` exposes `get_asset_amounts_per_shares()`, a
live share-price snapshot with no rate on its own — a rate needs a second
sample separated in time. `createDefindexRateSource` takes a fresh
snapshot on every call and persists it via a pluggable `RateSnapshotStore`,
keyed by the DeFindex vault's own contract address. The first time a given
vault is evaluated (or any time its snapshot has expired) this correctly
returns null — "rate unknown" — not a fabricated rate; a comparable
annualized rate is only returned once two snapshots exist at least 10
minutes apart. In production, `createDefaultRateSource` backs this store
with Upstash Redis over its plain HTTP REST API, reusing the same
`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` credentials
`apps/api/_lib/middleware.ts` already requires for its rate limiter — one
Upstash instance backs both, no new infrastructure to provision. Without
those set, it falls back to an in-memory store that does **not** survive
across separate serverless invocations (each Vercel Cron tick is a fresh
process), which in practice means DeFindex never accumulates a comparable
rate outside of Upstash being configured.

Rate comparison stays deliberately pluggable (`RateSourceFn` in
`migration-keeper.ts`): `createDefaultRateSource(config.network)` is
`runMigrationKeeper`'s default when the caller doesn't inject
`deps.rateSource` explicitly, but nothing about the mechanism assumes it's
the only possible implementation.

## Schedule

Expand All @@ -63,11 +84,12 @@ keeper's 15-minute schedule could express, so scheduling lives in GitHub
Actions instead (see #513 and `apps/docs/operations/accrual-keeper.md`).
Hourly, not every 15 minutes like the accrue keeper: a migration decision is
not time-sensitive the way interest accrual staleness is, and unnecessary
runs cost nothing while the rate source is unconfigured, but there is no
reason to poll faster than the decision needs.
runs cost nothing while no candidate adapters are configured (or DeFindex
hasn't accumulated a second snapshot yet, see above), but there is no reason
to poll faster than the decision needs.

The schedule runs unconditionally, independent of whether the feature is
actually ready (#511, #514). If `MERIDIAN_MIGRATION_KEEPER_SECRET_KEY`
actually ready (#514). If `MERIDIAN_MIGRATION_KEEPER_SECRET_KEY`
isn't set, the endpoint returns `200 { status: "disabled" }` rather than
throwing, so an intentionally-unfinished feature doesn't produce an hourly
false alarm.
Expand Down
12 changes: 8 additions & 4 deletions packages/stellar-sdk-helpers/src/blend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import { prepareSorobanTx } from "./tx";
import type { StellarNetwork } from "./types";
import type { PositionInfo } from "./positions";

const BLEND_RPC_TIMEOUT_MS = 10_000;
export const BLEND_RPC_TIMEOUT_MS = 10_000;

// The Blend SDK does not accept an AbortSignal, so we race the call against a
// manual timeout rejection. The underlying fetch will still complete, but the
// caller gets a fast failure it can retry rather than waiting for Vercel's
// function-level deadline.
const withBlendTimeout = <T>(fn: () => Promise<T>, ms = BLEND_RPC_TIMEOUT_MS) =>
withRaceTimeout(fn, ms, "Blend RPC");
// function-level deadline. Exported so other Blend-SDK call sites (see
// rate-sources.ts) share this instead of redefining it, and stay in sync if
// the timeout is ever tuned.
export const withBlendTimeout = <T>(
fn: () => Promise<T>,
ms = BLEND_RPC_TIMEOUT_MS
) => withRaceTimeout(fn, ms, "Blend RPC");

export interface BlendPoolConfig {
// Blend pool contract (C...) the request is submitted to.
Expand Down
65 changes: 64 additions & 1 deletion packages/stellar-sdk-helpers/src/defindex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {
buildDefindexWithdrawTx,
stroopsToUnits,
fetchDefindexPosition,
getDefindexAssetAmountPerShares,
} from "./defindex";
import { clearRpcServerCache } from "./internal";
import { clearRpcServerCache, getRpcServer } from "./internal";
import { Address, Contract, nativeToScVal, xdr } from "@stellar/stellar-sdk";
import type { StellarNetwork } from "./types";

Expand Down Expand Up @@ -290,6 +291,68 @@ describe("fetchDefindexPosition", () => {
});
});

// Shared by buildDefindexWithdrawTx, fetchDefindexPosition (covered above via
// their own null/empty-array edge cases), and rate-sources.ts's DeFindex
// share-price probe — this exercises it directly as the single place that
// response-parsing logic now lives.
describe("getDefindexAssetAmountPerShares", () => {
const VAULT_ID = "CVAULT000000000000000000000000000000000000000000000000000";

beforeEach(() => {
vi.clearAllMocks();
clearRpcServerCache();
});

it("calls get_asset_amounts_per_shares with the given shares and returns the first amount", async () => {
vi.mocked(simulateView).mockResolvedValueOnce([12_345_678n]);
const server = getRpcServer(network.rpcUrl, 5_000);

const amount = await getDefindexAssetAmountPerShares(
server,
VAULT_ID,
network.passphrase,
10_000_000n
);

expect(amount).toBe(12_345_678n);
expect(simulateView).toHaveBeenCalledWith(
server,
VAULT_ID,
network.passphrase,
"get_asset_amounts_per_shares",
expect.anything()
);
});

it("returns null when the response is null", async () => {
vi.mocked(simulateView).mockResolvedValueOnce(null);
const server = getRpcServer(network.rpcUrl, 5_000);

expect(
await getDefindexAssetAmountPerShares(
server,
VAULT_ID,
network.passphrase,
10_000_000n
)
).toBeNull();
});

it("returns null when the response array is empty", async () => {
vi.mocked(simulateView).mockResolvedValueOnce([]);
const server = getRpcServer(network.rpcUrl, 5_000);

expect(
await getDefindexAssetAmountPerShares(
server,
VAULT_ID,
network.passphrase,
10_000_000n
)
).toBeNull();
});
});

describe("slippage tolerance", () => {
it("default 0.1% tolerance produces minAmount strictly less than amount", () => {
const amount = 1_000_000_000n;
Expand Down
64 changes: 45 additions & 19 deletions packages/stellar-sdk-helpers/src/defindex.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Address, Contract, nativeToScVal, xdr } from "@stellar/stellar-sdk";
import {
Address,
Contract,
nativeToScVal,
rpc,
xdr,
} from "@stellar/stellar-sdk";
import { simulateView, prepareSorobanTx } from "./tx";
import type { StellarNetwork } from "./types";
import type { PositionInfo } from "./positions";
Expand All @@ -22,6 +28,32 @@ function i128(value: bigint): xdr.ScVal {
return nativeToScVal(value, { type: "i128" });
}

/**
* Quotes the underlying-asset value of `shares` DeFindex shares via
* get_asset_amounts_per_shares, returning the single-asset vault's one
* amount, or null when the simulation returned no usable value. Shared by
* buildDefindexWithdrawTx, fetchDefindexPosition, and rate-sources.ts's
* DeFindex share-price probe so the response parsing (the array check and
* toBigInt(amounts[0])) only needs to be right in one place.
*/
export async function getDefindexAssetAmountPerShares(
server: rpc.Server,
vaultId: string,
passphrase: string,
shares: bigint
): Promise<bigint | null> {
const amounts = (await simulateView(
server,
vaultId,
passphrase,
"get_asset_amounts_per_shares",
i128(shares)
)) as Array<bigint | number> | null;
return Array.isArray(amounts) && amounts.length > 0
? toBigInt(amounts[0])
: null;
}

/**
* Build an unsigned transaction that deposits `amount` (in stroops) of the
* vault's single underlying asset into a DeFindex vault on behalf of `depositor`,
Expand Down Expand Up @@ -79,17 +111,13 @@ export async function buildDefindexWithdrawTx(

// Quote the expected payout so we can compute a real floor.
const server = getRpcServer(config.network.rpcUrl, 12_000);
const expectedAmounts = (await simulateView(
server,
config.vaultId,
config.network.passphrase,
"get_asset_amounts_per_shares",
i128(shares)
)) as Array<bigint | number> | null;
const expectedAmount =
Array.isArray(expectedAmounts) && expectedAmounts.length > 0
? toBigInt(expectedAmounts[0])
: 0n;
(await getDefindexAssetAmountPerShares(
server,
config.vaultId,
config.network.passphrase,
shares
)) ?? 0n;
const minAmount = expectedAmount - (expectedAmount * slippageBps) / 10_000n;

const contract = new Contract(config.vaultId);
Expand Down Expand Up @@ -129,15 +157,13 @@ export async function fetchDefindexPosition(
);
if (shares <= 0n) return [];

const amounts = (await simulateView(
server,
vaultId,
network.passphrase,
"get_asset_amounts_per_shares",
i128(shares)
)) as Array<bigint | number> | null;
const underlying =
Array.isArray(amounts) && amounts.length > 0 ? toBigInt(amounts[0]) : 0n;
(await getDefindexAssetAmountPerShares(
server,
vaultId,
network.passphrase,
shares
)) ?? 0n;

return [
{
Expand Down
1 change: 1 addition & 0 deletions packages/stellar-sdk-helpers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from "./known-pools";
export * from "./migration-keeper";
export * from "./orchestration";
export * from "./positions";
export * from "./rate-sources";
export * from "./routing";
export * from "./tx";
export * from "./types";
Expand Down
36 changes: 35 additions & 1 deletion packages/stellar-sdk-helpers/src/migration-keeper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ vi.mock("./tx", () => ({
waitForTransaction: stellarMocks.waitForTransaction,
}));

// The real default rate source (rate-sources.ts) is covered by its own
// dedicated test file (rate-sources.test.ts): it talks to Blend/DeFindex
// over the network and needs its own fixtures and mocks. Here it's mocked
// out entirely so this file can stay focused on keeper mechanics
// (discovery, retry, submission) without dragging that in, while still
// proving runMigrationKeeper actually wires it up when no rateSource dep is
// injected (see "wires the real default rate source" below).
const rateSourcesMocks = vi.hoisted(() => ({
createDefaultRateSource: vi.fn(),
}));

vi.mock("./rate-sources", () => ({
createDefaultRateSource: rateSourcesMocks.createDefaultRateSource,
}));

import {
discoverMigrationVaults,
loadMigrationKeeperConfig,
Expand Down Expand Up @@ -158,6 +173,11 @@ beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vi.useRealTimers();
// Every test other than the one specifically about default wiring passes
// its own `rateSource` dep, which takes precedence over this; this default
// just keeps those unrelated tests from ever hitting the real
// createDefaultRateSource mock (undefined) if they forget to.
rateSourcesMocks.createDefaultRateSource.mockReturnValue(async () => null);
stellarMocks.getRpcServer.mockReturnValue(makeServer());
stellarMocks.keypairFromSecret.mockReturnValue({
publicKey: vi.fn(() => "GADMIN"),
Expand Down Expand Up @@ -415,8 +435,16 @@ describe("discoverMigrationVaults", () => {
});

describe("runMigrationKeeper", () => {
it("never migrates with the default rate source: no rate source is verified for either protocol yet", async () => {
it("wires the real default rate source (rate-sources.ts) when no rateSource dep is injected", async () => {
// #511: runMigrationKeeper used to fall back to a stub that always
// returned null, so it could never migrate anything in practice no
// matter how the rest of the mechanism was configured. It must now
// build the real Blend/DeFindex rate source (createDefaultRateSource,
// see rate-sources.ts and its own dedicated tests) from the run's
// network config whenever the caller doesn't supply one explicitly.
const submitMigration = vi.fn();
const stubRateSource = vi.fn(async () => null);
rateSourcesMocks.createDefaultRateSource.mockReturnValue(stubRateSource);

const result = await runMigrationKeeper(CONFIG, {
discoverVaults: async () => ({
Expand All @@ -426,6 +454,12 @@ describe("runMigrationKeeper", () => {
submitMigration,
});

expect(rateSourcesMocks.createDefaultRateSource).toHaveBeenCalledWith(
CONFIG.network
);
expect(stubRateSource).toHaveBeenCalledWith(
expect.objectContaining({ protocol: "blend" })
);
expect(submitMigration).not.toHaveBeenCalled();
expect(result.migrations).toEqual([]);
expect(result.skipped).toEqual([
Expand Down
Loading
Loading