From 30c15b6a997d5c70ec80c96e8363a4991265ea58 Mon Sep 17 00:00:00 2001 From: investify2826-ctrl Date: Sat, 27 Jun 2026 16:43:41 +0000 Subject: [PATCH] feat: xBull wallet support, i18n, bulk-create, and template CLI commands (closes #826, #827, #828, #829) React app (#827, #826): - Replace one-off Freighter integration with @creit.tech/stellar-wallets-kit, supporting Freighter and xBull from a unified wallet-selection screen in wallet.ts. Wallet choice and address are persisted in localStorage for auto-reconnect. Network-mismatch detection is preserved for Freighter; xBull trusts the configured testnet network. - Integrate react-i18next with English (en) and Spanish (es) locale files in src/locales/. The active language is auto-detected from navigator.language and can be switched at runtime via the EN/ES toggle in the header. All UI strings in App.tsx, IssuerPanel, UserPanel, and VerifierPanel are now driven from translation keys. Issuer CLI (#828, #829): - Add bulk-create command: reads subject addresses (and optional per-row claim_type, expiry_days, metadata) from a CSV file and issues one attestation per row, reporting per-row success/failure clearly. - Add template create, template list, and template delete subcommands that call create_template, list_templates, and delete_template on the contract, mirroring the pattern of existing CLI commands. Both READMEs updated with usage examples and wallet/i18n documentation. --- examples/issuer-cli/README.md | 103 +++++++ examples/issuer-cli/issuer-cli.mjs | 264 ++++++++++++++++++ examples/react-app/README.md | 32 ++- examples/react-app/package.json | 5 +- examples/react-app/src/App.tsx | 109 +++++--- examples/react-app/src/i18n.ts | 16 ++ examples/react-app/src/locales/en.json | 110 ++++++++ examples/react-app/src/locales/es.json | 110 ++++++++ examples/react-app/src/main.tsx | 1 + examples/react-app/src/panels/IssuerPanel.tsx | 118 +++----- examples/react-app/src/panels/UserPanel.tsx | 47 ++-- .../react-app/src/panels/VerifierPanel.tsx | 48 ++-- examples/react-app/src/wallet.ts | 68 +++-- 13 files changed, 849 insertions(+), 182 deletions(-) create mode 100644 examples/react-app/src/i18n.ts create mode 100644 examples/react-app/src/locales/en.json create mode 100644 examples/react-app/src/locales/es.json diff --git a/examples/issuer-cli/README.md b/examples/issuer-cli/README.md index a9b494db..52b77e8e 100644 --- a/examples/issuer-cli/README.md +++ b/examples/issuer-cli/README.md @@ -5,6 +5,8 @@ A simple command-line tool for issuers to manage attestations without writing co ## Features - **Issue attestations**: Create new claims for users with optional expiration and metadata +- **Bulk-create**: Batch-issue attestations to many subjects from a CSV file +- **Template management**: Create, list, and delete attestation templates - **Revoke attestations**: Revoke existing attestations with optional reason - **List issued**: View all attestations issued by this issuer with pagination - **Check claims**: Verify if a subject has a valid claim from this issuer @@ -251,6 +253,107 @@ $ node issuer-cli.mjs issue GBRPYHIL... KYC_PASSED ✗ Failed to create attestation: DuplicateAttestation ``` +### Bulk-Create from CSV + +Issue attestations to many subjects in one command using a CSV file. + +```bash +node issuer-cli.mjs bulk-create --file subjects.csv --claim-type KYC_PASSED [--expiry ] +``` + +The CSV must have an `address` column. Optional per-row columns override the command-line defaults: + +| Column | Required | Description | +|--------|----------|-------------| +| `address` | Yes | Subject's Stellar address | +| `claim_type` | No | Overrides `--claim-type` for this row | +| `expiry_days` | No | Overrides `--expiry` for this row | +| `metadata` | No | JSON metadata string | + +Example `subjects.csv`: + +```csv +address,claim_type,expiry_days,metadata +GBRPYHIL2CI3WHZDTOOQFC6EB4CGQOFSNHERX3UNFOK2MAGNTQEFU,KYC_PASSED,365, +GCZST3XVZZPE6EEZPQH3NXCWG4QG4EXIHKJL8ZQZQN7VQGGKXZ,,730,"{""tier"":""premium""}" +GXYZ123456789ABCDEF,AML_CLEARED,, +``` + +Output: + +``` +Bulk-creating 3 attestation(s) from subjects.csv… + + [row 2] OK GBRPYHIL... claim=KYC_PASSED id=att_abc123 + [row 3] OK GCZST3XV... claim=KYC_PASSED id=att_def456 + [row 4] FAIL GXYZ1234... claim=AML_CLEARED error=DuplicateAttestation + +Done: 2 succeeded, 1 failed out of 3 rows. +``` + +### Template Management + +Attestation templates let you pre-define claim type and metadata so you can issue consistently without repeating parameters. + +#### Create a Template + +```bash +node issuer-cli.mjs template create --id --claim-type [--metadata ] +``` + +Examples: + +```bash +node issuer-cli.mjs template create --id kyc-standard --claim-type KYC_PASSED +node issuer-cli.mjs template create --id accredited-inv --claim-type ACCREDITED_INVESTOR \ + --metadata '{"level":"accredited"}' +``` + +Output: + +``` +Creating template "kyc-standard" (KYC_PASSED)… +✓ Template "kyc-standard" created. +``` + +#### List Templates + +```bash +node issuer-cli.mjs template list +``` + +Output: + +``` +Listing templates for GABC… + + Found 2 template(s): + 1. ID: kyc-standard + Claim Type: KYC_PASSED + 2. ID: accredited-inv + Claim Type: ACCREDITED_INVESTOR + Metadata: {"level":"accredited"} +``` + +#### Delete a Template + +```bash +node issuer-cli.mjs template delete +``` + +Example: + +```bash +node issuer-cli.mjs template delete kyc-standard +``` + +Output: + +``` +Deleting template "kyc-standard"… +✓ Template "kyc-standard" deleted. +``` + ## Scripting Use the CLI in shell scripts for batch operations: diff --git a/examples/issuer-cli/issuer-cli.mjs b/examples/issuer-cli/issuer-cli.mjs index fe1509f2..6c7d1f63 100644 --- a/examples/issuer-cli/issuer-cli.mjs +++ b/examples/issuer-cli/issuer-cli.mjs @@ -605,6 +605,245 @@ async function exportAuditTrail() { } } +// --------------------------------------------------------------------------- +// CSV helpers +// --------------------------------------------------------------------------- + +function parseCsvLine(line) { + const values = []; + let current = ""; + let inQuotes = false; + for (const char of line) { + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === "," && !inQuotes) { + values.push(current); + current = ""; + } else { + current += char; + } + } + values.push(current); + return values.map((v) => v.trim()); +} + +function parseCsv(content) { + const lines = content.trim().split(/\r?\n/).filter((l) => l.trim()); + if (lines.length < 2) throw new Error("CSV must have a header row and at least one data row."); + const headers = parseCsvLine(lines[0]).map((h) => h.toLowerCase()); + const addrIdx = headers.indexOf("address"); + if (addrIdx === -1) throw new Error("CSV must have an 'address' column."); + const ctIdx = headers.indexOf("claim_type"); + const expIdx = headers.indexOf("expiry_days"); + const metaIdx = headers.indexOf("metadata"); + const rows = []; + for (let i = 1; i < lines.length; i++) { + const vals = parseCsvLine(lines[i]); + const address = vals[addrIdx]; + if (!address) continue; + rows.push({ + lineNum: i + 1, + address, + claim_type: ctIdx >= 0 ? vals[ctIdx] || null : null, + expiry_days: expIdx >= 0 && vals[expIdx] ? parseInt(vals[expIdx], 10) : null, + metadata: metaIdx >= 0 ? vals[metaIdx] || null : null, + }); + } + return rows; +} + +// --------------------------------------------------------------------------- +// bulk-create +// --------------------------------------------------------------------------- + +async function bulkCreate() { + required(config.contractId, "TRUSTLINK_CONTRACT_ID"); + required(config.issuerSecret, "ISSUER_SECRET"); + + const fileIdx = args.indexOf("--file"); + const claimTypeArg = args.find((a) => a === "--claim-type") ? args[args.indexOf("--claim-type") + 1] : null; + const defaultExpiry = args.find((a) => a === "--expiry") ? parseInt(args[args.indexOf("--expiry") + 1], 10) : 365; + + if (fileIdx === -1 || !args[fileIdx + 1]) { + console.error("Usage: issuer-cli bulk-create --file subjects.csv --claim-type [--expiry ]"); + console.error("CSV columns: address (required), claim_type, expiry_days, metadata"); + process.exit(1); + } + + const csvPath = args[fileIdx + 1]; + let csvContent; + try { + csvContent = fs.readFileSync(path.resolve(csvPath), "utf8"); + } catch (err) { + console.error(`Cannot read file ${csvPath}: ${err.message}`); + process.exit(1); + } + + const rows = parseCsv(csvContent); + if (rows.length === 0) { + console.error("No data rows found in CSV."); + process.exit(1); + } + + console.log(`\nBulk-creating ${rows.length} attestation(s) from ${csvPath}…\n`); + + const server = new SorobanRpc.Server(config.rpcUrl); + const contract = new Contract(config.contractId); + const issuer = Keypair.fromSecret(config.issuerSecret); + + let succeeded = 0; + let failed = 0; + + for (const row of rows) { + const ct = row.claim_type || claimTypeArg; + if (!ct) { + console.error(` [row ${row.lineNum}] SKIP ${row.address} — no claim_type; pass --claim-type or add a claim_type column`); + failed++; + continue; + } + + const expiryDays = row.expiry_days ?? defaultExpiry; + const expiration = Math.floor(Date.now() / 1000) + expiryDays * 24 * 60 * 60; + + const createOp = contract.call( + "create_attestation", + nativeToScVal(Address.fromString(issuer.publicKey()), { type: "address" }), + nativeToScVal(Address.fromString(row.address), { type: "address" }), + nativeToScVal(ct, { type: "string" }), + nativeToScVal(expiration, { type: "u64" }), + row.metadata ? nativeToScVal(row.metadata, { type: "string" }) : nativeToScVal(null, { type: "void" }) + ); + + try { + const res = await submitWrite(server, issuer, createOp, config.networkPassphrase); + const attestationId = res.returnValue ? scValToNative(res.returnValue) : "—"; + console.log(` [row ${row.lineNum}] OK ${row.address} claim=${ct} id=${attestationId}`); + succeeded++; + } catch (err) { + console.error(` [row ${row.lineNum}] FAIL ${row.address} claim=${ct} error=${err.message}`); + failed++; + } + } + + console.log(`\nDone: ${succeeded} succeeded, ${failed} failed out of ${rows.length} rows.`); + if (failed > 0) process.exit(1); +} + +// --------------------------------------------------------------------------- +// template management +// --------------------------------------------------------------------------- + +async function templateCreate() { + required(config.contractId, "TRUSTLINK_CONTRACT_ID"); + required(config.issuerSecret, "ISSUER_SECRET"); + + const idIdx = args.indexOf("--id"); + const ctIdx = args.indexOf("--claim-type"); + const metaIdx = args.indexOf("--metadata"); + + const templateId = idIdx >= 0 ? args[idIdx + 1] : null; + const claimType = ctIdx >= 0 ? args[ctIdx + 1] : null; + const metadata = metaIdx >= 0 ? args[metaIdx + 1] : null; + + if (!templateId || !claimType) { + console.error("Usage: issuer-cli template create --id --claim-type [--metadata ]"); + process.exit(1); + } + + const server = new SorobanRpc.Server(config.rpcUrl); + const contract = new Contract(config.contractId); + const issuer = Keypair.fromSecret(config.issuerSecret); + + console.log(`\nCreating template "${templateId}" (${claimType})…`); + + const op = contract.call( + "create_template", + nativeToScVal(Address.fromString(issuer.publicKey()), { type: "address" }), + nativeToScVal(templateId, { type: "string" }), + nativeToScVal(claimType, { type: "string" }), + metadata ? nativeToScVal(metadata, { type: "string" }) : nativeToScVal(null, { type: "void" }) + ); + + await submitWrite(server, issuer, op, config.networkPassphrase); + console.log(`✓ Template "${templateId}" created.`); +} + +async function templateList() { + required(config.contractId, "TRUSTLINK_CONTRACT_ID"); + required(config.issuerSecret, "ISSUER_SECRET"); + + const server = new SorobanRpc.Server(config.rpcUrl); + const contract = new Contract(config.contractId); + const issuer = Keypair.fromSecret(config.issuerSecret); + + console.log(`\nListing templates for ${issuer.publicKey()}…`); + + const op = contract.call( + "list_templates", + nativeToScVal(Address.fromString(issuer.publicKey()), { type: "address" }) + ); + + const retval = await simulateRead(server, issuer.publicKey(), op, config.networkPassphrase); + const templates = retval ? scValToNative(retval) : []; + + if (templates.length === 0) { + console.log(" (no templates)"); + return; + } + + console.log(`\n Found ${templates.length} template(s):`); + templates.forEach((tmpl, i) => { + console.log(` ${i + 1}. ID: ${tmpl.template_id}`); + console.log(` Claim Type: ${tmpl.claim_type}`); + if (tmpl.metadata) console.log(` Metadata: ${tmpl.metadata}`); + }); +} + +async function templateDelete() { + required(config.contractId, "TRUSTLINK_CONTRACT_ID"); + required(config.issuerSecret, "ISSUER_SECRET"); + + const templateId = args[2]; + if (!templateId) { + console.error("Usage: issuer-cli template delete "); + process.exit(1); + } + + const server = new SorobanRpc.Server(config.rpcUrl); + const contract = new Contract(config.contractId); + const issuer = Keypair.fromSecret(config.issuerSecret); + + console.log(`\nDeleting template "${templateId}"…`); + + const op = contract.call( + "delete_template", + nativeToScVal(Address.fromString(issuer.publicKey()), { type: "address" }), + nativeToScVal(templateId, { type: "string" }) + ); + + await submitWrite(server, issuer, op, config.networkPassphrase); + console.log(`✓ Template "${templateId}" deleted.`); +} + +async function handleTemplate() { + const subcommand = args[1]; + switch (subcommand) { + case "create": + await templateCreate(); + break; + case "list": + await templateList(); + break; + case "delete": + await templateDelete(); + break; + default: + console.error(`Unknown template subcommand: ${subcommand}`); + console.error("Usage: issuer-cli template [options]"); + process.exit(1); + } +} + function showHelp() { console.log(` TrustLink Issuer CLI @@ -639,6 +878,21 @@ Commands: Queries attestations from the indexer and fetches audit log entries from the contract. Output is JSON (default) or CSV. + bulk-create --file subjects.csv --claim-type [--expiry ] + Batch-create attestations from a CSV file. The CSV must have an 'address' + column; optional per-row columns: claim_type, expiry_days, metadata. + Per-row claim_type overrides --claim-type when present. + Reports success/failure for every row. + + template create --id --claim-type [--metadata ] + Create an attestation template for this issuer. + + template list + List all templates for this issuer. + + template delete + Delete an existing template. + Environment Variables: RPC_URL Stellar RPC endpoint (default: testnet) NETWORK_PASSPHRASE Stellar network (default: testnet) @@ -656,6 +910,10 @@ Examples: issuer-cli list-proposals issuer-cli import GBRPYHIL... KYC_PASSED --source-ref "external-id-123" --expiry 365 issuer-cli export-audit-trail --issuer GABC... --from 2024-01-01 --to 2024-12-31 --format csv --output audit.csv + issuer-cli bulk-create --file subjects.csv --claim-type KYC_PASSED --expiry 365 + issuer-cli template create --id kyc-standard --claim-type KYC_PASSED + issuer-cli template list + issuer-cli template delete kyc-standard `); } @@ -694,6 +952,12 @@ async function main() { case "export-audit-trail": await exportAuditTrail(); break; + case "bulk-create": + await bulkCreate(); + break; + case "template": + await handleTemplate(); + break; default: console.error(`Unknown command: ${command}`); showHelp(); diff --git a/examples/react-app/README.md b/examples/react-app/README.md index ebff538d..9e7f5c02 100644 --- a/examples/react-app/README.md +++ b/examples/react-app/README.md @@ -13,7 +13,7 @@ Reference implementation for the TrustLink attestation contract on Stellar testn ## Prerequisites -- [Freighter wallet](https://freighter.app) browser extension +- A Stellar wallet browser extension — **Freighter** ([freighter.app](https://freighter.app)) or **xBull** ([xbull.app](https://xbull.app)) - A Stellar testnet account funded via [Friendbot](https://friendbot.stellar.org) - A deployed TrustLink contract ID @@ -27,7 +27,32 @@ npm install npm run dev ``` -Open `http://localhost:5173`, connect Freighter, and switch to testnet inside the extension. +Open `http://localhost:5173`, choose a wallet, and switch to testnet inside the extension. + +## Wallet support + +The app uses [Stellar Wallets Kit](https://github.com/Creit-Tech/stellar-wallets-kit) to support multiple Stellar wallets from a single connection UI. Currently supported: + +| Wallet | Notes | +|--------|-------| +| **Freighter** | Full support including network-mismatch detection | +| **xBull** | Full support; network detection defers to the configured testnet | + +Adding more wallets (e.g. LOBSTR, Rabet) only requires importing the relevant module from `@creit.tech/stellar-wallets-kit` and appending it to the `modules` array in `src/wallet.ts`. + +## Internationalization (i18n) + +The app ships with English (`en`) and Spanish (`es`) locales using [react-i18next](https://react.i18next.com/). + +- The language is **auto-detected** from `navigator.language` on first load. +- Users can **switch language** at any time with the `EN / ES` button in the header. +- Translation files live in `src/locales/en.json` and `src/locales/es.json`. + +To add a new language: + +1. Copy `src/locales/en.json` to `src/locales/.json` and translate the values. +2. Import it in `src/i18n.ts` and add it to the `resources` map. +3. Add the locale code to `SUPPORTED_LANGS` in `src/App.tsx`. ## Deploy to GitHub Pages @@ -49,4 +74,5 @@ https://.github.io/TrustLink/ - Vite + React 18 + TypeScript - `@stellar/stellar-sdk` for contract interaction -- `@stellar/freighter-api` for wallet connection +- `@creit.tech/stellar-wallets-kit` for multi-wallet connection (Freighter, xBull, …) +- `i18next` + `react-i18next` for internationalization diff --git a/examples/react-app/package.json b/examples/react-app/package.json index 6091c2b8..cde71048 100644 --- a/examples/react-app/package.json +++ b/examples/react-app/package.json @@ -12,11 +12,14 @@ "test:watch": "vitest" }, "dependencies": { + "@creit.tech/stellar-wallets-kit": "^1.4.0", "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^14.6.1", + "i18next": "^23.0.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-i18next": "^14.0.0" }, "devDependencies": { "@axe-core/react": "^4.10.1", diff --git a/examples/react-app/src/App.tsx b/examples/react-app/src/App.tsx index 80a3c7df..abf31878 100644 --- a/examples/react-app/src/App.tsx +++ b/examples/react-app/src/App.tsx @@ -1,6 +1,15 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import i18n from "i18next"; import { Networks } from "@stellar/stellar-sdk"; -import { connectWallet, getWalletAddress, getConnectedNetwork, disconnectWallet } from "./wallet"; +import { + connectWallet, + getWalletAddress, + getConnectedNetwork, + disconnectWallet, + SUPPORTED_WALLETS, + type SupportedWalletId, +} from "./wallet"; import { ErrorBoundary } from "./ErrorBoundary"; import AdminPanel from "./panels/AdminPanel"; import IssuerPanel from "./panels/IssuerPanel"; @@ -16,23 +25,26 @@ import { useToasts, ToastContainer } from "./Toast"; type Tab = "admin" | "issuer" | "user" | "verifier" | "requests" | "multisig" | "council" | "delegation" | "whitelist"; +const SUPPORTED_LANGS = ["en", "es"] as const; + export default function App() { + const { t } = useTranslation(); const [address, setAddress] = useState(null); const [tab, setTab] = useState("user"); - const [connecting, setConnecting] = useState(false); + const [connecting, setConnecting] = useState(null); const [error, setError] = useState(null); const [networkMismatch, setNetworkMismatch] = useState(false); const [darkMode, setDarkMode] = useState(() => { const stored = localStorage.getItem("trustlink-theme"); return stored ? stored === "dark" : true; }); + const [lang, setLang] = useState(() => i18n.language ?? "en"); useEffect(() => { document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light"); localStorage.setItem("trustlink-theme", darkMode ? "dark" : "light"); }, [darkMode]); - // Auto-reconnect if Freighter is already authorised useEffect(() => { getWalletAddress().then((addr) => { if (addr) setAddress(addr); }); }, []); @@ -44,18 +56,18 @@ export default function App() { }); }, [address]); - async function handleConnect() { - setConnecting(true); + async function handleConnect(walletId: SupportedWalletId) { + setConnecting(walletId); setError(null); try { - const addr = await connectWallet(); + const addr = await connectWallet(walletId); setAddress(addr); const passphrase = await getConnectedNetwork(); setNetworkMismatch(passphrase != null && passphrase !== Networks.TESTNET); } catch (e: unknown) { setError((e as Error).message); } finally { - setConnecting(false); + setConnecting(null); } } @@ -66,6 +78,13 @@ export default function App() { setError(null); } + function cycleLang() { + const idx = SUPPORTED_LANGS.indexOf(lang as typeof SUPPORTED_LANGS[number]); + const next = SUPPORTED_LANGS[(idx + 1) % SUPPORTED_LANGS.length]; + i18n.changeLanguage(next); + setLang(next); + } + const { toasts, push: pushToast, dismiss: dismissToast } = useToasts(); useAttestationSubscription(address, pushToast); @@ -74,32 +93,49 @@ export default function App() { if (!address) { return (
-

TrustLink dApp

-

Connect your Freighter wallet to interact with the TrustLink attestation contract on Stellar testnet.

+

{t("app.dapp_title")}

+

{t("app.connect_prompt")}

{error &&
{error}
} - -

- Don't have Freighter?{" "} - - freighter.app - +

+ {t("wallet.choose")}

+
+ {SUPPORTED_WALLETS.map(({ id, name }) => ( + + ))} +
+
+ {SUPPORTED_WALLETS.map(({ id, name, url }) => ( +

+ {t(`wallet.${id === "freighter" ? "freighter" : "xbull"}`)}{" "} + + {url.replace("https://", "")} + +

+ ))} +
); } const TABS: { id: Tab; label: string }[] = [ - { id: "user", label: "My Attestations" }, - { id: "requests", label: "Requests" }, - { id: "multisig", label: "Multi-Sig" }, - { id: "delegation", label: "Delegation" }, - { id: "whitelist", label: "Whitelist" }, - { id: "issuer", label: "Issuer" }, - { id: "verifier", label: "Verifier" }, - { id: "admin", label: "Admin" }, - { id: "council", label: "Council" }, + { id: "user", label: t("tabs.user") }, + { id: "requests", label: t("tabs.requests") }, + { id: "multisig", label: t("tabs.multisig") }, + { id: "delegation", label: t("tabs.delegation") }, + { id: "whitelist", label: t("tabs.whitelist") }, + { id: "issuer", label: t("tabs.issuer") }, + { id: "verifier", label: t("tabs.verifier") }, + { id: "admin", label: t("tabs.admin") }, + { id: "council", label: t("tabs.council") }, ]; return ( @@ -107,20 +143,28 @@ export default function App() {

TrustLink

- + {short}
@@ -130,8 +174,7 @@ export default function App() { className="alert alert-error" style={{ margin: "1rem", borderRadius: "0.5rem", padding: "1rem 1.25rem", fontSize: "0.9rem" }} > - Wrong network. Your Freighter wallet is connected to a different network than - this app (Stellar Testnet). Please switch your wallet network to Testnet and reconnect. + {t("app.wrong_network")} {t("app.wrong_network_msg")} )} diff --git a/examples/react-app/src/i18n.ts b/examples/react-app/src/i18n.ts new file mode 100644 index 00000000..b1c9e240 --- /dev/null +++ b/examples/react-app/src/i18n.ts @@ -0,0 +1,16 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import en from "./locales/en.json"; +import es from "./locales/es.json"; + +i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + es: { translation: es }, + }, + lng: navigator.language.startsWith("es") ? "es" : "en", + fallbackLng: "en", + interpolation: { escapeValue: false }, +}); + +export default i18n; diff --git a/examples/react-app/src/locales/en.json b/examples/react-app/src/locales/en.json new file mode 100644 index 00000000..6cb2ef9d --- /dev/null +++ b/examples/react-app/src/locales/en.json @@ -0,0 +1,110 @@ +{ + "app": { + "dapp_title": "TrustLink dApp", + "connect_prompt": "Connect your Stellar wallet to interact with the TrustLink attestation contract on Stellar testnet.", + "connect_btn": "Connect {{name}}", + "connecting": "Connecting…", + "disconnect": "Disconnect", + "toggle_theme": "Toggle dark mode", + "wrong_network": "Wrong network.", + "wrong_network_msg": "Your wallet is connected to a different network than this app (Stellar Testnet). Please switch your wallet network to Testnet and reconnect.", + "no_wallet": "Don't have a Stellar wallet?" + }, + "wallet": { + "choose": "Choose a wallet", + "freighter": "Freighter", + "freighter_desc": "Browser extension", + "xbull": "xBull", + "xbull_desc": "Browser extension" + }, + "tabs": { + "user": "My Attestations", + "requests": "Requests", + "multisig": "Multi-Sig", + "delegation": "Delegation", + "whitelist": "Whitelist", + "issuer": "Issuer", + "verifier": "Verifier", + "admin": "Admin", + "council": "Council" + }, + "common": { + "loading": "Loading…", + "load": "Load", + "create": "Create", + "creating": "Creating…", + "delete": "Delete", + "deleting": "Deleting…", + "revoke": "Revoke", + "cancel": "Cancel", + "clear": "Clear", + "check": "Check", + "submit": "Submit", + "valid": "Valid", + "revoked": "Revoked", + "expired": "Expired", + "id": "ID: {{id}}", + "issuer": "Issuer: {{value}}", + "subject": "Subject: {{value}}", + "expires": "Expires: {{date}}", + "note": "Note: {{note}}", + "none_found": "No results found." + }, + "issuer": { + "title": "Issuer Panel", + "tab_dashboard": "Dashboard", + "tab_create": "Create", + "tab_revoke": "Revoke", + "tab_lookup": "Lookup", + "tab_templates": "Templates", + "create_title": "Create Attestation", + "rate_limit_warning": "Warning: you are near your rate-limit ({{current}}/{{limit}} used). This submission may be rejected with RateLimitExceeded.", + "claim_type_constraint": "This contract requires claim types to be pre-registered. Free-text claim types will be rejected.", + "subject_address": "Subject Address", + "claim_type": "Claim Type", + "metadata_optional": "Metadata (optional)", + "from_template_heading": "Or create from template", + "template_select": "Template", + "template_placeholder": "Select a template…", + "create_from_template": "Create from Template", + "revoke_title": "Revoke Attestation", + "attestation_id": "Attestation ID", + "reason_optional": "Reason (optional)", + "lookup_title": "My Issued Attestations", + "subject_placeholder": "Subject address G…", + "attestation_created": "Attestation created.", + "attestation_revoked": "Attestation revoked.", + "from_template_created": "Attestation created from template." + }, + "user": { + "title": "My Attestations", + "search_placeholder": "Search issuer or ID…", + "all_claim_types": "All claim types", + "all_statuses": "All statuses", + "no_attestations": "No attestations found for your address.", + "no_match": "No attestations match the current filters.", + "view_history": "View History", + "hide_history": "Hide History", + "show_qr": "Show QR", + "hide_qr": "Hide QR", + "history_loading": "Loading history…", + "history_error": "Failed to load history.", + "history_empty": "No history available." + }, + "verifier": { + "title": "Verifier Panel", + "tab_manual": "Manual", + "tab_scan": "Scan / Paste ID", + "scan_title": "Look Up by Attestation ID", + "scan_desc": "Paste or type the attestation ID from a QR code scan.", + "attestation_id_placeholder": "Attestation ID…", + "looking_up": "Looking up…", + "look_up": "Look Up", + "check_claim_title": "Check Claim", + "verify_claim": "Verify Claim", + "load_all": "Load All Attestations", + "claim_valid": "✓ {{address}} holds a valid \"{{claimType}}\" claim.", + "claim_invalid": "✗ No valid \"{{claimType}}\" claim found for this address.", + "all_for": "All Attestations for {{address}}" + } +} diff --git a/examples/react-app/src/locales/es.json b/examples/react-app/src/locales/es.json new file mode 100644 index 00000000..56dbffde --- /dev/null +++ b/examples/react-app/src/locales/es.json @@ -0,0 +1,110 @@ +{ + "app": { + "dapp_title": "TrustLink dApp", + "connect_prompt": "Conecta tu billetera Stellar para interactuar con el contrato de atestaciones TrustLink en la red de prueba Stellar.", + "connect_btn": "Conectar {{name}}", + "connecting": "Conectando…", + "disconnect": "Desconectar", + "toggle_theme": "Alternar modo oscuro", + "wrong_network": "Red incorrecta.", + "wrong_network_msg": "Tu billetera está conectada a una red diferente a la de esta app (Stellar Testnet). Cambia la red de tu billetera a Testnet y vuelve a conectar.", + "no_wallet": "¿No tienes una billetera Stellar?" + }, + "wallet": { + "choose": "Elige una billetera", + "freighter": "Freighter", + "freighter_desc": "Extensión del navegador", + "xbull": "xBull", + "xbull_desc": "Extensión del navegador" + }, + "tabs": { + "user": "Mis Atestaciones", + "requests": "Solicitudes", + "multisig": "Multi-Firma", + "delegation": "Delegación", + "whitelist": "Lista blanca", + "issuer": "Emisor", + "verifier": "Verificador", + "admin": "Administrador", + "council": "Consejo" + }, + "common": { + "loading": "Cargando…", + "load": "Cargar", + "create": "Crear", + "creating": "Creando…", + "delete": "Eliminar", + "deleting": "Eliminando…", + "revoke": "Revocar", + "cancel": "Cancelar", + "clear": "Limpiar", + "check": "Verificar", + "submit": "Enviar", + "valid": "Válido", + "revoked": "Revocado", + "expired": "Expirado", + "id": "ID: {{id}}", + "issuer": "Emisor: {{value}}", + "subject": "Sujeto: {{value}}", + "expires": "Expira: {{date}}", + "note": "Nota: {{note}}", + "none_found": "No se encontraron resultados." + }, + "issuer": { + "title": "Panel del Emisor", + "tab_dashboard": "Panel", + "tab_create": "Crear", + "tab_revoke": "Revocar", + "tab_lookup": "Buscar", + "tab_templates": "Plantillas", + "create_title": "Crear Atestación", + "rate_limit_warning": "Advertencia: estás cerca de tu límite de tasa ({{current}}/{{limit}} usadas). Esta solicitud puede ser rechazada con RateLimitExceeded.", + "claim_type_constraint": "Este contrato requiere que los tipos de reclamación estén pre-registrados. Los tipos de texto libre serán rechazados.", + "subject_address": "Dirección del Sujeto", + "claim_type": "Tipo de Reclamación", + "metadata_optional": "Metadatos (opcional)", + "from_template_heading": "O crear desde una plantilla", + "template_select": "Plantilla", + "template_placeholder": "Selecciona una plantilla…", + "create_from_template": "Crear desde Plantilla", + "revoke_title": "Revocar Atestación", + "attestation_id": "ID de Atestación", + "reason_optional": "Razón (opcional)", + "lookup_title": "Mis Atestaciones Emitidas", + "subject_placeholder": "Dirección del sujeto G…", + "attestation_created": "Atestación creada.", + "attestation_revoked": "Atestación revocada.", + "from_template_created": "Atestación creada desde plantilla." + }, + "user": { + "title": "Mis Atestaciones", + "search_placeholder": "Buscar emisor o ID…", + "all_claim_types": "Todos los tipos", + "all_statuses": "Todos los estados", + "no_attestations": "No se encontraron atestaciones para tu dirección.", + "no_match": "Ninguna atestación coincide con los filtros actuales.", + "view_history": "Ver Historial", + "hide_history": "Ocultar Historial", + "show_qr": "Mostrar QR", + "hide_qr": "Ocultar QR", + "history_loading": "Cargando historial…", + "history_error": "No se pudo cargar el historial.", + "history_empty": "Sin historial disponible." + }, + "verifier": { + "title": "Panel del Verificador", + "tab_manual": "Manual", + "tab_scan": "Escanear / Pegar ID", + "scan_title": "Buscar por ID de Atestación", + "scan_desc": "Pega o escribe el ID de atestación de un escaneo de código QR.", + "attestation_id_placeholder": "ID de Atestación…", + "looking_up": "Buscando…", + "look_up": "Buscar", + "check_claim_title": "Verificar Reclamación", + "verify_claim": "Verificar Reclamación", + "load_all": "Cargar Todas las Atestaciones", + "claim_valid": "✓ {{address}} tiene una reclamación válida de \"{{claimType}}\".", + "claim_invalid": "✗ No se encontró una reclamación válida de \"{{claimType}}\" para esta dirección.", + "all_for": "Todas las Atestaciones para {{address}}" + } +} diff --git a/examples/react-app/src/main.tsx b/examples/react-app/src/main.tsx index 78f2d44e..1926ca88 100644 --- a/examples/react-app/src/main.tsx +++ b/examples/react-app/src/main.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; +import "./i18n"; import { ErrorBoundary } from "./ErrorBoundary"; diff --git a/examples/react-app/src/panels/IssuerPanel.tsx b/examples/react-app/src/panels/IssuerPanel.tsx index 8dfcf482..5e22f12f 100644 --- a/examples/react-app/src/panels/IssuerPanel.tsx +++ b/examples/react-app/src/panels/IssuerPanel.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { createAttestation, revokeAttestation, getSubjectAttestations, listTemplates, createAttestationFromTemplate, getRateLimit, getRequireRegisteredClaimType, RateLimit, Attestation, AttestationTemplate } from "../contract"; import { SkeletonAttestationList } from "../SkeletonList"; import IssuerDashboard from "./IssuerDashboard"; @@ -8,6 +9,7 @@ import RateLimitPanel, { RATE_LIMIT_WARNING_THRESHOLD } from "./RateLimitPanel"; interface Props { address: string; } export default function IssuerPanel({ address }: Props) { + const { t } = useTranslation(); const [tab, setTab] = useState<"dashboard" | "create" | "revoke" | "lookup" | "templates">("dashboard"); const [subject, setSubject] = useState(""); const [claimType, setClaimType] = useState(""); @@ -20,7 +22,7 @@ export default function IssuerPanel({ address }: Props) { useEffect(() => { getRateLimit(address) .then(setRateLimit) - .catch(() => { /* rate limit info is advisory; silently ignore fetch errors */ }); + .catch(() => { /* rate limit info is advisory */ }); }, [address]); const nearLimit = rateLimit != null && rateLimit.limit > 0 @@ -54,7 +56,7 @@ export default function IssuerPanel({ address }: Props) { setStatus(null); try { await createAttestation(address, subject.trim(), claimType.trim(), null, metadata || null); - setStatus({ type: "success", msg: "Attestation created." }); + setStatus({ type: "success", msg: t("issuer.attestation_created") }); setSubject(""); setClaimType(""); setMetadata(""); } catch (e: unknown) { setStatus({ type: "error", msg: (e as Error).message }); @@ -69,7 +71,7 @@ export default function IssuerPanel({ address }: Props) { setStatus(null); try { await revokeAttestation(address, revokeId.trim(), revokeReason || null); - setStatus({ type: "success", msg: "Attestation revoked." }); + setStatus({ type: "success", msg: t("issuer.attestation_revoked") }); setRevokeId(""); setRevokeReason(""); } catch (e: unknown) { setStatus({ type: "error", msg: (e as Error).message }); @@ -84,7 +86,7 @@ export default function IssuerPanel({ address }: Props) { setStatus(null); try { await createAttestationFromTemplate(address, selectedTemplate, templateSubject.trim(), null); - setStatus({ type: "success", msg: "Attestation created from template." }); + setStatus({ type: "success", msg: t("issuer.from_template_created") }); setTemplateSubject(""); } catch (e: unknown) { setStatus({ type: "error", msg: (e as Error).message }); @@ -108,22 +110,24 @@ export default function IssuerPanel({ address }: Props) { } } + const allTabs = ["dashboard", "create", "revoke", "lookup", "templates"] as const; + const TabNav = () => ( @@ -142,13 +146,7 @@ export default function IssuerPanel({ address }: Props) { if (tab === "templates") { return (
-
- {(["dashboard", "create", "revoke", "lookup", "templates"] as const).map((t) => ( - - ))} -
+
); @@ -156,7 +154,7 @@ export default function IssuerPanel({ address }: Props) { return (
-

Issuer Panel

+

{t("issuer.title")}

{status && ( @@ -165,33 +163,21 @@ export default function IssuerPanel({ address }: Props) {
)} - {tab === "templates" && ( -
-

Attestation Templates

- {templatesLoading - ?

Loading templates…

- : templates.length === 0 - ?

No templates found.

- : } -
- )} - {tab === "create" && (
-

Create Attestation

+

{t("issuer.create_title")}

{nearLimit && (
- Warning: you are near your rate-limit ({rateLimit!.current_count}/{rateLimit!.limit} used). - This submission may be rejected with RateLimitExceeded. + {t("issuer.rate_limit_warning", { current: rateLimit!.current_count, limit: rateLimit!.limit })}
)} {requireRegisteredClaimType && (
- This contract requires claim types to be pre-registered. Free-text claim types will be rejected. + {t("issuer.claim_type_constraint")}
)}
- +
- +
- + - Create + {t("issuer.create_title")} {templates.length > 0 && (
-

Or create from template

+

+ {t("issuer.from_template_heading")} +

- +
- + setTemplateSubject(e.target.value)} @@ -256,7 +244,7 @@ export default function IssuerPanel({ address }: Props) { disabled={templateLoading || !selectedTemplate || !templateSubject} onClick={handleFromTemplate} > - {templateLoading ? "Creating…" : "Create from Template"} + {templateLoading ? t("common.creating") : t("issuer.create_from_template")}
)} @@ -265,9 +253,9 @@ export default function IssuerPanel({ address }: Props) { {tab === "revoke" && (
-

Revoke Attestation

+

{t("issuer.revoke_title")}

- +
- + - Revoke + {t("common.revoke")}
)} {tab === "lookup" && (
-

My Issued Attestations

+

{t("issuer.lookup_title")}

{lookupLoading ? : attestations.length === 0 - ?

No attestations found.

+ ?

{t("common.none_found")}

: }
)} @@ -332,6 +320,7 @@ export default function IssuerPanel({ address }: Props) { } function AttestationList({ items }: { items: Attestation[] }) { + const { t } = useTranslation(); return (
    {items.map((a) => ( @@ -339,30 +328,13 @@ function AttestationList({ items }: { items: Attestation[] }) {
    {a.claim_type} - {a.revoked ? "Revoked" : "Valid"} + {a.revoked ? t("common.revoked") : t("common.valid")}
    - Subject: {a.subject} - ID: {a.id} + {t("common.subject", { value: a.subject })} + {t("common.id", { id: a.id })} ))}
); } - -function TemplateList({ items }: { items: Template[] }) { - return ( -
- {items.map((t) => ( -
-
- {t.name} - {t.claim_type} -
- {t.description && {t.description}} - ID: {t.id} -
- ))} -
- ); -} diff --git a/examples/react-app/src/panels/UserPanel.tsx b/examples/react-app/src/panels/UserPanel.tsx index ee76dc8a..632ddd55 100644 --- a/examples/react-app/src/panels/UserPanel.tsx +++ b/examples/react-app/src/panels/UserPanel.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { QRCodeSVG } from "qrcode.react"; import { getSubjectAttestations, getAuditLog, Attestation, AuditEntry } from "../contract"; import { SkeletonAttestationList } from "../SkeletonList"; @@ -14,6 +15,7 @@ function deriveStatus(a: Attestation): "valid" | "revoked" | "expired" { } function AttestationTimeline({ attestationId }: { attestationId: string }) { + const { t } = useTranslation(); const [log, setLog] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -25,9 +27,9 @@ function AttestationTimeline({ attestationId }: { attestationId: string }) { .finally(() => setLoading(false)); }, [attestationId]); - if (loading) return

Loading history…

; - if (error) return

Failed to load history.

; - if (!log || log.length === 0) return

No history available.

; + if (loading) return

{t("user.history_loading")}

; + if (error) return

{t("user.history_error")}

; + if (!log || log.length === 0) return

{t("user.history_empty")}

; return (
@@ -51,6 +53,7 @@ function AttestationTimeline({ attestationId }: { attestationId: string }) { } export default function UserPanel({ address }: Props) { + const { t } = useTranslation(); const [attestations, setAttestations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -86,14 +89,14 @@ export default function UserPanel({ address }: Props) { function statusBadge(a: Attestation) { const s = deriveStatus(a); - if (s === "revoked") return Revoked; - if (s === "expired") return Expired; - return Valid; + if (s === "revoked") return {t("common.revoked")}; + if (s === "expired") return {t("common.expired")}; + return {t("common.valid")}; } return (
-

My Attestations

+

{t("user.title")}

{address}

@@ -105,7 +108,7 @@ export default function UserPanel({ address }: Props) {
setFilterText(e.target.value)} /> @@ -114,7 +117,7 @@ export default function UserPanel({ address }: Props) { value={filterClaimType} onChange={(e) => setFilterClaimType(e.target.value)} > - + {claimTypes.map((ct) => )} {(filterText || filterClaimType || filterStatus !== "all") && ( )} @@ -143,11 +146,11 @@ export default function UserPanel({ address }: Props) { )} {!loading && attestations.length === 0 && ( -

No attestations found for your address.

+

{t("user.no_attestations")}

)} {!loading && attestations.length > 0 && filtered.length === 0 && ( -

No attestations match the current filters.

+

{t("user.no_match")}

)}
@@ -157,20 +160,20 @@ export default function UserPanel({ address }: Props) { {a.claim_type} {statusBadge(a)}
- Issuer: {a.issuer} - {a.metadata && Note: {a.metadata}} + {t("common.issuer", { value: a.issuer })} + {a.metadata && {t("common.note", { note: a.metadata })}} {a.expiration && ( - Expires: {new Date(Number(a.expiration) * 1000).toLocaleDateString()} + {t("common.expires", { date: new Date(Number(a.expiration) * 1000).toLocaleDateString() })} )} - ID: {a.id} + {t("common.id", { id: a.id })} {expandedTimeline === a.id && } {expandedQR === a.id && (
diff --git a/examples/react-app/src/panels/VerifierPanel.tsx b/examples/react-app/src/panels/VerifierPanel.tsx index 0caa16a0..d88198a1 100644 --- a/examples/react-app/src/panels/VerifierPanel.tsx +++ b/examples/react-app/src/panels/VerifierPanel.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { hasValidClaim, getSubjectAttestations, getAttestation, isWhitelistEnabled, isWhitelisted, Attestation } from "../contract"; type InputMode = "manual" | "scan"; export default function VerifierPanel() { + const { t } = useTranslation(); const [subject, setSubject] = useState(""); const [claimType, setClaimType] = useState(""); const [issuer, setIssuer] = useState(""); @@ -76,7 +78,7 @@ export default function VerifierPanel() { return (
-

Verifier Panel

+

{t("verifier.title")}

{inputMode === "scan" && (
-

Look Up by Attestation ID

+

{t("verifier.scan_title")}

- Paste or type the attestation ID from a QR code scan. + {t("verifier.scan_desc")}

{scanError &&
{scanError}
}
@@ -107,10 +109,10 @@ export default function VerifierPanel() { style={{ flex: 1, background: "#0f1117", border: "1px solid #2d3148", borderRadius: "0.5rem", padding: "0.5rem 0.75rem", color: "#e2e8f0" }} value={scannedId} onChange={(e) => { setScannedId(e.target.value); setScannedAttestation(null); setScanError(null); }} - placeholder="Attestation ID…" + placeholder={t("verifier.attestation_id_placeholder")} />
{scannedAttestation && ( @@ -118,17 +120,17 @@ export default function VerifierPanel() {
{scannedAttestation.claim_type} - {scannedAttestation.revoked ? "Revoked" : "Valid"} + {scannedAttestation.revoked ? t("common.revoked") : t("common.valid")}
- Issuer: {scannedAttestation.issuer} - Subject: {scannedAttestation.subject} + {t("common.issuer", { value: scannedAttestation.issuer })} + {t("common.subject", { value: scannedAttestation.subject })} {scannedAttestation.expiration && ( - Expires: {new Date(Number(scannedAttestation.expiration) * 1000).toLocaleDateString()} + {t("common.expires", { date: new Date(Number(scannedAttestation.expiration) * 1000).toLocaleDateString() })} )} - ID: {scannedAttestation.id} + {t("common.id", { id: scannedAttestation.id })}
)}
@@ -138,47 +140,47 @@ export default function VerifierPanel() { <> {error &&
{error}
}
-

Check Claim

+

{t("verifier.check_claim_title")}

- + { setSubject(e.target.value); setCheckResult(null); }} placeholder="G..." />
- + { setClaimType(e.target.value); setCheckResult(null); }} placeholder="KYC, AML…" />
{checkResult !== null && (
{checkResult - ? `✓ ${subject.slice(0, 8)}… holds a valid "${claimType}" claim.` - : `✗ No valid "${claimType}" claim found for this address.`} + ? t("verifier.claim_valid", { address: subject.slice(0, 8) + "…", claimType }) + : t("verifier.claim_invalid", { claimType })}
)}
{attestations.length > 0 && (
-

All Attestations for {subject.slice(0, 12)}…

+

{t("verifier.all_for", { address: subject.slice(0, 12) + "…" })}

{attestations.map((a) => (
{a.claim_type} - {a.revoked ? "Revoked" : "Valid"} + {a.revoked ? t("common.revoked") : t("common.valid")}
- Issuer: {a.issuer} - ID: {a.id} + {t("common.issuer", { value: a.issuer })} + {t("common.id", { id: a.id })}
))}
diff --git a/examples/react-app/src/wallet.ts b/examples/react-app/src/wallet.ts index e8e7fe46..ab5ff7bb 100644 --- a/examples/react-app/src/wallet.ts +++ b/examples/react-app/src/wallet.ts @@ -1,38 +1,50 @@ import { - isConnected, - getAddress, - getNetworkDetails, - signTransaction, -} from "@stellar/freighter-api"; + StellarWalletsKit, + WalletNetwork, + FREIGHTER_ID, + XBULL_ID, + FreighterModule, + xBullModule, +} from "@creit.tech/stellar-wallets-kit"; +import { getNetworkDetails } from "@stellar/freighter-api"; + +export const SUPPORTED_WALLETS = [ + { id: FREIGHTER_ID, name: "Freighter", url: "https://freighter.app" }, + { id: XBULL_ID, name: "xBull", url: "https://xbull.app" }, +] as const; + +export type SupportedWalletId = typeof FREIGHTER_ID | typeof XBULL_ID; export interface WalletState { connected: boolean; address: string | null; } -export async function connectWallet(): Promise { - const connected = await isConnected(); - if (!connected) { - throw new Error("Freighter wallet not found. Please install the Freighter extension."); - } - const result = await getAddress(); - if (result.error) throw new Error(result.error.message); - localStorage.setItem("wallet_address", result.address); - return result.address; +const kit = new StellarWalletsKit({ + network: WalletNetwork.TESTNET, + selectedWalletId: FREIGHTER_ID, + modules: [new FreighterModule(), new xBullModule()], +}); + +export async function connectWallet(walletId: SupportedWalletId = FREIGHTER_ID): Promise { + kit.setWallet(walletId); + const { address } = await kit.getAddress(); + localStorage.setItem("wallet_address", address); + localStorage.setItem("wallet_id", walletId); + return address; } export async function getWalletAddress(): Promise { const stored = localStorage.getItem("wallet_address"); - if (!stored) return null; + const storedWalletId = localStorage.getItem("wallet_id") as SupportedWalletId | null; + if (!stored || !storedWalletId) return null; try { - const connected = await isConnected(); - if (!connected) return null; - const result = await getAddress(); - if (result.error) return null; - if (result.address !== stored) { - localStorage.setItem("wallet_address", result.address); + kit.setWallet(storedWalletId); + const { address } = await kit.getAddress(); + if (address !== stored) { + localStorage.setItem("wallet_address", address); } - return result.address; + return address; } catch { return null; } @@ -40,12 +52,15 @@ export async function getWalletAddress(): Promise { export async function disconnectWallet(): Promise { localStorage.removeItem("wallet_address"); + localStorage.removeItem("wallet_id"); } export async function getConnectedNetwork(): Promise { + // Only Freighter exposes network details via its extension API; other wallets + // trust the network configured in the kit (TESTNET). + const storedWalletId = localStorage.getItem("wallet_id"); + if (storedWalletId !== FREIGHTER_ID) return null; try { - const connected = await isConnected(); - if (!connected) return null; const details = await getNetworkDetails(); if (details.error) return null; return details.networkPassphrase ?? null; @@ -55,7 +70,6 @@ export async function getConnectedNetwork(): Promise { } export async function sign(xdr: string, network: string): Promise { - const result = await signTransaction(xdr, { networkPassphrase: network }); - if (result.error) throw new Error(result.error.message); - return result.signedTxXdr; + const { signedTxXdr } = await kit.signTransaction(xdr, { networkPassphrase: network }); + return signedTxXdr; }