@erc8426/client
v0.4.0
Published
Discover, resolve and acquire ERC-8426 wallet passes from any compliant token
Maintainers
Readme
@erc8426/client
Discover, resolve and acquire ERC-8426 wallet passes from any compliant token. For marketplaces, wallet apps, indexers and anyone who wants an "Add to Apple Wallet" or "Save to Google Wallet" action on a token they did not issue.
npm install @erc8426/client @erc8426/core viemWhat it does for you
- Discovery.
supportsWalletPassruns the full ERC-165 detection procedure for0xef5f1e71and answersfalse(never throws) for contracts without ERC-165. - Both configurations. A manifest served without a proof is the public configuration. A
401 proof_requiredis the gated configuration: the client fetches the acquire challenge, has your signer sign it, and retries with theWallet-Pass-ProofandWallet-Pass-Signatureheaders (and the pre-0.3.0X-names beside them, so a 0.2.x issuer still answers). - Protection for the signer. Before a challenge reaches the signer, the client checks it is scoped to what the user asked for. The challenge must be served from the same origin as the endpoint the proof will be sent to, and its SIWE domain must be exactly that endpoint's host (including a non-default port), so a signature meant for one verifier is never handed to another. The address must be the signer, the chain id the token's chain, and the resources exactly two in order: the CAIP-19 token, then the action URN (an extra resource such as a ReCap is refused). The expiration must be in the future and not too far away. The statement line is free text and is not relied on. Anything else is refused with
source: "client"and nothing is signed. - Safe URLs. Everything the client fetches came from a contract or an issuer, so it fetches only https (or http on a loopback host), and it never returns an acquisition URL for navigation unless it is https (or http on localhost).
javascript:,data:and non-localhttp:are refused. - No caching. The spec says clients MUST NOT durably cache acquisition URLs and SHOULD fetch the manifest at the moment of the add-to-wallet action. Every call re-reads
passURIand refetches the manifest. - Typed errors.
403is alwaysnot_owner(the spec reserves it for a verified proof from an account that is not entitled).503isread_failedwithretryable: trueandretryAfterSecondsfromRetry-After. Other refusals keep the issuer'serrorcode when it is a core code; an issuer code outside the core set (an integrator's cooldown,invalid_params) becomesaction_refusedfor a 4xx orserver_errorfor a 5xx, with the issuer's string inserverCode.networkmeans only a fetch that failed or timed out. - Typed chain reads. A
passURIortokenURIrevert (a token that does not exist or was burned) isunknown_capabilitywithsource: "chain"; an address with no contract isunsupported; an RPC that could not answer isnetworkwithretryable: true. No raw viem error escapes. - Freshness events.
PassUpdateandBatchPassUpdate(inclusive range) come through one callback shape.
Quick start
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";
import { createWalletPassClient } from "@erc8426/client";
const passes = createWalletPassClient({
publicClient: createPublicClient({ chain: mainnet, transport: http() }),
});
const token = { contract: "0x5F9B5a1cdED9d6B3f5E8a2C47B0e13d6A8F4c2e1", tokenId: 412n };
if (await passes.supportsWalletPass(token.contract)) {
const { manifest, configuration } = await passes.getManifest(token);
console.log(configuration, Object.keys(manifest.formats));
}Marketplace "Add to Wallet" button
Fetch on click, not on page load, and show the issuing contract next to the button (Client requirements: clients SHOULD present the issuing contract address alongside the action).
import { fromWalletClient, originMatches, WalletPassClientError } from "@erc8426/client";
async function onAddToWalletClick() {
try {
const { url } = await passes.addToWallet(token, {
// Only needed for gated passes. A viem LocalAccount works as is.
signer: walletClient ? fromWalletClient(walletClient) : undefined,
});
window.location.assign(url); // .pkpass hands off to Wallet; a Google link opens the save flow
} catch (e) {
if (e instanceof WalletPassClientError) {
if (e.code === "proof_required") return promptConnectWallet();
if (e.code === "not_owner") return showMessage("This wallet does not own the token.");
if (e.retryable) return showMessage(`Try again in ${e.retryAfterSeconds ?? 5}s.`);
}
throw e;
}
}
const issuer = await passes.issuerDisplay(token);
// issuer.contractShort -> "0x5F9B...c2e1", issuer.origin -> "https://issuer.example"addToWallet picks the platform from navigator.userAgent (iPhone, iPad and macOS Safari get Apple; Android gets Google; anything else gets Google, whose save link works in any browser, then Apple). Force one with platform: "apple". For React, use @erc8426/react.
Wallet app discovery
A wallet that shows a user's NFTs can surface passes for all of them, and keep them fresh:
for (const nft of ownedNfts) {
if (!(await passes.supportsWalletPass(nft.contract))) continue;
const display = await passes.issuerDisplay(nft);
const trusted = originMatches(display.passUri, collectionWebsites[nft.contract] ?? []);
showPassAction(nft, { issuer: display.contractShort, origin: display.origin, trusted });
}
// Regenerate or refresh when content changes. Omit the contract to watch every collection.
const unwatch = passes.watchPassUpdates(undefined, (u) => {
if (Array.isArray(u.tokenIds)) refresh(u.contract, u.tokenIds);
else refreshRange(u.contract, u.tokenIds.from, u.tokenIds.to); // inclusive
});
// Backfill after downtime.
const missed = await passes.getPassUpdates({ fromBlock: lastSeenBlock, toBlock: "latest" });The wallet_pass metadata mirror is available through readMetadataMirror(token). It is marked authoritative: false: when both exist the manifest behind passURI wins, and a gated issuer must not mirror URLs at all.
Checking who signs the passes
A manifest may carry an issuer member naming the platform signer of its passes (manifest.issuer.apple.teamIdentifier and passTypeIdentifier, manifest.issuer.google.issuerId), and a contract may commit to the same object on chain through passIssuer(). With verifyIssuer: true (per call, or as the client's default) the client reads the commitment and refuses the manifest (invalid_manifest, source: "client") unless its issuer equals it member for member; a contract that makes no commitment passes, and the result's issuerCommitment says which case it was.
const { manifest, issuerCommitment } = await passes.getManifest(token, { verifyIssuer: true });This compares the contract with the manifest. Comparing either with the signer identifiers inside the pass file a device receives is platform-specific and is not done by this package. The check costs four contract reads per manifest, which is why it is off unless asked for.
Signed actions
For pass-reachable actions on the signed path (the user signs each action), signedAction requests a challenge for the action, checks its scope, signs it, and POSTs { message, signature, params, chainId, contract, tokenId, action }:
const result = await passes.signedAction({
token,
action: "feed",
signer: fromWalletClient(walletClient),
params: { amount: 1 },
});Route conventions (the spec standardizes the proof, not the routes, so both are overridable):
- Challenge: the URL named in the manifest's
401body, with?address=0x...&action=feedadded. If the manifest is public (no 401),{passBase}/challenge. Override withchallengeEndpoint. - Action:
{passBase}/actions/{action}, wherepassBaseis the resolvedpassURIwithout query or trailing slash. Override withendpoint.
These match the @erc8426/issuer routes. Query parameters are always added with URLSearchParams, so a challenge URL that already carries a query (for example .../challenge?action=rotate) keeps working.
Rotating links
rotatePassLinks(token, { signer }) asks the issuer to rotate every acquisition URL and capability link for the token, the owner's remedy for a leaked link (issuers MUST offer it in the capability configuration). It signs a rotate challenge, which cannot acquire or act, and POSTs it to {passBase}/rotate.
requestChallenge(token, action, account) is the lower-level step on its own, returning the scope-checked message and its parsed fields.
Issuer notes (CORS)
The first manifest request is a plain GET, so it needs no preflight. The gated retry carries Wallet-Pass-Proof and Wallet-Pass-Signature (plus the X- names, which are still sent), so a browser client needs the issuer to answer the preflight with Access-Control-Allow-Headers naming them (PROOF_HEADER_NAMES from @erc8426/core is the list). An @erc8426/issuer sends no CORS headers unless configured (cors: { origins: [...] } or an explicit "*"), so a browser client on another origin needs the issuer to name it.
API
createWalletPassClient(options):
| option | |
| - | - |
| publicClient | viem client for the token's chain (required) |
| fetch | fetch implementation, default global fetch |
| ipfsGateway, arweaveGateway | gateways for ipfs:// and ar://, default https://ipfs.io and https://arweave.net |
| maxChallengeTtlSeconds | longest challenge lifetime the client will sign, default 3600 |
| trustedChallengeDomains | extra SIWE domains to accept, for development only |
| allowCrossOriginChallenge | follow a challenge URL on another origin than the proof's endpoint, for development only |
| allowInsecureHttp | fetch plain http from non-loopback hosts, for server-side development only |
| verifyIssuer | default for the verifyIssuer option of getManifest, getAcquisitionUrl and addToWallet; off unless set |
Returns:
| method | |
| - | - |
| supportsWalletPass(contract) | Promise<boolean> |
| getIssuerCommitment(contract) | Promise<PassIssuerIdentity \| null>: the identity the contract commits to through passIssuer() (ERC-165 id 0x470be7e9), or null when it makes no commitment |
| getPassURI(token) | Promise<string> raw passURI |
| resolvePassURI(uri) | gateway-rewritten URL (http, https, data pass through) |
| getManifest(token, { signer?, signal?, verifyIssuer? }) | Promise<{ manifest, configuration, passUri, url, issues, issuerCommitment? }> |
| getAcquisitionUrl(token, platform, { signer? }) | Promise<string> |
| addToWallet(token, { signer?, platform?, userAgent? }) | Promise<{ url, platform, configuration, manifest }> |
| readMetadataMirror(token) | Promise<{ authoritative: false, tokenUri, result }> |
| requestChallenge(token, action, account, { endpoint?, presentTo? }) | Promise<{ message, parsed, challengeUrl }> |
| signedAction({ token, action, signer, endpoint?, challengeEndpoint?, params? }) | Promise<{ status, body }> |
| rotatePassLinks(token, { signer, endpoint?, challengeEndpoint? }) | Promise<{ status, body }> |
| issuerDisplay(token) | Promise<{ chainId, contract, contractShort, tokenId, passUri, origin }> |
| watchPassUpdates(contract \| undefined, onUpdate, opts?) | unwatch function |
| getPassUpdates({ contract?, fromBlock?, toBlock? }) | Promise<PassUpdateNotice[]> |
Standalone helpers: detectPlatform(ua), choosePlatform(available, detected), resolveUri(uri, gateways), decodeDataUri(uri), uriOrigin(uri), originMatches(passUri, expectedOrigins), passBase(url), shortAddress(address), fromWalletClient(walletClient, account?), checkChallengeScope(message, expected), domainsForUrl(url), passUpdateCovers(update, tokenId), normalizePassUpdateLog(log), errorFromResponse(status, headers, body), errorFromChainRead(error, what), parseRetryAfter(value), isAllowedUrl(url, { allowInsecureHttp? }), isSafeNavigationUrl(url), isLoopbackHost(hostname), and the WalletPassClientError class (a WalletPassError with source (server, chain or client), retryable, retryAfterSeconds, challenge, serverCode, body; an issuer code outside the core set becomes action_refused (4xx) or server_error (5xx) and is kept verbatim in serverCode; network means only a failed or timed out fetch).
License
MIT
