This guide accompanies the TrustLink video tutorial.
It covers the same material in written form so you can follow along at your own pace, copy-paste commands, and refer back without scrubbing through video.
- What TrustLink is and why it exists
- How to deploy TrustLink to the Stellar testnet
- How to issue and verify attestations via the CLI
- How to integrate TrustLink into a Rust smart contract
- How to query TrustLink from a JavaScript / TypeScript frontend
| Tool | Install |
|---|---|
| Rust + Cargo | https://rustup.rs |
wasm32-unknown-unknown target |
rustup target add wasm32-unknown-unknown |
| Soroban CLI | cargo install --locked soroban-cli |
| Stellar CLI (alternative) | cargo install --locked stellar-cli --features opt |
| Node.js ≥ 18 (for JS section) | https://nodejs.org |
Fund a testnet account via Friendbot:
curl "https://friendbot.stellar.org?addr=YOUR_PUBLIC_KEY"TrustLink is a Soroban smart contract that provides a shared attestation layer on the Stellar blockchain. It solves a common problem: every dApp that needs identity verification (KYC, AML, accredited investor status) ends up building its own system from scratch.
With TrustLink:
- An admin controls which addresses are trusted issuers
- Issuers create attestations that link a claim type (e.g.
KYC_PASSED) to a subject wallet address - Any contract or frontend can call
has_valid_claimto check whether a wallet holds a valid attestation - Attestations support optional expiration and can be revoked at any time
This means one deployed TrustLink instance can serve as the trust backbone for an entire ecosystem of contracts.
git clone https://github.com/unixfundz/TrustLink.git
cd TrustLink
# Confirm tests pass
make test
# Build optimized wasm
make optimizeThe optimized wasm lands at target/wasm32-unknown-unknown/release/trustlink.wasm — that's what you deploy.
# Deploy the wasm
soroban contract deploy \
--wasm target/wasm32-unknown-unknown/release/trustlink.wasm \
--network testnet \
--source YOUR_SECRET_KEYSave the contract ID printed to stdout — you'll use it in every subsequent command.
# Initialize with your admin address
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source YOUR_SECRET_KEY \
-- initialize \
--admin YOUR_PUBLIC_KEYOnly the admin can register issuers. Only registered issuers can create attestations.
# Admin registers a trusted issuer
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source YOUR_SECRET_KEY \
-- register_issuer \
--issuer ISSUER_PUBLIC_KEY# Issuer creates a KYC attestation (no expiration)
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source ISSUER_SECRET_KEY \
-- create_attestation \
--issuer ISSUER_PUBLIC_KEY \
--subject SUBJECT_PUBLIC_KEY \
--claim_type KYC_PASSED \
--expiration null# Verify the claim (read-only, no signing needed)
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- has_valid_claim \
--subject SUBJECT_PUBLIC_KEY \
--claim_type KYC_PASSEDExpected output: true
| Claim Type | Meaning |
|---|---|
KYC_PASSED |
Subject passed KYC identity verification |
ACCREDITED_INVESTOR |
Subject qualifies as an accredited investor |
MERCHANT_VERIFIED |
Subject is a verified merchant |
AML_CLEARED |
Subject passed AML screening |
SANCTIONS_CHECKED |
Subject checked against sanctions lists |
You can also register custom claim types:
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source YOUR_SECRET_KEY \
-- register_claim_type \
--claim_type MY_CUSTOM_CLAIM \
--description "Description of what this claim means"Add TrustLink as a dependency in your contract's Cargo.toml:
[dependencies]
soroban-sdk = "21.0.0"
trustlink = { git = "https://github.com/unixfundz/TrustLink.git", tag = "v0.1.0" }Import the generated client and gate your function:
mod trustlink {
soroban_sdk::contractimport!(
file = "../trustlink/target/wasm32-unknown-unknown/release/trustlink.wasm"
);
}
#[contractimpl]
impl LendingContract {
pub fn borrow(
env: Env,
borrower: Address,
trustlink_id: Address,
amount: i128,
) -> Result<(), Error> {
borrower.require_auth();
let trustlink = trustlink::Client::new(&env, &trustlink_id);
let claim = String::from_str(&env, "KYC_PASSED");
if !trustlink.has_valid_claim(&borrower, &claim) {
return Err(Error::KYCRequired);
}
// lending logic
Ok(())
}
}
#[contracterror]
#[derive(Copy, Clone)]
#[repr(u32)]
pub enum Error {
KYCRequired = 1,
}The contractimport! macro generates a fully typed client at compile time — no manual ABI wrangling.
To test your contract in isolation, mock TrustLink using a test contract that implements the same interface:
cargo test// Require KYC AND AML clearance
let mut required = soroban_sdk::Vec::new(&env);
required.push_back(String::from_str(&env, "KYC_PASSED"));
required.push_back(String::from_str(&env, "AML_CLEARED"));
if !trustlink.has_all_claims(&borrower, &required) {
return Err(Error::InsufficientCredentials);
}npm install @stellar/stellar-sdkSimulating the transaction is enough for read-only calls — no wallet signing required. The result comes back as a native JS boolean.
import {
Contract, Networks, TransactionBuilder,
SorobanRpc, nativeToScVal, scValToNative,
} from "@stellar/stellar-sdk";
const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org");
const CONTRACT_ID = "<YOUR_CONTRACT_ID>";
async function hasValidClaim(subject: string, claimType: string): Promise<boolean> {
const contract = new Contract(CONTRACT_ID);
const op = contract.call(
"has_valid_claim",
nativeToScVal(subject, { type: "address" }),
nativeToScVal(claimType, { type: "string" })
);
const account = await server.getAccount(subject);
const tx = new TransactionBuilder(account, {
fee: "100",
networkPassphrase: Networks.TESTNET,
}).addOperation(op).setTimeout(30).build();
const sim = await server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(sim)) throw new Error(sim.error);
return scValToNative(sim.result?.retval);
}
// Example
const isKYCd = await hasValidClaim("GABC...XYZ", "KYC_PASSED");
console.log("KYC valid:", isKYCd);const ERRORS: Record<number, string> = {
1: "Already initialized",
2: "Not initialized",
3: "Unauthorized",
4: "Not found",
5: "Duplicate attestation",
6: "Already revoked",
7: "Expired",
};
function parseTrustLinkError(err: unknown): string {
const match = String(err).match(/Error\(Contract, #(\d+)\)/);
if (match) return ERRORS[parseInt(match[1])] ?? `Unknown error #${match[1]}`;
return String(err);
}High-value claims such as ACCREDITED_INVESTOR can require multiple registered issuers to co-sign before the attestation becomes active. This prevents a single compromised issuer from unilaterally issuing sensitive credentials.
- 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 the threshold, the attestation is finalized and stored as a normal active attestation.
- Proposals expire after 7 days if the threshold is not reached.
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source ISSUER_A_SECRET \
-- propose_attestation \
--proposer ISSUER_A_PUBLIC_KEY \
--subject SUBJECT_PUBLIC_KEY \
--claim_type ACCREDITED_INVESTOR \
--required_signers '[{"address":"ISSUER_A_PUBLIC_KEY"},{"address":"ISSUER_B_PUBLIC_KEY"},{"address":"ISSUER_C_PUBLIC_KEY"}]' \
--threshold 2Save the proposal_id returned in the output.
# issuer_b co-signs — threshold reached, attestation is now active
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--source ISSUER_B_SECRET \
-- cosign_attestation \
--issuer ISSUER_B_PUBLIC_KEY \
--proposal_id <PROPOSAL_ID>soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- has_valid_claim \
--subject SUBJECT_PUBLIC_KEY \
--claim_type ACCREDITED_INVESTORExpected output: true
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- get_multisig_proposal \
--proposal_id <PROPOSAL_ID>The returned struct includes:
| Field | Description |
|---|---|
signers |
Addresses that have signed so far |
threshold |
Required number of signatures |
finalized |
true once the attestation is active |
expires_at |
Unix timestamp after which co-signing is rejected |
// 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 — issuer_a auto-signs
let proposal_id = contract.propose_attestation(
&issuer_a,
&user_address,
&String::from_str(&env, "ACCREDITED_INVESTOR"),
&required_signers,
&2,
);
// 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")
));| Error | Meaning |
|---|---|
InvalidThreshold |
Threshold is 0 or exceeds the number of required signers |
Unauthorized |
Proposer or a required signer is not a registered issuer |
NotRequiredSigner |
Cosigner is not in the proposal's required-signers list |
AlreadySigned |
The issuer has already co-signed this proposal |
ProposalFinalized |
The proposal has already been activated |
ProposalExpired |
The 7-day window has passed without reaching threshold |
- README — full API reference
- Integration Guide — deeper patterns, pagination, testnet CLI examples
- Storage Layout — on-chain key reference for indexer developers
- GitHub Issues — bug reports and feature requests