The tables below list every public function on LinkoraContract grouped by module. Auth requirements use the following shorthand:
- caller — the first
Addressargument callsrequire_auth()on itself. - Admin role — caller must hold the
Adminrole granted viagrant_role. - Upgrader role — caller must hold the
Upgraderrole. - Moderator role — caller must hold the
Moderatorrole. - Multi-sig — a
Vec<Address>of signers all callrequire_auth(); must meet the pool's approval threshold. - none — read-only; no auth required.
| Function | Auth | Inputs | Returns |
|---|---|---|---|
initialize |
caller (admin) |
admin: Address, treasury: Address, fee_bps: u32 |
() |
grant_role |
Admin role | admin: Address, account: Address, role: Role |
() |
revoke_role |
Admin role | admin: Address, account: Address, role: Role |
() |
has_role |
none | account: Address, role: Role |
bool |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
set_profile |
caller (user) |
user: Address, username: String, creator_token: Address |
() |
get_profile |
none | user: Address |
Option<Profile> |
get_profile_count |
none | — | u64 |
delete_profile |
caller (user) |
user: Address |
() |
batch_cleanup_profile |
none | user: Address, max_entries: u32 |
() |
get_address_by_username |
none | username: String |
Option<Address> |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
follow |
caller (follower) |
follower: Address, followee: Address |
() |
unfollow |
caller (follower) |
follower: Address, followee: Address |
() |
get_following |
none | user: Address, offset: u32, limit: u32 |
Vec<Address> |
get_followers |
none | user: Address, offset: u32, limit: u32 |
Vec<Address> |
batch_follow |
caller (follower) |
follower: Address, followees: Vec<Address> |
() |
batch_unfollow |
caller (follower) |
follower: Address, followees: Vec<Address> |
() |
migrate_follow_graph |
Admin role | admin: Address, users: Vec<Address> |
() |
block_user |
caller (blocker) |
blocker: Address, blocked: Address |
() |
unblock_user |
caller (blocker) |
blocker: Address, blocked: Address |
() |
is_blocked |
none | blocker: Address, blocked: Address |
bool |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
create_post |
caller (author) |
author: Address, content: String |
u64 (post ID) |
get_post |
none | id: u64 |
Option<Post> |
get_post_count |
none | — | u64 |
delete_post |
caller (author) |
author: Address, post_id: u64 |
() |
batch_cleanup_post |
none | post_id: u64, max_entries: u32 |
() |
get_posts_by_author |
none | author: Address, offset: u32, limit: u32 |
Vec<u64> |
like_post |
caller (user) |
user: Address, post_id: u64 |
() |
batch_like |
caller (user) |
user: Address, post_ids: Vec<u64> |
() |
get_like_count |
none | post_id: u64 |
u64 |
has_liked |
none | user: Address, post_id: u64 |
bool |
tip |
caller (tipper) |
tipper: Address, post_id: u64, token: Address, amount: i128 |
() |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
create_pool |
caller (first signer) | signers: Vec<Address>, pool_id: Symbol, token: Address, threshold: u32 |
() |
pool_deposit |
caller (depositor) |
depositor: Address, pool_id: Symbol, token: Address, amount: i128 |
() |
pool_withdraw |
Multi-sig | signers: Vec<Address>, pool_id: Symbol, recipient: Address, amount: i128 |
() |
get_pool |
none | pool_id: Symbol |
Option<Pool> |
get_pool_admins |
none | pool_id: Symbol |
Option<Vec<Address>> |
add_pool_admin |
Multi-sig | signers: Vec<Address>, pool_id: Symbol, new_admin: Address |
() |
remove_pool_admin |
Multi-sig | signers: Vec<Address>, pool_id: Symbol, admin: Address |
() |
update_pool_threshold |
Multi-sig | signers: Vec<Address>, pool_id: Symbol, threshold: u32 |
() |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
gov_init_config |
Admin role | admin: Address, quorum: u32, time_lock_ledgers: u32, vote_window_ledgers: u32, quorum_decay_rate_bps: u32, quorum_floor: u32 |
() |
gov_get_config |
none | — | GovConfig |
gov_propose |
caller (proposer) |
proposer: Address, parameter: GovParameter, new_value: u64, new_address: Option<Address> |
u64 (proposal ID) |
gov_vote |
caller (voter) |
voter: Address, proposal_id: u64, support: bool |
() |
effective_quorum |
none | proposal_id: u64 |
u32 |
gov_execute |
Admin role | admin: Address, proposal_id: u64 |
() |
gov_veto |
Multi-sig | signers: Vec<Address>, pool_id: Symbol, proposal_id: u64 |
() |
gov_get_proposal |
none | proposal_id: u64 |
GovProposal |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
register_oracle |
Admin role | admin: Address, name: Symbol, pubkey: BytesN<32> |
() |
verify_analytics_attestation |
none | oracle_name: Symbol, report_cbor: Bytes, signature: BytesN<64> |
() |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
report_post |
caller (reporter) |
reporter: Address, post_id: u64, token: Address, stake_amount: i128, reason_hash: BytesN<32> |
() |
review_report |
Moderator role | moderator: Address, post_id: u64, reporter: Address, verdict: ReportStatus |
() |
get_report |
none | post_id: u64, reporter: Address |
Option<Report> |
get_report_count |
none | post_id: u64 |
u32 |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
set_fee |
Admin role | admin: Address, fee_bps: u32 |
() |
get_fee_bps |
none | — | u32 |
set_treasury |
Admin role | admin: Address, treasury: Address |
() |
get_treasury |
none | — | Option<Address> |
set_tip_cooldown_window |
Admin role | admin: Address, cooldown_ledgers: u32 |
() |
get_tip_cooldown_window |
none | — | u32 |
set_max_post_content_len |
Admin role | admin: Address, max_len: u32 |
() |
get_max_post_content_len |
none | — | u32 |
set_max_bio_len |
Admin role | admin: Address, max_len: u32 |
() |
get_max_bio_len |
none | — | u32 |
set_rent_rate_bps |
Admin role | admin: Address, rate: u32 |
() |
get_rent_rate_bps |
none | — | u32 |
pause |
Pauser role | admin: Address |
() |
unpause |
Pauser role | admin: Address |
() |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
pay_rent |
caller (user) |
user: Address, token: Address, amount: i128 |
() |
get_rent_expiry |
none | user: Address |
u32 (ledger sequence) |
batch_bump_user_graph |
Admin role | admin: Address, user: Address |
u32 (keys bumped) |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
propose_upgrade |
Upgrader role | upgrader: Address, new_wasm_hash: BytesN<32> |
() |
execute_upgrade |
Upgrader role | upgrader: Address |
() |
upgrade |
Upgrader role | upgrader: Address, new_wasm_hash: BytesN<32> |
() |
get_contract_state |
none | — | ContractState |
| Function | Auth | Inputs | Returns |
|---|---|---|---|
set_credential_authority |
Admin role | admin: Address, pubkey: BytesN<32> |
() |
update_credential_root |
caller (user) + authority signature |
user: Address, new_root: BytesN<32>, signature: BytesN<64> |
() |
verify_credential |
none (mutating) | user: Address, proof: Vec<BytesN<32>>, leaf: BytesN<32>, nullifier: BytesN<32> |
bool |
get_credential_root |
none | user: Address |
Option<BytesN<32>> |
publish_dm_key |
caller (user) |
user: Address, x25519_pubkey: BytesN<32> |
() |
get_dm_key |
none | user: Address |
Option<BytesN<32>> |
Scope note. The HTTP request authentication reference for the indexer REST API continues below. The Soroban storage layout and full event schema are in
packages/contracts/contracts/linkora-contracts/src/lib.rs.
- Stellar HTTP Authentication (v1)
- The Signed Message
- Canonical Path
- Body Hash
- The Authorization Header
- Verification Order and Error Codes
- Worked Example
- Protected Endpoints
- Known Limitations of v1
- Reputation Module (Post Scoring)
Protected endpoints authenticate the caller by an Ed25519 signature over a message derived from the request itself. The signature commits to the HTTP method, the request path, and a hash of the request body, so a credential captured on one endpoint cannot be replayed against another.
The canonical implementation lives in packages/types/src/auth.ts (buildAuthMessage,
canonicalizeAuthPath) and is shared by the verifier and the first-party clients. External
integrators should implement the format below directly — see the
migration guide for a self-contained reference implementation.
The message begins with a version tag so the scheme can be rotated without a flag day. A
future v2: can add fields (a nonce, header binding, the query string) while a server
accepts both prefixes during a transition window, routing each credential to the verifier
that matches its tag. Without the prefix, any change to the layout would require every
client and every server to cut over in the same instant.
The prefix is part of the signed bytes, so it cannot be swapped by an attacker to downgrade a v2 credential into a v1 one.
v1:{METHOD}:{canonicalPath}:{address}:{timestamp}:{bodyHash}
| Field | Description |
|---|---|
v1 |
Literal version tag. |
METHOD |
HTTP method, upper-cased: GET, POST, PATCH, DELETE. |
canonicalPath |
Request path after canonicalisation — see §3. |
address |
Signer's Stellar public key (G…), exactly as sent in the header payload. |
timestamp |
Unix epoch in milliseconds, as an integer with no separators. |
bodyHash |
Lowercase hex SHA-256 of the raw body — see §4. |
Fields are joined with a literal :. No field is escaped or length-prefixed. A path can
legitimately contain a : (/api/items:batchGet is a valid URL), so the message is not
unambiguous by construction — it is unambiguous because the last three fields have fixed
shapes (a 56-character base32 address, digits, 64 hex characters), which lets the suffix be
read unambiguously from the right no matter what the path contains.
What gets signed is the SHA-256 digest of this message, not the message itself:
signature = Ed25519_sign(privateKey, SHA256(message))
The digest is signed as 32 raw bytes. The signature is the raw 64-byte Ed25519 output, base64-encoded for transport.
Wallet signers do not do this today. The rule above is what the server verifies, and it is what a signer holding the key directly (
Keypair.sign) produces. Browser wallets are a different matter: Freighter'ssignBlobtreats its argument as an opaque message, wraps it in the SEP-0053 envelope and signsSHA256("Stellar Signed Message:\n" + blob)— never the 32 raw bytes. It also returns aBuffer, not the base64 string its TypeScript signature promises. Both facts are verified against a real Freighter 2.0.0 signature. Consequently the first-party browser client does not currently interoperate with this server, and reconciling the two is an open decision, not something this document describes as settled. If you sign with a wallet, expect the envelope; if you sign with a library, follow the rule above.
The server canonicalises req.originalUrl, which always carries the full path including
any router mount prefix. Clients must sign the same absolute path.
The rule, in order:
- Drop everything from the first
?onward. - Strip all trailing
/. - If the result is empty, use
/.
| Raw path | Canonical path |
|---|---|
/api/follows |
/api/follows |
/api/follows/ |
/api/follows |
/api/follows// |
/api/follows |
/api/follows?cursor=5 |
/api/follows |
/api/follows/?cursor=5 |
/api/follows |
/ |
/ |
// |
/ |
/api//follows |
/api//follows |
Two consequences worth internalising:
- Sign
/api/notifications/preferences, never the router-relative/preferences. Signing the relative path produces a different message and the request comes back401. - If your base URL carries a path segment of its own (
https://example.com/indexer), that segment reaches the server as part ofreq.originalUrlbut never appears in the path you passed to the signer. Keep the base URL an origin only, or include the prefix in the signed path.
bodyHash is the lowercase hex SHA-256 of the exact bytes sent as the request body.
When there is no body, hash the empty string:
SHA256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
This is the value used for every GET, and for any request sent without a body. The server
reaches the same value either way: a request with no JSON content type never triggers
body-parser's verify hook and its absent raw body is hashed as empty, while a JSON request
with an empty body triggers the hook with a zero-length buffer, which hashes to the same
digest.
Hash the bytes you send, not the object you serialised. Serialise once into a variable, then use that same variable for both the hash and the request body. Serialising separately for each can yield different bytes — key order, whitespace, number formatting — and the signature would then commit to a body that was never sent, producing a
401that is very hard to read.
Authorization: StellarSig <base64(JSON)>
The base64 payload decodes to a JSON object with exactly these three fields:
{
"address": "G...",
"timestamp": 1735689600000,
"signature": "<base64 of the raw 64-byte Ed25519 signature>"
}The header value is the literal StellarSig, one space, then the base64 payload. The parser
splits on a single space and rejects anything else, so the payload must not contain spaces.
address and timestamp are sent in the clear and covered by the signature — the
server uses the transmitted values to rebuild the message, then checks the signature against
it. Tampering with either produces a message that no longer verifies.
The server checks in this order and returns on the first failure:
| Order | Check | Status | Code |
|---|---|---|---|
| 1 | Header present, parsable, all fields | 400 |
INVALID_AUTH_HEADER |
| 2 | timestamp is not in the future |
403 |
INVALID_TIMESTAMP |
| 3 | timestamp is at most 30 s old |
403 |
EXPIRED_TIMESTAMP |
| 4 | Signature matches the rebuilt message | 401 |
INVALID_SIGNATURE |
Errors carry the shape:
{
"error": {
"code": "INVALID_SIGNATURE",
"message": "Invalid signature",
"requestId": "..."
}
}The order matters when diagnosing a replay. Timestamp checks run before the signature
check, so an expired credential returns 403 regardless of which path it is presented at —
you will not see the 401 that a wrong path would otherwise produce. A credential replayed
against a different path within the tolerance window is what yields 401.
The tolerance is 30 000 ms, defined by SIGNATURE_TIMESTAMP_TOLERANCE_MS in
services/indexer/src/middleware/stellarAuth.ts. Clock skew between client and server eats
directly into this budget; a client running more than 30 s fast is rejected outright as
INVALID_TIMESTAMP.
Every value below is reproducible — the keypair is seeded with 32 bytes of 0x07.
address GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57
method POST
path /api/follows
timestamp 1735689600000
body {"followee":"GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"}
Step 1 — hash the body:
bodyHash = 420fe3b7b804bb866cbfeb113d913ba1543099424aed7694297cc3f042ee29a9
Step 2 — build the message:
v1:POST:/api/follows:GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57:1735689600000:420fe3b7b804bb866cbfeb113d913ba1543099424aed7694297cc3f042ee29a9
Step 3 — digest it (base64, for inspection):
1bWsu8SkJG10Q5vEoWQZdo3ZIbseNrILzJmuOsP2VyM=
Step 4 — sign the digest and base64 the signature:
evOK1CFjMZSTvHtuXrxhDVImMQi9JByqzurli1avrRrEPyjvhhQHYwyAmJLvicSAgHEnOte0qXFBlafhOHjtAw==
Step 5 — assemble the header:
Authorization: StellarSig eyJhZGRyZXNzIjoiR0RWRVUzREQ0S09GRUNWNjZWSUhXRVpPWVg0WktSM1dWMjdMNDY0U0lJUE9VMklVSTNKQ1pBNTciLCJ0aW1lc3RhbXAiOjE3MzU2ODk2MDAwMDAsInNpZ25hdHVyZSI6ImV2T0sxQ0ZqTVpTVHZIdHVYcnhoRFZJbU1RaTlKQnlxenVybGkxYXZyUnJFUHlqdmhoUUhZd3lBbUpMdmljU0FnSEVuT3RlMHFYRkJsYWZoT0hqdEF3PT0ifQ==
Note that this example's timestamp is long past, so replaying it against a live server
returns 403 EXPIRED_TIMESTAMP. It is for verifying your message construction, not for
testing a live endpoint.
| Method | Path | Body |
|---|---|---|
POST |
/api/notifications/register |
JSON |
POST |
/api/notifications/deregister |
JSON |
GET |
/api/notifications/preferences |
none |
POST |
/api/notifications/preferences |
JSON |
POST |
/api/messages |
JSON |
All other indexer endpoints are public reads and take no Authorization header.
Community pools hold tokens on-chain under a multi-signature guard: every
withdrawal, admin change, and threshold update requires at least threshold
current admins to sign the same transaction. This section describes how that
process works, what invariants the contract enforces, and how to drive it from
TypeScript using the SDK.
- Overview
- Pool Data Model
- Multi-Sig Withdrawal — Step by Step
- Managing Admins
- Updating the Threshold
- Events
- Error Reference
- TypeScript Examples
Every pool has:
- a single token (SEP-41 contract address) that it holds,
- a balance tracked on-chain and reconciled against actual token transfers,
- a list of admins (Stellar
G…addresses), and - a threshold — the minimum number of distinct admin signatures required to authorise any mutating operation (withdraw, add/remove admin, update threshold).
This is an M-of-N scheme: if a pool has 5 admins and threshold = 3, any 3 of
those admins must sign the same transaction before the contract will execute it.
The contract validates:
- The
signersarray contains at leastthresholdentries. - Every entry in
signersis a current admin of the pool. - Each signer calls
require_auth(), so every signer's wallet must include a valid authorization entry in the transaction envelope.
Duplicate signers are rejected — each address may appear at most once per invocation.
Pool {
token: Address, // SEP-41 token contract
balance: i128, // current on-chain balance (stroops)
admins: Vec<Address>,
threshold: u32, // 1 ≤ threshold ≤ admins.len()
}
Read the current state of a pool at any time:
const pool = await client.getPool("my-pool-1");
// pool?.balance, pool?.admins, pool?.thresholdBefore building a withdrawal, confirm the pool has enough balance and that you know the current threshold and admin set:
const pool = await client.getPool("my-pool-1");
if (!pool) throw new Error("Pool not found");
console.log("Balance :", pool.balance.toString());
console.log("Threshold:", pool.threshold);
console.log("Admins :", pool.admins);Gather at least pool.threshold admin addresses that are willing to sign.
Every address in the signers array must:
- appear in
pool.admins, and - provide an authorization entry in the transaction envelope.
The contract checks both conditions and panics if either fails.
Use preparePoolWithdrawTx to produce a fully-simulated transaction envelope
ready for wallet signing. The first signer in the array is used as the
transaction source account:
const txXdr = await client.preparePoolWithdrawTx(
["GBFOY...", "GCO23..."], // signers — must meet threshold
"my-pool-1", // pool ID
500_000_000n, // amount in stroops (500 XLM)
"GDX..." // recipient
);The returned txXdr must be authorized by every admin in signers. Distribute
the envelope to each co-signer and collect their auth entries. The exact
coordination mechanism (shared URL, a relay service, etc.) is outside the
contract — what matters is that when the transaction is finally submitted every
require_auth() call in the invocation tree is satisfied.
When using the Freighter browser wallet or Ledger hardware signer, each admin passes the envelope through their wallet, which appends its authorization entry before returning the signed XDR.
Submit the fully-signed envelope to the Soroban RPC. Use TransactionQueue for
automatic retry with backoff:
import { TransactionQueue } from "linkora-sdk";
const queue = new TransactionQueue({ signer, rpc });
queue.enqueue(signedTxXdr);
await queue.run();The contract executes this sequence atomically. If any check fails the entire transaction is rolled back:
| Check | Error |
|---|---|
| Pool exists | pool not found (panic) |
signers.len() >= threshold |
InsufficientSigners (115) |
Every signer is in pool.admins |
UnauthorizedSigner (116) |
| Signer duplicates | signers must be unique (panic) |
pool.balance >= amount |
LowBalance (117) |
Token transfer happens before the on-chain balance is decremented. If the transfer fails the balance is never modified, leaving the pool in a consistent state.
Admin mutations (add, remove, threshold update) follow the same M-of-N pattern
as withdrawals: you must supply at least threshold current admin signatures.
The new admin is appended to pool.admins. The call panics if new_admin is
already an admin.
Contract function: add_pool_admin(signers, pool_id, new_admin)
// Build operation XDR (throwaway keypair — for batching or queue use)
const opXdr = client.addPoolAdmin(
["GBFOY...", "GCO23..."], // current admin signers (must meet threshold)
"my-pool-1",
"GNEW..." // address to add
);
// Or build a submittable envelope:
const txXdr = await client.prepareAddPoolAdminTx?.(
["GBFOY...", "GCO23..."],
"my-pool-1",
"GNEW..."
);After success a PoolAdminAddedEvent is emitted.
The address is removed from pool.admins. The contract also validates that the
remaining admin count still meets the threshold — removing an admin that would
make threshold > admins.len() is rejected with threshold unreachable after removal (panic).
Contract function: remove_pool_admin(signers, pool_id, admin)
const opXdr = client.removePoolAdmin(
["GBFOY...", "GCO23..."], // must meet threshold
"my-pool-1",
"GOUT..." // address to remove
);After success a PoolAdminRemovedEvent is emitted.
Important invariants:
- You cannot remove an admin if it would leave
admins.len() < threshold. Lower the threshold first (withupdatePoolThreshold) if needed. - The signer set must come entirely from the current admin list, including the admin being removed (they may be one of the signers).
The new threshold must satisfy 1 ≤ new_threshold ≤ admins.len(). The
operation itself requires the current threshold of signatures, not the new one.
Contract function: update_pool_threshold(signers, pool_id, threshold)
const opXdr = client.updatePoolThreshold(
["GBFOY...", "GCO23...", "GCDE..."], // signers meeting current threshold
"my-pool-1",
3 // new threshold
);After success a PoolThresholdUpdatedEvent is emitted containing both the old
and new threshold values.
All pool mutations emit on-chain events that the indexer captures and exposes over the REST/WebSocket API.
| Event | Emitted by | Key fields |
|---|---|---|
PoolCreatedEvent |
create_pool |
pool_id, token, admins, threshold |
PoolDepositEvent |
pool_deposit |
pool_id, depositor, amount |
PoolWithdrawEvent |
pool_withdraw |
pool_id, recipient, amount |
PoolAdminAddedEvent |
add_pool_admin |
pool_id, new_admin |
PoolAdminRemovedEvent |
remove_pool_admin |
pool_id, admin |
PoolThresholdUpdatedEvent |
update_pool_threshold |
pool_id, old_threshold, new_threshold |
Subscribe to pool events using the SDK event subscriber:
import { LinkoraEventSubscriber } from "linkora-sdk";
const sub = new LinkoraEventSubscriber({ rpcUrl, contractId });
sub.on("pool_withdraw", (event) => {
console.log(`Pool ${event.pool_id}: withdrew ${event.amount} to ${event.recipient}`);
});
await sub.start();| Code | Name | Cause |
|---|---|---|
| 112 | PoolNotFound |
No pool exists with the given pool_id |
| 113 | PoolExists |
create_pool called with a pool_id that is already in use |
| 114 | InvalidThreshold |
threshold is 0 or exceeds the number of admins |
| 115 | InsufficientSigners |
signers.len() < pool.threshold |
| 116 | UnauthorizedSigner |
A signer address is not in pool.admins |
| 117 | LowBalance |
Requested withdrawal amount exceeds pool.balance |
| 130 | PoolAdminNotFound |
Attempted to remove an address not in pool.admins |
| 131 | PoolAdminExists |
Attempted to add an address already in pool.admins |
The examples below use LinkoraClient from linkora-sdk and assume the client
has already been constructed with a valid RPC URL and contract ID.
import { LinkoraClient, TransactionQueue } from "linkora-sdk";
const client = new LinkoraClient({
rpcUrl: "https://soroban-testnet.stellar.org",
contractId: "CABC123...",
networkPassphrase: "Test SDF Network ; September 2015",
});
// 1. Read current pool state
const pool = await client.getPool("creator-fund");
if (!pool) throw new Error("Pool not found");
const SIGNERS = ["GBFOY...", "GCO23..."]; // must have at least pool.threshold entries
// 2. Build the transaction envelope
const txXdr = await client.preparePoolWithdrawTx(
SIGNERS,
"creator-fund",
1_000_000_000n, // 100 XLM in stroops
"GREC..." // recipient
);
// 3. Each signer authorizes (pseudo-code — real auth depends on wallet)
const signedByFirst = await walletA.sign(txXdr);
const fullySignedXdr = await walletB.addAuth(signedByFirst);
// 4. Submit with retry
const queue = new TransactionQueue({ signer: noopSigner, rpc });
queue.enqueue(fullySignedXdr);
queue.on("status", (e) => {
if (e.status === "confirmed") {
console.log("Withdrawal confirmed. Tx hash:", e.hash);
}
});
await queue.run();// Lower the threshold first if needed to make room
const lowerThresholdOp = client.updatePoolThreshold(["GBFOY...", "GCO23..."], "creator-fund", 1);
// Remove the outgoing admin
const removeOp = client.removePoolAdmin(
["GBFOY..."], // threshold is now 1
"creator-fund",
"GOUT..."
);
// Add the replacement
const addOp = client.addPoolAdmin(["GBFOY..."], "creator-fund", "GNEW...");
// Raise the threshold back
const raiseThresholdOp = client.updatePoolThreshold(["GBFOY...", "GNEW..."], "creator-fund", 2);Each of the above returns base64 operation XDR suitable for batching into a
single buildMultiOpTx call or submitting individually through TransactionQueue.
The signature covers the method, the canonical path, and the body. Everything else about the request — headers, query string, transport — is outside it. Three consequences deserve to be stated plainly.
Canonicalisation strips everything after ? before signing, so query parameters are not
authenticated. A credential captured for /api/posts?limit=10 is equally valid for
/api/posts?limit=1000 within the tolerance window.
No currently protected endpoint reads query parameters, so there is nothing to tamper with
today. That is a property of the current route set, not a guarantee of the scheme — do not
add a query parameter to a protected endpoint without moving it into the body or extending
the scheme to v2.
The scheme binds a credential to a request shape, not to one delivery of it. Anyone who observes a request can resend it byte-for-byte — same method, same path, same body — and it will be accepted until the 30-second window closes. For a non-idempotent endpoint that means the action happens twice.
The window is the only bound. Defending against identical replay needs a nonce carried in the signed message plus server-side storage of spent nonces for at least the tolerance period, which v1 does not have. Until then, TLS is what stands between an observer and a replayable credential: these endpoints must not be served over plaintext HTTP.
The server captures the raw body through express.json({ verify }), and body-parser only
invokes that hook when the request's Content-Type is JSON. For any other content type the
raw body is never captured, bodyHash falls back to the hash of the empty string, and the
body travels entirely outside the signature — a multipart/form-data upload on a
protected route would be freely tamperable while the request still verifies.
Every protected endpoint is JSON-only today, so this is latent rather than live. It becomes a
live vulnerability the moment a protected route accepts another content type. Any new
protected endpoint must be JSON, or the raw-body capture in
services/indexer/src/middleware/rawBody.ts must be widened to cover its content type first.
Creator rewards in Linkora flow through two complementary mechanisms: direct tipping
(peer-to-peer, synchronous) and analytics-attested rewards (oracle-driven, asynchronous).
There is no separate rewards.rs module — both mechanisms are implemented directly in
src/lib.rs.
Architecture note. The issue references an epoch/distribute/claim pattern. The current contract does not implement on-chain epoch accounting or a pull-based claim queue. Rewards reach creators in one of two ways:
- Tip — immediately transferred to the post author minus the protocol fee.
- Analytics attestation — the oracle verifies a signed CBOR report off-chain; the contract records the attestation on-chain and emits an event that the indexer uses to trigger an off-chain distribution action (e.g., airdrop or pool deposit).
Any user can tip a post. Tokens are split between the post author and the treasury at the time of the call — there is no claimable balance to withdraw later.
Function: tip(tipper, post_id, token, amount)
| Parameter | Type | Description |
|---|---|---|
tipper |
Address |
Address sending the tip (must be authenticated). |
post_id |
u64 |
ID of the post to tip. |
token |
Address |
SEP-41 token contract address. |
amount |
i128 |
Tip amount in smallest token units (must be > 0). |
Fee split:
fee_amount = floor(amount × fee_bps / 10_000)
author_amount = amount − fee_amount
fee_amount is transferred to the treasury. author_amount is transferred directly to the
post author. post.tip_total is incremented by author_amount (capped at 10^18).
Cooldown: One tip per tipper per post per TIP_COOLDOWN_WINDOW ledgers (default ~1 day
at 5 s/ledger). Configurable by Admin via set_tip_cooldown_window.
Errors:
- Post does not exist
- Tipper is the post author
- Either party has blocked the other
- Cooldown has not expired
tip_totalcap would be exceeded- Post author has no registered profile
The oracle pipeline lets an off-chain analytics service publish a signed report about a creator's activity (views, engagement, etc.). The contract verifies the Ed25519 signature, records a nullifier to prevent replay, and emits an event. Downstream reward distribution is handled off-chain by the indexer or a separate distribution service.
An epoch is defined by the window_start and window_end Unix timestamps in the
analytics report CBOR. The oracle computes this window off-chain based on its own scheduling
logic (e.g., weekly or monthly). The contract validates only that the current ledger
timestamp falls within the window.
Function: verify_analytics_attestation(oracle_name, report_cbor, signature, creator, window_start, window_end) → bool
| Parameter | Type | Description |
|---|---|---|
oracle_name |
Symbol |
Name of the oracle whose key is used for verification. |
report_cbor |
Bytes |
Raw CBOR-encoded analytics report. |
signature |
BytesN<64> |
Ed25519 signature of sha256(report_cbor) from the registered oracle key. |
creator |
Address |
Creator address this report is for. |
window_start |
u64 |
Unix timestamp of the epoch start. |
window_end |
u64 |
Unix timestamp of the epoch end. |
Returns true on successful verification.
Errors:
- Oracle not registered (
register_oraclehas not been called fororacle_name) - Signature verification fails
- Current ledger timestamp is outside
[window_start, window_end] - Attestation has already been submitted (nullifier replay)
The contract accepts an attestation only while the current ledger timestamp satisfies:
window_start ≤ ledger.timestamp() ≤ window_end
Attestations submitted after window_end are rejected with "attestation outside time window". This bounds the window during which the oracle must call the contract.
Each attestation is identified by sha256(report_cbor). The contract stores this hash as
AttestationNullifier(report_hash) → bool in persistent storage. Any second call with the
same report_cbor is rejected as "attestation already submitted".
Analytics Oracle LinkoraContract Indexer / Distribution
| | |
| -- register_oracle()-->| |
| (admin, one-time) | |
| | |
| [epoch window opens] | |
| | |
| -- verify_analytics_ | |
| attestation() ----->| |
| (report_cbor, | store nullifier |
| signature, | emit AttestationVerified |
| creator, window) | Event |
| | |
| true /<-------------| |
| | |
| |-- AttestationVerified --->|
| | Event (indexed) |
| | |
| | trigger off-chain |
| | distribution |
| | (airdrop / pool |
| | deposit) |
## 10. Pool Withdrawal Process
Community pools in Linkora are governed by a multi-sig model: a withdrawal can
only proceed once a configurable **threshold** of pool admins have signed off.
This section is a step-by-step guide for pool admins using the TypeScript SDK.
---
### Prerequisites
- The pool has been created and funded (via `create_pool` / `deposit_pool`).
- You have the Stellar public keys of all admins who will co-sign.
- Each admin's wallet (Freighter or Ledger) is connected to the same app
instance, or you are coordinating signatures out-of-band.
---
### Step 1 — Check the current threshold
Before initiating a withdrawal, confirm the number of signatures required:
```typescript
import { LinkoraClient } from "@linkora/sdk";
const client = new LinkoraClient({
contractId: "CCONTRACTID...",
networkPassphrase: "Test SDF Network ; September 2015",
rpcUrl: "https://soroban-testnet.stellar.org",
});
// Read the pool config to find the current threshold
const pool = await client.getPool("my-pool-1");
console.log(`Pool threshold: ${pool.threshold} of ${pool.admins.length} admins required`);
| Function | Required role | Description |
|---|---|---|
register_oracle |
Admin |
Registers (or rotates) an Ed25519 oracle public key by name. |
set_fee |
Admin |
Updates the tip protocol fee in basis points. |
set_treasury |
Admin |
Updates the treasury address that receives tip fees. |
set_tip_cooldown_window |
Admin |
Adjusts the per-tipper per-post cooldown in ledgers. |
| Event | Topics | Fields | Emitted when |
|---|---|---|---|
TipEvent |
tipper, post_id |
amount, fee |
A tip is successfully sent. |
AttestationVerifiedEvent |
oracle_name, report_hash |
creator, window_start, window_end |
An analytics attestation passes verification. |
Each admin signs the prepared withdrawal transaction envelope. All signers must
be current pool admins — if even one address in the signers array is not
registered as an admin the contract returns UnauthorizedSigner (code 116).
// Build the unsigned transaction envelope
const txXdr = await client.preparePoolWithdrawTx(
[adminAddress1, adminAddress2], // must meet or exceed the pool threshold
"my-pool-1", // pool ID
500_000_000n, // amount in stroops (500 XLM)
recipientAddress // Stellar G... address to receive tokens
);
// --- Admin 1 signs ---
// Pass txXdr to admin 1's wallet for signing (e.g. via Freighter)
const signedByAdmin1 = await wallet.signTransaction(txXdr);
// --- Admin 2 signs ---
// Pass the envelope on to admin 2 for a second signature
const signedByBoth = await wallet2.signTransaction(signedByAdmin1);Coordination note. In a typical flow each admin signs the same XDR envelope in sequence and passes it to the next. The final signed envelope (after all required admins have signed) is what you submit to Soroban.
import { submitTransaction } from "@linkora/sdk";
// Submit the fully-signed envelope to Soroban
const txHash = await submitTransaction(signedByBoth, {
rpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: "Test SDF Network ; September 2015",
});
console.log("Withdrawal submitted:", txHash);The contract enforces the threshold on-chain:
- It counts how many of the supplied
signersare registered admins of the pool. - If the count is below the pool's
threshold, it returnsInsufficientSigners(code 115). - If any address in
signersis not a pool admin, it returnsUnauthorizedSigner(code 116).
signers.length ≥ pool.threshold AND every signer ∈ pool.admins
When the threshold is met the contract transfers exactly amount stroops of the
pool's token from the pool's internal ledger storage to recipient. The pool
balance is decremented accordingly.
If the requested amount exceeds the pool balance, the contract returns
LowBalance (code 117) and no transfer occurs.
// Existing admins authorise the addition of a new admin.
// The number of authorising signers must still meet the current threshold.
const txXdr = client.addPoolAdmin(
[adminAddress1, adminAddress2], // authorising admins (must meet threshold)
"my-pool-1", // pool ID
newAdminAddress // G... address of the new admin
);The contract rejects the call with PoolAdminExists (code 131) if the address
is already an admin.
// Removing an admin also requires threshold authorisation.
const txXdr = client.removePoolAdmin(
[adminAddress1, adminAddress2], // authorising admins
"my-pool-1", // pool ID
adminToRemove // G... address of the admin to remove
);The contract returns PoolAdminNotFound (code 130) if the address is not an
admin, and CannotRemoveLastAdmin (code 141) if the removal would leave the
pool with zero admins.
// Raise or lower the required signature count.
// The call itself must be authorised by the current number of required signers.
const txXdr = client.updatePoolThreshold(
[adminAddress1, adminAddress2], // must meet the *current* threshold
"my-pool-1", // pool ID
3 // new threshold (must be 1 ≤ threshold ≤ admin count)
);The contract returns InvalidThreshold (code 114) if the new value is 0 or
greater than the number of registered admins.
| Situation | Contract code | SDK class |
|---|---|---|
| Signer is not a pool admin | 116 |
UnauthorizedError |
| Fewer signers than the pool threshold | 115 |
UnauthorizedError |
| Withdrawal amount exceeds pool balance | 117 |
InsufficientBalanceError |
| Pool ID does not exist | 112 |
NotFoundError |
| New threshold is 0 or > admin count | 114 |
ValidationError |
| Removing the last admin | 141 |
UnauthorizedError |
| Adding an address that is already an admin | 131 |
ValidationError |
| Removing an address that is not an admin | 130 |
NotFoundError |
import { LinkoraClient, UnauthorizedError, InsufficientBalanceError } from "@linkora/sdk";
const client = new LinkoraClient({
contractId: "CCONTRACTID...",
networkPassphrase: "Test SDF Network ; September 2015",
rpcUrl: "https://soroban-testnet.stellar.org",
publicKey: adminAddress1,
});
async function withdrawFromPool(
poolId: string,
amountStroops: bigint,
recipient: string,
signers: string[]
): Promise<string> {
// 1. Prepare the transaction
const txXdr = await client.preparePoolWithdrawTx(signers, poolId, amountStroops, recipient);
// 2. Collect signatures from each admin wallet (implementation depends on your wallet integration)
let signed = txXdr;
for (const signer of signers) {
signed = await collectSignature(signer, signed);
}
// 3. Submit and return the transaction hash
try {
return await client.submitSignedTransaction(signed);
} catch (err) {
if (err instanceof UnauthorizedError) {
throw new Error("Withdrawal failed: insufficient or invalid admin signatures.");
}
if (err instanceof InsufficientBalanceError) {
throw new Error("Withdrawal failed: pool balance is too low.");
}
throw err;
}
}The moderation module covers two distinct mechanisms:
- Post-level moderation — stake-backed reporting and moderator-reviewed verdicts that can remove posts and slash author tokens.
- User-level blocking — any user can block another user, which immediately severs their follow graph and prevents follows, tips, and likes across the block boundary.
There are no protocol-level account bans (e.g. a site-wide ban issued by an admin). The closest equivalent is the block system described in User blocking (ban mechanics) below.
- Any registered user files a
report_postand locks tokens as collateral. - A
Moderator-role account, co-signed by themodspool threshold, callsreview_report. - If the verdict is Upheld: the reported post is deleted, the author's creator tokens
may be slashed (if
MODERATION_SLASH_BPS > 0), and the reporter's stake is returned. - If the verdict is Dismissed: the reporter's stake is sent to the treasury.
Reports carry a reason_hash: BytesN<32> — the SHA-256 of the off-chain reason string.
The hash is stored on-chain; the raw reason text is stored off-chain (e.g. in the indexer).
This keeps on-chain storage minimal while giving indexers a tamper-evident link to the
full reason.
Clients must compute the hash before calling report_post:
reason_hash = sha256(reason_text_utf8)
The contract does not validate or interpret the hash contents. Suggested reason strings for off-chain convention (not enforced on-chain):
| Suggested reason string | Meaning |
|---|---|
"spam" |
Repetitive or unsolicited promotional content |
"harassment" |
Targeted abuse or threatening language |
"misinformation" |
Demonstrably false claims presented as fact |
"illegal_content" |
Content that may violate applicable law |
"copyright" |
Unauthorised reproduction of copyrighted material |
"other" |
Any reason not covered above (include detail off-chain) |
Store the raw string alongside the SHA-256 digest in the indexer so that moderators can read the reason when reviewing reports.
Reporter Contract Moderator
| | |
|-- report_post() --------->| |
| (stake locked in | |
| contract escrow) | |
| | |
| |<-- review_report() -----------|
| | (Upheld or Dismissed) |
| | |
| IF UPHELD: | |
|<-- stake returned --------| |
| post deleted |
| author slashed (if slash_bps > 0) |
| | |
| IF DISMISSED: | |
| stake → treasury |
| Guard | Limit |
|---|---|
| Open reports per reporter | 10 (enforced by OpenReports(reporter) counter) |
| Reporter cannot report own post | validated in validate_reporter_can_report |
| Function | Required role | Auth requirements | Description |
|---|---|---|---|
review_report |
Moderator |
Moderator address signs + M-of-N mods pool admin co-signers |
Reviews a pending report and issues a verdict. |
grant_role |
Admin |
Admin address signs | Grants the Moderator role to an account. |
revoke_role |
Admin |
Admin address signs | Revokes the Moderator role from an account. |
gov_execute (ModerationSlashBps) |
Admin |
Admin executes a passed governance proposal | Changes the slash percentage applied to upheld reports. |
| Function | Signature | Description |
|---|---|---|
report_post |
(reporter, post_id, token, stake_amount, reason_hash) |
Files a report and locks stake_amount tokens in contract escrow. Panics if the post does not exist, reporter is the author, already reported, or the reporter has ≥ 10 open reports. |
review_report |
(moderator, signers, post_id, reporter, verdict) |
Issues a verdict on a pending report. Requires Moderator role and M-of-N mods pool co-signatures. |
get_report |
(post_id, reporter) → Option<Report> |
Returns the Report struct for a given (post_id, reporter) pair, or None. |
get_report_count |
(post_id) → u32 |
Returns the total number of reports filed against a post. |
Report struct
| Field | Type | Description |
|---|---|---|
post_id |
u64 |
ID of the reported post. |
reporter |
Address |
Address that filed the report. |
stake_amount |
i128 |
Amount of tokens locked as collateral. |
token |
Address |
SEP-41 token contract used for the stake. |
reason_hash |
BytesN<32> |
SHA-256 of the off-chain reason string. |
created_ledger |
u32 |
Ledger sequence when the report was filed. |
status |
ReportStatus |
Current state: Pending, Upheld, or Dismissed. |
ReportStatus enum
| Variant | Meaning |
|---|---|
Pending |
Report filed; awaiting moderator review. |
Upheld |
Moderator ruled the post violated policy. Post deleted, stake returned to reporter. |
Dismissed |
Moderator ruled the report invalid. Stake sent to treasury. |
- A post deleted via
review_report(verdict = Upheld) is removed from persistent storage and the author'sAuthorPostsindex. Associated likes, reports, and tip-cooldown entries are lazily cleaned up viabatch_cleanup_post. - Slashing burns creator tokens from the post author's balance using
burn_from. The contract must have been pre-approved viatoken.approve(). If the allowance is insufficient, slashing is skipped gracefully — the rest of the upheld flow (post deletion, stake refund) still completes. - The
MODERATION_SLASH_BPSparameter is governed via theModerationSlashBpsgovernance proposal type and defaults to0(no slashing) at initialization.
| Event | Topics | Fields | Emitted when |
|---|---|---|---|
PostReportedEvent |
post_id, reporter |
stake_amount |
report_post succeeds. |
PostRemovedByModerationEvent |
post_id, reporter |
— | Verdict = Upheld. |
ReportDismissedEvent |
post_id, reporter |
— | Verdict = Dismissed. |
The contract provides a user-controlled blocking system. Any account can block any other account. Blocking is bidirectional in effect: once A blocks B, neither A nor B can follow, tip, or like across that boundary, regardless of which direction the action comes from.
There is no admin-issued ban. Moderators can only remove posts via review_report.
To prevent an account from interacting with the platform entirely, an admin would
need to use off-chain tooling (e.g. a deny-list in the indexer or UI layer).
| Function | Auth | Inputs | Returns |
|---|---|---|---|
block_user |
caller (blocker) |
blocker: Address, blocked: Address |
() |
unblock_user |
caller (blocker) |
blocker: Address, blocked: Address |
() |
is_blocked |
none | blocker: Address, blocked: Address |
bool |
User A Contract
| |
|-- block_user(A, B) ---------->|
| | 1. Records Blocks(A) → {B: ()}
| | 2. Records BlockedBy(B) → {A: ()}
| | 3. Removes A→B and B→A follow edges
| | 4. Removes cross-block like entries
| | 5. Emits BlockEvent{blocker: A, blocked: B}
|<-- (done) --------------------|
User A Contract
| |
|-- unblock_user(A, B) -------->|
| | 1. Removes B from Blocks(A)
| | 2. Removes A from BlockedBy(B)
| | 3. Emits UnblockEvent{blocker: A, blocked: B}
|<-- (done) --------------------|
Unblocking does not restore any follow edges that were removed when the block was created. Users must re-follow each other manually after unblocking.
The contract enforces a bidirectional block check (is_either_blocked) before
allowing social interactions. Either party blocking the other is sufficient to
reject the operation.
| Operation | Effect when a block exists between the two parties |
|---|---|
follow |
Panics with "blocked: cannot follow — one user has blocked the other" |
tip |
Panics with "blocked: cannot tip — one user has blocked the other" |
like_post |
Block check enforced via cleanup_likes_on_block; likes between blocked users are removed at block time |
report_post |
Reporter cannot report the post of a user who has blocked them (validated by validate_reporter_can_report) |
create_post |
Not affected — a blocked user can still post; blocking is interaction-level, not account-level |
When block_user(A, B) is called, the contract immediately removes any existing
follow edges between A and B:
- A's following list no longer includes B.
- B's following list no longer includes A.
- Follower counts for both parties are decremented accordingly.
This cleanup is synchronous and happens inside the same block_user invocation.
The contract maintains two storage keys per block relationship:
| Storage key | Value type | Meaning |
|---|---|---|
Blocks(blocker) |
Map<Address, ()> |
Set of addresses that blocker has blocked. |
BlockedBy(blocked) |
Map<Address, ()> |
Set of addresses that have blocked blocked. |
Both keys are kept in sync. is_blocked(A, B) reads only Blocks(A) and is therefore O(1).
The reverse index BlockedBy is used by the indexer and UI to efficiently enumerate
"who has blocked me?" without iterating over all blocker maps.
| Event | Topics | Fields | Emitted when |
|---|---|---|---|
BlockEvent |
blocker, blocked |
— | block_user succeeds. |
UnblockEvent |
blocker, blocked |
— | unblock_user succeeds. |