diff --git a/packages/contracts/vault/src/lib.rs b/packages/contracts/vault/src/lib.rs index 8f3992b7..30c5c694 100644 --- a/packages/contracts/vault/src/lib.rs +++ b/packages/contracts/vault/src/lib.rs @@ -28,6 +28,13 @@ const MIG_ACTIVE: Symbol = symbol_short!("MIG_ACT"); /// stabilise. At ~5 s per Stellar ledger close, 12 ledgers ≈ 1 minute. const MIN_LEDGER_GAP: u32 = 12; +// --------------------------------------------------------------------------- +// Event topics +// --------------------------------------------------------------------------- + +/// Top-level topic shared by all vault admin-action events. +const ADMIN_EVT: Symbol = symbol_short!("admin"); + // Virtual shares/assets offset (OpenZeppelin ERC-4626 mitigation against the // first-depositor inflation attack). Share price is computed against // `total_assets + OFFSET` over `total_shares + OFFSET` instead of the raw @@ -730,6 +737,8 @@ impl MeridianVault { pub fn set_paused(env: Env, paused: bool) -> Result<(), ContractError> { Self::require_admin(&env)?; env.storage().instance().set(&PAUSED, &paused); + env.events() + .publish((ADMIN_EVT, symbol_short!("paused")), paused); Ok(()) } @@ -748,6 +757,8 @@ impl MeridianVault { pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), ContractError> { Self::require_admin(&env)?; env.storage().instance().set(&PEND_ADM, &new_admin); + env.events() + .publish((ADMIN_EVT, symbol_short!("transfer")), new_admin.clone()); Ok(()) } @@ -765,6 +776,8 @@ impl MeridianVault { pending.require_auth(); env.storage().instance().set(&ADMIN, &pending); env.storage().instance().remove(&PEND_ADM); + env.events() + .publish((ADMIN_EVT, symbol_short!("accept")), pending.clone()); Ok(()) } @@ -802,6 +815,8 @@ impl MeridianVault { return Err(ContractError::AdapterSwapUnsafe); } env.storage().instance().set(&ADAPTER, &new_adapter); + env.events() + .publish((ADMIN_EVT, symbol_short!("adapter")), new_adapter.clone()); Ok(()) } @@ -1018,6 +1033,10 @@ impl MeridianVault { env.storage() .instance() .set(&ADPT_SH, &new_adapter_client.total_shares()); + env.events().publish( + (ADMIN_EVT, symbol_short!("migrate")), + (old_adapter_addr.clone(), new_adapter.clone()), + ); env.storage().instance().set(&MIG_ACTIVE, &0_i128); Ok(()) } diff --git a/packages/stellar-sdk-helpers/src/admin-history.test.ts b/packages/stellar-sdk-helpers/src/admin-history.test.ts index 71abd731..f973a54e 100644 --- a/packages/stellar-sdk-helpers/src/admin-history.test.ts +++ b/packages/stellar-sdk-helpers/src/admin-history.test.ts @@ -1,9 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { decodeScVal, summarizeAction, getAdminActionHistory, + getRpcAdminHistory, } from "./admin-history"; +import { Address, rpc, xdr } from "@stellar/stellar-sdk"; import type { StellarNetwork } from "./types"; const network: StellarNetwork = { @@ -390,3 +392,181 @@ describe("getAdminActionHistory", () => { ); }); }); + +describe("getRpcAdminHistory", () => { + const NEW_ADMIN = "GC7MCAT5QBXWXOUDN57SFAM3T353J7KMNVDRK4J5ZSILUJVRL7M3OUIN"; + const OLD_ADAPTER = + "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526"; + const NEW_ADAPTER = + "CABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAFNSZ"; + + function adminEvent( + action: string, + value: xdr.ScVal, + ledgerClosedAt = "2026-08-27T20:00:00Z" + ): rpc.Api.EventResponse { + return { + type: "contract", + ledger: 100, + ledgerClosedAt, + id: "0000000100000000-0000000001", + pagingToken: "0000000100000000-0000000001", + topic: [xdr.ScVal.scvSymbol("admin"), xdr.ScVal.scvSymbol(action)], + value, + inSuccessfulContractCall: true, + txHash: "HASH", + } as unknown as rpc.Api.EventResponse; + } + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("parses a paused event", async () => { + vi.spyOn(rpc.Server.prototype, "getEvents").mockResolvedValueOnce({ + events: [adminEvent("paused", xdr.ScVal.scvBool(true))], + latestLedger: 100, + cursor: "next-cursor", + } as never); + + const { actions, nextCursor } = await getRpcAdminHistory( + network.rpcUrl, + VAULT_ID + ); + + expect(actions).toHaveLength(1); + expect(actions[0]).toMatchObject({ + action: "paused", + ledgerSequence: 100, + payload: { paused: true }, + }); + expect(actions[0]!.timestamp).toEqual(new Date("2026-08-27T20:00:00Z")); + expect(nextCursor).toBe("next-cursor"); + }); + + it("parses a transfer event", async () => { + vi.spyOn(rpc.Server.prototype, "getEvents").mockResolvedValueOnce({ + events: [adminEvent("transfer", Address.fromString(NEW_ADMIN).toScVal())], + latestLedger: 100, + } as never); + + const { actions } = await getRpcAdminHistory(network.rpcUrl, VAULT_ID); + + expect(actions[0]).toMatchObject({ + action: "transfer", + payload: { newAdmin: NEW_ADMIN }, + }); + }); + + it("parses an accept event", async () => { + vi.spyOn(rpc.Server.prototype, "getEvents").mockResolvedValueOnce({ + events: [adminEvent("accept", Address.fromString(NEW_ADMIN).toScVal())], + latestLedger: 100, + } as never); + + const { actions } = await getRpcAdminHistory(network.rpcUrl, VAULT_ID); + + expect(actions[0]).toMatchObject({ + action: "accept", + payload: { newAdmin: NEW_ADMIN }, + }); + }); + + it("parses an adapter event", async () => { + vi.spyOn(rpc.Server.prototype, "getEvents").mockResolvedValueOnce({ + events: [ + adminEvent("adapter", Address.fromString(NEW_ADAPTER).toScVal()), + ], + latestLedger: 100, + } as never); + + const { actions } = await getRpcAdminHistory(network.rpcUrl, VAULT_ID); + + expect(actions[0]).toMatchObject({ + action: "adapter", + payload: { newAdapter: NEW_ADAPTER }, + }); + }); + + it("parses a migrate event with old and new adapter addresses", async () => { + vi.spyOn(rpc.Server.prototype, "getEvents").mockResolvedValueOnce({ + events: [ + adminEvent( + "migrate", + xdr.ScVal.scvVec([ + Address.fromString(OLD_ADAPTER).toScVal(), + Address.fromString(NEW_ADAPTER).toScVal(), + ]) + ), + ], + latestLedger: 100, + } as never); + + const { actions } = await getRpcAdminHistory(network.rpcUrl, VAULT_ID); + + expect(actions[0]).toMatchObject({ + action: "migrate", + payload: { oldAdapter: OLD_ADAPTER, newAdapter: NEW_ADAPTER }, + }); + }); + + it("skips events with an unrecognised action symbol", async () => { + vi.spyOn(rpc.Server.prototype, "getEvents").mockResolvedValueOnce({ + events: [adminEvent("unknown_action", xdr.ScVal.scvBool(true))], + latestLedger: 100, + } as never); + + const { actions } = await getRpcAdminHistory(network.rpcUrl, VAULT_ID); + + expect(actions).toHaveLength(0); + }); + + it("skips a migrate event with fewer than two vec items", async () => { + vi.spyOn(rpc.Server.prototype, "getEvents").mockResolvedValueOnce({ + events: [ + adminEvent("migrate", xdr.ScVal.scvVec([xdr.ScVal.scvBool(true)])), + ], + latestLedger: 100, + } as never); + + const { actions } = await getRpcAdminHistory(network.rpcUrl, VAULT_ID); + + expect(actions).toHaveLength(0); + }); + + it("skips a paused event whose value is not a bool", async () => { + vi.spyOn(rpc.Server.prototype, "getEvents").mockResolvedValueOnce({ + events: [adminEvent("paused", xdr.ScVal.scvSymbol("not-a-bool"))], + latestLedger: 100, + } as never); + + const { actions } = await getRpcAdminHistory(network.rpcUrl, VAULT_ID); + + expect(actions).toHaveLength(0); + }); + + it("omits timestamp when ledgerClosedAt is not provided", async () => { + vi.spyOn(rpc.Server.prototype, "getEvents").mockResolvedValueOnce({ + events: [adminEvent("paused", xdr.ScVal.scvBool(true), "")], + latestLedger: 100, + } as never); + + const { actions } = await getRpcAdminHistory(network.rpcUrl, VAULT_ID); + + expect(actions[0]!.timestamp).toBeUndefined(); + }); + + it("uses a cursor request instead of startLedger when a cursor is supplied", async () => { + const spy = vi + .spyOn(rpc.Server.prototype, "getEvents") + .mockResolvedValueOnce({ events: [], latestLedger: 100 } as never); + + await getRpcAdminHistory(network.rpcUrl, VAULT_ID, { + cursor: "some-cursor", + }); + + const callArgs = spy.mock.calls[0]![0] as Record; + expect(callArgs.cursor).toBe("some-cursor"); + expect(callArgs).not.toHaveProperty("startLedger"); + }); +}); diff --git a/packages/stellar-sdk-helpers/src/admin-history.ts b/packages/stellar-sdk-helpers/src/admin-history.ts index ed00ea01..f67c75eb 100644 --- a/packages/stellar-sdk-helpers/src/admin-history.ts +++ b/packages/stellar-sdk-helpers/src/admin-history.ts @@ -1,6 +1,13 @@ -import { Address, xdr } from "@stellar/stellar-sdk"; +import { Address, rpc, xdr } from "@stellar/stellar-sdk"; import type { StellarNetwork } from "./types"; +/* ─────────────────────────────────────────────────────────────────────────── + * Horizon-based admin history (issue #616) + * Reads past admin actions by scanning Horizon `invoke_host_function` + * operations. This is the data source confirmed in the upstream codebase and + * is covered by admin-history.test.ts. + * ──────────────────────────────────────────────────────────────────────── */ + export interface AdminAction { id: string; type: AdminActionType; @@ -224,3 +231,179 @@ export async function getAdminActionHistory( return actions; } + +/* ─────────────────────────────────────────────────────────────────────────── + * RPC getEvents admin history (issue #697) + * Reads vault admin actions from Soroban RPC `getEvents` — filtering + * server-side by contract ID and the `admin` topic emitted by the vault + * contract. This scales to mainnet because the RPC filters on the server. + * Types are prefixed with `Rpc` to avoid colliding with the Horizon types + * above. + * ──────────────────────────────────────────────────────────────────────── */ + +export type RpcAdminActionType = + "paused" | "transfer" | "accept" | "adapter" | "migrate"; + +export interface RpcAdminAction { + /** The kind of admin action recorded on-chain. */ + action: RpcAdminActionType; + /** Ledger sequence at which the event was emitted. */ + ledgerSequence: number; + /** Approximate wall-clock time if the RPC response includes it. */ + timestamp?: Date; + /** Topic-parsed payload. */ + payload: RpcAdminActionPayload; +} + +export type RpcAdminActionPayload = + | { paused: boolean } + | { newAdmin: string } + | { newAdapter: string } + | { oldAdapter: string; newAdapter: string }; + +export interface RpcAdminHistoryOptions { + /** First ledger to scan. Defaults to 0 (genesis). Ignored when `cursor` is set. */ + startLedger?: number; + /** Maximum events to fetch in one request. Defaults to 200. */ + limit?: number; + /** Cursor for pagination. Mutually exclusive with `startLedger`. */ + cursor?: string; +} + +/** + * Base64 XDR of `ScVal::Symbol("admin")`, the top-level topic the vault + * attaches to every admin-action event. + * + * Derived from the SDK at module load rather than hardcoded so the wire + * encoding can never drift from the library that decodes it: + * `xdr.ScVal.scvSymbol("admin").toXDR("base64") === "AAAADwAAAAVhZG1pbgAAAA=="`. + */ +const ADMIN_TOPIC_XDR: string = xdr.ScVal.scvSymbol("admin").toXDR("base64"); + +/** + * Reads vault admin actions from RPC `getEvents` instead of paging through + * Horizon's global invocation history. + * + * Filters to the vault contract and the top-level `admin` topic, then + * parses each event into a typed {@link RpcAdminAction}. This scales to mainnet + * because the RPC filters server-side by contract ID and topic. + */ +export async function getRpcAdminHistory( + rpcUrl: string, + vaultContractId: string, + options: RpcAdminHistoryOptions = {} +): Promise<{ actions: RpcAdminAction[]; nextCursor?: string }> { + const server = new rpc.Server(rpcUrl); + const limit = options.limit ?? 200; + + const filters: rpc.Api.EventFilter[] = [ + { + type: "contract", + contractIds: [vaultContractId], + topics: [[ADMIN_TOPIC_XDR]], + }, + ]; + + // `GetEventsRequest` is a discriminated union: a request is either a ledger + // range or a cursor continuation, never both. + const response = options.cursor + ? await server.getEvents({ filters, cursor: options.cursor, limit }) + : await server.getEvents({ + filters, + startLedger: options.startLedger ?? 0, + limit, + }); + + const actions: RpcAdminAction[] = []; + for (const event of response.events ?? []) { + const parsed = parseAdminEvent(event); + if (parsed) actions.push(parsed); + } + + return { actions, nextCursor: response.cursor }; +} + +/** + * Converts one RPC event into an {@link RpcAdminAction}, or `null` when the + * event is not a recognised admin action. + * + * The vault emits `(admin, )` topics, so `topic[0]` is the constant + * `admin` symbol and `topic[1]` carries the action. The event value is already + * a decoded `xdr.ScVal`, so no base64 round-trip is needed. + */ +function parseAdminEvent(event: rpc.Api.EventResponse): RpcAdminAction | null { + const action = parseTopicAction(event.topic[1]); + if (!action) return null; + + // `timestamp` is spread conditionally: with `exactOptionalPropertyTypes` an + // optional property may not be assigned an explicit `undefined`. + const base: Omit = { + action, + ledgerSequence: event.ledger, + ...(event.ledgerClosedAt + ? { timestamp: new Date(event.ledgerClosedAt) } + : {}), + }; + + const value = event.value; + switch (action) { + case "paused": { + if (value.switch().name !== "scvBool") return null; + return { ...base, payload: { paused: value.b() } }; + } + case "transfer": + case "accept": { + const newAdmin = parseScValAddress(value); + return newAdmin ? { ...base, payload: { newAdmin } } : null; + } + case "adapter": { + const newAdapter = parseScValAddress(value); + return newAdapter ? { ...base, payload: { newAdapter } } : null; + } + case "migrate": { + // The value is a two-element vec: (old_adapter, new_adapter). + if (value.switch().name !== "scvVec") return null; + const items = value.vec(); + if (!items || items.length < 2) return null; + const oldAdapter = parseScValAddress(items[0]!); + const newAdapter = parseScValAddress(items[1]!); + if (!oldAdapter || !newAdapter) return null; + return { ...base, payload: { oldAdapter, newAdapter } }; + } + } +} + +/** + * Reads the action symbol out of `topic[1]`. Accessing a mismatched arm on an + * `xdr.ScVal` throws, so the switch is checked before reading. + */ +function parseTopicAction( + topic: xdr.ScVal | undefined +): RpcAdminActionType | null { + if (!topic || topic.switch().name !== "scvSymbol") return null; + + const symbol = topic.sym().toString(); + switch (symbol) { + case "paused": + case "transfer": + case "accept": + case "adapter": + case "migrate": + return symbol; + default: + return null; + } +} + +/** + * Decodes an `ScVal` holding a contract or account `Address` into its strkey + * form. Mirrors the `Address.fromScVal` decoding used by {@link decodeScVal}. + */ +function parseScValAddress(val: xdr.ScVal): string | null { + if (val.switch().name !== "scvAddress") return null; + try { + return Address.fromScVal(val).toString(); + } catch { + return null; + } +}