Context
Reflector's CEX oracle (CAFJZQWS…) emits prices for off-chain crypto assets (BTC, ETH, USDT, SOL, XRP, ADA, AVAX, DOT, LINK, USDC, …) as Asset::Other(Symbol) on-chain. The Symbol is a bare ticker like "BTC" — not a Stellar classic asset (no issuer), not a Soroban contract, and not a fiat currency in the ISO-4217 sense.
Our canonical model today has four AssetType variants — native, classic, soroban, fiat (ADR-0010). None of them fit a crypto ticker:
classic: requires an issuer G-address. BTC as a reference has
no issuer.
soroban: requires a C-address. Reflector doesn't provide one —
BTC is referenced by ticker alone.
fiat: semantically wrong (BTC is not fiat) and bound to an
ISO-4217 allow-list that excludes crypto.
PR 164a's initial decoder path rejected these symbols with ErrUnknownFiatSymbol, skipping 100% of CEX-oracle updates. PR 164d's real-fixture harness t.Skiped all three CEX fixtures under test/fixtures/reflector/v6-2026-04-23/ with a pointer at this ADR. Time to fix.
Decision
Extend `canonical.AssetType` with a fifth variant: `crypto`.
const (
AssetNative AssetType = "native"
AssetClassic AssetType = "classic"
AssetSoroban AssetType = "soroban"
AssetFiat AssetType = "fiat"
AssetCrypto AssetType = "crypto" // NEW
)
type Asset struct {
Type AssetType
Code string // "BTC", "ETH", ... for crypto; existing use preserved
Issuer string // unused for crypto/fiat
ContractID string // unused for crypto/fiat
}Canonical wire form for crypto:
- String form:
crypto:BTC,crypto:ETH, etc. Unambiguous
prefix so ParseAsset dispatches in O(1), same pattern as fiat:USD.
- Object form:
{"type": "crypto", "code": "BTC"}. - SQL storage: text column,
crypto:prefix distinguishes it.
Allow-listed codes (observed in mainnet Reflector CEX traffic 2026-04-23, plus the largest-cap tickers not yet seen but very likely to appear):
ADA ATOM AVAX BCH BNB BTC DOGE DOT ETH LINK LTC MATIC NEAR SHIB
SOL TON TRX UNI USDC USDT XLM XRPExtension is a one-line amendment to this ADR (same pattern as ADR-0010's fiat list).
Consequences
- Positive: Reflector CEX feed's 10 symbols-per-event decode
end-to-end. All three variants of Asset::Other(Symbol) — fiat, crypto, unknown — now have explicit handling in the decoder.
- Positive:
crypto:USDC≠USDC:GA5ZSEJY…(Circle's Stellar
classic asset). The canonical model keeps them distinct even though they share the USDC ticker. A price quoted against "USDC the global crypto asset" and a price quoted against "Circle's Stellar USDC classic asset" are semantically different; making them textually different prevents accidental mixing.
- Negative: Adds one more variant callers must handle in
switch asset.Type ladders. Mitigated by keeping the allow-list small.
- Negative: Tickers are ambiguous without context. BTC on
Reflector = BTC on Binance = BTC on Coinbase. We rely on oracle metadata (base(), data source) to know which backing market the price represents. Not a new problem — Reflector itself doesn't encode the venue in the event.
- Operational impact: Minimal. Storage is the same text
column; pair IDs in Timescale grow a few new variants.
- Downstream design impact:
- Pricing aggregation logic that cross-pairs crypto assets must use crypto:BTC as the key, not bare BTC. No free-form string matching. - Eventually, a SAC bridge might map crypto:USDC to Circle's on-chain USDC-GA5ZSEJY… for cross-venue arbitrage — but that's a registry decision, not an Asset type decision. See ADR-0010 §SAC bridge for prior art.
Alternatives considered
- **Broaden
AssetFiattoAssetExternalRef(rename + relax
allow-list)** — rejected. Fiat and crypto have different semantic properties (ISO-4217 vs tradable token). Renaming loses the type-level clarity that fiat:USD gives us today. If we ever need to represent "generic off-chain reference" we add a third variant; we don't conflate fiat with crypto.
- Use `AssetSoroban` with a synthetic contract ID — rejected.
Would require minting a fake C-address per ticker; breaks the invariant that AssetSoroban.ContractID is a real on-chain address.
- Encode as `classic` with empty issuer — rejected for the
same reason ADR-0010 rejected it for fiat: the empty-issuer string round-trips through JSON as "BTC-", which ParseAsset rejects. Round-trip breakage is a cardinal sin of serialization design.
- Leave it and skip crypto symbols permanently — rejected.
Reflector's CEX oracle is a deliberate integration target (required coverage); skipping it defeats the purpose.
Amendments
_Append new crypto codes here as a one-liner. Never supersede this ADR for an addition._
- 2026-04-23 — initial allow-list of 22 codes (observed in CEX
oracle traffic + top-cap global cryptos). See canonical.IsKnownCrypto for the live list.
- 2026-05-22 (#53) — added
SolvBTC,SolvBTC_FUNDAMENTAL,
SolvBTC.BBN_FUNDAMENTAL — tokenized-BTC feeds from RedStone's Stellar deployment. BTC-backed crypto tokens, so crypto not the ADR-0028 rwa variant. Each feed_id is its own code (market vs NAV observations stay distinct).
References
- Related ADRs:
- ADR-0010 (fiat representation) — same pattern; crypto is the sibling variant. - ADR-0003 (i128 no-truncation) — crypto amounts still flow through the i128 path; no change.
- Implementation:
internal/canonical/asset_crypto.go(allow-list
+ constructor), internal/canonical/asset.go (type + String + ParseAsset + Validate).