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 */ }
});
AttributeMeaning
requestcredential, 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.
relayFor 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-baseWhere 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-nameThe name the holder's app shows for you in a condition request. Default: this page's host.
langko or en.
rpcJSON-RPC endpoint. Default https://en-hkg.x-phere.com.
registryTrust registry address. Default 0x852688D815410e3FEa015FD3a07028BF6b981d80.
allow-issuer-serverOff 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-listThe 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:

  1. The widget asks the trust registry which circuits are registered and refuses any condition none backs.
  2. 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.
  3. 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? }] }; currency is 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), so tier>=N comes from the same credential as the other conditions. The app sends its answer to the request's relay; without one, the holder shows it for you to paste or scan (an answer in several parts can be pasted together, one per line).
  4. 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.
  5. 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 equals commitment(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.
  6. 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".

CallWhat 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.

ConditionCircuitPublic inputs must say
age>=Nage-over-v1birth year at most (this year) - N - 1: everyone accepted is N or older on every day of this year
nationality-in:CODESnationality-in-v1the Merkle root of your list of ISO 3166-1 numeric codes (rebuilt here from your list)
nationality-not-in:CODESnationality-not-in-v1the sorted-leaf root of your list. The list is yours; Super Wallet does not supply one
identity-verifiedidentity-verified-v1expected = 1 (identity confirmed by an institution, evidence L2 or above)
asset-verifiedasset-verified-v1expected = 1 (assets confirmed, evidence L2 or above; not an accredited-investor determination)
tier>=Ncredit-tier-v1threshold 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.

ConditionCircuitPublic inputs must sayWhat it rests on
score>=N (0..850)score-over-v1threshold between N and 850The 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-v1threshold between N and 3Badges an institution confirmed (evidence L2 or above): 1 identity and residence, 2 plus employment, 3 plus assets.
income>=AMOUNT:CURincome-over-<cur>-v1: krw, lak, vnd, thb, kgs, usdthreshold 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-v1max 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-v1max at most NThe longest delinquency in the last 12 months, same ledger, same caveat.
repaid-loans>=N (0..255)repaid-loans-over-v1threshold between N and 255Loans repaid with no day late, from the same ledger.
pd<=Nbp or pd<=N% (0..10000 bp)pd-max-v1max at most N basis pointsThe model's estimated probability of default. Issued only while the model is calibrated; a holder without a calibrated estimate cannot answer.
employment-verifiedemployment-verified-v1expected = 1The employment_verified badge at L2 or above. No occupation or industry exists in the credential.
residence-verifiedresidence-verified-v1expected = 1The residence_attested badge at L2 or above.
business-verifiedbusiness-verified-v1expected = 1The 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

CheckSourceHow
Issuer signaturelocal, key from the issuer's did:web documentecdsa-jcs-2019 Data Integrity proof, or ES256 for SD-JWT / VC-JWT
Issuer registered and activechainissuer(keccak256(issuer DID))
Signing key registeredchainRFC 7638 thumbprint of the key that verified the signature = keyFingerprint
Anchored, unchangedchaincredential(keccak256(vc.id)).digest = sha256(JCS(credential without proof))
Not revokedchaincredential(...).revokedAt = 0
Status listissuer server (read by the widget by default; opt-in in verify())Bitstring Status List bit
ZK proofchaincircuit(circuitId) gives the verifier contract; verifyProof by eth_call; commitment(credentialId, circuitId) = the proof's commitment
Answers this requestlocalthe response's nonce and every proof's last public input equal the nonce the widget issued
Meets the conditionlocalpublic 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

Limits