@chaindaddy/js 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.d.ts CHANGED
@@ -27,6 +27,50 @@ export interface paths {
27
27
  patch?: never;
28
28
  trace?: never;
29
29
  };
30
+ "/api/v2/public/signed/coins/{chain}/{symbol}": {
31
+ parameters: {
32
+ query?: never;
33
+ header?: never;
34
+ path?: never;
35
+ cookie?: never;
36
+ };
37
+ /**
38
+ * Get a coin's registration, signed
39
+ * @description **Not available yet.** It answers 404 until Chain Daddy turns it on; the record on
40
+ * `chaindaddy.eth` and `verifyAnswer` arrive with it.
41
+ *
42
+ * Whether a ticker is registered on a chain and which wallet holds the registration, signed by
43
+ * Chain Daddy so you can check it wherever you got it from: a cache, a gateway, or another site's
44
+ * server. `jws` is the signed answer (a compact JWS); `claims` repeats what it says so you can read
45
+ * it, but trust only what you get out of `jws` after verifying it.
46
+ *
47
+ * `status` is `registered` (the ticker has a verified page on that chain), `not_registered` (it
48
+ * has none) or `removed` (it has one and Chain Daddy took it down). All three answer 200 and are
49
+ * signed, so nobody between you and Chain Daddy can make up a missing registration. Only
50
+ * `registered` carries `tokenAddress`, `crownId`, `holder`, `pageUrl` and, when known,
51
+ * `registeredAt`.
52
+ *
53
+ * To verify: split `jws` at the dots; the first part is the header (`alg` must be `EdDSA`, and
54
+ * `kid` names the key), the second the claims. Check the Ed25519 signature over the first two
55
+ * parts against the key with that `kid`, then check `typ` is `cd-answer+v1`, `iss` is
56
+ * `https://chaindaddy.io` and `exp` has not passed. `verifyAnswer` in `@chaindaddy/js/verify` does
57
+ * all of it. The keys are at `GET /api/v2/iap/jwks` (the keys receipts are signed with). They are
58
+ * also named on `chaindaddy.eth`, in the text record `io.chaindaddy.signing-keys`: `ed25519:<x>`
59
+ * entries separated by a space, the current key first, where `<x>` is the key's `x` in the key
60
+ * set. Read the record yourself and you do not depend on this API for the keys either.
61
+ *
62
+ * An answer is valid for 15 minutes (`exp`), and caches keep it for less than that. Keyless, the
63
+ * same for every caller.
64
+ */
65
+ get: operations["getPublicSignedCoin"];
66
+ put?: never;
67
+ post?: never;
68
+ delete?: never;
69
+ options?: never;
70
+ head?: never;
71
+ patch?: never;
72
+ trace?: never;
73
+ };
30
74
  "/api/v2/public/coins/{chain}/{symbol}/apps": {
31
75
  parameters: {
32
76
  query?: never;
@@ -138,7 +182,8 @@ export interface paths {
138
182
  /**
139
183
  * Receipt signing keys (JWKS)
140
184
  * @description The Ed25519 public keys receipts are signed with (the current key and the previous one), as a JSON Web Key Set.
141
- * Match a receipt's `kid` header to a key and verify its EdDSA signature offline.
185
+ * Match a receipt's `kid` header to a key and verify its EdDSA signature offline. Signed answers
186
+ * (`GET /api/v2/public/signed/coins/{chain}/{symbol}`) are signed with the same keys.
142
187
  */
143
188
  get: operations["getIapJwks"];
144
189
  put?: never;
@@ -1496,6 +1541,73 @@ export interface components {
1496
1541
  /** @description Machine-readable error code */
1497
1542
  code?: string;
1498
1543
  };
1544
+ /** @description A signed answer and what it says. Trust `claims` only as read out of `jws` after verifying it. */
1545
+ PublicSignedAnswer: {
1546
+ /** @description The signed answer: a compact JWS (header, claims and signature, base64url, joined by dots), signed with Ed25519 (`alg` `EdDSA`) by the key its header's `kid` names */
1547
+ jws: string;
1548
+ claims: components["schemas"]["SignedAnswerClaims"];
1549
+ };
1550
+ /** @description What a signed answer says. The same object is the payload of `jws`. */
1551
+ SignedAnswerClaims: {
1552
+ /**
1553
+ * @description Who signed it. Always `https://chaindaddy.io`.
1554
+ * @example https://chaindaddy.io
1555
+ */
1556
+ iss: string;
1557
+ /**
1558
+ * @description What kind of signed document this is. Always `cd-answer+v1`; a store receipt, signed by the same keys, is `iap-receipt+v1`.
1559
+ * @enum {string}
1560
+ */
1561
+ typ: "cd-answer+v1";
1562
+ /**
1563
+ * Format: int64
1564
+ * @description When it was signed, in Unix seconds
1565
+ */
1566
+ iat: number;
1567
+ /**
1568
+ * Format: int64
1569
+ * @description When it stops being valid, in Unix seconds, 15 minutes after `iat`. Do not act on it after this.
1570
+ */
1571
+ exp: number;
1572
+ /**
1573
+ * @description What the answer is about. `registration` is a ticker's registration on a chain.
1574
+ * @enum {string}
1575
+ */
1576
+ kind: "registration";
1577
+ /**
1578
+ * @description CAIP-2 chain ID
1579
+ * @example eip155:8453
1580
+ */
1581
+ chainId: string;
1582
+ /**
1583
+ * @description Chain key
1584
+ * @example base
1585
+ */
1586
+ chain: string;
1587
+ /** @description The ticker: as registered when `status` is `registered`, otherwise the one asked about, in upper case. Tickers match in any case, so compare without regard to case. */
1588
+ symbol: string;
1589
+ /**
1590
+ * @description `registered`: the ticker has a verified page on the chain. `not_registered`: it has none. `removed`: it has one, and Chain Daddy took it down.
1591
+ * @enum {string}
1592
+ */
1593
+ status: "registered" | "not_registered" | "removed";
1594
+ /** @description The registered token's address (lowercase on EVM chains). Only when `status` is `registered`. */
1595
+ tokenAddress?: string;
1596
+ /**
1597
+ * Format: int64
1598
+ * @description The registration's ID on that chain. Only when `status` is `registered`.
1599
+ */
1600
+ crownId?: number;
1601
+ /** @description The wallet that holds the registration (lowercase on EVM chains). Only when `status` is `registered`. */
1602
+ holder?: string;
1603
+ /**
1604
+ * Format: date-time
1605
+ * @description When the registration was made. Only when `status` is `registered`, and left out when not known.
1606
+ */
1607
+ registeredAt?: string;
1608
+ /** @description The coin's page on chaindaddy.io. Only when `status` is `registered`. */
1609
+ pageUrl?: string;
1610
+ };
1499
1611
  IapError: {
1500
1612
  /** @description Human-readable description */
1501
1613
  error: string;
@@ -3147,6 +3259,96 @@ export interface operations {
3147
3259
  };
3148
3260
  };
3149
3261
  };
3262
+ getPublicSignedCoin: {
3263
+ parameters: {
3264
+ query?: never;
3265
+ header?: never;
3266
+ path: {
3267
+ /** @description Chain key, lowercase, as in the page's own address (e.g. `base`, `solana`). A numeric chain ID answers 404. */
3268
+ chain: string;
3269
+ /** @description The coin's ticker (letters and digits, any case) */
3270
+ symbol: string;
3271
+ };
3272
+ cookie?: never;
3273
+ };
3274
+ requestBody?: never;
3275
+ responses: {
3276
+ /** @description The signed answer, whatever the status */
3277
+ 200: {
3278
+ headers: {
3279
+ [name: string]: unknown;
3280
+ };
3281
+ content: {
3282
+ "application/json": components["schemas"]["PublicSignedAnswer"];
3283
+ };
3284
+ };
3285
+ /** @description The request came with the user agent of an AI or LLM crawler, and no cached answer was there to serve instead. Never sent to a browser or to a server using its own user agent. */
3286
+ 403: {
3287
+ headers: {
3288
+ [name: string]: unknown;
3289
+ };
3290
+ content: {
3291
+ /**
3292
+ * @example {
3293
+ * "error": "ai_bot_blocked",
3294
+ * "message": "AI/LLM scraping is not permitted. Use our API with a developer-tier API key for programmatic access."
3295
+ * }
3296
+ */
3297
+ "application/json": components["schemas"]["Error"];
3298
+ };
3299
+ };
3300
+ /** @description An unknown chain, or a ticker that is not letters and digits. Not signed; there is nothing to attest. */
3301
+ 404: {
3302
+ headers: {
3303
+ [name: string]: unknown;
3304
+ };
3305
+ content: {
3306
+ /**
3307
+ * @example {
3308
+ * "error": "not_found",
3309
+ * "code": "not_found",
3310
+ * "message": "Not found."
3311
+ * }
3312
+ */
3313
+ "application/json": components["schemas"]["Error"];
3314
+ };
3315
+ };
3316
+ /** @description More than 400 requests in a minute from one IP address, across the whole API. Never cached. */
3317
+ 429: {
3318
+ headers: {
3319
+ /** @description Seconds to wait before trying again */
3320
+ "Retry-After"?: number;
3321
+ [name: string]: unknown;
3322
+ };
3323
+ content: {
3324
+ /**
3325
+ * @example {
3326
+ * "error": "Too many requests. Please try again later.",
3327
+ * "code": "rate_limit_exceeded",
3328
+ * "message": "Too many requests. Please try again later."
3329
+ * }
3330
+ */
3331
+ "application/json": components["schemas"]["Error"];
3332
+ };
3333
+ };
3334
+ /** @description The answer could not be read or signed right now. Never cached, never signed; try again. */
3335
+ 503: {
3336
+ headers: {
3337
+ [name: string]: unknown;
3338
+ };
3339
+ content: {
3340
+ /**
3341
+ * @example {
3342
+ * "error": "unavailable",
3343
+ * "code": "unavailable",
3344
+ * "message": "Unavailable right now. Try again."
3345
+ * }
3346
+ */
3347
+ "application/json": components["schemas"]["Error"];
3348
+ };
3349
+ };
3350
+ };
3351
+ };
3150
3352
  listPublicCoinApps: {
3151
3353
  parameters: {
3152
3354
  query?: never;
@@ -5231,7 +5433,8 @@ export interface operations {
5231
5433
  };
5232
5434
  /**
5233
5435
  * @description `STORE_KEY_REQUIRED`: called with a signed-in session instead of a store key. `SCOPE_REQUIRED`: the key
5234
- * lacks `orders:write`. `STORE_NOT_LIVE`: the store is paused or its plan lapsed. `SELLER_TERMS_REQUIRED`:
5436
+ * lacks `orders:write`. `STORE_NOT_LIVE`: the store is paused or its plan lapsed, or (`reason`
5437
+ * `owner_changed`) its page has a new owner who has not opened it yet. `SELLER_TERMS_REQUIRED`:
5235
5438
  * the wallet that opened the store has not signed the Seller Terms
5236
5439
  */
5237
5440
  403: {
@@ -5447,7 +5650,8 @@ export interface operations {
5447
5650
  /**
5448
5651
  * @description `STORE_KEY_REQUIRED`: called with a signed-in session instead of a store key. `SCOPE_REQUIRED`: the
5449
5652
  * key lacks `orders:write`. `CHECKOUT_SESSION_WALLET`: the session names another wallet.
5450
- * `IAP_PLAN_REQUIRED` or `STORE_NOT_LIVE`: the store's plan lapsed or the store is paused.
5653
+ * `IAP_PLAN_REQUIRED` or `STORE_NOT_LIVE`: the store's plan lapsed or the store is paused, or (`reason`
5654
+ * `owner_changed`) its page has a new owner who has not opened it yet.
5451
5655
  * `SESSION_REQUIRED`: the session's item belongs to an installed app
5452
5656
  */
5453
5657
  403: {
package/dist/cd.js CHANGED
@@ -1,4 +1,4 @@
1
- "use strict";(()=>{var R="0.3.4";function C(t){let e="";for(let i of t)e+=String.fromCharCode(i);return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function w(){return C(crypto.getRandomValues(new Uint8Array(32)))}function T(t,e,i,o,r){let u=n=>{if(n&&typeof n=="object"&&n.type===t)try{i(n)}catch{}},l=n=>{n.origin===e&&(!r||n.source===r)&&u(n.data)};window.addEventListener("message",l);let a=null;if(o&&typeof BroadcastChannel=="function"&&e===location.origin)try{a=new BroadcastChannel(o),a.onmessage=n=>u(n.data)}catch{a=null}return()=>{window.removeEventListener("message",l),a?.close()}}var y={get(t){try{return sessionStorage.getItem(t)}catch{return null}},set(t,e){try{sessionStorage.setItem(t,e)}catch{}},remove(t){try{sessionStorage.removeItem(t)}catch{}}};var F="https://api.chaindaddy.io",G={"https://api.chaindaddy.io":"https://chaindaddy.io/_oauth/authorize","https://staging-api.chaindaddy.io":"https://staging.chaindaddy.io/_oauth/authorize"},b="cd-signin",U="cd:signin",v="cd.signin",_=300;async function K(t){let e=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(t));return C(new Uint8Array(e))}function W(t="openid"){let e=t.split(/\s+/).filter(Boolean);return(e.includes("openid")?e:["openid",...e]).join(" ")}async function Y(t,e,i){let o=new URLSearchParams({response_type:"code",client_id:e.clientId,redirect_uri:e.redirectUri,scope:W(t.scope),state:e.state,nonce:e.nonce,code_challenge:await K(e.verifier),code_challenge_method:"S256"});return t.claims&&o.set("claims",typeof t.claims=="string"?t.claims:JSON.stringify(t.claims)),`${i}?${o}`}function A(t,e){return{code:e,codeVerifier:t.verifier,state:t.state,nonce:t.nonce,issuer:t.issuer,clientId:t.clientId,redirectUri:t.redirectUri}}var m;function k(t){let e=(t.issuer??F).replace(/\/$/,""),i=t.authorizeUrl??G[e];if(!i)return Promise.reject(new Error("authorizeUrl is required for this issuer"));if(!t.clientId||!t.redirectUri)return Promise.reject(new Error("clientId and redirectUri are required"));if(m&&!m.popup.closed)return m.popup.focus(),m.waiting;let o={state:w(),nonce:w(),verifier:w(),issuer:e,clientId:t.clientId,redirectUri:t.redirectUri};y.set(v,JSON.stringify(o));let r=window.open("",b,"popup,width=480,height=720"),u=(async()=>{let l=await Y(t,o,i);if(!r)return location.assign(l),null;r.location.href=l;let a=new URL(t.redirectUri,location.href).origin;return new Promise((n,c)=>{let s,d=()=>{g(),clearInterval(h),clearTimeout(s)},g=T(U,a,p=>{p.state===o.state&&(d(),y.remove(v),typeof p.error=="string"&&p.error?c(new Error(p.error)):p.iss!==e?c(new Error("the answer came from another issuer")):typeof p.code!="string"||!p.code?c(new Error("the answer carried no code")):n(A(o,p.code)))},b,r),h=setInterval(()=>{r.closed&&(clearInterval(h),s=setTimeout(()=>{d(),c(new Error("closed"))},_))},_);t.signal?.addEventListener("abort",()=>{d(),c(new Error("aborted"))})})})();if(r){m={popup:r,waiting:u};let l=()=>{m&&m.popup===r&&(m=void 0)};u.then(l,l)}return u}async function L(){let t=new URLSearchParams(location.search),e={type:U,code:t.get("code"),state:t.get("state"),iss:t.get("iss"),error:t.get("error")};if(window.name===b){try{window.opener?.postMessage(e,location.origin)}catch{}try{let r=new BroadcastChannel(b);r.postMessage(e),r.close()}catch{}return window.close(),null}let i=y.get(v);y.remove(v);let o=i?JSON.parse(i):null;if(!o||e.state!==o.state)throw new Error("the sign-in state does not match: start again");if(e.error)throw new Error(e.error);if(e.iss!==o.issuer)throw new Error("the answer came from another issuer");if(!e.code)throw new Error("the answer carried no code");return A(o,e.code)}async function M(t){let e=await fetch(`${t.issuer}/api/v2/oauth/token`,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:t.code,redirect_uri:t.redirectUri,client_id:t.clientId,code_verifier:t.codeVerifier})}),i=await e.json();if(!e.ok)throw new Error(`${i.error??e.status}: ${i.error_description??""}`.trim());return i}var J='<svg viewBox="0 0 24 24" fill="#FFD700" width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5z" stroke="#B8860B" stroke-width="0.5"/><path d="M5 18h14v2H5v-2z" stroke="#B8860B" stroke-width="0.5"/></svg>',X=`
1
+ "use strict";(()=>{var R="0.3.5";function C(t){let e="";for(let i of t)e+=String.fromCharCode(i);return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function w(){return C(crypto.getRandomValues(new Uint8Array(32)))}function T(t,e,i,o,r){let u=n=>{if(n&&typeof n=="object"&&n.type===t)try{i(n)}catch{}},l=n=>{n.origin===e&&(!r||n.source===r)&&u(n.data)};window.addEventListener("message",l);let a=null;if(o&&typeof BroadcastChannel=="function"&&e===location.origin)try{a=new BroadcastChannel(o),a.onmessage=n=>u(n.data)}catch{a=null}return()=>{window.removeEventListener("message",l),a?.close()}}var y={get(t){try{return sessionStorage.getItem(t)}catch{return null}},set(t,e){try{sessionStorage.setItem(t,e)}catch{}},remove(t){try{sessionStorage.removeItem(t)}catch{}}};var F="https://api.chaindaddy.io",G={"https://api.chaindaddy.io":"https://chaindaddy.io/_oauth/authorize","https://staging-api.chaindaddy.io":"https://staging.chaindaddy.io/_oauth/authorize"},b="cd-signin",U="cd:signin",v="cd.signin",_=300;async function K(t){let e=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(t));return C(new Uint8Array(e))}function W(t="openid"){let e=t.split(/\s+/).filter(Boolean);return(e.includes("openid")?e:["openid",...e]).join(" ")}async function Y(t,e,i){let o=new URLSearchParams({response_type:"code",client_id:e.clientId,redirect_uri:e.redirectUri,scope:W(t.scope),state:e.state,nonce:e.nonce,code_challenge:await K(e.verifier),code_challenge_method:"S256"});return t.claims&&o.set("claims",typeof t.claims=="string"?t.claims:JSON.stringify(t.claims)),`${i}?${o}`}function A(t,e){return{code:e,codeVerifier:t.verifier,state:t.state,nonce:t.nonce,issuer:t.issuer,clientId:t.clientId,redirectUri:t.redirectUri}}var m;function k(t){let e=(t.issuer??F).replace(/\/$/,""),i=t.authorizeUrl??G[e];if(!i)return Promise.reject(new Error("authorizeUrl is required for this issuer"));if(!t.clientId||!t.redirectUri)return Promise.reject(new Error("clientId and redirectUri are required"));if(m&&!m.popup.closed)return m.popup.focus(),m.waiting;let o={state:w(),nonce:w(),verifier:w(),issuer:e,clientId:t.clientId,redirectUri:t.redirectUri};y.set(v,JSON.stringify(o));let r=window.open("",b,"popup,width=480,height=720"),u=(async()=>{let l=await Y(t,o,i);if(!r)return location.assign(l),null;r.location.href=l;let a=new URL(t.redirectUri,location.href).origin;return new Promise((n,c)=>{let s,d=()=>{g(),clearInterval(h),clearTimeout(s)},g=T(U,a,p=>{p.state===o.state&&(d(),y.remove(v),typeof p.error=="string"&&p.error?c(new Error(p.error)):p.iss!==e?c(new Error("the answer came from another issuer")):typeof p.code!="string"||!p.code?c(new Error("the answer carried no code")):n(A(o,p.code)))},b,r),h=setInterval(()=>{r.closed&&(clearInterval(h),s=setTimeout(()=>{d(),c(new Error("closed"))},_))},_);t.signal?.addEventListener("abort",()=>{d(),c(new Error("aborted"))})})})();if(r){m={popup:r,waiting:u};let l=()=>{m&&m.popup===r&&(m=void 0)};u.then(l,l)}return u}async function L(){let t=new URLSearchParams(location.search),e={type:U,code:t.get("code"),state:t.get("state"),iss:t.get("iss"),error:t.get("error")};if(window.name===b){try{window.opener?.postMessage(e,location.origin)}catch{}try{let r=new BroadcastChannel(b);r.postMessage(e),r.close()}catch{}return window.close(),null}let i=y.get(v);y.remove(v);let o=i?JSON.parse(i):null;if(!o||e.state!==o.state)throw new Error("the sign-in state does not match: start again");if(e.error)throw new Error(e.error);if(e.iss!==o.issuer)throw new Error("the answer came from another issuer");if(!e.code)throw new Error("the answer carried no code");return A(o,e.code)}async function M(t){let e=await fetch(`${t.issuer}/api/v2/oauth/token`,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:t.code,redirect_uri:t.redirectUri,client_id:t.clientId,code_verifier:t.codeVerifier})}),i=await e.json();if(!e.ok)throw new Error(`${i.error??e.status}: ${i.error_description??""}`.trim());return i}var J='<svg viewBox="0 0 24 24" fill="#FFD700" width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5z" stroke="#B8860B" stroke-width="0.5"/><path d="M5 18h14v2H5v-2z" stroke="#B8860B" stroke-width="0.5"/></svg>',X=`
2
2
  :host { display: inline-block; }
3
3
  button { display: inline-flex; align-items: center; justify-content: center; gap: 10px; box-sizing: border-box;
4
4
  min-height: 40px; padding: 0 16px; border-radius: 10px; font: 600 15px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
package/dist/cd.js.sri CHANGED
@@ -1 +1 @@
1
- sha384-Skwsv/FQCtenMYT3zex2SCnlERwJPOsytod8T05hRGAb8yUoTlD/nF2tx+0d590b
1
+ sha384-yNIDKP+1edth+ChOSlYVUWmXdlE3j2HVn66BpXQ0x1J4Bs87EUmozo6w2bxKCXFZ
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/core.ts
2
- var VERSION = true ? "0.3.4" : "0.0.0";
2
+ var VERSION = true ? "0.3.5" : "0.0.0";
3
3
  function b64url(bytes) {
4
4
  let s = "";
5
5
  for (const b of bytes) s += String.fromCharCode(b);
package/dist/verify.d.ts CHANGED
@@ -13,7 +13,7 @@ export declare const MIN_CONFIRMATIONS: Readonly<Record<string, number>>;
13
13
  /** `token` of an order paid in the chain's own coin (ETH on Base, POL on Polygon). */
14
14
  export declare const NATIVE_TOKEN = "0x0000000000000000000000000000000000000000";
15
15
  export type VerifyErrorCode =
16
- /** A Solana order: follow the manual recipe for now. */
16
+ /** A Solana order: follow the manual recipe for now. Or, from verifyAnswer, a runtime whose Web Crypto has no Ed25519. */
17
17
  'unsupported'
18
18
  /** What you passed (options, expect) is missing or malformed. */
19
19
  | 'bad_options'
@@ -26,8 +26,24 @@ export type VerifyErrorCode =
26
26
  /** The block is not the order, asset, payout address or amount you expect. */
27
27
  | 'expectation_mismatch'
28
28
  /** The RPC endpoint failed or answered something that is not an answer. */
29
- | 'rpc_error';
30
- /** Every failure of verifyPayment. A payment that is simply not there yet is not an error: it resolves `paid: false`. */
29
+ | 'rpc_error'
30
+ /** verifyAnswer: not a compact JWS, not signed with EdDSA, not a signed answer (a receipt, say), not from the issuer you expect, or missing a claim an answer carries. */
31
+ | 'bad_answer'
32
+ /** verifyAnswer: the answer names a key that is not one of the keys. */
33
+ | 'unknown_key'
34
+ /** verifyAnswer: the signature is not that key's signature over this answer. */
35
+ | 'bad_signature'
36
+ /** verifyAnswer: a genuine answer past its `exp`. Fetch a new one. */
37
+ | 'expired'
38
+ /** The keys you passed, or the record value, are not Ed25519 keys. */
39
+ | 'bad_keys'
40
+ /** verifyAnswer: the key set could not be fetched, or holds no Ed25519 key. */
41
+ | 'keys_unavailable';
42
+ /**
43
+ * Every failure of verifyPayment and verifyAnswer. A payment that is simply not
44
+ * there yet is not an error: it resolves `paid: false`. Nor is a coin that is
45
+ * not registered: that is a signed answer with `status: "not_registered"`.
46
+ */
31
47
  export declare class VerifyError extends Error {
32
48
  readonly code: VerifyErrorCode;
33
49
  constructor(code: VerifyErrorCode, message: string);
@@ -124,3 +140,104 @@ export declare function isKnownCheckout(chainId: string | number, checkout: stri
124
140
  * `paid: true` on a doubt.
125
141
  */
126
142
  export declare function verifyPayment(verification: Verification, options: VerifyOptions): Promise<PaymentResult>;
143
+ /** The `typ` claim of a signed answer. A store receipt, signed by the same keys, is "iap-receipt+v1" and is refused. */
144
+ export declare const ANSWER_TYPE = "cd-answer+v1";
145
+ /** The `iss` claim of a signed answer. */
146
+ export declare const ANSWER_ISSUER = "https://chaindaddy.io";
147
+ /** Where the signing keys are published, as a JSON Web Key Set. */
148
+ export declare const SIGNING_KEYS_URL = "https://api.chaindaddy.io/api/v2/iap/jwks";
149
+ /** The text record on chaindaddy.eth that names the signing keys: `ed25519:<x>` entries separated by a space, the current key first. */
150
+ export declare const SIGNING_KEYS_RECORD = "io.chaindaddy.signing-keys";
151
+ /** One Ed25519 signing key. */
152
+ export interface SigningKey {
153
+ /** The first 16 hex characters of the SHA-256 of the raw public key: what a JWS header's `kid` names. */
154
+ kid: string;
155
+ /** The raw 32-byte public key, base64url without padding (a JWK's `x`). */
156
+ x: string;
157
+ }
158
+ /** What a signed answer says. */
159
+ export interface AnswerClaims {
160
+ iss: string;
161
+ typ: typeof ANSWER_TYPE;
162
+ /** Unix seconds. */
163
+ iat: number;
164
+ /** Unix seconds: 15 minutes after `iat`. */
165
+ exp: number;
166
+ /** What the answer is about. More kinds may be added. */
167
+ kind: 'registration' | (string & {});
168
+ /** CAIP-2, e.g. "eip155:8453". */
169
+ chainId: string;
170
+ /** The chain key, e.g. "base". */
171
+ chain: string;
172
+ /** The ticker: as registered when `status` is "registered", otherwise the one asked about, in upper case. */
173
+ symbol: string;
174
+ /** "registered": a verified page. "not_registered": none. "removed": one Chain Daddy took down. More may be added: treat one you do not know as not registered. */
175
+ status: 'registered' | 'not_registered' | 'removed' | (string & {});
176
+ /** The fields below are present only when `status` is "registered". Addresses are lowercase on EVM chains. */
177
+ tokenAddress?: string;
178
+ /** The registration's ID on that chain. */
179
+ crownId?: number;
180
+ /** The wallet that holds the registration. */
181
+ holder?: string;
182
+ /** RFC 3339. Left out when not known. */
183
+ registeredAt?: string;
184
+ pageUrl?: string;
185
+ }
186
+ /** What you asked about. An answer about anything else is refused. */
187
+ export interface AnswerExpectation {
188
+ /** The chain key you asked about, e.g. "base". */
189
+ chain?: string;
190
+ /** Or its CAIP-2 ID, e.g. "eip155:8453". */
191
+ chainId?: string;
192
+ /** The ticker you asked about, in any case. */
193
+ symbol?: string;
194
+ /** The kind of answer, e.g. "registration". */
195
+ kind?: string;
196
+ }
197
+ export interface VerifyAnswerOptions {
198
+ /**
199
+ * The keys to trust: what signingKeysFromRecord returns, or JWKs (the `keys`
200
+ * of the key set). A key's `kid` is always computed from the key itself.
201
+ */
202
+ keys?: readonly (SigningKey | {
203
+ x: string;
204
+ kty?: string;
205
+ crv?: string;
206
+ })[];
207
+ /** Instead of keys: a key set to fetch. Default SIGNING_KEYS_URL, when neither is passed. */
208
+ jwksUrl?: string;
209
+ /**
210
+ * What you asked about. Pass it whenever the answer did not come straight
211
+ * from Chain Daddy: whoever relays answers can hand you a genuine one about
212
+ * a different coin.
213
+ */
214
+ expect?: AnswerExpectation;
215
+ /** The `iss` to accept. Default ANSWER_ISSUER. */
216
+ issuer?: string;
217
+ /** Seconds past `exp` an answer is still accepted, for a clock that runs fast. Default 0. */
218
+ clockToleranceSeconds?: number;
219
+ /** The time to check `exp` against, in Unix seconds. Default: the clock. */
220
+ now?: number;
221
+ /** Used to fetch the key set, in place of the global fetch. */
222
+ fetch?: typeof fetch;
223
+ /** Aborts the key set request. */
224
+ signal?: AbortSignal;
225
+ }
226
+ /**
227
+ * The signing keys named by the text record SIGNING_KEYS_RECORD on
228
+ * chaindaddy.eth: `ed25519:<x>` entries separated by whitespace, the current
229
+ * key first. An entry of another kind is skipped (a new kind of key can be
230
+ * added to the record); an `ed25519:` entry that is not a key throws, so a
231
+ * damaged record is never read as fewer keys.
232
+ */
233
+ export declare function signingKeysFromRecord(value: string): SigningKey[];
234
+ /**
235
+ * Checks a signed answer and resolves what it says. It resolves only when the
236
+ * JWS is signed with EdDSA by one of the keys, is a signed answer (`typ`) from
237
+ * the issuer you expect (`iss`), has not expired (`exp`) and, when you pass
238
+ * `expect`, is about what you asked. Anything else rejects with a VerifyError.
239
+ *
240
+ * A coin that is not registered resolves too, with `status: "not_registered"`:
241
+ * read `status` before anything else.
242
+ */
243
+ export declare function verifyAnswer(jws: string, options?: VerifyAnswerOptions): Promise<AnswerClaims>;
package/dist/verify.js CHANGED
@@ -330,13 +330,259 @@ async function verifyPayment(verification, options) {
330
330
  const paid = payer !== NATIVE_TOKEN;
331
331
  return { paid, payer: paid ? payer : null, blockNumber: Number(at), ...result };
332
332
  }
333
+ var ANSWER_TYPE = "cd-answer+v1";
334
+ var ANSWER_ISSUER = "https://chaindaddy.io";
335
+ var SIGNING_KEYS_URL = "https://api.chaindaddy.io/api/v2/iap/jwks";
336
+ var SIGNING_KEYS_RECORD = "io.chaindaddy.signing-keys";
337
+ function fromBase64Url(v) {
338
+ if (typeof v !== "string" || !/^[A-Za-z0-9_-]*$/.test(v) || v.length % 4 === 1) return null;
339
+ let bin;
340
+ try {
341
+ bin = atob(v.replace(/-/g, "+").replace(/_/g, "/"));
342
+ } catch {
343
+ return null;
344
+ }
345
+ const out = new Uint8Array(bin.length);
346
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
347
+ return toBase64Url(out) === v ? out : null;
348
+ }
349
+ function toBase64Url(bytes) {
350
+ let bin = "";
351
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
352
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
353
+ }
354
+ var SHA256_K = new Uint32Array([
355
+ 1116352408,
356
+ 1899447441,
357
+ 3049323471,
358
+ 3921009573,
359
+ 961987163,
360
+ 1508970993,
361
+ 2453635748,
362
+ 2870763221,
363
+ 3624381080,
364
+ 310598401,
365
+ 607225278,
366
+ 1426881987,
367
+ 1925078388,
368
+ 2162078206,
369
+ 2614888103,
370
+ 3248222580,
371
+ 3835390401,
372
+ 4022224774,
373
+ 264347078,
374
+ 604807628,
375
+ 770255983,
376
+ 1249150122,
377
+ 1555081692,
378
+ 1996064986,
379
+ 2554220882,
380
+ 2821834349,
381
+ 2952996808,
382
+ 3210313671,
383
+ 3336571891,
384
+ 3584528711,
385
+ 113926993,
386
+ 338241895,
387
+ 666307205,
388
+ 773529912,
389
+ 1294757372,
390
+ 1396182291,
391
+ 1695183700,
392
+ 1986661051,
393
+ 2177026350,
394
+ 2456956037,
395
+ 2730485921,
396
+ 2820302411,
397
+ 3259730800,
398
+ 3345764771,
399
+ 3516065817,
400
+ 3600352804,
401
+ 4094571909,
402
+ 275423344,
403
+ 430227734,
404
+ 506948616,
405
+ 659060556,
406
+ 883997877,
407
+ 958139571,
408
+ 1322822218,
409
+ 1537002063,
410
+ 1747873779,
411
+ 1955562222,
412
+ 2024104815,
413
+ 2227730452,
414
+ 2361852424,
415
+ 2428436474,
416
+ 2756734187,
417
+ 3204031479,
418
+ 3329325298
419
+ ]);
420
+ var rotr = (x, n) => x >>> n | x << 32 - n;
421
+ function keyId(key) {
422
+ const block = new Uint8Array(64);
423
+ block.set(key);
424
+ block[32] = 128;
425
+ block[62] = 1;
426
+ const view = new DataView(block.buffer);
427
+ const w = new Uint32Array(64);
428
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(i * 4);
429
+ for (let i = 16; i < 64; i++) {
430
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ w[i - 15] >>> 3;
431
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ w[i - 2] >>> 10;
432
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1;
433
+ }
434
+ const h = [1779033703, 3144134277, 1013904242, 2773480762, 1359893119, 2600822924, 528734635, 1541459225];
435
+ let [a, b, c, d, e, f, g, hh] = h;
436
+ for (let i = 0; i < 64; i++) {
437
+ const t1 = hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + SHA256_K[i] + w[i] >>> 0;
438
+ const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) >>> 0;
439
+ hh = g;
440
+ g = f;
441
+ f = e;
442
+ e = d + t1 >>> 0;
443
+ d = c;
444
+ c = b;
445
+ b = a;
446
+ a = t1 + t2 >>> 0;
447
+ }
448
+ return [a, b].map((v, i) => (h[i] + v >>> 0).toString(16).padStart(8, "0")).join("");
449
+ }
450
+ function keyFromX(x) {
451
+ const raw = typeof x === "string" && x.length === 43 ? fromBase64Url(x) : null;
452
+ return raw && raw.length === 32 ? { kid: keyId(raw), x, raw } : null;
453
+ }
454
+ function keyFromObject(v) {
455
+ if (!isObject(v)) return null;
456
+ if (v.kty !== void 0 && v.kty !== "OKP" || v.crv !== void 0 && v.crv !== "Ed25519") return null;
457
+ return keyFromX(v.x);
458
+ }
459
+ function signingKeysFromRecord(value) {
460
+ if (typeof value !== "string") throw new VerifyError("bad_keys", "the record value is not a string");
461
+ const out = [];
462
+ for (const entry of value.split(/\s+/)) {
463
+ if (!entry.startsWith("ed25519:")) continue;
464
+ const key = keyFromX(entry.slice("ed25519:".length));
465
+ if (!key) throw new VerifyError("bad_keys", `"${entry.slice(0, 60)}" is not an Ed25519 key: expected ed25519: and 43 base64url characters`);
466
+ if (!out.some((k) => k.kid === key.kid)) out.push({ kid: key.kid, x: key.x });
467
+ }
468
+ return out;
469
+ }
470
+ async function fetchKeys(url, o) {
471
+ const doFetch = o.fetch ?? globalThis.fetch;
472
+ if (typeof doFetch !== "function") throw new VerifyError("bad_options", "no fetch here: pass options.fetch, or options.keys");
473
+ let body;
474
+ try {
475
+ const res = await doFetch(url, { headers: { accept: "application/json" }, signal: o.signal });
476
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
477
+ body = await res.json();
478
+ } catch (e) {
479
+ throw new VerifyError("keys_unavailable", `the key set at ${url} could not be read: ${e instanceof Error ? e.message : String(e)}`);
480
+ }
481
+ const keys = isObject(body) && Array.isArray(body.keys) ? body.keys.map(keyFromObject).filter((k) => k !== null) : [];
482
+ if (keys.length === 0) throw new VerifyError("keys_unavailable", `the key set at ${url} holds no Ed25519 key`);
483
+ return keys;
484
+ }
485
+ function passedKeys(v) {
486
+ if (!Array.isArray(v) || v.length === 0) throw new VerifyError("bad_keys", "options.keys must be a list of keys, one or more");
487
+ return Array.from(v, (k, i) => {
488
+ const key = keyFromObject(k);
489
+ if (!key) throw new VerifyError("bad_keys", `options.keys[${i}] is not an Ed25519 key (x: 43 base64url characters)`);
490
+ return key;
491
+ });
492
+ }
493
+ function parseAnswerExpectation(v) {
494
+ if (v === void 0) return null;
495
+ const fields = ["chain", "chainId", "symbol", "kind"];
496
+ if (!isObject(v) || Object.keys(v).length === 0 || Object.keys(v).some((k) => !fields.includes(k) || typeof v[k] !== "string" || v[k] === "")) {
497
+ throw new VerifyError("bad_options", "options.expect takes chain or chainId, symbol and kind, each a string: what you asked about");
498
+ }
499
+ return v;
500
+ }
501
+ function jsonObject(bytes) {
502
+ try {
503
+ const v = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
504
+ return isObject(v) ? v : null;
505
+ } catch {
506
+ return null;
507
+ }
508
+ }
509
+ async function ed25519Verify(key, signature, data) {
510
+ const subtle = globalThis.crypto?.subtle;
511
+ if (!subtle) throw new VerifyError("unsupported", "this runtime has no Web Crypto (crypto.subtle) to check an Ed25519 signature with");
512
+ let imported;
513
+ try {
514
+ imported = await subtle.importKey("raw", key, { name: "Ed25519" }, false, ["verify"]);
515
+ } catch (e) {
516
+ throw new VerifyError("unsupported", `this runtime's Web Crypto cannot check an Ed25519 signature: ${e instanceof Error ? e.message : String(e)}`);
517
+ }
518
+ return subtle.verify({ name: "Ed25519" }, imported, signature, data);
519
+ }
520
+ var isText = (v) => typeof v === "string" && v !== "";
521
+ async function verifyAnswer(jws, options = {}) {
522
+ if (!isObject(options)) throw new VerifyError("bad_options", "options must be an object: keys or jwksUrl, and expect");
523
+ const o = options;
524
+ const tolerance = o.clockToleranceSeconds ?? 0;
525
+ if (typeof tolerance !== "number" || !Number.isSafeInteger(tolerance) || tolerance < 0) {
526
+ throw new VerifyError("bad_options", "options.clockToleranceSeconds must be a whole number of seconds, 0 or more");
527
+ }
528
+ const now = o.now ?? Math.floor(Date.now() / 1e3);
529
+ if (typeof now !== "number" || !Number.isFinite(now)) throw new VerifyError("bad_options", "options.now must be a time in Unix seconds");
530
+ const issuer = o.issuer ?? ANSWER_ISSUER;
531
+ if (!isText(issuer)) throw new VerifyError("bad_options", "options.issuer must be a string");
532
+ const expect = parseAnswerExpectation(o.expect);
533
+ if (o.keys !== void 0 && o.jwksUrl !== void 0) throw new VerifyError("bad_options", "pass keys or jwksUrl, one of the two");
534
+ if (o.jwksUrl !== void 0 && !isText(o.jwksUrl)) throw new VerifyError("bad_options", "options.jwksUrl must be a URL");
535
+ const parts = typeof jws === "string" ? jws.trim().split(".") : [];
536
+ if (parts.length !== 3) throw new VerifyError("bad_answer", "not a compact JWS: three base64url parts joined by dots");
537
+ const headerBytes = fromBase64Url(parts[0]);
538
+ const payloadBytes = fromBase64Url(parts[1]);
539
+ const signature = fromBase64Url(parts[2]);
540
+ const header = headerBytes && jsonObject(headerBytes);
541
+ if (!header || !payloadBytes || !signature) throw new VerifyError("bad_answer", "not a compact JWS: a part is not base64url, or the header is not JSON");
542
+ if (header.alg !== "EdDSA") throw new VerifyError("bad_answer", `the answer is signed with ${JSON.stringify(header.alg)}, not EdDSA`);
543
+ if (signature.length !== 64) throw new VerifyError("bad_answer", "the signature is not an Ed25519 signature");
544
+ const keys = o.keys !== void 0 ? passedKeys(o.keys) : await fetchKeys(o.jwksUrl ?? SIGNING_KEYS_URL, o);
545
+ const key = keys.find((k) => k.kid === header.kid);
546
+ if (!key) throw new VerifyError("unknown_key", `the answer is signed by key ${JSON.stringify(header.kid)}, which is not one of the keys`);
547
+ if (!await ed25519Verify(key.raw, signature, new TextEncoder().encode(`${parts[0]}.${parts[1]}`))) {
548
+ throw new VerifyError("bad_signature", "the signature does not match: the answer was changed, or another key signed it");
549
+ }
550
+ const c = jsonObject(payloadBytes);
551
+ if (!c) throw new VerifyError("bad_answer", "the signed payload is not a JSON object");
552
+ if (c.typ !== ANSWER_TYPE) throw new VerifyError("bad_answer", `not a signed answer: typ is ${JSON.stringify(c.typ)}, not "${ANSWER_TYPE}"`);
553
+ if (c.iss !== issuer) throw new VerifyError("bad_answer", `the answer is from ${JSON.stringify(c.iss)}, not ${issuer}`);
554
+ if (!Number.isSafeInteger(c.iat) || !Number.isSafeInteger(c.exp)) throw new VerifyError("bad_answer", "the answer has no iat or no exp");
555
+ for (const name of ["kind", "chainId", "chain", "symbol", "status"]) {
556
+ if (!isText(c[name])) throw new VerifyError("bad_answer", `the answer has no ${name}`);
557
+ }
558
+ if (c.status === "registered" && (!isText(c.tokenAddress) || !isText(c.holder) || !Number.isSafeInteger(c.crownId))) {
559
+ throw new VerifyError("bad_answer", "a registered answer must name the token, the registration and its holder");
560
+ }
561
+ if (now >= c.exp + tolerance) throw new VerifyError("expired", `the answer expired at ${c.exp} (Unix seconds): fetch a new one`);
562
+ const claims = c;
563
+ if (expect) {
564
+ if (expect.chain !== void 0 && claims.chain !== expect.chain) throw new VerifyError("expectation_mismatch", `the answer is about chain ${claims.chain}, not ${expect.chain}`);
565
+ if (expect.chainId !== void 0 && claims.chainId !== expect.chainId) throw new VerifyError("expectation_mismatch", `the answer is about chain ${claims.chainId}, not ${expect.chainId}`);
566
+ if (expect.symbol !== void 0 && claims.symbol.toUpperCase() !== expect.symbol.toUpperCase()) {
567
+ throw new VerifyError("expectation_mismatch", `the answer is about ${claims.symbol}, not ${expect.symbol}`);
568
+ }
569
+ if (expect.kind !== void 0 && claims.kind !== expect.kind) throw new VerifyError("expectation_mismatch", `the answer is a ${claims.kind}, not a ${expect.kind}`);
570
+ }
571
+ return claims;
572
+ }
333
573
  export {
574
+ ANSWER_ISSUER,
575
+ ANSWER_TYPE,
334
576
  CHECKOUTS,
335
577
  MIN_CONFIRMATIONS,
336
578
  NATIVE_TOKEN,
579
+ SIGNING_KEYS_RECORD,
580
+ SIGNING_KEYS_URL,
337
581
  VerifyError,
338
582
  encodeHashOrder,
339
583
  encodePaidBy,
340
584
  isKnownCheckout,
585
+ signingKeysFromRecord,
586
+ verifyAnswer,
341
587
  verifyPayment
342
588
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaindaddy/js",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Chain Daddy for any website: Sign in with Chain Daddy (a popup and a button), a coin's apps on its own site (<cd-app>), checkout for a coin's store in a popup, and a server-side check that a store order was paid on chain",
5
5
  "homepage": "https://docs.chaindaddy.io/developer/javascript-sdk",
6
6
  "repository": {