Super Wallet Verifier
Check a Super Wallet credential, presentation or zero-knowledge proof on your own page. The answers come from the Xphere chain, not from Super Wallet's servers.
Add it to a page
<script src="https://trendy.storydot.kr/sdk/sw-verify.js"></script>
<sw-verify request="credential"></sw-verify>
Visitors paste, scan (camera, where the browser supports BarcodeDetector) or upload what their wallet shows. Your page gets the result as an event:
document.querySelector("sw-verify").addEventListener("sw-verify-result", (event) => {
const report = event.detail; // verdict, checks[], warnings[]
if (report.valid) { /* every required check passed */ }
});
| Attribute | Meaning |
|---|---|
request | credential, any (default), or conditions (see Ask for a condition): age>=19, nationality-in:410,418,704, nationality-not-in:408,364, identity-verified, asset-verified, tier>=2 / tier>=GOOD, and what a lender asks (see Conditions for lenders): score>=600, level>=2, income>=2000000:KRW (KRW, LAK, VND, THB, KGS or USD), adverse-loans<=0, max-dpd<=10, repaid-loans>=1, pd<=300bp, employment-verified, residence-verified, business-verified; comma-separated. A condition whose circuit is not registered on chain is refused on screen. |
relay | For a condition request the widget puts a relay URL in the request (relay-base + the nonce); the holder's app sends its answer there and the widget picks it up by itself. relay="off" leaves it out: the holder then shows the answer for you to paste or scan. Pasting works either way. |
relay-base | Where the relay is (https only). Default https://trendy.storydot.kr/superapp/api/public/zk/relay/. Anything that is not https is ignored and the widget says so. |
verifier-name | The name the holder's app shows for you in a condition request. Default: this page's host. |
lang | ko or en. |
rpc | JSON-RPC endpoint. Default https://en-hkg.x-phere.com. |
registry | Trust registry address. Default 0x852688D815410e3FEa015FD3a07028BF6b981d80. |
allow-issuer-server | Off by default. When present, Super Wallet's API may be used as a fallback when the node cannot be read; those checks are labelled "issuer server". |
status-list | The widget reads the credential's status list from the issuer's server by default (it already reads the issuer's did:web document to check the signature). status-list="off" turns it off. The SDK function verify() stays chain-only unless you pass statusList: true. |
Ask for a condition (attribute proofs)
With a condition in request, the widget asks the holder's app for zero-knowledge proofs: the holder shows that the condition holds for what an issuer recorded, without showing the value. The flow:
- The widget asks the trust registry which circuits are registered and refuses any condition none backs.
- It makes a nonce (16 random bytes from
crypto.getRandomValues) and shows a request as a QR code:swzk:1:+ base64url(JSON{ v: 1, nonce, predicates: [{ circuit, params }], verifier, registry, chainId, relay }). The QR is drawn in the page; nothing is fetched to draw it. - The holder's app proves each condition on the phone and answers with
swzkp:1:+ base64url(JSON{ v: 1, nonce, credentialId, issuer, proofs: [{ circuit, proof, publicSignals, currency? }] };currencyis an optional, unproven label next to an income proof and decides nothing). A response names one credential, and the app answers every condition from one credential: the holder's KYC attribute attestation, which also carries the credit tier (creditTier, committed under credit-tier-v1), sotier>=Ncomes from the same credential as the other conditions. The app sends its answer to the request'srelay; without one, the holder shows it for you to paste or scan (an answer in several parts can be pasted together, one per line). - The widget reads the relay every 2 seconds (for 5 minutes, or until every condition is proven) and verifies everything received so far as one set, showing the result as answers arrive.
- Each response is checked on its own: the nonce is the one the widget issued (and every proof carries it as its last public input, so a proof made for another request fails the pairing check); each circuit is registered with the published key (vkHash); the verifier contract accepts each proof (
eth_call); each proof's commitment equalscommitment(credentialId, circuitId)on the registry for that response's credential; that credential is anchored and not revoked and its issuer active; and each proof's public inputs state exactly what was asked. Then the set is accepted only if every condition you asked for is proven by exactly one proof, and every response names the same credential: a condition no response proves, one proven twice, or answers from more than one credential make the result invalid. The result lists each condition with the answer and credential that proved it. - Why one credential: a proof shows that the holder of some credential knows the value behind its commitment and that it was made for your nonce. It does not say who that holder is, and anyone who saw the request QR can answer it. Proofs from two credentials therefore cannot be shown to come from one person, so the widget refuses such a set as one answer (code
multiple_credentials_unbound) even when each proof is sound, and says so under the conditions.
The relay
A QR code holds about one proof, so the answer usually travels through a relay instead of back through the camera. The relay only carries text; it checks nothing, and the verification still happens in your visitor's browser against the chain.
The first poster owns the nonce. The first successful POST for a nonce is answered with a relay token, and every later POST for that nonce must carry it (X-Relay-Token), or the relay refuses it with 403. So a bystander who saw the request QR cannot add a proof to the holder's answer. The relay does not decide who answers, though: a bystander who posts before the holder owns the slot, the holder's app is refused, and what you read is the bystander's answer from the bystander's own credential. If the holder says their app could not send its answer, press "New request".
| Call | What it does |
|---|---|
POST <relay-base><nonce> | Body: one swzkp:1: response, Content-Type: text/plain, at most 64 KB. The first POST for a nonce answers 201 {"relayToken": "<hex>"}; a later POST for that nonce must send header X-Relay-Token: <that token>, else 403. Up to 4 responses are kept per nonce, for 5 minutes. |
GET <relay-base><nonce> | Answers 200 {"responses": ["swzkp:1:...", ...]}, possibly an empty list. Reading deletes nothing; responses expire. |
The nonce is decimal digits (at most 78). Both calls are open to any origin (CORS), so your page can read the relay directly. To run your own relay, implement these two calls and set relay-base.
| Condition | Circuit | Public inputs must say |
|---|---|---|
age>=N | age-over-v1 | birth year at most (this year) - N - 1: everyone accepted is N or older on every day of this year |
nationality-in:CODES | nationality-in-v1 | the Merkle root of your list of ISO 3166-1 numeric codes (rebuilt here from your list) |
nationality-not-in:CODES | nationality-not-in-v1 | the sorted-leaf root of your list. The list is yours; Super Wallet does not supply one |
identity-verified | identity-verified-v1 | expected = 1 (identity confirmed by an institution, evidence L2 or above) |
asset-verified | asset-verified-v1 | expected = 1 (assets confirmed, evidence L2 or above; not an accredited-investor determination) |
tier>=N | credit-tier-v1 | threshold between N and 4. While credit-tier-v1 is not registered, tier-threshold-v1 is used instead, with a visible note: its proofs carry no nonce and could be replayed |
The six circuits' keys come from a ceremony on 2026-09-27: the public PSE Powers of Tau (phase 1), one Super Wallet contribution (phase 2), then the hash of Xphere block 49677068 as a public beacon. One contributor means the keys are sound only if Super Wallet destroyed its randomness; each circuit's page has the full transcript and how an institution can add a contribution (a new circuit id, -v2). All six are registered in the trust registry with these keys (the widget asks the registry each time and refuses a condition whose circuit is missing or registered with another key).
Conditions for lenders
What a lender usually asks, each answered yes or no: the value itself (the score, the income, the number of late loans) is never sent. Only values whose source was verified or observed are provable; the income and debts a person types into an application are not, and reach a lender only as self-declared figures. Every condition comes from the same KYC attribute attestation as the ones above, so a request may mix them freely.
| Condition | Circuit | Public inputs must say | What it rests on |
|---|---|---|---|
score>=N (0..850) | score-over-v1 | threshold between N and 850 | The latest HACS score. Each level has its own scorecard and every level's table reaches 850, so a score says how reliable a person is on their level: ask for level>=N with it. |
level>=N (0..3 or NONE, FOUNDATIONAL, INCOME, FINANCIAL) | level-over-v1 | threshold between N and 3 | Badges an institution confirmed (evidence L2 or above): 1 identity and residence, 2 plus employment, 3 plus assets. |
income>=AMOUNT:CUR | income-over-<cur>-v1: krw, lak, vnd, thb, kgs, usd | threshold between AMOUNT x 100 and 248 - 1 (the circuit compares minor units) | The average monthly inflow observed on a connected bank account (evidence L3). AMOUNT is a whole number of major units; CUR is KRW, LAK, VND, THB, KGS or USD (any other is refused). The currency is bound by the circuit id: the commitment covers the amount only, and the issuer commits an income only under its own currency's circuit, so a LAK income presented as income-over-krw-v1 finds no commitment (no_commitment). All six share the key of income-over-v1, which itself is never registered. |
adverse-loans<=N (0..255) | adverse-loans-max-v1 | max at most N (the range is [0, max]) | Late loans (any days past due: repaid late, still late, defaulted or written off) in the last 12 months, counted as the limit step-up counts them, from Super Wallet's own loan ledger. Not confirmed by the lenders or a credit bureau. |
max-dpd<=N (days, 0..65535) | max-dpd-max-v1 | max at most N | The longest delinquency in the last 12 months, same ledger, same caveat. |
repaid-loans>=N (0..255) | repaid-loans-over-v1 | threshold between N and 255 | Loans repaid with no day late, from the same ledger. |
pd<=Nbp or pd<=N% (0..10000 bp) | pd-max-v1 | max at most N basis points | The model's estimated probability of default. Issued only while the model is calibrated; a holder without a calibrated estimate cannot answer. |
employment-verified | employment-verified-v1 | expected = 1 | The employment_verified badge at L2 or above. No occupation or industry exists in the credential. |
residence-verified | residence-verified-v1 | expected = 1 | The residence_attested badge at L2 or above. |
business-verified | business-verified-v1 | expected = 1 | The business_registered badge at L2 or above. |
The seven new keys come from a second ceremony on 2026-09-27, made the same way: the public PSE Powers of Tau truncated to 29 (the smallest file every one of them fits), one Super Wallet contribution, then the hash of Xphere block 49689564 as the beacon. The three -verified conditions above have no key of their own: they are identity-verified-v1's circuit, key and verifier contract registered under their own circuit ids, and the commitment the issuer records under each id is what makes a proof speak about employment, residence or a business. None of these ten is registered in the trust registry yet: until it is, the widget refuses the condition.
Try it
The first sample is the registry id of a production credential: only its chain record can be checked, because no document is shown. The second is a real proof accepted by the verifier contract, but it names no credential, so its commitment cannot be tied to an issuer and the result is "could not be fully verified".
Last sw-verify-result event
(none yet)
What is checked, and where the answer comes from
| Check | Source | How |
|---|---|---|
| Issuer signature | local, key from the issuer's did:web document | ecdsa-jcs-2019 Data Integrity proof, or ES256 for SD-JWT / VC-JWT |
| Issuer registered and active | chain | issuer(keccak256(issuer DID)) |
| Signing key registered | chain | RFC 7638 thumbprint of the key that verified the signature = keyFingerprint |
| Anchored, unchanged | chain | credential(keccak256(vc.id)).digest = sha256(JCS(credential without proof)) |
| Not revoked | chain | credential(...).revokedAt = 0 |
| Status list | issuer server (read by the widget by default; opt-in in verify()) | Bitstring Status List bit |
| ZK proof | chain | circuit(circuitId) gives the verifier contract; verifyProof by eth_call; commitment(credentialId, circuitId) = the proof's commitment |
| Answers this request | local | the response's nonce and every proof's last public input equal the nonce the widget issued |
| Meets the condition | local | public inputs = the request's parameters (age bound, Merkle root of your list, expected = 1, tier threshold) |
Every check is pass, fail, unavailable or not applicable. "Unavailable" (node unreachable, key not fetchable, a copy that is not the anchored document) is never counted as a pass or as a failure: the verdict is then "could not be fully verified".
From code
npm i @superwallet/verifier-sdk
const { verify } = require("@superwallet/verifier-sdk");
const report = await verify(scannedText, { request: "tier>=2" });
console.log(report.verdict); // "valid" | "invalid" | "incomplete"
for (const c of report.checks) console.log(c.id, c.status, c.source, c.detail);
// A condition request of your own: make the request, show it, verify the answer against it.
const { parseRequest, availablePredicates, planPredicates, requestPredicates, randomNonce, encodeZkRequest } = require("@superwallet/verifier-sdk");
const plan = planPredicates(parseRequest("age>=19,nationality-not-in:408,364").predicates, await availablePredicates());
const zkRequest = encodeZkRequest({ v: 1, nonce: randomNonce(), predicates: requestPredicates(plan), verifier: "Example shop" });
// ... show zkRequest as a QR, receive the app's swzkp:1: answer ...
const answer = await verify(swzkpText, { zkRequest });
// An answer read from the relay (or pasted in several parts) is verified as one set; it must come
// from one credential, or the verdict is invalid (multiple_credentials_unbound).
const { verifyZkResponses, readZkRelay, zkRelayUrl } = require("@superwallet/verifier-sdk");
// with relay: zkRelayUrl(nonce) in the request; later, the responses the app posted there:
const responses = await readZkRelay(zkRelayUrl(nonce));
const set = await verifyZkResponses(responses, zkRequest); // same as verify(responses, { zkRequest })
for (const p of set.predicates) console.log(p.circuit, p.status, p.response, p.credentialId);
// status: "proven" | "failed" | "unverified" | "missing" | "duplicate"
// A holder's app posts with postZkRelay(url, response, token?): the first POST returns the relay
// token, and a later POST for the same nonce sends it (X-Relay-Token).
Each check also carries message (an English template with {0} placeholders and its values) so a user interface can show it in another language; the widget does this for Korean.
The 2.0 package is not on the npm registry yet; until it is, install it from the repository folder sw-verifier-sdk. In a browser page, the script above also defines window.SuperWalletVerify.verify.
Check the checker
- Trust registry: source, ABI, the exact compiler input, and a script that compares it with the deployed code (explorer).
- tier-threshold-v1 circuit: circuit source, verification key and its vkHash, the verifier contract and how to rebuild it (explorer).
- Condition circuits, each with source, proving and verification keys, Verifier.sol, the exact compiler input and the ceremony transcript: age-over-v1, nationality-in-v1, nationality-not-in-v1, identity-verified-v1, asset-verified-v1, credit-tier-v1; for lenders score-over-v1, level-over-v1, income-over-v1 (key only; asked for as income-over-krw-v1, -lak-, -vnd-, -thb-, -kgs-, -usd-v1), adverse-loans-max-v1, max-dpd-max-v1, repaid-loans-over-v1, pd-max-v1, and the aliases employment-verified-v1, residence-verified-v1, business-verified-v1.
- What the platform issuer's key is: did:web:trendy.storydot.kr,
#es256-1.
Limits
- A QR from the app that only points to a presentation (
presentationUid) carries nothing the chain can check; verify it with the SDK's challenge flow (SuperWalletVerifier.verify). - A presentation from the app today embeds a re-serialised copy of each credential, not the anchored document, so the "unchanged" check reports "not comparable" for it.
- The holder's own signature on a presentation is not verified here.
- The tier-threshold-v1 proving key was generated by a single party (1 ceremony contribution, as recorded on chain), and its proofs carry no nonce.
- The condition circuits have one phase-2 contributor (Super Wallet) plus a public beacon; the registry will record 1 contribution for each.
- The income currency is not inside the commitment; it is bound by the circuit id (one income circuit per currency, the issuer commits under its currency's only). A currency outside KRW, LAK, VND, THB, KGS and USD cannot be asked. Delinquency and repayment counts come from Super Wallet's own loan ledger, not from lenders or a credit bureau. A score is on its level's own scale.
- The nonce proves a response was made for your request, not who is holding the phone: the app answers for the holder whose attestation it holds.
- Anyone who has seen the request QR knows its nonce and can answer it from their own credential. The relay keeps a nonce for its first poster only, and answers from more than one credential are refused as one answer, so a bystander cannot complete or change the holder's answer. A bystander who answers first, from their own credential, is answering about themselves; if the holder's app says the answer could not be sent, press "New request" for a fresh nonce.