road-runner-common 0.30.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Canonical Novu workflow trigger identifiers — the single Rust-side source of truth.
//!
//! A value here must match the workflow's trigger identifier in the notification center
//! **character for character**; an unknown identifier yields HTTP 422
//! `workflow_not_found`. The Java sender (`cex-keycloak`) and the notification service
//! (`cex-notification`) keep mirrored constants; all three must agree.
//!
//! Every identifier is the center's kebab-case slug. That is what the center generates
//! when a workflow is created and what it matches a trigger against; the
//! `Pascal_Snake_Case` text is only the workflow's display *name*. These constants used to
//! carry the names, so 53 of the 56 workflows here could never be triggered at all — each
//! answered 422 and was logged as one undelivered notification, which reads like an outage
//! rather than a typo, and nothing else gave it away.
//!
//! **Copy the identifier from the center; never derive it from the name.** It is not
//! reliably derivable: `Admin_Fiat_Unmatched_Deposit` is `fiat-unmatched-deposit`, and
//! `2FA_Enabled` is `2-fa-enabled`, not `2fa-enabled`. Add the constant here too, so no
//! service ever spells one out inline.

// --- Identity & security -------------------------------------------------------

/// Sensitive-action confirmation OTP delivered by e-mail (withdraw / api-key /
/// 2FA-method change / PII reveal / ...). Email and SMS are separate workflows —
/// see [`ACTION_PHONE_CONFIRMATION`] — so each channel owns its own template and
/// delivery controls; there is no single combined `action-confirmation` workflow.
pub const ACTION_EMAIL_CONFIRMATION: &str = "action-email-confirmation";
/// Sensitive-action confirmation OTP delivered by SMS. See [`ACTION_EMAIL_CONFIRMATION`].
pub const ACTION_PHONE_CONFIRMATION: &str = "action-phone-confirmation";
/// Security alert when the account swaps one second-factor method for another.
///
/// Only the swap. Turning a factor on and losing one are the two constants below, and each
/// is a different sentence to the member — routing all three here (which the account did)
/// answers "you enabled Google Authenticator" with the removal e-mail.
pub const SECURITY_TWO_FACTOR_CHANGED: &str = "security-two-factor-changed";
/// Two-factor authentication was turned on where the account had none chosen.
pub const TWO_FACTOR_ENABLED: &str = "2-fa-enabled";
/// Two-factor authentication was turned off, or a reset was requested.
///
/// The two are one message on purpose: from the account owner's side both mean "the second
/// factor protecting this account is no longer what it was", and that is the sentence
/// somebody who did not do it needs to read.
pub const TWO_FACTOR_DISABLED_RESET: &str = "2-fa-disabled-reset";
/// The account's e-mail address or phone number was replaced.
///
/// One workflow for both, with the payload naming which changed and showing the masked old
/// and new values — the sentence the owner needs is the same either way, and it is the one
/// an account takeover would try to outrun. Always delivered to the **old** address: the
/// new one has already been proven, the old one is the party that has to be warned.
pub const SECURITY_CONTACT_CHANGED: &str = "security-contact-changed";
/// A sign-in from a device this member has not used before.
pub const NEW_DEVICE_LOGIN: &str = "new-device-login";
/// A sign-in from a country the member has not signed in from before, or one that could
/// not plausibly follow their previous sign-in.
pub const NEW_IP_LOCATION_LOGIN: &str = "new-ip-location-login";
/// Pre-registration email verification OTP.
pub const REGISTER_EMAIL_VERIFICATION: &str = "register-email-verification";
/// Pre-registration phone verification OTP.
pub const REGISTER_PHONE_VERIFICATION: &str = "register-phone-verification";

// --- Identity verification (KYC) --------------------------------------------------
//
// Driven by the case log in cex-compliance: one workflow per fact the log can record, so
// which message a member gets is decided by the same event that moved their case.
//
//   application received → under review → approved
//                                       ↘ additional document requested (resubmit)
//                                       ↘ rejected (no retry path)

/// The member entered verification; the case now exists.
pub const KYC_APPLICATION_RECEIVED: &str = "kyc-application-received";
/// The case is with the provider or a compliance officer, awaiting a verdict.
pub const KYC_UNDER_REVIEW: &str = "kyc-under-review";
/// Verification succeeded.
pub const KYC_APPROVED: &str = "kyc-approved";
/// Rejected with **no** retry path — the member cannot fix this by resubmitting.
pub const KYC_REJECTED: &str = "kyc-rejected";
/// A nudge to a member who started verification and stopped, sent once per member and
/// track. Only for states where the next move is theirs — never for a case waiting on us.
pub const KYC_REMINDER: &str = "kyc-reminder";
/// Rejected **but retryable**: the provider asked for a document again (unreadable scan,
/// bad selfie, missing proof of address). A distinct message from [`KYC_REJECTED`]
/// because the member is being asked to act, not told the answer is no.
pub const KYC_ADDITIONAL_DOCUMENT_REQUESTED: &str = "kyc-additional-document-requested";

// --- Account standing ------------------------------------------------------------
//
// What the member may do, and who changed it. Distinct from KYC: verification is about
// who they are, these are about what the platform currently permits.

/// The account was disabled in Keycloak — the member cannot sign in.
///
/// Keycloak's `enabled` flag is the single source: it is what actually stops them, so a
/// freeze announced from anywhere else would promise an enforcement the platform does not
/// make. A KYC hold suspends *verification*, which is a different thing and stays silent.
pub const ACCOUNT_FROZEN: &str = "account-frozen";
/// Access the member had lost is back — the account was re-enabled in Keycloak, or a
/// permission taken by [`ACCOUNT_RESTRICTED`] was given back.
///
/// One workflow for both because the center has one "you have your access back" template,
/// and the identifier is what picks the template. The lift used to trigger
/// [`ACCOUNT_RESTRICTED`] with an `action: removed` field to tell the two apart; nothing read
/// that field, so re-opening a member's withdrawals sent them the restriction notice a second
/// time. Naming which permission returned would need its own workflow in the center — this
/// message carries no payload.
pub const ACCOUNT_REACTIVATED: &str = "account-reactivated";
/// A specific permission was withdrawn (trading, crypto or fiat withdrawal) while the
/// account otherwise stays open. Narrower than [`ACCOUNT_FROZEN`], which suspends
/// everything gated on verification.
pub const ACCOUNT_RESTRICTED: &str = "account-restricted";

/// A withdrawal was requested from an unfamiliar device shortly after the credentials
/// changed — the shape of an account takeover. **Unwired:** cex-tx-risk-manager scores
/// `RECENT_PASSWORD_RESET`, `RECENT_2FA_CHANGE` and `NEW_DEVICE` independently, but nothing
/// composes them into a takeover verdict, and inventing that rule is a risk-policy decision
/// rather than a notification one.
pub const ACCOUNT_TAKEOVER: &str = "account-takeover";
/// The account was closed, by the member or by the platform. **Unwired:** no closure or
/// off-boarding flow exists in the platform — there is no endpoint, no status and no job
/// that closes an account.
pub const ACCOUNT_CLOSED: &str = "account-closed";

// --- Trading -----------------------------------------------------------------------
//
// Driven by the matching engine's own order-event stream, consumed by cex-history. Unlike
// the rest of this registry these scale with trading volume: an active trader produces
// hundreds a day and one resting order can produce dozens of partial fills. Configure the
// workflows accordingly — in-app rather than email/SMS unless the member opts in.

/// A new order passed validation and rests on the book.
pub const TRADE_ORDER_CREATED: &str = "trade-order-created";
/// An order matched in part; the remainder is still working.
pub const TRADE_ORDER_PARTIALLY_FILLED: &str = "trade-order-partially-filled";
/// An order matched in full and is closed.
pub const TRADE_ORDER_FILLED: &str = "trade-order-filled";
/// An order was cancelled by the member or by an admin. Distinct from expiry, which is an
/// order dying by its own time-in-force rules rather than anyone cancelling it.
pub const TRADE_ORDER_CANCELLED: &str = "trade-order-cancelled";

/// A stop or trigger order reached its trigger price. **Unwired:** the matching engine has
/// no stop orders — `OrderType` is `LIMIT` or `MARKET` only, and there is no trigger price
/// anywhere in it. Wiring this needs the engine feature first.
pub const TRADE_ORDER_TRIGGERED: &str = "trade-order-triggered";
/// Suspected wash trading, spoofing or market manipulation. **Unwired:** the engine
/// *prevents* self-trades per order (`RejectReason::SelfTradePrevented`) but nothing looks
/// for the pattern, and cex-trade-risk-manager is a pre-trade filter chain (balance, KYC,
/// notional, open orders) with no abuse detection. Deciding what counts as abuse is a
/// surveillance rule, not a notification one.
pub const TRADE_ABUSE: &str = "trade-abuse";

// --- Fiat withdrawal ------------------------------------------------------------
//
// The lifecycle, and which service owns each transition:
//   requested (ledger) → approved (ledger) → sent to bank (ledger)
//     → completed (history)                       … the bank settled it
//     → failed (history) → failed+refunded (ledger) … the bank returned it

/// The withdrawal request was accepted into the system and the balance reserved.
pub const FIAT_WITHDRAWAL_REQUESTED: &str = "fiat-withdrawal-requested";
/// An admin (or the automated decision) approved the request; the payout is next.
pub const FIAT_WITHDRAWAL_APPROVED: &str = "fiat-withdrawal-approved";
/// The bank accepted the payout instruction; settlement is now the bank's move.
pub const FIAT_WITHDRAWAL_SENT_TO_BANK: &str = "fiat-withdrawal-sent-to-bank";
/// The bank settled the transfer — the withdrawal is closed.
pub const FIAT_WITHDRAWAL_COMPLETED: &str = "fiat-withdrawal-completed";
/// The bank reported the payout as failed. The balance is **still reserved** at this
/// point — it goes back to admin review, so this is the "we are on it" message, not a
/// refund confirmation. The refund, if it happens, is a separate event below.
///
/// **Unwired in practice, though the code path exists:** `FiatWithdrawRequestService::
/// handle_settled(bank_transaction_id, succeeded)` in cex-history genuinely branches on
/// `succeeded` and would fire this on `false` — but cex-vakifbank's `cex_vakifbank.
/// fiat_withdraw` topic only ever publishes *successful* payouts (see that handler's own
/// doc comment), and nothing else calls `handle_settled` with `false`. The bank
/// integration has no "payout failed" signal to wire this to yet.
pub const FIAT_WITHDRAWAL_FAILED: &str = "fiat-withdrawal-failed";
/// A fiat withdrawal the bank returned or an admin rejected; the reserved balance
/// has been released back to available. Triggered from the ledger's withdraw
/// decision — the point where the release event is actually written — so the
/// message is never sent for a refund that did not happen.
pub const FIAT_WITHDRAWAL_FAILED_REFUNDED: &str = "fiat-withdrawal-failed-refunded";

// --- Fiat deposit ---------------------------------------------------------------

/// A bank movement was matched to the depositor and the amount is now on their
/// available balance.
pub const FIAT_DEPOSIT_CREDITED: &str = "fiat-deposit-credited";
/// A deposit an admin rejected or returned to the sender. Only reaches a customer when
/// the movement was matched to one — an unmatched rejection has no recipient and is
/// [`ADMIN_FIAT_UNMATCHED_DEPOSIT`]'s story instead.
pub const FIAT_DEPOSIT_REJECTED: &str = "fiat-deposit-rejected";

// --- Operations channels ---------------------------------------------------------
//
// Addressed to an `AdminChannel`, not to a person: routed to a topic whose membership
// lives in the notification center. The `Admin_` prefix in the identifier is the reminder
// that no customer ever receives these — which is also why they can fire at moments where
// no member has been identified yet.

/// A bank movement was ingested from the statement, before anything is known about who
/// sent it. Operations sees the platform's incoming flow as it lands.
pub const ADMIN_FIAT_DEPOSIT_DETECTED: &str = "fiat-deposit-detected";
/// An incoming amount held because the sender's name/IBAN did not match a member.
/// By definition no member was matched, so there is no subscriber to send to.
pub const ADMIN_FIAT_UNMATCHED_DEPOSIT: &str = "fiat-unmatched-deposit";
/// The custody reserve ratio (95% cold / 5% hot of customer liabilities, per asset) changed
/// status: a warning or a breach to the alert channel, a return inside the bounds to info.
/// Sent by custody-account.
pub const ADMIN_CUSTODY_RESERVE_RATIO: &str = "custody-reserve-ratio";
/// The cold-signing path's package signer was changed by an approved proposal: the enclave vault
/// whose key signs offline packages is now another one. Always the alert channel — when the key
/// changes, the offline signer refuses every package until a ceremony pins the new key.
/// Sent by custody-account.
pub const ADMIN_CUSTODY_OFFLINE_PACKAGE_SIGNER_CHANGED: &str =
    "custody-offline-package-signer-changed";
/// Live durable Solana transfers that a newly active policy bundle no longer admits (payer,
/// nonce account, mint or screening decision). Always the alert channel, high severity, one
/// notification per activated bundle: a signed durable transaction stays valid until its nonce
/// advances, so operations decides whether to cancel each one. Sent by custody-account.
pub const ADMIN_CUSTODY_DURABLE_AT_RISK: &str = "custody-durable-at-risk";

// --- Quorum governance (cex-policy) ----------------------------------------------
//
// Addressed to individual admins by their Keycloak subject rather than to a topic: who
// may vote is a property of the proposal (the roster allowed to act on that policy's
// action, minus the proposer), so the audience differs per proposal and cannot be a
// static channel membership.

/// A quorum proposal was opened and is waiting for votes. Sent to the admins eligible
/// to vote on it — a proposal nobody is told about is a proposal that expires.
pub const ADMIN_POLICY_CREATED: &str = "admin-policy-created";
/// A proposal reached its approval threshold and was applied.
pub const ADMIN_POLICY_APPROVED: &str = "admin-policy-approved";
/// A proposal was rejected: enough votes against that the threshold can no longer be met.
pub const ADMIN_POLICY_REJECTED: &str = "admin-policy-rejected";
/// A proposal ran out of time before reaching a decision. A distinct outcome from a
/// rejection: nobody decided against it, the window simply closed.
pub const ADMIN_POLICY_EXPIRED: &str = "admin-policy-expired";

// --- Registered in the notification center, no producer in the platform yet ------
//
// These exist as workflows but nothing triggers them, because the code has no point
// that means what they describe. Listed so the registry mirrors the notification
// center rather than silently diverging from it; delete or wire them deliberately.

/// A corporate onboarding application was received. **Unwired:** there is no KYB flow.
/// The only corporate concept in the codebase is the MKK investor *kind*, which is a
/// registry filing for an already-verified member, not an application to onboard.
pub const KYB_APPLICATION_RECEIVED: &str = "kyb-application-received";
/// A deposit held for risk/limit review. **Unwired:** the fiat deposit path has no
/// risk or limit gate — everything that reaches `PENDING_ADMIN` gets there through
/// identity matching, which is [`ADMIN_FIAT_UNMATCHED_DEPOSIT`].
pub const FIAT_DEPOSIT_MANUAL_REVIEW: &str = "fiat-deposit-manual-review";

// --- User bank accounts --------------------------------------------------------

/// A member added a bank account as a payout destination.
pub const BANK_ACCOUNT_ADDED: &str = "bank-account-added";
/// A member removed one of their withdrawal bank accounts. Sent for the same reason as
/// the add: a bank account leaving the whitelist is a security-relevant change the
/// member has to be able to notice, whether or not they made it.
pub const BANK_ACCOUNT_DELETED: &str = "bank-account-deleted";

/// Several consecutive authentication failures were detected for a member.
pub const FAILED_LOGIN_WARNING: &str = "failed-login-warning";

/// An official legal or regulatory information request was recorded.
pub const LEGAL_REQUEST_RECEIVED: &str = "legal-request-received";

/// A KVMKS (MKK) reporting obligation needs a human: a figure MKK holds differs from the one
/// sent, a day missed its transmission window, an asset was left out of a report, or a row is
/// sitting unreported in the exception queue.
///
/// Sent to the admin alert channel, never to a member. The payload names the chapter (`5.7`,
/// `5.13`, …), the finding code and the subjects, so one workflow covers every finding rather
/// than needing a template per code.
pub const MKK_REPORTING_FINDING: &str = "mkk-reporting-finding";

/// The "forgot my password" link, carrying Keycloak's single-use reset token in
/// `payload.url`.
///
/// Triggered by **Keycloak**, not by any Rust service: the token is minted by the realm's
/// reset-credentials flow and only exists inside it, so the send has to happen where the
/// link does. `cex-keycloak`'s `notification-center` e-mail template provider replaces the
/// SMTP hand-off with a trigger on this workflow — the realm carries no `smtpServer`, so
/// before that provider the forgot-password path failed at the last step, after telling the
/// member to check their inbox. The constant is here because this registry mirrors the
/// centre; it is spelled out once more, in Java, in that provider.
///
/// The confirmation the member gets *after* the reset completes is [`PASSWORD_CHANGE`],
/// which cex-account already sends off the resulting `UPDATE_CREDENTIAL` event.
pub const PASSWORD_RESET_REQUEST: &str = "password-reset-request";
/// A link that verifies the member's e-mail address.
///
/// Triggered by **Keycloak**, like [`PASSWORD_RESET_REQUEST`] and for the same reason: the
/// token is a realm action token that only exists inside the send. Reached two ways — the
/// realm's own `VERIFY_EMAIL` required action, and the admin panel's "send verification
/// link" quick action, which asks Keycloak to send it.
///
/// Deliberately **not** [`REGISTER_EMAIL_VERIFICATION`]: that one carries a six-digit code
/// the member types into the sign-up form, and this carries a URL they click. Sending a
/// link through a template that renders a code produces a message with an empty box in it.
pub const EMAIL_VERIFICATION_LINK: &str = "email-verification-link";
/// A member's password was changed successfully.
pub const PASSWORD_CHANGE: &str = "password-change";
/// A member signed in successfully.
pub const SUCCESSFUL_LOGIN: &str = "successful-login";

/// A member's withdrawal address whitelist was changed.
///
/// **Superseded, not unwired:** cex-account has a `WhitelistAddressChanged` struct for
/// this that nothing ever constructed — the actual add/remove notification is
/// [`CRYPTO_ADDRESS_ADDED`]/[`CRYPTO_ADDRESS_DELETED`], which `crypto_address_service.rs`
/// really does send. A candidate for retirement in the notification center rather than
/// for wiring up.
pub const WHITELIST_ADDRESS_CHANGE: &str = "whitelist-address-change";

pub const CRYPTO_ADDRESS_ADDED: &str = "crypto-address-added";
pub const CRYPTO_ADDRESS_DELETED: &str = "crypto-address-deleted";
pub const CRYPTO_DEPOSIT_CONFIRMED: &str = "crypto-deposit-confirmed";
pub const CRYPTO_DEPOSIT_CREDITED: &str = "crypto-deposit-credited";
pub const CRYPTO_DEPOSIT_DECLARATION_REQUIRED: &str = "crypto-deposit-declaration-required";
pub const CRYPTO_DEPOSIT_DETECTED: &str = "crypto-deposit-detected";
pub const CRYPTO_DEPOSIT_MANUAL_REVIEW: &str = "crypto-deposit-manual-review";
pub const CRYPTO_DEPOSIT_UNCREDITED: &str = "crypto-deposit-uncredited";
pub const CRYPTO_WITHDRAWAL_APPROVED: &str = "crypto-withdrawal-approved";
pub const CRYPTO_WITHDRAWAL_BROADCASTED: &str = "crypto-withdrawal-broadcasted";
pub const CRYPTO_WITHDRAWAL_COMPLETED: &str = "crypto-withdrawal-completed";
/// Two producers, deliberately: cex-ledger fires it pre-broadcast (rejected before
/// there was ever a tx), cex-history fires it post-broadcast (a chain-level failure,
/// carries `txId`). `txId` is producer-dependent — `null` from cex-ledger, formally,
/// not omitted. Each producer scopes its `transactionId` (`...:ledger:{id}` /
/// `...:history:{id}`) so Novu's dedup cannot drop one producer's event because the
/// other already used the plain `{id}` key.
pub const CRYPTO_WITHDRAWAL_FAILED_REJECTED: &str = "crypto-withdrawal-failed-rejected";
/// A withdrawal that never got a chain tx assigned. **Unwired:** no struct, no call
/// site, nowhere in the platform — there is no stall-detection job that would notice a
/// withdrawal stuck in this state and no definition yet of when one counts as "no tx"
/// (vs. still broadcasting). Needs that semantic contract before this can be wired.
pub const CRYPTO_WITHDRAWAL_NO_TX: &str = "crypto-withdrawal-no-tx";
pub const CRYPTO_WITHDRAWAL_REQUESTED: &str = "crypto-withdrawal-requested";

// --- Member-requested reports ----------------------------------------------------
//
// The member asks for a statement of their own history over a date range; cex-history
// renders it asynchronously and this is the message that tells them it is ready. The
// file itself never rides the notification — the payload carries a link back into the
// app, where the download is authenticated like any other read of their own data.

/// A requested account statement (trade or deposit/withdrawal history) finished
/// rendering and can be downloaded. Payload: `reportType`, `format`, `rangeFrom`,
/// `rangeTo`, `rowCount`, `expiresAt`, `downloadUrl`.
pub const REPORT_READY: &str = "report-ready";