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
19 changes: 19 additions & 0 deletions packages/contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(())
}

Expand All @@ -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(())
}

Expand All @@ -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(())
}

Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(())
}
Expand Down
182 changes: 181 additions & 1 deletion packages/stellar-sdk-helpers/src/admin-history.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<string, unknown>;
expect(callArgs.cursor).toBe("some-cursor");
expect(callArgs).not.toHaveProperty("startLedger");
});
});
Loading
Loading