TrustLink is a Soroban smart contract that provides a reusable trust layer for the Stellar blockchain. It enables trusted issuers, bridge contracts, and administrators to create, import, manage, and revoke attestations about wallet addresses, allowing other contracts and applications to verify claims before executing financial operations.
TrustLink solves the problem of decentralized identity verification and trust establishment on-chain. Instead of each application building its own KYC/verification system, TrustLink provides a shared attestation infrastructure that can be queried by any smart contract or dApp.
- Authorized Issuers: Admin-controlled registry of trusted attestation issuers
- Claim Type Registry: Admin-managed registry of standard claim types with descriptions
- Flexible Claims: Support for any claim type (KYC_PASSED, ACCREDITED_INVESTOR, MERCHANT_VERIFIED, etc.)
- Expiration Support: Optional time-based expiration for attestations
- Historical Import: Admin can import externally verified attestations with original timestamps
- Cross-Chain Bridge Support: Trusted bridge contracts can bring attestations from other chains on-chain
- Configurable Fees: Admin can require a token-denominated fee for native attestation creation
- Revocation: Issuers can revoke attestations at any time
- Deterministic IDs: Attestations have unique, reproducible identifiers
- Event Emission: All state changes emit events for off-chain indexing
- Query Interface: Easy verification of claims for other contracts
- Pagination: Efficient listing of attestations per subject or issuer
src/
├── lib.rs # Main contract implementation
├── types.rs # Data structures and error definitions
├── storage.rs # Storage patterns and key management
├── validation.rs # Authorization and access control
├── events.rs # Event emission for indexers
└── test.rs # Comprehensive unit tests
Attestation Structure:
{
id: String, // Deterministic hash-based ID
issuer: Address, // Who issued the attestation
subject: Address, // Who the attestation is about
claim_type: String, // Type of claim (e.g., "KYC_PASSED")
timestamp: u64, // When it was created
expiration: Option<u64>, // Optional expiration time
revoked: bool, // Revocation status
metadata: Option<String>, // Optional issuer-supplied metadata
imported: bool, // True when migrated from an external source
bridged: bool, // True when created by a trusted bridge contract
source_chain: Option<String>, // Chain where the original attestation exists
source_tx: Option<String> // Source transaction or reference
}Storage Keys:
Admin: Contract administrator addressFeeConfig: Global attestation fee settingsIssuer(Address): Authorized issuer registryBridge(Address): Authorized bridge contract registryAttestation(String): Individual attestation dataSubjectAttestations(Address): Index of attestations per subjectIssuerAttestations(Address): Index of attestations per issuerClaimType(String): Registered claim type info keyed by identifierClaimTypeList: Ordered list of all registered claim type identifiers
// Deploy and initialize with admin and optional custom TTL (days)
// ttl_days: None uses default 30 days, or Some(7) for custom TTL
contract.initialize(&admin_address, &None);Fees are disabled by default. When enabled, create_attestation transfers the
configured amount from the issuer to the configured collector before the
attestation is stored.
The contract stores an explicit fee_token because Soroban fee collection must
transfer a concrete token contract rather than an abstract currency amount.
let fee_token = token_contract_address;
contract.set_fee(
&admin,
&25,
&collector_address,
&Some(fee_token),
);
let fee_config = contract.get_fee_config();
assert_eq!(fee_config.attestation_fee, 25);
assert_eq!(fee_config.fee_collector, collector_address);// Admin registers a trusted issuer
contract.register_issuer(&admin, &issuer_address);
// Check if address is authorized
let is_authorized = contract.is_issuer(&issuer_address);Bridge contracts use a separate trust registry from regular issuers.
contract.register_bridge(&admin, &bridge_contract_address);
let is_bridge = contract.is_bridge(&bridge_contract_address);The contract ships with a set of standard claim types that the admin can pre-register on deployment.
| Claim Type | Description |
|---|---|
KYC_PASSED |
Subject has passed KYC identity verification |
ACCREDITED_INVESTOR |
Subject qualifies as an accredited investor |
MERCHANT_VERIFIED |
Subject is a verified merchant |
AML_CLEARED |
Subject has passed AML screening |
SANCTIONS_CHECKED |
Subject has been checked against sanctions lists |
// Admin registers a claim type
contract.register_claim_type(
&admin,
&String::from_str(&env, "KYC_PASSED"),
&String::from_str(&env, "Subject has passed KYC identity verification"),
);
// Look up a description
let desc = contract.get_claim_type_description(&String::from_str(&env, "KYC_PASSED"));
// List registered types (paginated)
let page1 = contract.list_claim_types(&0, &10);If fees are enabled, the issuer must hold enough of the configured token for the transfer to succeed.
// Issuer creates a KYC attestation
let attestation_id = contract.create_attestation(
&issuer,
&user_address,
&String::from_str(&env, "KYC_PASSED"),
&None, // No expiration
&None // No metadata
);
// Create attestation with expiration
let expiration_time = current_timestamp + 365 * 24 * 60 * 60; // 1 year
let attestation_id = contract.create_attestation(
&issuer,
&user_address,
&String::from_str(&env, "ACCREDITED_INVESTOR"),
&Some(expiration_time),
&None
);Use this when migrating records from another verified system. The admin performs the import, but the imported record is still attached to a registered issuer.
let historical_timestamp = 1_700_000_000;
let expiration = Some(1_731_536_000);
let imported_id = contract.import_attestation(
&admin,
&issuer,
&user_address,
&String::from_str(&env, "KYC_PASSED"),
&historical_timestamp,
&expiration,
);
let attestation = contract.get_attestation(&imported_id);
assert!(attestation.imported);
assert_eq!(attestation.timestamp, historical_timestamp);Use this when a trusted bridge contract is mirroring an attestation that was verified on another chain. The bridge contract becomes the on-chain attestation creator, while the original source is preserved on the record.
let bridged_id = contract.bridge_attestation(
&bridge_contract_address,
&user_address,
&String::from_str(&env, "KYC_PASSED"),
&String::from_str(&env, "ethereum"),
&String::from_str(&env, "0xabc123"),
);
let attestation = contract.get_attestation(&bridged_id);
assert!(attestation.bridged);
assert_eq!(attestation.source_chain, Some(String::from_str(&env, "ethereum")));
assert_eq!(attestation.source_tx, Some(String::from_str(&env, "0xabc123")));// Check if user has valid KYC
let has_kyc = contract.has_valid_claim(
&user_address,
&String::from_str(&env, "KYC_PASSED")
);
if has_kyc {
// Proceed with financial operation
}
// Check if user has valid KYC from a specific issuer
let has_specific_kyc = contract.has_valid_claim_from_issuer(
&user_address,
&String::from_str(&env, "KYC_PASSED"),
&specific_issuer_address
);has_any_claim(env: Env, subject: Address, claim_types: Vec<String>) -> bool
| Parameter | Type | Description |
|---|---|---|
env |
Env |
Soroban environment (ledger time, storage) |
subject |
Address |
The address whose attestations are queried |
claim_types |
Vec<String> |
One or more claim type identifiers to check |
Returns true if the subject holds at least one valid attestation matching any of the listed claim types; false otherwise.
Behavior:
- Uses OR-logic — returns
trueon the first valid match found (short-circuit evaluation) - An empty
claim_typeslist always returnsfalse - Revoked, expired, and pending attestations are excluded from matching
// Check if user has either KYC or an accredited investor credential
let claim_types = vec![
&env,
String::from_str(&env, "KYC_PASSED"),
String::from_str(&env, "ACCREDITED_INVESTOR"),
String::from_str(&env, "MERCHANT_VERIFIED"),
];
let has_any = contract.has_any_claim(&user_address, &claim_types);
if has_any {
// Proceed — user satisfies at least one required credential
}Relationship to has_valid_claim: Calling has_any_claim with a single-element list is equivalent to calling has_valid_claim with that same claim type. Use has_valid_claim when checking a single claim type, and has_any_claim when OR-logic across multiple claim types is needed.
has_all_claims(env: Env, subject: Address, claim_types: Vec<String>) -> bool
| Parameter | Type | Description |
|---|---|---|
env |
Env |
Soroban environment (ledger time, storage) |
subject |
Address |
The address whose attestations are queried |
claim_types |
Vec<String> |
All claim type identifiers that must be valid |
Returns true only if the subject holds a valid attestation for every claim type in the list; false as soon as any one is missing, revoked, expired, or pending.
Behavior:
- Uses AND-logic — short-circuits and returns
falseon the first unsatisfied claim type - An empty
claim_typeslist always returnstrue(vacuous truth) - Revoked, expired, and pending attestations are excluded from matching
// Require the user to hold ALL three credentials before proceeding
let mut required = soroban_sdk::Vec::new(&env);
required.push_back(String::from_str(&env, "KYC_PASSED"));
required.push_back(String::from_str(&env, "ACCREDITED_INVESTOR"));
required.push_back(String::from_str(&env, "AML_CLEARED"));
let fully_verified = trustlink.has_all_claims(&user_address, &required);
if fully_verified {
// All credentials present and valid — proceed with restricted operation
} else {
// At least one credential is missing, revoked, or expired
return Err(Error::InsufficientCredentials);
}Relationship to has_any_claim: has_any_claim uses OR-logic (at least one match), while has_all_claims uses AND-logic (every claim must match). Use has_all_claims when a workflow requires a complete set of credentials, such as high-value lending that demands both KYC and AML clearance.
// Issuer revokes an attestation
contract.revoke_attestation(&issuer, &attestation_id);High-value claims (e.g. ACCREDITED_INVESTOR) can require M-of-N registered issuers to co-sign before the attestation becomes active. This prevents a single compromised issuer from unilaterally issuing sensitive credentials.
Flow:
- A registered issuer calls
propose_attestation— they automatically count as the first signer. - Other required issuers call
cosign_attestationwith the returnedproposal_id. - Once the number of signatures reaches
threshold, the attestation is finalized and stored as a normal active attestation. - Proposals expire after 7 days if the threshold is not reached.
// Build the required-signers list (all must be registered issuers)
let mut required_signers = soroban_sdk::Vec::new(&env);
required_signers.push_back(issuer_a.clone());
required_signers.push_back(issuer_b.clone());
required_signers.push_back(issuer_c.clone());
// Propose a 2-of-3 multi-sig attestation
let proposal_id = contract.propose_attestation(
&issuer_a, // proposer (auto-signs)
&user_address, // subject
&String::from_str(&env, "ACCREDITED_INVESTOR"), // claim type
&required_signers, // all required signers
&2, // threshold
);
// issuer_b co-signs — threshold reached, attestation activated
contract.cosign_attestation(&issuer_b, &proposal_id);
assert!(contract.has_valid_claim(&user_address, &String::from_str(&env, "ACCREDITED_INVESTOR")));Inspect a proposal:
let proposal = contract.get_multisig_proposal(&proposal_id);
// proposal.signers — addresses that have signed so far
// proposal.threshold — required number of signatures
// proposal.finalized — true once the attestation is active
// proposal.expires_at — unix timestamp after which cosigning is rejectedError cases:
InvalidThreshold— threshold is 0 or exceeds the number of required signersUnauthorized— proposer or a required signer is not a registered issuerNotRequiredSigner— cosigner is not in the proposal's required-signers listAlreadySigned— the issuer has already co-signed this proposalProposalFinalized— the proposal has already been activatedProposalExpired— the 7-day window has passed without reaching threshold
Events emitted:
topics: ["ms_prop", subject_address] data: (proposal_id, proposer, threshold)
topics: ["ms_sign", signer_address] data: (proposal_id, signatures_so_far, threshold)
topics: ["ms_actv"] data: (proposal_id, attestation_id)
// Get specific attestation
let attestation = contract.get_attestation(&attestation_id);
// Check status
let status = contract.get_attestation_status(&attestation_id);
// Returns: Valid, Expired, or Revoked
// Find the most recent valid attestation by subject + claim type
let attestation = contract.get_attestation_by_type(&user_address, &String::from_str(&env, "KYC_PASSED"));
// Count queries — returns total count, no pagination needed
let total = contract.get_subject_attestation_count(&user_address); // all attestations (incl. revoked/expired)
let issued = contract.get_issuer_attestation_count(&issuer_address); // all issued by this issuer
let valid = contract.get_valid_claim_count(&user_address); // only non-revoked, non-expired
// List user's attestations (paginated)
let attestations = contract.get_subject_attestations(&user_address, &0, &10);
// List issuer's attestations
let issued = contract.get_issuer_attestations(&issuer_address, &0, &10);Here's how another contract would verify attestations:
use soroban_sdk::{contract, contractimpl, Address, Env, String};
#[contract]
pub struct LendingContract;
#[contractimpl]
impl LendingContract {
pub fn borrow(
env: Env,
borrower: Address,
trustlink_contract: Address,
amount: i128
) -> Result<(), Error> {
borrower.require_auth();
// Create client for TrustLink contract
let trustlink = trustlink::Client::new(&env, &trustlink_contract);
// Verify borrower has valid KYC
let kyc_claim = String::from_str(&env, "KYC_PASSED");
let has_kyc = trustlink.has_valid_claim(&borrower, &kyc_claim);
if !has_kyc {
return Err(Error::KYCRequired);
}
// Proceed with lending logic
// ...
Ok(())
}
}TrustLink defines clear error types:
AlreadyInitialized: Contract already initializedNotInitialized: Contract not yet initializedUnauthorized: Caller lacks required permissionsNotFound: Attestation doesn't existDuplicateAttestation: Attestation with same hash already existsAlreadyRevoked: Attestation already revokedExpired: Attestation has expiredInvalidThreshold: Multi-sig threshold is 0 or exceeds signer countNotRequiredSigner: Cosigner is not in the proposal's required-signers listAlreadySigned: Issuer has already co-signed the proposalProposalFinalized: Proposal has already been activated into an attestationProposalExpired: Proposal window (7 days) elapsed without reaching threshold
TrustLink emits events for off-chain indexing:
AttestationCreated:
topics: ["created", subject_address]
data: (attestation_id, issuer, claim_type, timestamp)AttestationRevoked:
topics: ["revoked", issuer_address]
data: attestation_idAttestationRenewed:
topics: ["renewed", issuer_address]
data: (attestation_id, new_expiration)IssuerRegistered:
topics: ["iss_reg", issuer_address]
data: (admin_address, timestamp)IssuerRemoved:
topics: ["iss_rem", issuer_address]
data: (admin_address, timestamp)ClaimTypeRegistered:
topics: ["clmtype"]
data: (claim_type, description)- Rust 1.70+
- Soroban CLI
- wasm32-unknown-unknown target
# Run tests
make test
# Build contract
make build
# Build optimized version
make optimize
# Clean artifacts
make clean
# Format code
make fmt
# Run linter
make clippycargo testTo verify the WASM build target compiles correctly for Stellar deployment:
# Build for wasm32-unknown-unknown target
cargo build --target wasm32-unknown-unknown --release
# Verify the WASM artifact exists
ls -la target/wasm32-unknown-unknown/release/trustlink.wasm
# Validate the WASM binary (requires wasm-tools)
cargo install wasm-tools --locked
wasm-tools validate target/wasm32-unknown-unknown/release/trustlink.wasmOr use the Makefile target:
make buildBuild Verification Criteria:
- ✅ Build exits with code 0
- ✅
trustlink.wasmartifact exists intarget/wasm32-unknown-unknown/release/ - ✅ WASM file size is reasonable (< 100KB after optimization)
- ✅ No std dependency errors (
#![no_std]is respected) - ✅ WASM binary is valid and can be inspected with wasm-objdump
Tests cover:
- Initialization and admin management
- Issuer registration and removal
- Attestation creation with validation
- Duplicate prevention
- Revocation logic
- Expiration handling
- Authorization enforcement
- Pagination
- Cross-contract verification
- Authorization: Only admin can manage issuers; only issuers can create attestations
- Deterministic IDs: Prevents replay attacks and ensures uniqueness
- Immutable History: Attestations are never deleted, only marked as revoked
- Time-based Expiration: Automatic invalidation of expired claims
- Event Transparency: All changes are logged for auditability
- DeFi Protocols: Verify KYC before lending/borrowing
- Token Sales: Ensure accredited investor status
- Payment Systems: Verify merchant credentials
- Governance: Validate voter eligibility
- Marketplaces: Confirm seller reputation
- Insurance: Verify policyholder identity
# Build optimized contract
make optimize
# Deploy to network
soroban contract deploy \
--wasm target/wasm32-unknown-unknown/release/trustlink.wasm \
--network testnet
# Initialize
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- initialize \
--admin <ADMIN_ADDRESS>New to TrustLink or Soroban? Watch the TrustLink Video Tutorial for a 10–15 minute walkthrough covering what TrustLink is, how to deploy it, and how to integrate it into your contracts and frontend.
A companion written guide with all commands and code snippets is available at docs/video-tutorial-guide.md.
For a step-by-step walkthrough covering Rust cross-contract patterns, JavaScript/TypeScript usage, error handling, and testnet testing, see docs/integration-guide.md.
For a full reference of every on-chain storage key, the data each holds, TTL policy, serialization format, and a practical RPC read example for indexer developers, see docs/storage-layout.md.
MIT
See CHANGELOG.md for a history of notable changes.
Contributions welcome! See CONTRIBUTING.md for setup instructions, code style requirements, and the PR process.
For issues or questions, please open a GitHub issue.