stellar-did-credit is a three-contract protocol on Stellar/Soroban that lets any wallet address build a verifiable, portable credit identity. An off-chain TypeScript SDK wraps the contracts for application developers. The contracts are fully independent — each can be upgraded or replaced without breaking the others — and communicate only through explicit cross-contract calls or off-chain coordination via the feeder role.
graph TD
CON_APP[Application / Lender UI]
CON_SDK[TypeScript SDK]
SC_ID[identity-oracle\nCATORJPJ...]
SC_CR[credit-oracle\nCBMMX6GJ...]
SC_RV[revocation-registry\nCDNQLXKK...]
OFF_FEEDER[Trusted Feeder\noff-chain indexer]
OFF_ISSUER[Credential Issuer]
OFF_SUBJECT[Subject / Wallet]
OFF_SUBJECT -->|anchor_did| SC_ID
OFF_ISSUER -->|anchor_vc| SC_ID
OFF_ISSUER -->|revoke| SC_RV
SC_ID -.->|mark_vc_revoked| SC_ID
OFF_FEEDER -->|set_vc_count\nupdate_tx_stats| SC_CR
CON_APP -->|record_repayment| SC_CR
CON_APP -->|compute_score| SC_CR
SC_CR -->|get_active_vc_count\n(if IdentityOracleId set)| SC_ID
SC_CR -.->|read VcCount\n(if IdentityOracleId NOT set)| SC_CR
CON_SDK -->|getScore\nisVerified\nanchorDID\nissueVC| SC_ID
CON_SDK -->|getScore| SC_CR
CON_APP --> CON_SDK
Stores decentralised identifiers (DIDs) and verifiable credential (VC) anchors for subjects. It is the source of truth for whether a wallet address has been verified by a trusted issuer.
The protocol admin must register each trusted issuer before that address can call anchor_vc. This is done through register_issuer(admin, issuer) on the identity-oracle contract; the admin can later revoke trust with deregister_issuer.
Key functions
| Function | Caller | Description |
|---|---|---|
initialize(admin) |
deployer | Sets the contract administrator |
register_issuer(admin, issuer) |
admin | Whitelists a credential issuer |
anchor_did(subject, did_doc_cid) |
subject | Stores an IPFS CID pointing to the subject's DID document |
anchor_vc(issuer, subject, vc_hash) |
issuer | Records a SHA-256 hash of an off-chain VC |
mark_vc_revoked(issuer, subject, vc_hash) |
issuer | Marks a specific VC as revoked |
is_verified(subject) |
anyone | Returns true if the subject has at least one non-revoked VC |
get_active_vc_count(subject) |
anyone | Returns anchored VC count excluding revoked entries |
verify_vc(subject, vc_hash) |
anyone | Returns true if a specific VC exists and is not revoked |
Storage layout
| Key | Type | Description |
|---|---|---|
Admin |
Address |
Instance storage — contract admin |
TrustedIssuer(Address) |
bool |
Persistent — tombstone flag: true while a registered issuer is trusted, false once deregistered |
IssuersIndex |
Vec<Address> |
Persistent — append-only list of every address ever registered; list_issuers() filters this against TrustedIssuer |
DIDDocument(Address) |
String |
Persistent — IPFS CID of the subject's DID document |
VCAnchors(Address) |
Vec<VCRecord> |
Persistent — list of VC anchor records for a subject |
Computes and stores a credit score (300–850) for any subject address. It relies on three data inputs: VC count (fed by a trusted feeder), transaction statistics (fed by a trusted feeder), and repayment history (recorded by trusted lenders). The scoring formula is deterministic and fully on-chain.
Key functions
| Function | Caller | Description |
|---|---|---|
initialize(admin) |
deployer | Sets admin and default scoring weights (40/30/30) |
register_feeder(admin, feeder) |
admin | Whitelists a data feeder |
register_lender(admin, lender) |
admin | Whitelists a lender |
set_vc_count(feeder, subject, count) |
feeder | Caches the subject's VC count from identity-oracle |
update_tx_stats(feeder, subject, stats) |
feeder | Updates 30-day transaction volume and count |
record_repayment(lender, subject, amount, on_time) |
lender | Records a repayment event; current v1 behavior does not verify a real loan relationship and should be treated as a lender attestation rather than proof of disbursement |
compute_score(subject) |
anyone | Runs the scoring formula and persists the result |
get_score(subject) |
anyone | Returns the last computed ScoreRecord |
update_weights(weights) |
admin | Changes scoring weights (must sum to 100) |
Storage layout
| Key | Type | Description |
|---|---|---|
Admin |
Address |
Instance storage — contract admin |
Config |
ScoringWeights |
Instance storage — vc/tx/repayment weights |
TrustedFeeder(Address) |
bool |
Persistent — registered feeder flag |
TrustedLender(Address) |
bool |
Persistent — registered lender flag |
TxStats(Address) |
TxStats |
Persistent — 30-day tx volume and count |
RepaymentRecord(Address) |
RepaymentRecord |
Persistent — on-time and total repayment counts |
VcCount(Address) |
u32 |
Persistent — cached VC count from identity-oracle |
Score(Address) |
ScoreRecord |
Persistent — last computed score with metadata |
A minimal, standalone registry that maps VC hashes to their revocation status. It is intentionally separate from identity-oracle so that revocation can be checked by any party without needing to traverse the full VC anchor list.
Key functions
| Function | Caller | Description |
|---|---|---|
initialize(admin) |
deployer | Sets the contract administrator |
revoke(issuer, vc_hash) |
issuer | Marks a VC hash as revoked (issuer authority enforced per vc_hash) |
batch_revoke(issuer, vc_hashes) |
issuer | Revokes multiple VC hashes in one transaction (issuer authority enforced per vc_hash) |
| is_revoked(vc_hash) | anyone | Returns true if the hash has been revoked |
Storage layout
| Key | Type | Description |
|---|---|---|
Admin |
Address |
Instance storage — contract admin |
Status(BytesN<32>) |
bool |
Persistent — revocation flag for a VC hash |
RegisteredVCIssuer(BytesN<32>) |
Address |
Persistent — authority that is allowed to revoke this hash (first issuer wins) |
IssuerOfVC(BytesN<32>) |
Address |
Persistent — which issuer performed the latest revoke call for this hash |
Soroban entries have a limited time-to-live (TTL) measured in ledgers. If the TTL of a contract's instance storage entry reaches zero, the contract becomes archived — all its data is lost and it can never be called again. To prevent this, every function that reads or writes instance storage must periodically call extend_ttl.
Each contract defines two constants:
| Constant | Value | Purpose |
|---|---|---|
INSTANCE_BUMP_THRESHOLD |
5 000 | Extend when fewer than ~7 hours of ledgers remain |
INSTANCE_BUMP_AMOUNT |
500 000 | Extend TTL to ~30 days from now |
The call is placed after authentication succeeds in every admin-gated function:
env.storage().instance().extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT);All three contracts apply this pattern in initialize and every admin-gated function:
identity-oracle
initialize,register_issuer,deregister_issuer,upgrade
credit-oracle
initialize,register_feeder,deregister_feeder,register_lender,deregister_lender,propose_weights,upgrade
revocation-registry
initialize,upgrade
Non-admin functions such as anchor_did, anchor_vc, compute_score, revoke, etc. touch only persistent storage and do not need to extend the instance TTL. If the contract is not called by an admin for an extended period, anyone can call any of the covered admin-gated functions (with admin authentication) to refresh the TTL.
Full treatment: The Epoch Model document covers TTL management in depth — what each function bumps, what gets invalidated on expiry, and how to maintain liveness. It also explains related ledger-based mechanisms (compute cooldown, weight timelock, score staleness) that are not covered here.
The credit-oracle supports a dual-path mechanism for resolving a subject's VC count during score computation. The active path depends on whether an IdentityOracleId is configured in the contract's instance storage.
If IdentityOracleId is configured, compute_score dynamically queries the target contract:
sequenceDiagram
participant Caller
participant CreditOracle
participant IdentityOracle
Caller->>CreditOracle: compute_score(subject)
CreditOracle->>IdentityOracle: get_active_vc_count(subject)
IdentityOracle-->>CreditOracle: u32
CreditOracle->>CreditOracle: run scoring formula
CreditOracle-->>Caller: score: u32
In this path, the credit-oracle uses env.invoke_contract to obtain a live VC count directly from the identity-oracle. This ensures real-time accuracy but incurs cross-contract call overhead.
If IdentityOracleId is not set, compute_score falls back to reading a cached VcCount from persistent storage. This value is updated asynchronously by an off-chain trusted feeder calling set_vc_count.
While this avoids cross-contract overhead, the cached VcCount can become stale if the off-chain feeder halts or falls behind.
To migrate a deployment from the cached fallback path to the live cross-contract path:
- Configure the Oracle ID: The admin calls
set_identity_oracle(identity_oracle_id)oncredit-oracle. - Path Switch: Once the ID is set, all subsequent
compute_scorecalls will automatically use the cross-contract lookup. - Deprecate Feeder Input: The trusted feeder should stop calling
set_vc_count. Any further updates viaset_vc_countwill be successfully written to persistent storage but entirely ignored bycompute_score. - Failure Caveat: There is no automatic fallback if the cross-contract call fails. If the configured
IdentityOracleIdpoints to an invalid contract or one that doesn't implementget_active_vc_count, thecompute_scoretransaction will unconditionally fail.
Soroban persistent and instance storage entries have a time-to-live (TTL) measured in ledgers. If an entry's TTL expires, the entry is archived (removed from storage). To prevent data loss, all three contracts proactively extend TTLs on every write and provide an admin-only maintain_storage function for passive maintenance.
-
Automatic extension on every write — whenever a contract writes to persistent storage (
set), it immediately callsextend_ttlon that entry. This ensures actively-used data stays alive without any external coordination. -
maintain_storageadmin function — extends instance storage TTL (Admin, Config, PendingWeights). Can be called periodically by a cron job or manually to protect a contract whose configuration rarely changes.
| Constant | Value | Approximate real time | Scope |
|---|---|---|---|
INST_TTL_THRESHOLD |
120 960 | ~7 days | Instance |
INST_TTL_EXTEND |
6 307 200 | ~1 year | Instance |
PERS_TTL_THRESHOLD |
120 960 | ~7 days | Persistent |
PERS_TTL_EXTEND |
518 400 | ~30 days | Persistent |
Ledger time is calculated at ≈5 s/ledger (Stellar network average).
identity-oracle
| Operation | Entry extended |
|---|---|
initialize |
Instance storage (Admin) |
register_issuer |
TrustedIssuer(issuer) |
anchor_did |
DIDDocument(subject) |
anchor_vc |
VCAnchors(subject) |
mark_vc_revoked |
VCAnchors(subject) |
maintain_storage |
Instance storage |
credit-oracle
| Operation | Entry extended |
|---|---|
initialize |
Instance storage (Admin, Config) |
register_feeder |
TrustedFeeder(feeder) |
register_lender |
TrustedLender(lender) |
update_tx_stats |
TxStats(subject) |
record_repayment |
RepaymentRecord(subject) |
set_vc_count |
VcCount(subject) |
compute_score |
Score(subject), TxStats(subject), RepaymentRecord(subject), VcCount(subject) |
maintain_storage |
Instance storage |
revocation-registry
| Operation | Entry extended |
|---|---|
initialize |
Instance storage (Admin) |
revoke |
Status(vc_hash), IssuerOfVC(vc_hash) |
batch_revoke |
Status(vc_hash), IssuerOfVC(vc_hash) |
maintain_storage |
Instance storage |
- Deploy an off-chain cron job (or serverless function) that calls
maintain_storageon all three contracts at least once every 6 months (well within the 1‑year instance TTL). - No additional action is needed for persistent entries — their TTLs are extended automatically whenever they are written.
- If an entry has not been touched for more than ~30 days, it may be archived. This is by design: orphaned data can be garbage-collected by the network.
The subject calls anchor_did on identity-oracle with an IPFS CID pointing to their DID document (a JSON-LD file stored off-chain). This anchors their decentralised identifier on-chain and emits a DIDAnch event.
A trusted issuer (registered by the admin) calls anchor_vc with the subject's address and the SHA-256 hash of an off-chain VC JSON. The VC itself stays off-chain; only its hash is stored. After this call, is_verified(subject) returns true.
An off-chain indexer (the feeder) monitors the subject's on-chain activity, queries identity-oracle for their VC count, and periodically calls set_vc_count and update_tx_stats on credit-oracle to keep the scoring inputs fresh.
When a lender disburses a loan and the subject repays, the lender calls record_repayment on credit-oracle, flagging each repayment as on-time or late.
This is a deliberate v1 limitation: the contract currently accepts repayment data from any registered lender without verifying that the lender actually disbursed a loan to that subject. In other words, record_repayment is an attestation from a trusted lender, not proof of an existing loan relationship. A future version should add explicit loan-tracking state or signed disbursement/repayment attestations to close this gap.
Anyone (the subject, a lender, or an application) calls compute_score(subject). The contract reads the three input components, runs the weighted formula, clamps the result to 300–850, and persists a ScoreRecord.
A lender UI or the TypeScript SDK calls get_score(subject) to read the last computed ScoreRecord. The SDK's getScore() method does this via a read-only simulation — no transaction fees required.
Status: Accepted
Context
compute_score(subject) in credit-oracle writes a ScoreRecord to persistent
storage but requires no require_auth() call. During the initial security review
the absence of an auth check was flagged as potentially unintentional.
Decision
The open-call design is intentional. The function reads only data that has already been submitted by trusted parties (feeders and lenders) and writes only the subject's own score record. There is no way for an adversarial caller to inflate, deflate, or corrupt a score beyond what the on-chain inputs support. Keeping the function permissionless:
- allows lenders and applications to refresh a score without holding a subject signature,
- lets the off-chain feeder refresh scores in the same transaction as a data update, and
- treats score computation as a public utility rather than a privileged action.
Consequences
Successful recomputations are rate-limited per subject by the configured
ComputeCooldownLedgers value. The default interval is one ledger, which
prevents same-ledger timestamp grinding while preserving the open-call design.
The last successful computation ledger is stored as LastComputed(Address), and
admin/governance can update the interval with update_compute_cooldown.
Status: Accepted
Context
Prior to this change the three contracts used two different admin-auth styles:
update_weights/propose_weightscalledstored_admin.require_auth()directly after loading the admin from storage (implicit lookup).register_feeder,register_lender,register_issueretc. required the caller to passadminas an explicit parameter, then compared it against storage before callingrequire_auth()on the passed-in value.
The mixed styles made the auth model hard to reason about and audit.
Decision
Extract a private fn require_admin(env: &Env) -> Address in each contract.
The helper loads the stored admin, immediately calls require_auth() on it, and
returns the address. Every admin-gated function now calls require_admin first,
then (for the explicit-parameter variants) compares the returned address to the
caller-supplied admin to preserve the existing API surface.
Consequences
- A single read path for the admin address — easier to audit.
require_auth()is always called on the stored admin, not on an unvalidated caller-supplied value.- The public function signatures are unchanged; no SDK or script updates needed.
For a detailed catalog of events emitted by the smart contracts and instructions on subscribing to them for off-chain sync, see the Event Indexing Guide.