Read-client that fetches a 0–100 risk score for a Stellar address or asset from an on-chain risk oracle, and exposes it to the Gryd Lock extension behind a stable interface.
grydlock-oracle-adapter is the closest thing Gryd Lock has to a backend — but it runs no server. It is a small, read-only client: given a destination, it calls a Soroban smart contract, reads a score, and returns it. Nothing more.
Status:
StubOracleis implemented and returns scores from the vendoredgrydlock-testkitfixtures.SorobanOracle(src/SorobanOracle.ts) implements the finality-aware protocol described indocs/adr/0001-soroban-oracle-protocol.mdagainst an injectableSorobanRpcTransport, but this package does not yet ship a real network transport — see SorobanOracle below for exactly what is and isn't implemented.
Gryd Lock needs to warn users about risky Stellar addresses and assets before they sign a transaction, but it should not be in the business of computing that risk itself. Embedding scoring logic directly in the extension would mean:
- The extension would need direct chain access and scoring logic baked into its own codebase
- Swapping or upgrading the scoring engine would require an extension release
- There would be no way to develop or test the extension's warning flow without a live oracle
At a high level, it does one thing, deliberately narrowly scoped:
- 🔎 Reads — takes a destination (Stellar address or asset) and calls the on-chain risk oracle's
get_score()function via Soroban - 🔌 Adapts — normalizes the oracle response behind a single, stable
RiskOracleinterface so the scoring backend can be swapped without touching the extension - 📤 Exposes — returns a plain 0–100 score to the Gryd Lock extension, with no chain-specific types leaking across the boundary
Full API reference is generated from the source JSDoc with TypeDoc and
published to GitHub Pages on every push to main:
📖 gryd-lock.github.io/grydlock-oracle-adapter
To build the reference locally:
npm run docs # generate the HTML reference into docs/
npm run docs:check # validate JSDoc coverage without emitting files (used in CI)docs:check fails if any exported symbol is missing a doc comment, so the published reference
stays complete as the public surface grows.
RiskOracleinterface — one method,getScore(destination), that both implementations satisfyStubOracle— lookup-table score source backed by vendoredgrydlock-testkitfixtures, for local development and thegrydlock-testkitevaluation; no network callsProvenanceOracle— wraps anyRiskOracleand emits a structured provenance record (source, timestamp, cache status, latency) for every score, via an injectableLoggerLoggerinterface — minimal structured logging seam (debug/info/warn/error) with a no-op default; the library never writes to the console on its ownSorobanOracle(protocol + scaffolding implemented; no live transport yet) — finality-aware client for theget_score()Soroban contract protocol described in the ADR, implemented against an injectableSorobanRpcTransport; this package ships that interface and a deterministic fake for tests, not a real network implementation of it- Fallback (planned) — a slow or unreachable oracle degrades gracefully instead of stalling the signing flow
graph TB
subgraph Extension["Gryd Lock Extension"]
UI[Signing Flow UI]
end
subgraph Adapter["grydlock-oracle-adapter"]
IFACE[RiskOracle interface]
STUB[StubOracle]
SOROBAN[SorobanOracle]
TRANSPORT[SorobanRpcTransport - no live implementation yet]
end
subgraph Chain["Stellar Network"]
CONTRACT[On-chain Risk Oracle Contract - not deployed yet]
end
UI -->|getScore destination| IFACE
IFACE --> STUB
IFACE --> SOROBAN
SOROBAN --> TRANSPORT
TRANSPORT -.->|get_score, real transport not implemented| CONTRACT
| Component | Role | Status |
|---|---|---|
src/RiskOracle.ts |
Defines the getScore(destination) contract and the ScoredResult metadata types |
Implemented |
src/StubOracle.ts |
Lookup-table score source, backed by vendored grydlock-testkit fixtures |
Implemented, tested |
src/ProvenanceOracle.ts |
Decorator that logs a structured provenance record for every score | Implemented, tested |
src/Logger.ts |
Injectable structured Logger interface with a no-op default |
Implemented |
src/SorobanOracle.ts |
Finality-aware client against the injectable SorobanRpcTransport seam (protocol: ADR) |
Protocol + scaffolding implemented, tested against a fake transport; no live network transport yet |
src/fixtures/testkit/ is a vendored, point-in-time copy of grydlock-testkit's
destinations.json and scores.json — not a live sync. If the testkit fixtures change, re-copy
them here, then run npm run generate:fixtures to refresh their raw-text companion modules (see
below), to pick up the update.
Because these files are manually copied rather than pulled in as a dependency, a bad copy —
truncated file, wrong schema version, non-numeric score — would otherwise pass TypeScript's
structural typing silently and only surface as a confusing runtime failure. To catch that at the
source, src/fixtures/testkit/schema.ts validates the shape of both files, and
src/fixtures/testkit/index.ts runs that validation once, when the module is first imported
(not on every StubOracle.getScore() call). A malformed fixture throws a FixtureValidationError
naming the offending file, field, and — since validation happens during parsing rather than after
it — the entry's exact position in the source file:
FixtureValidationError: Invalid vendored fixture "scores.json": score for "GABC..." must be a finite number, got string ("10") (at line 4, column 32, offset 118)
Loading is incremental, not materialize-then-validate. scores.ts/destinations.ts don't
import the .json files directly (which would hand a bundler's JSON loader — or JSON.parse — the
whole file to parse into a JS object graph before validation could even start). Instead:
scripts/generate-fixture-text.mjsemitsscores.text.ts/destinations.text.ts, each just the vendored JSON file's exact source characters embedded as a string constant (export default "...";). That's a plain string literal any bundler or the browser loads like any other module — no Node-onlyfs/streaming API ships in the bundle, and no special bundler loader config is required downstream.tests/fixtureText.sync.test.tsfails the build if a generated file drifts from its source.json.src/fixtures/testkit/jsonScanner.tsis a small hand-rolled, token-at-a-time JSON tokenizer (JsonScanner) over that raw text, tracking line/column/offset as it scans.schema.ts'sparseScoresFixtureIncremental/parseDestinationsFixtureIncrementaluse it to validate each entry the moment it's tokenized, so a malformed entry throws — with its source position — before any later entry in the file is even scanned. (validateScoresFixture/validateDestinationsFixture, validating an already-parsed value, remain for callers that have one on hand — e.g. tests.)
tests/benchmarks/fixtureStreaming.budget.test.ts enforces this with hard budgets at 50,000
synthetic destinations (an order of magnitude past the real fixture's size): import-to-first-lookup
latency stays under 600ms, and — the check that specifically rules out a "parse fully but budget
generously" shortcut — the incremental parser's peak retained heap stays under a small constant
multiple of a deliberately-materialize-twice baseline built solely for that comparison, verified
structurally (via a JSON.parse call-count spy) rather than by absolute numbers alone.
StubOracle and the test suite both import the validated scores / destinations exports from
src/fixtures/testkit/index.ts rather than reading the JSON files directly, so any re-copy of the
testkit fixtures is checked before it can reach either.
The adapter exposes one job: turn a destination into a score.
interface RiskOracle {
// Returns a risk score 0–100 for a Stellar address or asset.
getScore(destination: string): Promise<number>;
}The extension depends on this shape and nothing beneath it. Two implementations exist:
- StubOracle — returns a score from the vendored
grydlock-testkitfixture lookup table (falling back to a default for unrecognized destinations). Used for development and for thegrydlock-testkitevaluation. No network. - SorobanOracle — validates a request, calls
get_score()on the configured Soroban contract through an injectableSorobanRpcTransport, and defensively decodes the response against a versioned protocol schema before returning a score — see SorobanOracle below. This package does not yet ship a real, network-connectedSorobanRpcTransport, soSorobanOraclecannot talk to a live contract today.
Every destination is validated and canonicalized before any score lookup, so a malformed or
forged identifier surfaces as an InvalidDestinationError rather than silently scoring as the
default. The grammar is:
| Shape | Example | Decision |
|---|---|---|
G… ed25519 account |
GCRRYBV5… |
Accepted as-is |
M… muxed account |
MCRRYBV5… |
Accepted, scored as its base G account |
C… contract address |
CADQOBYH… |
Accepted as-is |
L… liquidity pool |
LAEQSCIJ… |
Accepted as-is |
<code>:<issuer> asset |
SCAM:GAJLLIIP… |
Accepted; SEP-11 code + G issuer |
S… T… X… P… B… |
— | Rejected: signer/transaction constructs |
| anything else | not-a-stellar-address |
Rejected |
A muxed address is the same underlying ed25519 account with a routing tag attached, so its on-chain risk is the base account's risk — rejecting it would leave a user paying a custodial exchange deposit address with no warning at all. The 64-bit subaccount id is still decoded and exposed on the validation result for future use.
src/StrKeyCodec.ts implements base32, the CRC16-XModem checksum and version-byte/payload-length
checks from the strkey specification rather than delegating to @stellar/stellar-sdk. The SDK's
StrKey appears in exactly one file — tests/StrKeyCodec.differential.test.ts — where it is the
ground-truth oracle for a 5,000+ input differential fuzz suite that asserts identical
accept/reject verdicts and identical decoded payload bytes.
Alongside the bare-number contract, the interface file defines an opt-in metadata shape
(issue #17's design) so richer sources — fallback chains, caches — can report how a score
was produced without breaking getScore consumers:
interface ScoredResult {
score: number; // 0–100
timestamp: number; // epoch ms when the score was produced
source: OracleSource; // which oracle/tier answered, e.g. "StubOracle", "soroban"
cacheStatus: CacheStatus; // "live" | "cache-fresh" | "cache-stale" | "default" | "unknown"
confidence?: number;
}
interface DetailedRiskOracle extends RiskOracle {
getScoreDetailed(destination: string): Promise<ScoredResult>;
}ProvenanceOracle is a decorator that wraps any RiskOracle and emits one structured
provenance record per call through an injected Logger — callers keep calling
getScore(dest) exactly as before:
const oracle = new ProvenanceOracle(new StubOracle(), { logger: myLogger });
const score = await oracle.getScore(dest); // same number as before
// myLogger.info('score_provenance', {
// event: 'score_provenance',
// destination: 'G...',
// score: 95,
// source: 'StubOracle',
// cacheStatus: 'unknown',
// timestamp: 1752762896000,
// latencyMs: 1,
// outcome: 'success',
// })If the wrapped oracle implements getScoreDetailed, its reported source and cache status
flow into the record; otherwise the record uses the wrapper's configured source label and an
"unknown" cache status. Failed calls are rethrown unchanged and logged at error level
with outcome: 'error'.
The provenance log is the recommended way to answer "why did the user see this score."
Wire a real Logger into ProvenanceOracle (routing to the extension's own logging), then
filter for score_provenance entries for the destination in question. Each entry tells you:
source— which oracle/tier actually answered (live contract, cache, stub, default)cacheStatus— whether the value was live, fresh-from-cache, stale, or a fallback defaulttimestamp/latencyMs— when the call completed and how long it tookoutcome/error— whether the underlying source failed and why
A disputed Critical-tier warning that traces to source: "StubOracle" or
cacheStatus: "cache-stale" is a very different bug than one backed by a live on-chain read —
the provenance record makes that distinction visible after the fact.
SorobanOracle (src/SorobanOracle.ts) implements RiskOracle + DetailedRiskOracle +
CancellableRiskOracle against the finality-aware Soroban contract protocol defined in
docs/adr/0001-soroban-oracle-protocol.md. Read that
ADR for the full design; this section is a pointer to it plus the practical "what does this PR
actually ship" summary.
What's implemented:
- The protocol itself — contract method, destination encoding, response schema, absence
semantics, score range, interface-version checking, and a finality policy that distinguishes
"live/verified," "insufficiently final" (rejected), and "stale" (returned but downgraded)
results — all defined and runtime-validated in
src/fixtures/soroban/. - Every failure mode as a distinct typed error in
src/OracleError.ts(UnsupportedInterfaceVersionError,WrongNetworkError,WrongContractError,MalformedOracleResponseError,InsufficientFinalityError,ScoreNotYetComputedError, plus reuse of the existingUnrecognizedDestinationError). None of them are a numeric score — this is the direct fix forStubOracle'sDEFAULT_SCORE = 0fallback, which is correct for a dev stub but must never appear on a production oracle's "I don't know" path. SorobanOracleitself, implemented against an injectableSorobanRpcTransportinterface — the network/XDR call is a seam, not a concrete@stellar/stellar-sdkServercall baked in.- A deterministic in-memory fake transport (
tests/support/FakeSorobanRpcTransport.ts) and a full test suite (tests/SorobanOracle.test.ts) exercising it.
What's deliberately NOT implemented in this package yet:
- A real, network-connected
SorobanRpcTransport(an@stellar/stellar-sdk-backed implementation that actually calls a Soroban contract and decodes real XDR). - Failover or consistency logic across multiple allowlisted RPC endpoints.
- Any live testnet call with a real result — see
tests/SorobanOracle.testnet.test.ts, gated behindGRYDLOCK_TESTNET_CONTRACT_IDand a no-op without it. - An externally-authoritative contract deployment to target — none exists yet that this repo's owners control; the protocol in the ADR is this adapter's own target, not a transcription of a ratified interface.
Configuration (SorobanOracleConfig, validated eagerly and synchronously — a bad config
throws at construction time, not on the first call):
import { SorobanOracle, SorobanOracleConfig, SorobanRpcTransport } from 'grydlock-oracle-adapter';
const config: SorobanOracleConfig = {
environment: 'production', // 'production' | 'staging' | 'development' | 'test' — required, no default
networkPassphrase: 'Public Global Stellar Network ; September 2015',
rpcEndpoints: ['https://mainnet.sorobanrpc.com'], // non-empty, well-formed http(s) URLs
contractId: 'C...', // must decode as a valid Soroban contract address
supportedInterfaceVersionRange: { min: 1, max: 1 },
finalityPolicy: { minConfirmations: 2, maxResultAgeMs: 30_000 },
requestBudgetMs: 5_000,
};
declare const transport: SorobanRpcTransport; // a real implementation — not shipped by this package yet
const oracle = new SorobanOracle(config, transport);
const score = await oracle.getScore('G...'); // throws rather than returning 0 for "I don't know"If environment is 'production', the constructor refuses a transport whose transportKind
is 'fixture' — a mechanical guard, not a convention, against a fixture/stub source ever backing
a production code path.
// illustrative
const oracle = new StubOracle(); // swap for SorobanOracle later
const score = await oracle.getScore(dest); // 0–100
showWarning(score); // extension maps score → tiergrydlock-oracle-adapter/
│
├── README.md ← This file
├── docs/adr/0001-soroban-oracle-protocol.md ← ADR: the Soroban protocol SorobanOracle targets
├── package.json ← Package manifest and npm scripts
├── tsconfig.json ← TypeScript compiler config (strict mode)
├── eslint.config.mjs ← ESLint flat config
├── .prettierrc.json ← Prettier config
├── vitest.config.ts ← Vitest config
├── commitlint.config.js ← Conventional-commits lint rules
├── stryker.config.json ← Mutation testing config (src/ tree)
│
├── .husky/commit-msg ← Local commit-msg hook, runs commitlint
├── .github/workflows/ci.yml ← CI: typecheck, lint, format check, test, bundle size, build, smoke tests, commitlint
│
├── scripts/
│ ├── bundle-size.mjs ← esbuild-based bundle-size budget + tree-shaking check
│ └── generate-fixture-text.mjs ← Emits *.text.ts raw-text modules from the vendored *.json
│
├── src/
│ ├── RiskOracle.ts ← Interface definition + ScoredResult metadata types
│ ├── AllDetailed.ts ← Type-level check: every tier in a chain is DetailedRiskOracle
│ ├── StubOracle.ts ← Lookup-table implementation, backed by fixtures/
│ ├── DefaultOracle.ts ← Oracle that always returns a fixed configured score
│ ├── CoalescingOracle.ts ← De-duplicates concurrent getScore calls per destination
│ ├── ProvenanceOracle.ts ← Decorator emitting a provenance record per score
│ ├── CircuitBreakerOracle.ts ← Trips on repeated infrastructure failures, sheds load while open
│ ├── FallbackOracle.ts ← Bandit-routed tier chain with graceful degradation
│ ├── TypedFallbackOracle.ts ← Typed constructor wrapper around FallbackOracle
│ ├── FallbackObserver.ts ← Observer interface for fallback-chain degradation events
│ ├── RiskOracleAggregator.ts ← Weighted-median aggregation across multiple oracle sources
│ ├── OracleMiddleware.ts ← Middleware type + compose() for chaining oracle decorators
│ ├── OracleError.ts ← Typed error hierarchy for oracle failures
│ ├── BatchRiskOracle.ts ← Batched multi-destination scoring interface
│ ├── StrKeyCodec.ts ← From-scratch Stellar strkey codec (base32 + CRC16-XModem)
│ ├── DestinationValidator.ts ← Destination grammar: G/M/C/L addresses + SEP-11 assets
│ ├── Logger.ts ← Injectable structured Logger interface, no-op default
│ ├── SorobanOracle.ts ← Finality-aware protocol client (see docs/adr/0001-...); no live transport yet
│ ├── middleware/
│ │ ├── withCache.ts ← Cost/confidence-aware cache with stale-while-revalidate
│ │ ├── withRateLimit.ts ← Token-bucket rate limiting, optional cross-tab broadcast
│ │ ├── withTimeout.ts ← Per-call timeout budget
│ │ └── withProvenance.ts ← Middleware form of ProvenanceOracle
│ ├── fixtures/testkit/
│ │ ├── destinations.json ← Vendored grydlock-testkit fixture (labelled destinations)
│ │ ├── scores.json ← Vendored grydlock-testkit fixture (destination -> score)
│ │ ├── destinations.text.ts ← Generated: destinations.json's raw text as a string constant
│ │ ├── scores.text.ts ← Generated: scores.json's raw text as a string constant
│ │ ├── jsonScanner.ts ← Hand-rolled incremental JSON tokenizer + position tracking
│ │ ├── schema.ts ← Runtime shape validation (object-based + incremental) for both files
│ │ └── index.ts ← Validates + exports the fixtures once, at module load
│ ├── fixtures/soroban/
│ │ ├── protocol.ts ← Versioned protocol descriptor (contract method, version range, absence variants), validated at module load
│ │ ├── schema.ts ← SorobanScoreRequest/SorobanRawResponse types + decodeSorobanRawResponse validator
│ │ └── index.ts ← Barrel export for this protocol's types/validators
│ └── index.ts ← Barrel export — the package's public API surface
│
└── tests/
├── StubOracle.test.ts ← getScore range + label-ordering tests against the fixtures
├── StrKeyCodec.test.ts ← base32 / CRC16-XModem / version-byte unit tests
├── StrKeyCodec.differential.test.ts ← 5k-input differential fuzz against the SDK's StrKey
├── DestinationValidator.test.ts ← destination grammar + SEP-11 asset-code boundary tests
├── fixtureSchema.incremental.test.ts ← Incremental parser parity + position assertions
├── fixtureText.sync.test.ts ← Fails if a generated *.text.ts drifts from its source .json
├── benchmarks/fixtureStreaming.budget.test.ts ← Enforced latency/memory budgets at 50k entries
├── ProvenanceOracle.test.ts ← provenance record shape, pass-through, and error-path tests
├── SorobanOracle.test.ts ← protocol/finality/error-mapping/cancellation tests against a fake transport
├── SorobanOracle.testnet.test.ts ← gated live-testnet check; no-ops unless GRYDLOCK_TESTNET_CONTRACT_ID is set
└── support/FakeSorobanRpcTransport.ts ← deterministic in-memory SorobanRpcTransport fake used by the above
npm install
npm run build # compile src/ to dist/cjs (CommonJS) and dist/esm (ES modules), with declarations
npm test # run the test suite
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm run format # prettier --write .
npm run size # bundle-size budget + tree-shaking checkimport { CoalescingOracle, Logger, StubOracle } from 'grydlock-oracle-adapter';
// Optional: structured logger injection (no-op by default).
const logger: Logger = {
debug: (message, meta) => console.debug(message, meta),
info: (message, meta) => console.info(message, meta),
warn: (message, meta) => console.warn(message, meta),
error: (message, meta) => console.error(message, meta),
};
const oracle = new CoalescingOracle(new StubOracle(), logger);
const score = await oracle.getScore('GAJLLIIPHII6OCG4KQJIGPCHVN6DNCRBXHX6DEUTPE7MQ6OONAYBRLET'); // 95, labelled "malicious" in grydlock-testkitThe imports above come from the package entry point (src/index.ts), which also
exports the other oracle implementations (DefaultOracle, CircuitBreakerOracle,
FallbackOracle, RiskOracleAggregator), the error taxonomy (OracleError and its
subclasses), and the withCache / withTimeout / withProvenance / withRateLimit
middleware.
The middleware compose through compose (or by hand, since each one is a plain
(next: RiskOracle) => RiskOracle wrapper). The FIRST middleware listed is the
OUTERMOST layer — it sees the caller's getScore first and its result last, exactly
like reading the list top-to-bottom as layers around the oracle:
import { compose, withCache, withTimeout, withProvenance, StubOracle } from './src';
const oracle = compose(
withCache({ ttlMs: 30_000 }), // outermost: a cache hit skips everything below
withProvenance(), // emits one score_provenance record per call
withTimeout({ timeoutMs: 1_500 }), // innermost: bounds the raw call
)(new StubOracle());The recommended production order puts withCache outermost (so hits skip every other
concern), withProvenance next (so the provenance record reflects cached serves),
then withRateLimit, with withTimeout innermost directly around the raw oracle so
each underlying attempt gets its own budget. compose's result type reflects exactly
which layers preserve or add getScoreDetailed.
- TypeScript
- Soroban SDK — reading the on-chain score
- Stellar SDK (JS) — address / asset handling
- Stellar Testnet — all development
npm testCovers:
StubOracle.getScorereturns a number within 0–100 for every destination in the vendoredgrydlock-testkitfixtures, and a default score for unrecognized destinations- Fixture destinations labelled
maliciousscore higher than those labelledclean ProvenanceOraclepasses scores through unchanged, emits one structured provenance record pergetScore/getScoreDetailedcall (source, timestamp, cache status, latency), reflects metadata fromDetailedRiskOracleinners, and logs anerroroutcome when the wrapped oracle throws
Because this package ships inside a browser extension (grydlock-extension), its footprint
directly affects extension load time and web-store review. CI enforces both a size budget and
tree-shaking behavior on every PR:
npm run sizeThe check (scripts/bundle-size.mjs) bundles the package with esbuild (minified ESM, from the
TypeScript source — the same consumption path the extension's bundler will use) for
representative import patterns:
| Import pattern | Current size (minified) | Budget |
|---|---|---|
import { StubOracle } only |
~9.6 KB (~3.9 KB gzip) | 10 KB |
Full barrel (export * from ..) |
~30.2 KB (~10.9 KB gzip) | 40 KB |
Two things fail the check:
- Budget regression — a pattern's minified size exceeds its budget. If the growth is
intentional (a real feature), raise the budget in
scripts/bundle-size.mjsin the same PR and call it out in the PR description. - Tree-shaking leak — the
StubOracle-only pattern bundles any module outside its explicit allowlist (StubOracle, theRiskOracletypes, and the score fixtures). This guarantees that importing onlyStubOraclenever drags inSorobanOracle, aggregation, or other future code; when new modules are added to the barrel, they must be tree-shakeable (no module-level side effects) or the check fails.
For extension-side contributors: the "StubOracle only" row is the integration cost of the current recommended usage — under 4 KB gzipped added to the extension bundle.
Line/branch coverage only shows whether code executed during a test run, not whether the test
actually asserted on the result. Stryker Mutator is configured
(stryker.config.json) to measure that: it makes small deliberate changes ("mutants") to
src/**/*.ts — e.g. flipping ?? to &&, deleting a return value — and reruns the test suite
against each one. A mutant that still passes the suite ("survived") marks a gap: the code path
ran, but nothing would have caught it breaking.
npm run test:mutationBaseline (established alongside this tooling, src/StubOracle.ts at the time): Stryker found
2 mutants in the only file with executable logic (RiskOracle.ts is a type-only interface and
index.ts is a barrel export — TypeScript erases the former and there's nothing to mutate in the
latter). Of those 2:
- 1 killed — the
??→&&mutation ingetScoreis caught by the existing "malicious scores higher than clean scores" test. - 1 compile error — deleting the function body trips
tsc's "must return a value" check before the mutant ever reaches a test run. This is caught by thetypescriptchecker, not a test; it's effectively an equivalent mutant given the codebase'sstrictTypeScript config, and isn't counted against the mutation score. - 0 survived.
Mutation score: 100% (1/1 of the mutants Stryker could actually run against).
Threshold policy: informational-only for now — thresholds.break is null, so a low score
won't fail CI, and mutation testing runs nightly via
.github/workflows/mutation.yml (also triggerable manually)
rather than gating every PR, since a full mutation run is slower than the rest of the CI pipeline.
Revisit this once SorobanOracle and the resilience features land and there's a meaningfully
larger surface — at that point, consider setting thresholds.break and/or moving the check into
the main ci.yml pipeline.
- Define the
RiskOracleinterface and shipStubOracle - Back
StubOraclewith vendoredgrydlock-testkitfixtures instead of a hardcoded table - Wire
StubOracleinto the extension and confirm the query path end to end on testnet - Define the finality-aware Soroban oracle protocol (ADR) and scaffold
SorobanOracleagainst an injectableSorobanRpcTransport - Implement a real, network-connected
SorobanRpcTransport(@stellar/stellar-sdk-backed) and verifySorobanOracleagainst a deployed testnet contract - Add caching and a timeout / fallback so a slow or unreachable oracle degrades gracefully instead of stalling the signing flow
- For the extension — never talks to the chain directly; it just asks the adapter for a score
- For the scoring backend — pluggable; swap the oracle and nothing upstream changes
- For development — the signing-flow UI can be built and tested against
StubOraclewith no live backend at all
- TypeScript ^6.0.3, Vitest ^4.1.10, ESLint ^10.6.0 + typescript-eslint ^8.63.0, Prettier ^3.9.4 — see
package.jsonfor the full, pinned list @stellar/stellar-sdk— currently only used bytests/StrKeyCodec.differential.test.tsas a reference oracle to fuzz-testsrc/StrKeyCodec.tsagainst; not imported anywhere insrc/(destination validation is a from-scratch, dependency-free reimplementation — see that file's doc comment).SorobanOracle(src/SorobanOracle.ts) is deliberately written against an injectableSorobanRpcTransportseam rather than a concrete SDK call, so this dependency will become load-bearing once a real transport implementation lands, not as part of this increment.package.json'soverrides.axiospinsaxiosto^1.18.1:@stellar/stellar-sdk(every release from 15.0.1 through the current 16.0.1) pins an exact, olderaxiosversion that falls in several since-patched advisories' vulnerable ranges (all fixed inaxios@1.18.0). Since nothing insrc/calls into the SDK's HTTP layer yet, overriding carries no runtime risk today; remove this override once the SDK bumps its ownaxiospin upstream.
MIT
grydlock-oracle-adapter is being developed as an open-source contribution to the Stellar ecosystem. We are actively looking for collaborators with experience in:
- Stellar / Soroban smart contract development (Rust)
- TypeScript backend and browser-extension development
- On-chain data analysis and Stellar Horizon API integration
- Testing and evaluation methodology (
grydlock-testkit)
Quick checklist for contributions:
- All tests pass:
npm test - Code follows project style guidelines:
npm run lintandnpm run format:check - New features include tests
- Documentation is updated
- Commit messages follow the Conventional Commits style below
Commit messages are linted with commitlint using the
@commitlint/config-conventional
preset, so that commit history stays parseable for automated semantic versioning. Every commit
message must follow the Conventional Commits format:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Common <type> values:
| Type | Use for |
|---|---|
feat |
A new feature |
fix |
A bug fix |
docs |
Documentation-only changes |
style |
Formatting changes with no code meaning change (e.g. Prettier) |
refactor |
A code change that neither fixes a bug nor adds a feature |
test |
Adding or correcting tests |
chore |
Tooling, dependency, or build-process changes |
Examples:
feat: add SorobanOracle implementation
fix(RiskOracle): handle missing destination score
docs: update README license section to MIT
chore: add commitlint and husky commit-msg hook
This is enforced two ways:
- Locally — a husky
commit-msghook runscommitlinton every commit. Runnpm installonce after cloning so husky installs the hook (via thepreparescript). - In CI — the
commitlintjob in.github/workflows/ci.ymllints every commit on a pull request, covering contributors who bypass the local hook (e.g.git commit --no-verify).
Versioning and CHANGELOG.md are automated with
semantic-release, driven entirely by the Conventional
Commits history above — there is no manual version bump.
- Trigger:
.github/workflows/release.ymlruns semantic-release on every push tomain. - Version bump: derived from commit types since the last release —
fix:→ patch,feat:→ minor, aBREAKING CHANGE:footer → major. Commits that don't map to a bump (docs,chore,style, etc.) don't trigger a release. - Output: a Git tag, an updated
CHANGELOG.md, and a GitHub Release with generated notes. Thepackage.jsonversionfield is updated and committed back tomainby the@semantic-release/gitplugin (commit messagechore(release): <version> [skip ci], which intentionally skips re-triggering CI/release). - npm publishing: not enabled. This package is
"private": trueand not intended for npm consumption today, so the@semantic-release/npmplugin is deliberately omitted from.releaserc.json. Releases are tags + changelog + GitHub Release only. If the package needs to be published to npm later, that's a separate decision — add the@semantic-release/npmplugin and flipprivatetofalseat that point. - Tooling install: semantic-release and its plugins are resolved by
npxat release time rather than added todevDependencies/package-lock.json, so this workflow doesn't require a lockfile change to adopt.
Gryd Lock is split across four repos in the Gryd-lock GitHub org:
| Repo | Role | Has code? |
|---|---|---|
grydlock-research |
Design study: threat model, system design, warning-tier thresholds, evaluation methodology. The reasoning the other three repos implement. | No — design docs only |
grydlock-extension |
Browser extension. Intercepts a wallet's signing flow (Freighter first), decodes the pending transaction, asks the oracle adapter for a score, and shows a tiered warning. | Yes — early build: Freighter intercept, XDR decode, and warning popup implemented |
grydlock-oracle-adapter (this repo) |
Read-only client. Exposes RiskOracle.getScore(destination) to the extension; backed by StubOracle today, SorobanOracle once a live transport exists. |
Yes — RiskOracle + StubOracle implemented and tested; SorobanOracle protocol + scaffolding implemented and tested against a fake transport (no live transport yet) |
grydlock-testkit |
Testnet fixtures and stub scores used to evaluate the extension + adapter together. | Yes — labelled destinations, stub scores, and sample XDRs implemented, with a fixture validator in CI |
graph LR
SIGN[Wallet signing flow] --> EXT[grydlock-extension\nintercept + decode]
EXT -->|getScore destination| ADAPTER[grydlock-oracle-adapter\nRiskOracle]
ADAPTER -->|StubOracle today| SCORE[0-100 score]
ADAPTER -.->|SorobanOracle later| CHAIN[On-chain risk oracle]
SCORE --> EXT
EXT --> TIER[Warning tier]
TIER --> USER{User decision}
grydlock-testkit supplies the fixture destinations and expected scores that grydlock-extension
and grydlock-oracle-adapter are evaluated against. grydlock-research is upstream of all
three — it defines the threat model and the warning-tier thresholds below.
1. RiskOracle interface — defined here at src/RiskOracle.ts:
interface RiskOracle {
getScore(destination: string): Promise<number>; // 0-100
}grydlock-extension depends on this shape only — it does not know whether the score came from
StubOracle or a live oracle. If this signature changes, grydlock-extension needs a matching
update.
2. Warning tiers — defined in grydlock-research, consumed by grydlock-extension to decide
how loudly to warn:
| Score | Tier | Behaviour |
|---|---|---|
| 0–20 | Low | Proceed |
| 21–50 | Elevated | Soft warning |
| 51–75 | High | Strong warning, require confirm |
| 76–100 | Critical | Recommend abort |
Don't verify the shared contracts above by reading READMEs across repos — that's exactly the manual process that lets drift slip through. The canonical way to check sync is:
npm run sync:checkscripts/check-cross-repo-sync.mjs fetches the current state of the contracts from the other
public repos and diffs them against this repo:
- Warning tiers — parses the canonical table in
grydlock-research's README and compares it against bothgrydlock-extension's implementation (src/lib/tiers.ts) and this repo's own README table RiskOracle.getScore— compares the signature insrc/RiskOracle.tsagainst the stand-in the extension declares insrc/adapter/oracleAdapter.ts
On drift it prints a PASS/FAIL report with the expected (canonical) vs actual values, writes
drift-report.md, and exits non-zero. CI runs it weekly (Mondays 06:00 UTC, plus on PRs that
touch a contract surface — see .github/workflows/cross-repo-sync.yml), since drift
originates in the other repos rather than in pushes here; a scheduled run that finds drift
automatically opens (or updates) a tracking issue labelled cross-repo-drift. A fetch or
parse failure exits with a distinct code (2) and is reported as "needs a human look", not as
confirmed drift.
- Treat this section as the source of truth for cross-repo context. Each repo's own README covers repo-local conventions.
- Before assuming a name/function/interface still exists in another repo, verify it there — this
reflects each repo's state as of the last time it was checked, not a live feed. For the two
shared contracts above, run
npm run sync:checkinstead of eyeballing. - If a change here affects
RiskOracleor the warning-tier thresholds, call it out so the corresponding repo can be updated.
For issues and questions:
- GitHub Issues: https://github.com/Gryd-lock/grydlock-oracle-adapter/issues
- Stellar Discord: https://discord.gg/stellar
grydlock-oracle-adapter — the one door Gryd Lock knocks on for a risk score.
Part of the Gryd Lock project. Interface defined, live oracle not yet wired.
| Error | Code | Meaning | Typical Cause |
|---|---|---|---|
| OracleUnavailableError | ORACLE_UNAVAILABLE | The oracle could not be reached. | Network outage, RPC unavailable |
| OracleTimeoutError | ORACLE_TIMEOUT | The oracle request timed out. | Slow network or unresponsive RPC |
| OracleCancelledError | ORACLE_CANCELLED | The request was cancelled via an AbortSignal. |
Caller (or middleware) aborted the request |
| InvalidDestinationError | INVALID_DESTINATION | The supplied Stellar destination is invalid. | Malformed address or asset identifier |
| UnrecognizedDestinationError | UNRECOGNIZED_DESTINATION | The destination is valid but not recognized. | Destination not present in oracle data |
| ContractIncompatibilityError | CONTRACT_INCOMPATIBILITY | The adapter is incompatible with the oracle contract. | ABI/version mismatch |
| UnsupportedInterfaceVersionError | UNSUPPORTED_INTERFACE_VERSION | (SorobanOracle) Contract's reported interface version is out of range. instanceof ContractIncompatibilityError also holds. |
Contract upgraded/downgraded past this adapter's supported range |
| WrongNetworkError | WRONG_NETWORK | (SorobanOracle) Response reported a different network passphrase. |
Misconfigured RPC endpoint or contract id |
| WrongContractError | WRONG_CONTRACT | (SorobanOracle) Response reported a different contract id. |
Misconfigured contract id |
| MalformedOracleResponseError | MALFORMED_ORACLE_RESPONSE | (SorobanOracle) Response failed protocol schema validation. |
Transport/contract bug |
| InsufficientFinalityError | INSUFFICIENT_FINALITY | (SorobanOracle) Result hasn't reached the configured finality policy. |
Read too soon after the ledger closed |
| ScoreNotYetComputedError | SCORE_NOT_YET_COMPUTED | (SorobanOracle) Destination is tracked but scoring is still pending. |
Destination recently seen, not yet scored |
| QuorumNotMetError | QUORUM_NOT_MET | Fewer than the required quorum of sources answered. | Aggregator sources failed/timed out |
All of the above (plus the base OracleError) are exported from the package entry point
(src/index.ts). Prefer instanceof or the code field over parsing error messages. See
docs/adr/0001-soroban-oracle-protocol.md for what
distinguishes the SorobanOracle-specific errors above from one another.