@cloakely/sdk 0.8.0 → 0.8.1

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.
@@ -104,6 +104,8 @@ export declare class CloakPrivate {
104
104
  private state;
105
105
  /** Pull new leaves from the pool's events, trial-decrypt them, and refresh spent flags. */
106
106
  sync(pool: Address): Promise<OwnedNote[]>;
107
+ /** Every note this account has received in `pool`, spent ones included (each with `spent` and its nullifier). */
108
+ history(pool: Address): Promise<OwnedNote[]>;
107
109
  balance(pool: Address): Promise<bigint>;
108
110
  /** The most a single relayed call of this kind can move from `pool`: the balance minus the relayer's fee. */
109
111
  maxSpendable(pool: Address, kind?: RelayKind): Promise<bigint>;
@@ -249,6 +249,11 @@ export class CloakPrivate {
249
249
  s.notes.forEach((x, i) => (x.spent = Boolean(flags[i])));
250
250
  return s.notes.filter((x) => !x.spent);
251
251
  }
252
+ /** Every note this account has received in `pool`, spent ones included (each with `spent` and its nullifier). */
253
+ async history(pool) {
254
+ await this.sync(pool);
255
+ return [...this.state(pool).notes];
256
+ }
252
257
  async balance(pool) {
253
258
  return (await this.sync(pool)).reduce((a, x) => a + x.amount, 0n);
254
259
  }
@@ -22,6 +22,18 @@ export declare const newLinkSecret: () => Uint8Array;
22
22
  export declare function linkKeys(secret: Uint8Array, passcode?: string): Promise<CloakKeys>;
23
23
  /** The cloak: address a link's money is sent to. */
24
24
  export declare function linkAddress(keys: CloakKeys): Promise<string>;
25
+ /** The 8-hex-digit check a locked link carries: the first 4 bytes of a hash of the account the passcode opens. */
26
+ export declare function linkCheck(keys: CloakKeys): Promise<string>;
27
+ /** Thrown when a link's check says the passcode is wrong. */
28
+ export declare class WrongPasscodeError extends Error {
29
+ constructor(riddle: boolean);
30
+ }
31
+ /**
32
+ * Opens a link's account. A locked link with a check tries the code (and the ways a phone may have mangled it) and
33
+ * throws WrongPasscodeError if none matches, without touching the network. A link without a check (made before
34
+ * checks existed) opens with the code as typed; an empty account is then all a wrong code looks like.
35
+ */
36
+ export declare function openLinkKeys(l: ClaimLink, passcode?: string): Promise<CloakKeys>;
25
37
  export interface ClaimLink {
26
38
  secret: Uint8Array;
27
39
  /** Block the link was funded in. Shown to the claimer; scanning still starts at the vaults' first block, because
@@ -40,7 +52,15 @@ export interface ClaimLink {
40
52
  hint?: string;
41
53
  /** Split into this many equal shares: each person who opens the link claims one (a red envelope). */
42
54
  shares?: number;
55
+ /** A short check of the account a passcode opens, so a wrong passcode is caught on the device, before any
56
+ * network call. It tells a guesser nothing a copy of the link didn't already: each guess still costs a full scrypt. */
57
+ check?: string;
43
58
  }
59
+ /**
60
+ * A claim link: https://cloak.boblabs.tech/claim#c<letters and digits>. Everything after the # is one run of letters
61
+ * and digits (base 62), because chat apps end a link at characters like "-", "_", "." or "%" (WhatsApp and Telegram
62
+ * both cut links that way) and a cut link loses the money's key.
63
+ */
44
64
  export declare function buildClaimLink(l: ClaimLink, origin?: string): string;
45
65
  /** Reads a claim link (the whole URL or just its fragment). Throws on anything that isn't one. */
46
66
  export declare function parseClaimLink(link: string): ClaimLink;
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { scryptAsync } from "@noble/hashes/scrypt";
23
23
  import { randomBytes } from "@noble/hashes/utils";
24
- import { concat, isAddress, keccak256, toBytes } from "viem";
24
+ import { concat, isAddress, keccak256, toBytes, toHex } from "viem";
25
25
  import { CloakKeys, formatCloakAddress, parseCloakAddress } from "./crypto.js";
26
26
  export const LINK_ORIGIN = "https://cloak.boblabs.tech";
27
27
  export const LINK_MESSAGE_MAX = 140;
@@ -64,9 +64,46 @@ export async function linkKeys(secret, passcode) {
64
64
  export async function linkAddress(keys) {
65
65
  return formatCloakAddress((await keys.main()).pubKey, keys.encPub);
66
66
  }
67
- export function buildClaimLink(l, origin = LINK_ORIGIN) {
67
+ /** The 8-hex-digit check a locked link carries: the first 4 bytes of a hash of the account the passcode opens. */
68
+ export async function linkCheck(keys) {
69
+ return keccak256(concat([toBytes("cloak-link-check-v1"), toBytes(await linkAddress(keys))])).slice(2, 10);
70
+ }
71
+ /** Thrown when a link's check says the passcode is wrong. */
72
+ export class WrongPasscodeError extends Error {
73
+ constructor(riddle) {
74
+ super(riddle ? "That's not the answer. Check the spelling and try again." : "That's not the passcode. Check it with the sender and try again.");
75
+ this.name = "WrongPasscodeError";
76
+ }
77
+ }
78
+ /** Ways a phone may have typed the same code: curly quotes and long dashes from autocorrect, a trailing full stop. */
79
+ function passcodeVariants(code) {
80
+ const base = normalizePasscode(code);
81
+ const straight = base.replace(/[\u2018\u2019\u201b\u2032]/g, "'").replace(/[\u201c\u201d\u2033]/g, '"').replace(/[\u2013\u2014\u2212]/g, "-");
82
+ return [...new Set([base, straight, straight.replace(/[.!?]+$/, "")])].filter(Boolean);
83
+ }
84
+ /**
85
+ * Opens a link's account. A locked link with a check tries the code (and the ways a phone may have mangled it) and
86
+ * throws WrongPasscodeError if none matches, without touching the network. A link without a check (made before
87
+ * checks existed) opens with the code as typed; an empty account is then all a wrong code looks like.
88
+ */
89
+ export async function openLinkKeys(l, passcode) {
90
+ if (!l.locked)
91
+ return linkKeys(l.secret);
92
+ if (!passcode || !normalizePasscode(passcode))
93
+ throw new Error(l.hint ? "Type the answer." : "Enter the passcode.");
94
+ if (!l.check)
95
+ return linkKeys(l.secret, passcode);
96
+ for (const v of passcodeVariants(passcode)) {
97
+ const k = await linkKeys(l.secret, v);
98
+ if ((await linkCheck(k)) === l.check)
99
+ return k;
100
+ }
101
+ throw new WrongPasscodeError(!!l.hint);
102
+ }
103
+ /** The fields of a claim link as query parameters (the secret in hex). */
104
+ function claimParams(l) {
68
105
  const p = new URLSearchParams();
69
- p.set("k", b64url(l.secret));
106
+ p.set("k", toHex(l.secret).slice(2));
70
107
  p.set("b", l.block.toString());
71
108
  if (l.token)
72
109
  p.set("t", l.token);
@@ -85,24 +122,69 @@ export function buildClaimLink(l, origin = LINK_ORIGIN) {
85
122
  p.set("q", q);
86
123
  if (l.shares && l.shares > 1)
87
124
  p.set("n", String(Math.min(Math.floor(l.shares), LINK_SHARES_MAX)));
88
- return `${origin.replace(/\/+$/, "")}/claim#${p.toString()}`;
125
+ if (l.check && /^[0-9a-f]{8}$/.test(l.check))
126
+ p.set("c", l.check);
127
+ return p;
128
+ }
129
+ /**
130
+ * A claim link: https://cloak.boblabs.tech/claim#c<letters and digits>. Everything after the # is one run of letters
131
+ * and digits (base 62), because chat apps end a link at characters like "-", "_", "." or "%" (WhatsApp and Telegram
132
+ * both cut links that way) and a cut link loses the money's key.
133
+ */
134
+ export function buildClaimLink(l, origin = LINK_ORIGIN) {
135
+ return `${origin.replace(/\/+$/, "")}/claim#c${toBase62(new TextEncoder().encode(claimParams(l).toString()))}`;
136
+ }
137
+ const B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
138
+ function toBase62(bytes) {
139
+ // a leading 1 byte keeps leading zero bytes; the payload is a few hundred bytes, so plain bigint math is fine
140
+ let n = BigInt(toHex(concat([new Uint8Array([1]), bytes])));
141
+ let out = "";
142
+ while (n > 0n) {
143
+ out = B62[Number(n % 62n)] + out;
144
+ n /= 62n;
145
+ }
146
+ return out;
147
+ }
148
+ function fromBase62(s) {
149
+ let n = 0n;
150
+ for (const ch of s) {
151
+ const v = B62.indexOf(ch);
152
+ if (v < 0)
153
+ throw new Error("bad link");
154
+ n = n * 62n + BigInt(v);
155
+ }
156
+ let hex = n.toString(16);
157
+ if (hex.length % 2)
158
+ hex = "0" + hex;
159
+ const b = toBytes(`0x${hex}`);
160
+ if (b[0] !== 1)
161
+ throw new Error("bad link");
162
+ return b.slice(1);
89
163
  }
90
164
  /** Reads a claim link (the whole URL or just its fragment). Throws on anything that isn't one. */
91
165
  export function parseClaimLink(link) {
92
- const frag = link.includes("#") ? link.slice(link.indexOf("#") + 1) : link;
166
+ let frag = (link.includes("#") ? link.slice(link.indexOf("#") + 1) : link).trim();
167
+ if (/^c[0-9A-Za-z]+$/.test(frag)) {
168
+ try {
169
+ frag = new TextDecoder("utf-8", { fatal: true }).decode(fromBase62(frag.slice(1)));
170
+ }
171
+ catch {
172
+ throw new Error("This claim link is cut short or damaged. Ask the sender to copy the whole link again.");
173
+ }
174
+ }
93
175
  const p = new URLSearchParams(frag);
94
176
  const k = p.get("k");
95
177
  if (!k)
96
178
  throw new Error("This isn't a Cloak claim link.");
97
179
  let secret;
98
180
  try {
99
- secret = unb64url(k);
181
+ secret = /^[0-9a-fA-F]{64}$/.test(k) ? toBytes(`0x${k}`) : unb64url(k);
100
182
  }
101
183
  catch {
102
184
  throw new Error("This claim link is damaged. Ask the sender to copy it again.");
103
185
  }
104
186
  if (secret.length !== 32)
105
- throw new Error("This claim link is cut short. Ask the sender to copy it again.");
187
+ throw new Error("This claim link is cut short. Ask the sender to copy the whole link again.");
106
188
  const b = p.get("b") ?? "0";
107
189
  const t = p.get("t");
108
190
  const a = p.get("a");
@@ -116,6 +198,7 @@ export function parseClaimLink(link) {
116
198
  locked: p.get("p") === "1",
117
199
  hint: cleanLinkText(p.get("q"), LINK_HINT_MAX) || undefined,
118
200
  shares: /^\d{1,2}$/.test(p.get("n") ?? "") && Number(p.get("n")) > 1 ? Math.min(Number(p.get("n")), LINK_SHARES_MAX) : undefined,
201
+ check: /^[0-9a-f]{8}$/.test(p.get("c") ?? "") ? p.get("c") : undefined,
119
202
  };
120
203
  }
121
204
  /** Checks an amount and returns it as a plain decimal, or throws a plain-language error. Empty means "payer chooses". */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloakely/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Cloak on Elysium: sealed-bid token launches and private trading (shielded balances, private swaps, batch auctions, private sends) with on-device zero-knowledge proofs.",
5
5
  "keywords": [
6
6
  "cloak",