This document describes how the Vici app integrates with the icdc-core prediction-market
registry as an Engine, and how user roles in Juno are automatically reflected as role
grants on that engine.
icdc-core runs two deployments, each with its own canister IDs (see icdc-core's
canister_ids.json):
| Deployment | dfx network | registry | clearing |
|---|---|---|---|
| Production | ic |
g5pxl-pyaaa-… |
g2or7-caaaa-… |
| Staging | staging |
5p3j2-miaaa-… |
4lwgi-viaaa-… |
Both are wired into this repo's dfx.json under remote.id (ic and
staging) and selectable through the script helper
scripts/lib/utils.sh:
--staging→ dfx networkstaging→ icdc-core staging.--production(alias for--ic) → dfx networkic→ icdc-core production.
Caution
Historically this repo had a single dfx.json entry, keyed staging, that actually held
the production canister IDs — so every --staging command (registry reinstall, role
grants, market seeding) hit production. That has been fixed: staging now points at
icdc-core staging, and ic holds production. Still treat any production operation with
care: dfx deploy --network ic --mode reinstall registry wipes production engine / role /
oracle / series state.
The Vici frontend pins the production registry/clearing IDs in
src/lib/constants/canisters.constants.ts, so
the running app talks to icdc-core production regardless of which network you deploy or
seed against. The Vici app's own Juno satellite remains a staging app environment.
icdc-core supports multi-tenant authorization through a first-class Engine model:
- Controllers of the registry canister can do anything.
- Engine admins can grant / revoke engine-scoped roles (
Creator,OracleAdmin) to any principal, without being controllers of the registry. - Role grants authorize the holder to call
add_series/fork_series(forCreator) oradd_oracle/update_oracle_metadata/manage_oracle_principals(forOracleAdmin) without being a canister controller.
Registering Vici as its own engine lets us:
- Scale the app beyond "only the deploy principal can create markets".
- Delegate market creation to app admins/creators through normal Juno auth flows.
- Keep Juno as the single source of truth for user roles; the registry just mirrors them.
See docs/architecture/engines.md
in icdc-core for the protocol-side design.
flowchart LR
A[User / Admin] -->|assigns role| J[Juno Datastore<br/>ROLES collection]
J -->|onSetDoc / onDeleteDoc| H[Satellite hook<br/>syncRoleToEngine]
H -->|grant/revoke_engine_role| R[icdc-core Registry<br/>Vici Engine]
H -->|manage_oracle_principals| O[icdc-core Registry<br/>VICI_ORACLE_V1]
R -->|add_series / fork_series| M[Market created<br/>engine_id = Vici]
F[Frontend<br/>addSeries] -->|engine_id = eng_0| R
O -. settle_series .-> M
Responsibilities:
| Component | Role |
|---|---|
scripts/init/init.icdc-engine.sh |
Registers the Vici engine, adds the satellite + human admins |
src/satellite/services/engine-sync.services.ts |
Hook that grants/revokes engine roles in response to roles doc changes |
src/lib/constants/icdc.constants.ts |
Single place that pins the engine id (eng_0) used by the frontend + satellite |
src/lib/services/market.services.ts |
Passes engine_id to add_series so non-controller admins can create markets |
scripts/init/init.registry.sh |
Registers the oracle + principals, then delegates market seeding to deploy-markets.sh |
scripts/deploy-markets.sh |
Deploys any JSON market deck via add_series, tagging each with engine_id = opt "$VICI_ENGINE_ID" |
scripts/test/test-engine-sync.sh |
Smoke test: verifies engine registration, admin list, allowed roles, grant flow |
Regular users have no role doc in Juno. That state is what was previously encoded as
USER / MODERATOR and grants nothing — no engine role, no entry in
VICI_ORACLE_V1.authorized_principals. See UserRole in src/lib/enums/user.ts for the
canonical list of grantable roles.
Juno UserRole |
icdc-core EngineRoles granted |
Added to VICI_ORACLE_V1.authorized_principals? |
|---|---|---|
CONTROLLER |
(none — controllers bypass) | (no — controllers bypass) |
ADMIN |
Creator, OracleAdmin |
yes |
SOLVER |
OracleAdmin |
yes |
CREATOR |
Creator |
no |
The hook computes the diff between the previous and new role and issues only the needed
grant_engine_role / revoke_engine_role calls, plus a single manage_oracle_principals
call when the user's OracleAdmin membership changes. Everything is idempotent:
RoleAlreadyGranted / RoleNotGranted are treated as no-ops, and the oracle's
authorized_principals is a set so duplicate adds / missing removes are no-ops at the
protocol level. On delete, the hook revokes whatever the deleted doc was mapped to.
If the oracle is not yet registered when the hook runs (e.g. a role is assigned before
init:registry has been executed), OracleNotFound is logged as engine_sync_skipped_missing_oracle
and the engine-role grant still proceeds. Once the oracle is bootstrapped, save the role
doc again to replay the sync — or reconcile manually via dfx.
Vici runs a single oracle (VICI_ORACLE_V1) today. Anyone who has EngineRole::OracleAdmin
on the Vici engine can already manage the oracle's authorized list; it would be a UX
footgun to require a second, manual step to actually use it for settlement. Merging the
two lists (via the hook) collapses two authorization layers into one and removes an easy-to-
forget admin ritual.
If we ever add a second oracle or a role that should be OracleAdmin without being a settler,
split this out into its own mapping in engine-sync.services.ts.
The mappings live in ROLE_TO_ENGINE_ROLES and shouldBeOracleSettler inside
engine-sync.services.ts. Update both those and this document when adding a new
EngineRole on the protocol side.
The engine's admins vec is the list of principals that can call grant_engine_role /
revoke_engine_role / update_engine_* on the Vici engine. It contains:
- The Juno satellite canister principal — so the
syncRoleToEnginehook can callgrant_engine_roleas the caller. This is the hot path. - The 2
SATELLITE_CONTROLLERS(fromsrc/lib/constants/controllers.constants.ts) — human admins, so if the satellite hook is ever broken or a manual override is needed we can still reach the engine directly viadfx. - The deploying
dfx identity— only meaningful on local, added for convenience.
To rotate admins:
dfx canister call --network staging registry add_engine_admins "(record {
engine_id = \"eng_0\";
principals = vec { principal \"...\" }
})"
dfx canister call --network staging registry remove_engine_admins "(record {
engine_id = \"eng_0\";
principals = vec { principal \"...\" }
})"Note: the engine Creator (the caller of register_engine) cannot be removed. See
EngineError::CannotRemoveCreator.
VICI_ENGINE_ID is pinned in src/lib/constants/icdc.constants.ts to eng_0. This works
because:
icdc-coreassigns engine ids sequentially (eng_0,eng_1, ...).- For every network, we register the Vici engine as the first engine after a registry install/reinstall.
- There are no other tenants on the current registry deployments.
If you ever reinstall the registry with a different first-engine ordering, the init script
will warn you and you must update VICI_ENGINE_ID in the constant and redeploy the
satellite.
Operational procedures live as step-by-step workflows under .agents/workflows/:
deployment.md— local deploy with engine init.icdc-engine-reset.md— fresh registry reset on local, staging, or production (wipes engine + role + series state).icdc-engine-operations.md— day-2 ops: reconcile a grant, audit role grants, rotate admins, kill-switch.
dfx deploy --mode reinstall registry wipes eng_0.role_grants. The intended source of
truth is the Juno ROLES collection, and syncRoleToEngineOnSet mirrors writes into the
registry — but only on writes, not on startup. So after a reset the engine's role_grants
starts empty until role docs are re-saved (or grants are replayed out-of-band via dfx).
Three options today, in order of durability:
-
Grant once via
dfx(fastest, ephemeral). Seeicdc-engine-operations.md. Wiped on the next reinstall. -
Set a
rolesdoc in Juno (durable across reinstalls, but needs a manual re-save after each reset to retrigger the hook). Set from the admin UI with valueADMINorCREATOR. Recommended for any principal that should survive long-term. -
Local dev bootstrap via
DEV_CREATOR_PRINCIPAL(recommended for a dev laptop):export DEV_CREATOR_PRINCIPAL="<your-II-principal>" npm run init:icdc
The trailing
init:icdc-dev-bootstrapstep ininit:icdcrunsscripts/init/init.icdc-dev-bootstrap.sh, which — whenNETWORK=localand the env var is set — grantsCreator+OracleAdminoneng_0and adds the principal toVICI_ORACLE_V1.authorized_principals. No-op on any non-local network (staging/ic), hard-gated. Idempotent: replays returnRoleAlreadyGrantedand the oracle set is aBTreeSet.You can also run it standalone after a grant drift:
DEV_CREATOR_PRINCIPAL="<your-II-principal>" npm run init:icdc-dev-bootstrap
Note
Option 3 is intentionally out-of-band from Juno (no roles doc is written). The
cross-environment fix is a Juno-driven reconcile step on the satellite — either an
admin endpoint that re-fires the sync for every roles doc, or an init-script pass
that enumerates the collection and replays writes. Tracked as future work.
src/lib/services/market.services.tspicksengine_idper-call based on the caller's Juno role:ADMIN/CREATOR→engine_id = opt "$VICI_ENGINE_ID"so the registry routes the call toCreationTier::Creatorand runs thehas_engine_role_oncheck against the caller's grant oneng_0.- Regular users (no role) →
engine_id = nullso the registry routes the call toCreationTier::Social, which is the only tier available to users that hold no engine role. This path only accepts Social markets (BalanceDomain::Social+NonMonetarypayout +Restrictedtrading access); anything else is rejected withEngineIdRequired.
forkMarketin the same file passesengine_id = opt "$VICI_ENGINE_ID"on everyfork_seriescall. Forks always go through the Creator tier, so the caller must holdCreatoroneng_0. Non-controller callers are rejected withEngineIdRequiredorEngineRoleNotHeldotherwise.- Market creation is still gated to
ADMIN/CREATORusers in the UI viamarket.services.ts— the engine-levelCreatorgrant is a necessary, not sufficient, condition for callingadd_series. To open creation to a wider audience, relax the check inmarket.services.tsand ensure the target users have the JunoCREATORrole.