Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
58 changes: 35 additions & 23 deletions apps/docs/architecture/vault-contract.md

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions apps/docs/operations/migration-keeper.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,42 @@ vault; if a second Meridian vault is ever added to `KNOWN_POOLS`, this
would need to become per-vault-scoped first (tracked on #511 alongside the
rate source work, since both matter most once a second vault is likely).

## Two-Phase Migration (`begin_migration` / `migrate_adapter`)

`migrate_adapter` requires an active `begin_migration` snapshot for the same
target adapter, at least `MIN_LEDGER_GAP` ledgers old (~1 minute), before it
will run. This closes a front-running window (issue #567): without it, an
attacker could transiently inflate a candidate adapter's reported valuation
right as a migration lands, then drain it once the funds arrive.

The keeper does not track this phase itself; the on-chain snapshot recorded
by `begin_migration` (readable via `get_migration_snapshot`) is the only
state it needs. On each run, before attempting `migrate_adapter` for the
chosen candidate:

1. Read `get_migration_snapshot()`. If it traps (no active migration) or its
`adapter` doesn't match the candidate, this run's target has no live
cooldown in progress.
2. In that case, submit `begin_migration(candidate)` instead of
`migrate_adapter`, and stop for this vault β€” the result records this as
`skipped`, not `failed`. The submission lease taken for this run is
released immediately (`releaseIfUnsent`) rather than held for the
`submissionTtlMs` window, so it doesn't block a subsequent run from
picking the vault back up.
3. If the snapshot does match the candidate, proceed to `migrate_adapter` as
before. The keeper does not duplicate the contract's ledger-gap math to
decide whether the cooldown has elapsed: if it hasn't, `migrate_adapter`
itself rejects the call with `MigrationCooldownNotMet` during simulation
(no fee, nothing sent), which is reported as a `failures` entry and
naturally retried on a later scheduled run. This is deliberately simple
rather than precise β€” `MIN_LEDGER_GAP` is ~1 minute and this keeper runs
on an hourly schedule, so the cooldown has always long since elapsed by
the run after `begin_migration` fired.

A migration to a given candidate therefore normally spans two scheduled
runs: one that calls `begin_migration`, and the next that calls
`migrate_adapter` once the on-chain snapshot is old enough.

## Retry And Failure Handling

Discovery and submission follow the same shape as the accrue keeper (see
Expand Down
508 changes: 480 additions & 28 deletions packages/contracts/vault/src/lib.rs

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions packages/stellar-sdk-helpers/src/admin-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,28 @@ describe("summarizeAction", () => {
).toBe("Adapter migrated");
});

it("summarizes begin_migration with the target adapter address", () => {
expect(
summarizeAction("begin_migration", [
{ type: "Address", value: "C..." },
{ type: "Sym", value: "begin_migration" },
{
type: "Address",
value: "CNEWADAPTER34567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCD",
},
])
).toBe("Migration cooldown started for CNEWADAP...ABCD");
});

it("falls back to a generic message when begin_migration's address param is missing", () => {
expect(
summarizeAction("begin_migration", [
{ type: "Address", value: "C..." },
{ type: "Sym", value: "begin_migration" },
])
).toBe("Migration cooldown started");
});

it("summarizes transfer_admin with a nominee address", () => {
expect(
summarizeAction("transfer_admin", [
Expand Down
8 changes: 8 additions & 0 deletions packages/stellar-sdk-helpers/src/admin-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type AdminActionType =
| "set_paused"
| "set_adapter"
| "migrate_adapter"
| "begin_migration"
| "transfer_admin"
| "accept_admin";

Expand All @@ -24,6 +25,7 @@ const ADMIN_FUNCTIONS = new Set<AdminActionType>([
"set_paused",
"set_adapter",
"migrate_adapter",
"begin_migration",
"transfer_admin",
"accept_admin",
]);
Expand Down Expand Up @@ -95,6 +97,12 @@ export function summarizeAction(
? `Migrated adapter to ${adapter.slice(0, 8)}...${adapter.slice(-4)}`
: "Adapter migrated";
}
case "begin_migration": {
const adapter = params[2]?.value;
return typeof adapter === "string"
? `Migration cooldown started for ${adapter.slice(0, 8)}...${adapter.slice(-4)}`
: "Migration cooldown started";
}
case "transfer_admin": {
const nominee = params[2]?.value;
return typeof nominee === "string"
Expand Down
209 changes: 196 additions & 13 deletions packages/stellar-sdk-helpers/src/migration-keeper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,30 @@ function makeServer(overrides: Record<string, unknown> = {}) {
};
}

// Configures the shared simulateView mock for the real (non-injected)
// submission path: get_adapter (the pre-submit "vault hasn't moved since
// discovery" guard) resolves to `liveAdapterId`, and get_migration_snapshot
// resolves to an already-active, matching, cooldown-elapsed snapshot for
// `candidateAdapterId` so the test exercises the real migrate_adapter call
// instead of the begin_migration deferral path.
function mockLiveAdapterAndActiveSnapshot(
liveAdapterId: string,
candidateAdapterId: string
) {
stellarMocks.simulateView.mockImplementation(
async (_server, _contractId, _passphrase, method) => {
if (method === "get_migration_snapshot") {
return {
adapter: candidateAdapterId,
total_assets: 0n,
ledger_seq: 0,
};
}
return liveAdapterId;
}
);
}

beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
Expand Down Expand Up @@ -901,8 +925,9 @@ describe("runMigrationKeeper", () => {
stellarMocks.waitForTransaction.mockResolvedValue({ ledger: 321 });
// The pre-submit "vault hasn't moved since discovery" guard reads
// get_adapter() fresh; keep it matching DISCOVERED_VAULT's adapter.
stellarMocks.simulateView.mockResolvedValue(
DISCOVERED_VAULT.currentAdapterId
mockLiveAdapterAndActiveSnapshot(
DISCOVERED_VAULT.currentAdapterId,
"CDEFINDEXADAPTER"
);
const rateSource = vi.fn(async ({ protocol }: { protocol: string }) =>
protocol === "blend" ? 500 : 700
Expand Down Expand Up @@ -950,8 +975,9 @@ describe("runMigrationKeeper", () => {
stellarMocks.waitForTransaction
.mockRejectedValueOnce(new Error("Soroban RPC timed out after 100ms"))
.mockResolvedValueOnce({ ledger: 321 });
stellarMocks.simulateView.mockResolvedValue(
DISCOVERED_VAULT.currentAdapterId
mockLiveAdapterAndActiveSnapshot(
DISCOVERED_VAULT.currentAdapterId,
"CDEFINDEXADAPTER"
);
const rateSource = vi.fn(async ({ protocol }: { protocol: string }) =>
protocol === "blend" ? 500 : 700
Expand Down Expand Up @@ -1201,7 +1227,7 @@ describe("runMigrationKeeper", () => {
// since this run's discovery read; submitting anyway would build a
// migrate_adapter call using stale assumptions about the vault's
// current adapter.
stellarMocks.simulateView.mockResolvedValue("CSOMEOTHERADAPTER");
mockLiveAdapterAndActiveSnapshot("CSOMEOTHERADAPTER", "CDEFINDEXADAPTER");
const rateSource = vi.fn(async ({ protocol }: { protocol: string }) =>
protocol === "blend" ? 500 : 700
);
Expand Down Expand Up @@ -1238,7 +1264,7 @@ describe("runMigrationKeeper", () => {
// trigger that redaction, which would otherwise mask this exact
// scenario. This proves the skip reason survives with real addresses.
const realAddress = `C${"A".repeat(55)}`;
stellarMocks.simulateView.mockResolvedValue(realAddress);
mockLiveAdapterAndActiveSnapshot(realAddress, "CDEFINDEXADAPTER");
const rateSource = vi.fn(async ({ protocol }: { protocol: string }) =>
protocol === "blend" ? 500 : 700
);
Expand All @@ -1262,6 +1288,160 @@ describe("runMigrationKeeper", () => {
]);
});

it("submits begin_migration and defers migrate_adapter when no snapshot exists yet", async () => {
const server = makeServer();
stellarMocks.getRpcServer.mockReturnValue(server);
stellarMocks.waitForTransaction.mockResolvedValue({ ledger: 321 });
// get_migration_snapshot traps (no active migration); get_adapter
// resolves normally for the staleness guard, which is never reached.
stellarMocks.simulateView.mockImplementation(
async (_server, _contractId, _passphrase, method) => {
if (method === "get_migration_snapshot") {
throw new Error("MigrationNotInitialized");
}
return DISCOVERED_VAULT.currentAdapterId;
}
);
const rateSource = vi.fn(async ({ protocol }: { protocol: string }) =>
protocol === "blend" ? 500 : 700
);

const result = await runMigrationKeeper(CONFIG, {
logger: logger(),
discoverVaults: async () => ({
vaults: [DISCOVERED_VAULT],
failures: [],
}),
rateSource,
resolveCandidatePool: async () => "CDEFINDEXPOOL",
sleep: vi.fn(),
});

expect(server.sendTransaction).toHaveBeenCalledOnce();
expect(result.migrations).toEqual([]);
expect(result.failures).toEqual([]);
expect(result.skipped).toMatchObject([
{
vaultId: "meridian-usdc",
reason: expect.stringContaining("begin_migration submitted"),
},
]);
});

it("reports a transient failure reading the migration snapshot instead of assuming none exists", async () => {
// A timeout/rate-limit reading get_migration_snapshot must not be
// treated the same as a genuine MigrationNotInitialized trap: doing so
// would fire begin_migration and reset a possibly already
// cooldown-elapsed snapshot's ledger_seq, delaying a ready migration by
// a full MIN_LEDGER_GAP for no reason.
const server = makeServer();
stellarMocks.getRpcServer.mockReturnValue(server);
stellarMocks.simulateView.mockImplementation(
async (_server, _contractId, _passphrase, method) => {
if (method === "get_migration_snapshot") {
throw new Error("Soroban RPC timed out after 100ms");
}
return DISCOVERED_VAULT.currentAdapterId;
}
);
const rateSource = vi.fn(async ({ protocol }: { protocol: string }) =>
protocol === "blend" ? 500 : 700
);

const result = await runMigrationKeeper(CONFIG, {
logger: logger(),
discoverVaults: async () => ({
vaults: [DISCOVERED_VAULT],
failures: [],
}),
rateSource,
resolveCandidatePool: async () => "CDEFINDEXPOOL",
sleep: vi.fn(),
});

// No begin_migration (or anything else) was sent this run.
expect(server.sendTransaction).not.toHaveBeenCalled();
expect(result.migrations).toEqual([]);
expect(result.skipped).toEqual([]);
expect(result.failures).toMatchObject([
{
vaultId: "meridian-usdc",
transient: true,
error: expect.stringContaining("could not read the migration snapshot"),
},
]);
});

it("submits begin_migration and defers migrate_adapter when the existing snapshot is for a different adapter", async () => {
const server = makeServer();
stellarMocks.getRpcServer.mockReturnValue(server);
stellarMocks.waitForTransaction.mockResolvedValue({ ledger: 321 });
mockLiveAdapterAndActiveSnapshot(
DISCOVERED_VAULT.currentAdapterId,
"CSOMEOTHERCANDIDATE"
);
const rateSource = vi.fn(async ({ protocol }: { protocol: string }) =>
protocol === "blend" ? 500 : 700
);

const result = await runMigrationKeeper(CONFIG, {
logger: logger(),
discoverVaults: async () => ({
vaults: [DISCOVERED_VAULT],
failures: [],
}),
rateSource,
resolveCandidatePool: async () => "CDEFINDEXPOOL",
sleep: vi.fn(),
});

expect(server.sendTransaction).toHaveBeenCalledOnce();
expect(result.migrations).toEqual([]);
expect(result.skipped).toMatchObject([
{
vaultId: "meridian-usdc",
reason: expect.stringContaining("begin_migration submitted"),
},
]);
});

it("releases the submission lease after begin_migration so a later run isn't blocked", async () => {
const server = makeServer();
stellarMocks.getRpcServer.mockReturnValue(server);
stellarMocks.waitForTransaction.mockResolvedValue({ ledger: 321 });
stellarMocks.simulateView.mockImplementation(
async (_server, _contractId, _passphrase, method) => {
if (method === "get_migration_snapshot") {
throw new Error("MigrationNotInitialized");
}
return DISCOVERED_VAULT.currentAdapterId;
}
);
const rateSource = vi.fn(async ({ protocol }: { protocol: string }) =>
protocol === "blend" ? 500 : 700
);
const stateStore = createInMemoryKeeperStateStore();
const key = submissionStateKey(
"migration",
"testnet",
DISCOVERED_VAULT.vaultId
);

await runMigrationKeeper(CONFIG, {
logger: logger(),
discoverVaults: async () => ({
vaults: [DISCOVERED_VAULT],
failures: [],
}),
rateSource,
resolveCandidatePool: async () => "CDEFINDEXPOOL",
sleep: vi.fn(),
stateStore,
});

expect(await stateStore.get(key)).toBeNull();
});

it("skips evaluation entirely when no candidate adapters are configured", async () => {
const rateSource = vi.fn();
const noCandidatesConfig: MigrationKeeperConfig = {
Expand Down Expand Up @@ -1413,8 +1593,9 @@ describe("runMigrationKeeper cross-invocation dedup", () => {
});
stellarMocks.getRpcServer.mockReturnValue(server);
stellarMocks.waitForTransaction.mockResolvedValue({ ledger: 321 });
stellarMocks.simulateView.mockResolvedValue(
DISCOVERED_VAULT.currentAdapterId
mockLiveAdapterAndActiveSnapshot(
DISCOVERED_VAULT.currentAdapterId,
"CDEFINDEXADAPTER"
);
const stateStore = await store({
hash: "LANDED_HASH",
Expand All @@ -1437,8 +1618,9 @@ describe("runMigrationKeeper cross-invocation dedup", () => {
});
stellarMocks.getRpcServer.mockReturnValue(server);
stellarMocks.waitForTransaction.mockResolvedValue({ ledger: 321 });
stellarMocks.simulateView.mockResolvedValue(
DISCOVERED_VAULT.currentAdapterId
mockLiveAdapterAndActiveSnapshot(
DISCOVERED_VAULT.currentAdapterId,
"CDEFINDEXADAPTER"
);

const result = await run(
Expand All @@ -1462,8 +1644,9 @@ describe("runMigrationKeeper cross-invocation dedup", () => {
}),
});
stellarMocks.getRpcServer.mockReturnValue(server);
stellarMocks.simulateView.mockResolvedValue(
DISCOVERED_VAULT.currentAdapterId
mockLiveAdapterAndActiveSnapshot(
DISCOVERED_VAULT.currentAdapterId,
"CDEFINDEXADAPTER"
);
stellarMocks.waitForTransaction.mockResolvedValue({ ledger: 321 });

Expand Down Expand Up @@ -1498,7 +1681,7 @@ describe("runMigrationKeeper cross-invocation dedup", () => {
// The stale-adapter guard aborts before the transaction is built.
// Leaving the claim behind would lock the vault out of migrating for
// the claim's whole window over a transaction that never existed.
stellarMocks.simulateView.mockResolvedValue("CSOMEOTHERADAPTER");
mockLiveAdapterAndActiveSnapshot("CSOMEOTHERADAPTER", "CDEFINDEXADAPTER");
const stateStore = await store();
stellarMocks.getRpcServer.mockReturnValue(makeServer());

Expand Down
Loading
Loading