Skip to content

feat(vault): contract-side admin events + RPC admin-history reader - #698

Merged
collinsezedike merged 1 commit into
drydocs:mainfrom
ZacLou:feat-vault-admin-events-697
Sep 2, 2026
Merged

feat(vault): contract-side admin events + RPC admin-history reader#698
collinsezedike merged 1 commit into
drydocs:mainfrom
ZacLou:feat-vault-admin-events-697

Conversation

@ZacLou

@ZacLou ZacLou commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes #697

Adds explicit event emission to the vault contract's admin functions and an RPC-based reader so the admin action history no longer requires scanning Horizon's global operation feed.

Contract changes:

  • set_paused emits (admin, "paused", paused)
  • transfer_admin emits (admin, "transfer", new_admin)
  • accept_admin emits (admin, "accept", new_admin)
  • set_adapter emits (admin, "adapter", new_adapter)
  • migrate_adapter emits (admin, "migrate", (old_adapter, new_adapter))

SDK changes:

  • New packages/stellar-sdk-helpers/src/admin-history.ts uses RPC getEvents filtered by vault contract ID and the top-level admin topic, returning typed AdminAction[].
  • Exported from packages/stellar-sdk-helpers/src/index.ts.

@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

@ZacLou is attempting to deploy a commit to the Collins' projects Team on Vercel.

A member of the Team first needs to authorize it.

@collinsezedike

Copy link
Copy Markdown
Collaborator

@ZacLou this PR now has merge conflicts with main after today's admin-dashboard and vault contract merges. Could you rebase onto the latest main and resolve them?

@ZacLou
ZacLou force-pushed the feat-vault-admin-events-697 branch from b7af5e8 to 7c01dc0 Compare September 1, 2026 11:02
@ZacLou

ZacLou commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main — all conflicts resolved.

The vault contract change was straightforward: upstream's MIG_ACTIVE reset and our ADMIN_EVT publish are complementary, so both are kept (event first, then reset).

For admin-history.ts, upstream had independently added a Horizon-based reader (getAdminActionHistory) with its own AdminAction/AdminActionType types and tests. Our RPC getEvents reader was renamed to getRpcAdminHistory with Rpc-prefixed types (RpcAdminAction, RpcAdminActionType, etc.) to avoid name collisions while keeping both implementations available.

@ZacLou

ZacLou commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Hi @collinsezedike, the Vercel deployment check is failing with 'Authorization Required' for team 'Collins''' projects'. As an external contributor I don't have permission to authorize Vercel for your team. Could you please click the authorize link in the failing check? The code is ready for review. Thanks!

@collinsezedike collinsezedike left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The vault-side event emissions build cleanly and are low-risk: every publish() call is added after the existing require_admin-gated state mutation, none of that logic is touched. The new RPC-based admin-history reader in admin-history.ts is where the real problems are.

Running pnpm --filter @meridian/stellar-sdk-helpers build on this branch fails to compile this file outright: Server and SorobanRpc are not exports of the installed @stellar/stellar-sdk (14.6.1), ScVal has no .obj() method in this version, and the timestamp field trips exactOptionalPropertyTypes. Beyond the compile errors, the hardcoded topic filter is also wrong: I decoded it, AAAABWFkbWluAAAA is 00 00 00 05 61 64 6d 69 6e 00 00 00, missing the SCV_SYMBOL discriminant (15) entirely. The correct encoding of ScVal::Symbol("admin") is AAAADwAAAAVhZG1pbgAAAA== (verified by encoding it directly with this repo's own installed SDK). Even patched to compile, this filter would never match a single real event from the vault contract, so getRpcAdminHistory would silently return an empty list forever.

@@ -1,6 +1,13 @@
import { Address, xdr } from "@stellar/stellar-sdk";
import { Address, Server, xdr, SorobanRpc } from "@stellar/stellar-sdk";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither Server nor SorobanRpc exists on @stellar/stellar-sdk 14.6.1, confirmed by tsc: "has no exported member 'Server'" and "has no exported member named 'SorobanRpc'". This SDK exposes the RPC client as rpc.Server and the event type as rpc.Api.EventResponse (import { rpc } from "@stellar/stellar-sdk").

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b66e84d. Now import { rpc } from "@stellar/stellar-sdk", with new rpc.Server(rpcUrl) as the client and rpc.Api.EventResponse as the event type. Address and xdr are unchanged.

One thing worth flagging: because Server / SorobanRpc did not resolve, server and event were typed as any, which suppressed every downstream type error in this function. Once the import was correct, tsc surfaced several more mismatches against the real 14.6.1 types. All are fixed in the same commit:

  • event.ledger is the field name, not ledgerSequence
  • response.cursor is the pagination token, not nextCursor
  • topic is xdr.ScVal[], not string[]
  • value is an xdr.ScVal, not a { xdr() } wrapper, so the base64 round-trip is gone
  • Api.GetEventsRequest is a discriminated union that rejects a request carrying both startLedger and cursor, so the request is now built in two branches depending on which pagination mode is in use

The Horizon reader and the vault contract are untouched.

{
type: 'contract',
contractIds: [vaultContractId],
topics: [['AAAABWFkbWluAAAA']], // xdr.ScSymbol("admin") base64

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This base64 is wrong. Decoded, it's 00 00 00 05 61 64 6d 69 6e 00 00 00, missing the leading ScVal discriminant for SCV_SYMBOL (15), so it isn't valid ScVal XDR at all, and won't match the topic the vault actually publishes. The correct value, produced by xdr.ScVal.scvSymbol("admin").toXDR("base64") in this repo's own SDK, is AAAADwAAAAVhZG1pbgAAAA==. As written this filter matches nothing, ever.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and agreed this was the one that mattered. Confirmed locally with this repo's installed SDK (@stellar/stellar-sdk 14.6.1, stellar-base 14.1.0):

> xdr.ScVal.scvSymbol("admin").toXDR("base64")
'AAAADwAAAAVhZG1pbgAAAA=='
> xdr.ScVal.scvSymbol("admin").toXDR("hex")
'0000000f0000000561646d696e000000'

Matches your value exactly. For the record, the old literal does not decode to the wrong discriminant but to an entirely different arm:

> xdr.ScVal.fromXDR("AAAABWFkbWluAAAA", "base64").switch().name
'scvU64'

so it was structurally the wrong ScVal, not just a bad prefix — as you say, it would never have matched a real vault event.

Rather than paste the literal back in, the topic is now derived at module load:

const ADMIN_TOPIC_XDR: string = xdr.ScVal.scvSymbol("admin").toXDR("base64");

That way it cannot drift from the encoding the same SDK decodes. I verified the outgoing request carries topics: [["AAAADwAAAAVhZG1pbgAAAA=="]] and that all five action topics (paused, transfer, accept, adapter, migrate) now parse.

const base: Omit<RpcAdminAction, 'payload'> = {
action,
ledgerSequence: event.ledgerSequence,
timestamp: event.ledgerClosedAt ? new Date(event.ledgerClosedAt) : undefined,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tsc rejects this under exactOptionalPropertyTypes: timestamp?: Date doesn't accept an explicit undefined value, only omission of the key. Either widen the type to timestamp?: Date | undefined or build the object without the key when there's no ledgerClosedAt.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed using the second option — the key is only constructed when ledgerClosedAt is present, so undefined is never explicitly written:

const base: Omit<RpcAdminAction, "payload"> = {
  action,
  ledgerSequence: event.ledger,
  ...(event.ledgerClosedAt
    ? { timestamp: new Date(event.ledgerClosedAt) }
    : {}),
};

I kept timestamp?: Date narrow rather than widening to Date | undefined: callers still read Date | undefined either way, but the property is genuinely absent when there is no close time. Verified against an event with an empty ledgerClosedAt — the key is absent from the returned object rather than set to undefined.

}
case 'migrate': {
const tuple = xdr.ScVal.fromXDR(valueXdr, 'base64');
const items = tuple.obj()?.vec();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScVal has no .obj() method in this SDK version, confirmed by tsc. Same issue at line 376. Use the accessor the installed SDK actually exposes for reading a vec/address out of an ScVal (e.g. .vec() / .address() directly, matching the pattern the rest of this file already uses for parseScValAddress).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed at both sites. ScVal in 14.6.1 exposes the accessors directly, so parseScValAddress now reads the switch and delegates to Address.fromScVal — the same decoding decodeScVal already uses elsewhere in this file:

function parseScValAddress(val: xdr.ScVal): string | null {
  if (val.switch().name !== "scvAddress") return null;
  try {
    return Address.fromScVal(val).toString();
  } catch {
    return null;
  }
}

The migrate arm reads .vec() directly instead of .obj()?.vec(). Both it and the paused arm (.b()) are now guarded by a switch().name check first, because reading a mismatched arm on an xdr.ScVal throws at runtime — a malformed or unexpected event is skipped rather than crashing the whole read.

Since getEvents returns already-decoded ScVals, parseAddressXdr and its base64 round-trip are gone as dead code.

@ZacLou

ZacLou commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@collinsezedike thanks for the review — all four findings are fixed and I've replied inline to each thread. Head is now b66e84d; the vault contract is untouched as you suggested.

Summary of what changed in packages/stellar-sdk-helpers/src/admin-history.ts:

  1. Importimport { rpc } from "@stellar/stellar-sdk", using rpc.Server and rpc.Api.EventResponse.
  2. Topic filter — replaced AAAABWFkbWluAAAA with a value derived from the SDK at module load (xdr.ScVal.scvSymbol("admin").toXDR("base64")), which I confirmed in this repo resolves to AAAADwAAAAVhZG1pbgAAAA== and matches the value you verified. The old literal decodes to scvU64, not scvSymbol.
  3. ScVal.accessors — dropped .obj(); parseScValAddress uses Address.fromScVal(...) (the pattern decodeScVal already uses), and migrate reads .vec() directly.
  4. exactOptionalPropertyTypestimestamp is spread in conditionally, so the key is omitted rather than set to undefined.

Fixing #1 had a knock-on effect worth calling out: Server / SorobanRpc being unresolved meant server and event were any, which hid every type error downstream. With the correct types, tsc surfaced five more mismatches that would otherwise have failed your build on the next run — event.ledger (not ledgerSequence), response.cursor (not nextCursor), topic is ScVal[] (not string[]), value is an ScVal (not { xdr() }), and GetEventsRequest being a discriminated union that rejects startLedger and cursor together. All are fixed; the request is now built per pagination mode.

One behavioural change beyond the four items, flagging it explicitly in case you'd rather I revert it: the adapter action previously produced { newAdmin: <adapter address> }. The RpcAdminActionPayload union already declared a { newAdapter: string } variant that was never constructed, and the contract publishes an adapter address there, so adapter now maps to newAdapter. transfer and accept still produce newAdmin. This means every variant in the union is now reachable.

Verification:

  • pnpm --filter @meridian/stellar-sdk-helpers build — passes, no errors. (I confirmed it failed on the previous commit with exactly the errors you reported.)
  • pnpm --filter @meridian/stellar-sdk-helpers test — 367 passed, 20 files.
  • tsc --noEmit and prettier --check on the file both clean.
  • I also exercised the reader against a stubbed getEvents covering all five event shapes the vault emits: the outgoing filter carries AAAADwAAAAVhZG1pbgAAAA==, each action parses with the right payload, malformed events are skipped instead of throwing, and timestamp is absent when ledgerClosedAt is empty. That script was throwaway and is not part of the diff.

The diff is limited to this one file (93 insertions, 69 deletions).

@ZacLou

ZacLou commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@collinsezedike Thanks for the detailed review! I have addressed all the requested changes:

  1. Import fix: Changed Server/SorobanRpc to rpc (import { rpc } from @stellar/stellar-sdk)
  2. Topic XDR encoding: Fixed — the previous base64 value decoded to scvU64 instead of scvSymbol. The correct encoding AAAADwAAAAVhZG1pbgAAAA== includes the SCV_SYMBOL discriminant.
  3. ScVal parsing: Replaced .obj() (does not exist) with .vec()/.address() per the actual SDK API.
  4. GetEventsRequest: Split into two branches (cursor vs startLedger) since it is a discriminated union.
  5. Timestamp/ledger fields: Used event.ledger instead of ledgerSequence, and response.cursor instead of nextCursor.
  6. Adapter action: Maps to { newAdapter } matching the Rust contract publish((ADMIN_EVT, symbol_short!("adapter")), new_adapter).

GitHub reports this PR as mergeable: true (no conflicts). Could you re-check? If a specific file still conflicts on your end, let me know and I will rebase immediately.

@collinsezedike
collinsezedike force-pushed the feat-vault-admin-events-697 branch from b66e84d to 84df9cd Compare September 2, 2026 21:49
@collinsezedike
collinsezedike force-pushed the feat-vault-admin-events-697 branch from 84df9cd to 7efe87b Compare September 2, 2026 22:04
@collinsezedike

Copy link
Copy Markdown
Collaborator

Thank you for the contribution, @ZacLou. Verified: all required checks are green, and the RPC-based admin-history reader is additive to the existing Horizon-based one (issue #616), not a duplicate. Fixed three mechanical issues along the way: cargo fmt on the vault contract, prettier formatting on the new test file, and added coverage for the RPC reader's event-parsing branches (paused/transfer/accept/adapter/migrate, plus the unrecognised-action and short-vec skip paths) since the package's coverage thresholds require it. If you have a moment, a star on the repo would be appreciated. Merging now.

@collinsezedike
collinsezedike merged commit 5c1cea2 into drydocs:main Sep 2, 2026
8 of 9 checks passed
@collinsezedike collinsezedike changed the title feat(vault): contract-side admin events + RPC admin-history reader (#697) feat(vault): contract-side admin events + RPC admin-history reader Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin action history feed needs contract-side events before mainnet

2 participants