@cloakely/sdk 0.7.0 → 0.8.0
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/abis.d.ts +654 -0
- package/dist/abis.js +841 -0
- package/dist/bytecode.d.ts +1 -0
- package/dist/bytecode.js +1 -1
- package/dist/deployments.d.ts +2 -0
- package/dist/deployments.js +2 -0
- package/dist/private/client.d.ts +14 -0
- package/dist/private/client.js +66 -2
- package/dist/private/index.d.ts +1 -0
- package/dist/private/index.js +1 -0
- package/dist/private/links.d.ts +65 -0
- package/dist/private/links.js +186 -0
- package/dist/private/shared.d.ts +30 -3
- package/dist/private/shared.js +115 -14
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +1 -0
- package/package.json +1 -1
package/dist/deployments.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface CloakDeployment {
|
|
|
48
48
|
sharedBlock?: bigint;
|
|
49
49
|
/** SharedRouter: private buys, sells and hidden sends for the shared vault. */
|
|
50
50
|
sharedRouter?: Address;
|
|
51
|
+
/** SharedBatcher: batch rounds for the shared vault's tokens. */
|
|
52
|
+
sharedBatcher?: Address;
|
|
51
53
|
hypePool: Address;
|
|
52
54
|
whype: Address;
|
|
53
55
|
/** Hosted relayer (POST /relay, GET /quote). */
|
package/dist/deployments.js
CHANGED
|
@@ -21,6 +21,7 @@ export const deployments = {
|
|
|
21
21
|
shared: "0x0ce2ecb0195e07899377e3d7b01e927ab3f87610",
|
|
22
22
|
sharedBlock: 21426599n,
|
|
23
23
|
sharedRouter: "0xe2a0769a5acbd987c1b6725247c56187a112dc7e",
|
|
24
|
+
sharedBatcher: "0xdc725bdeb0fca923bf2dc65232f54463b0f80820",
|
|
24
25
|
router: "0x343ab9a7aac93cee28321410510ea24417cc6386",
|
|
25
26
|
routerBlock: 19485063n,
|
|
26
27
|
batcher: "0xc69e4bca18020ae93bc9f17093985bfb139a3203",
|
|
@@ -103,6 +104,7 @@ export async function liveDeployment(publicClient, base) {
|
|
|
103
104
|
shared: reg.shared ?? base.shared,
|
|
104
105
|
sharedBlock: reg.sharedBlock ?? base.sharedBlock,
|
|
105
106
|
sharedRouter: reg.sharedRouter ?? base.sharedRouter,
|
|
107
|
+
sharedBatcher: reg.sharedBatcher ?? base.sharedBatcher,
|
|
106
108
|
hypePool,
|
|
107
109
|
whype,
|
|
108
110
|
previous: reg.previous.length
|
package/dist/private/client.d.ts
CHANGED
|
@@ -248,6 +248,20 @@ export declare class CloakPrivate {
|
|
|
248
248
|
send(token: Address, to: string, amount: bigint, onStatus?: Status): Promise<Hex>;
|
|
249
249
|
/** Private tokens (a launch token, or an asset such as USDC) → any address. */
|
|
250
250
|
unshieldToken(token: Address, to: Address, amount: bigint, onStatus?: Status): Promise<Hex>;
|
|
251
|
+
/**
|
|
252
|
+
* Move everything this account holds of `token` to `to` in one relayed call, leaving no change behind: to a cloak:
|
|
253
|
+
* address (stays private) or out to a 0x address (a withdrawal; HYPE arrives as native HYPE, minus the withdrawal
|
|
254
|
+
* fee). The relayer's fee comes out of the amount. Spends the two largest notes of one tree epoch, so an account
|
|
255
|
+
* with more notes than that needs another call. Claim links use this, so a claimed link is left empty. With `one`,
|
|
256
|
+
* it moves a single note picked at random: one share of a link split into several.
|
|
257
|
+
*/
|
|
258
|
+
sweep(token: Address, to: string, onStatus?: Status, opts?: {
|
|
259
|
+
one?: boolean;
|
|
260
|
+
}): Promise<{
|
|
261
|
+
txHash: Hex;
|
|
262
|
+
amount: bigint;
|
|
263
|
+
left: bigint;
|
|
264
|
+
}>;
|
|
251
265
|
/**
|
|
252
266
|
* Move every private balance `legacy` (a client on the earlier router, with the earlier keys) can see into this
|
|
253
267
|
* deployment, note by note, through `CloakMigrator`. Each move is one relayed transaction: the old proof withdraws to
|
package/dist/private/client.js
CHANGED
|
@@ -21,14 +21,17 @@ const json = (v) => JSON.stringify(v, (_, x) => (typeof x === "bigint" ? x.toStr
|
|
|
21
21
|
export function relayerSubmitter(url) {
|
|
22
22
|
const base = url.replace(/\/$/, "");
|
|
23
23
|
const submit = async (call) => {
|
|
24
|
-
|
|
24
|
+
// a relay normally answers in seconds; past two minutes the connection is dead, and saying so beats waiting forever
|
|
25
|
+
const res = await fetch(`${base}/relay`, { method: "POST", headers: { "content-type": "application/json" }, body: json(call), signal: AbortSignal.timeout(120_000) }).catch((e) => {
|
|
26
|
+
throw new Error(e?.name === "TimeoutError" ? "The relayer didn't answer. Check your balance in a minute before trying again." : `Couldn't reach the relayer: ${e?.message ?? e}`);
|
|
27
|
+
});
|
|
25
28
|
const out = await res.json().catch(() => ({}));
|
|
26
29
|
if (!res.ok)
|
|
27
30
|
throw new Error(out.error ?? `relay failed (${res.status})`);
|
|
28
31
|
return out.txHash;
|
|
29
32
|
};
|
|
30
33
|
submit.quote = async (kind, pool) => {
|
|
31
|
-
const res = await fetch(`${base}/quote?kind=${kind}&pool=${pool}
|
|
34
|
+
const res = await fetch(`${base}/quote?kind=${kind}&pool=${pool}`, { signal: AbortSignal.timeout(30_000) });
|
|
32
35
|
const out = await res.json().catch(() => ({}));
|
|
33
36
|
if (!res.ok)
|
|
34
37
|
throw new Error(out.error ?? `relayer quote failed (${res.status})`);
|
|
@@ -754,6 +757,67 @@ export class CloakPrivate {
|
|
|
754
757
|
});
|
|
755
758
|
return this.submit({ kind: "transact", pool, proof, extData });
|
|
756
759
|
}
|
|
760
|
+
/**
|
|
761
|
+
* Move everything this account holds of `token` to `to` in one relayed call, leaving no change behind: to a cloak:
|
|
762
|
+
* address (stays private) or out to a 0x address (a withdrawal; HYPE arrives as native HYPE, minus the withdrawal
|
|
763
|
+
* fee). The relayer's fee comes out of the amount. Spends the two largest notes of one tree epoch, so an account
|
|
764
|
+
* with more notes than that needs another call. Claim links use this, so a claimed link is left empty. With `one`,
|
|
765
|
+
* it moves a single note picked at random: one share of a link split into several.
|
|
766
|
+
*/
|
|
767
|
+
async sweep(token, to, onStatus = () => { }, opts = {}) {
|
|
768
|
+
const pool = this.market(token).pool;
|
|
769
|
+
const isHype = pool.toLowerCase() === this.hypeMarket.pool.toLowerCase();
|
|
770
|
+
const out = !to.startsWith("cloak:");
|
|
771
|
+
if (out && !/^0x[0-9a-fA-F]{40}$/.test(to))
|
|
772
|
+
throw new Error("send to a cloak: address or a 0x address");
|
|
773
|
+
const kind = out && isHype ? "unshieldHype" : "transact";
|
|
774
|
+
const [live, q] = await Promise.all([this.sync(pool), this.relayFee(kind, pool)]);
|
|
775
|
+
if (!live.length)
|
|
776
|
+
throw new Error("nothing to move: this balance is empty");
|
|
777
|
+
const sorted = [...live].sort((a, b) => (a.amount > b.amount ? -1 : a.amount < b.amount ? 1 : 0));
|
|
778
|
+
// `one`: take a single note, picked at random, so people claiming shares of one link at once rarely collide
|
|
779
|
+
const worth = sorted.filter((n) => n.amount > q.fee);
|
|
780
|
+
const inputs = opts.one
|
|
781
|
+
? worth.length ? [worth[Math.floor(Math.random() * worth.length)]] : []
|
|
782
|
+
: sorted.filter((n) => (n.epoch ?? 0) === (sorted[0].epoch ?? 0)).slice(0, 2);
|
|
783
|
+
if (!inputs.length)
|
|
784
|
+
throw new Error("this balance is smaller than the relayer's fee");
|
|
785
|
+
const sum = inputs.reduce((a, x) => a + x.amount, 0n);
|
|
786
|
+
const amount = sum - q.fee;
|
|
787
|
+
if (amount <= 0n)
|
|
788
|
+
throw new Error("this balance is smaller than the relayer's fee");
|
|
789
|
+
onStatus("Generating zero-knowledge proof on this device…");
|
|
790
|
+
let built;
|
|
791
|
+
if (out) {
|
|
792
|
+
built = await buildTransaction({
|
|
793
|
+
tree: await this.treeFor(pool, inputs),
|
|
794
|
+
inputs,
|
|
795
|
+
outputs: [],
|
|
796
|
+
extAmount: -amount,
|
|
797
|
+
fee: q.fee,
|
|
798
|
+
relayer: q.relayer,
|
|
799
|
+
recipient: isHype ? this.router : to,
|
|
800
|
+
...(isHype ? { data: encodeAbiParameters([{ type: "address" }], [to]) } : {}),
|
|
801
|
+
...(await this.dummy()),
|
|
802
|
+
prover: await this.prover(),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
const rcpt = parseCloakAddress(to);
|
|
807
|
+
built = await buildTransaction({
|
|
808
|
+
tree: await this.treeFor(pool, inputs),
|
|
809
|
+
inputs,
|
|
810
|
+
outputs: [{ amount, pubKey: rcpt.pubKey, memo: (b) => encryptFullMemo(rcpt.encPub, amount, b, MAIN_KEY) }],
|
|
811
|
+
fee: q.fee,
|
|
812
|
+
relayer: q.relayer,
|
|
813
|
+
...(await this.dummy()),
|
|
814
|
+
prover: await this.prover(),
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
onStatus(out ? "Relaying withdrawal…" : "Relaying private transfer…");
|
|
818
|
+
const txHash = await this.submit({ kind, pool, proof: built.proof, extData: built.extData });
|
|
819
|
+
return { txHash, amount, left: live.reduce((a, x) => a + x.amount, 0n) - sum };
|
|
820
|
+
}
|
|
757
821
|
// ───────────── moving a balance from an earlier deployment ─────────────
|
|
758
822
|
/**
|
|
759
823
|
* Move every private balance `legacy` (a client on the earlier router, with the earlier keys) can see into this
|
package/dist/private/index.d.ts
CHANGED
package/dist/private/index.js
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Hex } from "viem";
|
|
2
|
+
import { CloakKeys } from "./crypto.js";
|
|
3
|
+
export declare const LINK_ORIGIN = "https://cloak.boblabs.tech";
|
|
4
|
+
export declare const LINK_MESSAGE_MAX = 140;
|
|
5
|
+
export declare const LINK_NAME_MAX = 40;
|
|
6
|
+
export declare const LINK_HINT_MAX = 120;
|
|
7
|
+
/** Most shares one link can be split into. Each share is its own private send, so more means a longer wait to make it. */
|
|
8
|
+
export declare const LINK_SHARES_MAX = 20;
|
|
9
|
+
/** Tokens a link can carry today. */
|
|
10
|
+
export declare const LINK_TOKENS: readonly ["HYPE", "USDC"];
|
|
11
|
+
export type LinkToken = (typeof LINK_TOKENS)[number];
|
|
12
|
+
/** Unicode-safe trim to `max` characters, with control characters removed. */
|
|
13
|
+
export declare function cleanLinkText(s: string | null | undefined, max: number): string;
|
|
14
|
+
/** A passcode as both sides type it: case, outer spaces and repeated spaces don't matter. */
|
|
15
|
+
export declare const normalizePasscode: (code: string) => string;
|
|
16
|
+
/** A fresh link secret (32 random bytes). */
|
|
17
|
+
export declare const newLinkSecret: () => Uint8Array;
|
|
18
|
+
/**
|
|
19
|
+
* The private account a claim link opens. Without a passcode it comes from the secret alone. With one, the seed is
|
|
20
|
+
* scrypt(passcode, secret), so a copy of the link is useless without the code, and guessing codes is slow.
|
|
21
|
+
*/
|
|
22
|
+
export declare function linkKeys(secret: Uint8Array, passcode?: string): Promise<CloakKeys>;
|
|
23
|
+
/** The cloak: address a link's money is sent to. */
|
|
24
|
+
export declare function linkAddress(keys: CloakKeys): Promise<string>;
|
|
25
|
+
export interface ClaimLink {
|
|
26
|
+
secret: Uint8Array;
|
|
27
|
+
/** Block the link was funded in. Shown to the claimer; scanning still starts at the vaults' first block, because
|
|
28
|
+
* a note's Merkle proof needs every leaf before it. */
|
|
29
|
+
block: bigint;
|
|
30
|
+
/** What the sender put in (a display hint; the claim page reads the real balance). */
|
|
31
|
+
token?: LinkToken;
|
|
32
|
+
amount?: string;
|
|
33
|
+
/** A message from the sender. */
|
|
34
|
+
message?: string;
|
|
35
|
+
/** The sender's name, as they chose to give it. */
|
|
36
|
+
from?: string;
|
|
37
|
+
/** Needs a passcode. */
|
|
38
|
+
locked?: boolean;
|
|
39
|
+
/** A hint or riddle whose answer is the passcode. */
|
|
40
|
+
hint?: string;
|
|
41
|
+
/** Split into this many equal shares: each person who opens the link claims one (a red envelope). */
|
|
42
|
+
shares?: number;
|
|
43
|
+
}
|
|
44
|
+
export declare function buildClaimLink(l: ClaimLink, origin?: string): string;
|
|
45
|
+
/** Reads a claim link (the whole URL or just its fragment). Throws on anything that isn't one. */
|
|
46
|
+
export declare function parseClaimLink(link: string): ClaimLink;
|
|
47
|
+
export interface PaymentRequest {
|
|
48
|
+
/** A cloak: address (paid privately) or a 0x address (paid from the payer's private balance). */
|
|
49
|
+
to: string;
|
|
50
|
+
token: LinkToken;
|
|
51
|
+
/** Whole units as a decimal ("25", "0.5"); leave out to let the payer choose. */
|
|
52
|
+
amount?: string;
|
|
53
|
+
/** What it's for: an invoice number, an order. Only the payer sees it. */
|
|
54
|
+
note?: string;
|
|
55
|
+
/** Who is asking. */
|
|
56
|
+
name?: string;
|
|
57
|
+
}
|
|
58
|
+
/** Checks an amount and returns it as a plain decimal, or throws a plain-language error. Empty means "payer chooses". */
|
|
59
|
+
export declare function normalizeLinkAmount(input: string, decimals: number): string;
|
|
60
|
+
/** Throws unless `to` is a cloak: address or a 0x address. */
|
|
61
|
+
export declare function checkPayee(to: string): "cloak" | "wallet";
|
|
62
|
+
export declare function buildRequestLink(r: PaymentRequest, origin?: string): string;
|
|
63
|
+
/** Reads a request link (fragment first, then the query string, so hand-made `?to=…` links work too). */
|
|
64
|
+
export declare function parseRequestLink(link: string): PaymentRequest;
|
|
65
|
+
export type { Hex };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payment links: money and requests that travel as a URL.
|
|
3
|
+
*
|
|
4
|
+
* claim link https://cloak.boblabs.tech/claim#k=<secret>&b=<block>&t=HYPE&m=Happy+birthday
|
|
5
|
+
* The link holds a whole throwaway private account. The sender funds it with a private send, and
|
|
6
|
+
* whoever opens the link moves that money into their own private balance, or out to any wallet. The
|
|
7
|
+
* relayer submits the claim and takes its fee from the link, so the claimer needs no gas and no
|
|
8
|
+
* Cloak account. Until someone claims it, the sender can take it back the same way.
|
|
9
|
+
* With a passcode (`p=1`), the account's seed also needs the code, sent another way or posed as a
|
|
10
|
+
* riddle (`q=`): a link posted in public that only the right person can open.
|
|
11
|
+
* Split into shares (`n=`), it is a red envelope: several equal notes, and each person who opens the
|
|
12
|
+
* link claims one, first come first served.
|
|
13
|
+
*
|
|
14
|
+
* request link https://cloak.boblabs.tech/pay#to=cloak:…&amount=25&token=USDC¬e=Invoice+42&name=Robin
|
|
15
|
+
* Asks someone to pay you. `to` is a cloak: address (a private payment) or a 0x address (paid from
|
|
16
|
+
* the payer's private balance, so the payer's wallet never shows). Leave out the amount for a tip
|
|
17
|
+
* jar. Nothing about a request is on chain.
|
|
18
|
+
*
|
|
19
|
+
* Everything sits in the URL fragment (after #), which browsers never send to a server or put in a referrer, so a
|
|
20
|
+
* link stays between the people who share it.
|
|
21
|
+
*/
|
|
22
|
+
import { scryptAsync } from "@noble/hashes/scrypt";
|
|
23
|
+
import { randomBytes } from "@noble/hashes/utils";
|
|
24
|
+
import { concat, isAddress, keccak256, toBytes } from "viem";
|
|
25
|
+
import { CloakKeys, formatCloakAddress, parseCloakAddress } from "./crypto.js";
|
|
26
|
+
export const LINK_ORIGIN = "https://cloak.boblabs.tech";
|
|
27
|
+
export const LINK_MESSAGE_MAX = 140;
|
|
28
|
+
export const LINK_NAME_MAX = 40;
|
|
29
|
+
export const LINK_HINT_MAX = 120;
|
|
30
|
+
/** Most shares one link can be split into. Each share is its own private send, so more means a longer wait to make it. */
|
|
31
|
+
export const LINK_SHARES_MAX = 20;
|
|
32
|
+
/** Tokens a link can carry today. */
|
|
33
|
+
export const LINK_TOKENS = ["HYPE", "USDC"];
|
|
34
|
+
const b64url = (b) => btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
35
|
+
const unb64url = (s) => {
|
|
36
|
+
const t = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
37
|
+
return Uint8Array.from(atob(t + "=".repeat((4 - (t.length % 4)) % 4)), (c) => c.charCodeAt(0));
|
|
38
|
+
};
|
|
39
|
+
/** Unicode-safe trim to `max` characters, with control characters removed. */
|
|
40
|
+
export function cleanLinkText(s, max) {
|
|
41
|
+
// eslint-disable-next-line no-control-regex
|
|
42
|
+
return Array.from((s ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim()).slice(0, max).join("");
|
|
43
|
+
}
|
|
44
|
+
/** A passcode as both sides type it: case, outer spaces and repeated spaces don't matter. */
|
|
45
|
+
export const normalizePasscode = (code) => code.normalize("NFKC").trim().toLowerCase().replace(/\s+/g, " ");
|
|
46
|
+
/** A fresh link secret (32 random bytes). */
|
|
47
|
+
export const newLinkSecret = () => randomBytes(32);
|
|
48
|
+
/**
|
|
49
|
+
* The private account a claim link opens. Without a passcode it comes from the secret alone. With one, the seed is
|
|
50
|
+
* scrypt(passcode, secret), so a copy of the link is useless without the code, and guessing codes is slow.
|
|
51
|
+
*/
|
|
52
|
+
export async function linkKeys(secret, passcode) {
|
|
53
|
+
if (secret.length !== 32)
|
|
54
|
+
throw new Error("bad link secret");
|
|
55
|
+
if (passcode === undefined)
|
|
56
|
+
return CloakKeys.fromSeed(toBytes(keccak256(concat([toBytes("cloak-link-v1"), secret]))));
|
|
57
|
+
const code = normalizePasscode(passcode);
|
|
58
|
+
if (!code)
|
|
59
|
+
throw new Error("enter the passcode");
|
|
60
|
+
const seed = await scryptAsync(toBytes(code), concat([toBytes("cloak-link-lock-v1"), secret]), { N: 2 ** 15, r: 8, p: 1, dkLen: 32 });
|
|
61
|
+
return CloakKeys.fromSeed(seed);
|
|
62
|
+
}
|
|
63
|
+
/** The cloak: address a link's money is sent to. */
|
|
64
|
+
export async function linkAddress(keys) {
|
|
65
|
+
return formatCloakAddress((await keys.main()).pubKey, keys.encPub);
|
|
66
|
+
}
|
|
67
|
+
export function buildClaimLink(l, origin = LINK_ORIGIN) {
|
|
68
|
+
const p = new URLSearchParams();
|
|
69
|
+
p.set("k", b64url(l.secret));
|
|
70
|
+
p.set("b", l.block.toString());
|
|
71
|
+
if (l.token)
|
|
72
|
+
p.set("t", l.token);
|
|
73
|
+
if (l.amount)
|
|
74
|
+
p.set("a", l.amount);
|
|
75
|
+
const m = cleanLinkText(l.message, LINK_MESSAGE_MAX);
|
|
76
|
+
if (m)
|
|
77
|
+
p.set("m", m);
|
|
78
|
+
const f = cleanLinkText(l.from, LINK_NAME_MAX);
|
|
79
|
+
if (f)
|
|
80
|
+
p.set("f", f);
|
|
81
|
+
if (l.locked)
|
|
82
|
+
p.set("p", "1");
|
|
83
|
+
const q = cleanLinkText(l.hint, LINK_HINT_MAX);
|
|
84
|
+
if (l.locked && q)
|
|
85
|
+
p.set("q", q);
|
|
86
|
+
if (l.shares && l.shares > 1)
|
|
87
|
+
p.set("n", String(Math.min(Math.floor(l.shares), LINK_SHARES_MAX)));
|
|
88
|
+
return `${origin.replace(/\/+$/, "")}/claim#${p.toString()}`;
|
|
89
|
+
}
|
|
90
|
+
/** Reads a claim link (the whole URL or just its fragment). Throws on anything that isn't one. */
|
|
91
|
+
export function parseClaimLink(link) {
|
|
92
|
+
const frag = link.includes("#") ? link.slice(link.indexOf("#") + 1) : link;
|
|
93
|
+
const p = new URLSearchParams(frag);
|
|
94
|
+
const k = p.get("k");
|
|
95
|
+
if (!k)
|
|
96
|
+
throw new Error("This isn't a Cloak claim link.");
|
|
97
|
+
let secret;
|
|
98
|
+
try {
|
|
99
|
+
secret = unb64url(k);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new Error("This claim link is damaged. Ask the sender to copy it again.");
|
|
103
|
+
}
|
|
104
|
+
if (secret.length !== 32)
|
|
105
|
+
throw new Error("This claim link is cut short. Ask the sender to copy it again.");
|
|
106
|
+
const b = p.get("b") ?? "0";
|
|
107
|
+
const t = p.get("t");
|
|
108
|
+
const a = p.get("a");
|
|
109
|
+
return {
|
|
110
|
+
secret,
|
|
111
|
+
block: /^\d{1,12}$/.test(b) ? BigInt(b) : 0n,
|
|
112
|
+
token: LINK_TOKENS.includes(t ?? "") ? t : undefined,
|
|
113
|
+
amount: a && /^\d{1,15}(\.\d{1,18})?$/.test(a) ? a : undefined,
|
|
114
|
+
message: cleanLinkText(p.get("m"), LINK_MESSAGE_MAX) || undefined,
|
|
115
|
+
from: cleanLinkText(p.get("f"), LINK_NAME_MAX) || undefined,
|
|
116
|
+
locked: p.get("p") === "1",
|
|
117
|
+
hint: cleanLinkText(p.get("q"), LINK_HINT_MAX) || undefined,
|
|
118
|
+
shares: /^\d{1,2}$/.test(p.get("n") ?? "") && Number(p.get("n")) > 1 ? Math.min(Number(p.get("n")), LINK_SHARES_MAX) : undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Checks an amount and returns it as a plain decimal, or throws a plain-language error. Empty means "payer chooses". */
|
|
122
|
+
export function normalizeLinkAmount(input, decimals) {
|
|
123
|
+
const s = input.trim().replace(/,/g, "");
|
|
124
|
+
if (!s)
|
|
125
|
+
return "";
|
|
126
|
+
if (!/^(\d+\.?\d*|\.\d+)$/.test(s))
|
|
127
|
+
throw new Error("Use numbers and one dot for the amount, like 25 or 0.5.");
|
|
128
|
+
const [i = "", f = ""] = s.split(".");
|
|
129
|
+
if (f.length > decimals)
|
|
130
|
+
throw new Error(`Use at most ${decimals} decimal places.`);
|
|
131
|
+
const int = i.replace(/^0+(?=\d)/, "") || "0";
|
|
132
|
+
const frac = f.replace(/0+$/, "");
|
|
133
|
+
if (int.length > 15)
|
|
134
|
+
throw new Error("That amount is too large.");
|
|
135
|
+
if (/^0*$/.test(int) && !frac)
|
|
136
|
+
throw new Error("Use an amount above zero, or leave it empty.");
|
|
137
|
+
return frac ? `${int}.${frac}` : int;
|
|
138
|
+
}
|
|
139
|
+
/** Throws unless `to` is a cloak: address or a 0x address. */
|
|
140
|
+
export function checkPayee(to) {
|
|
141
|
+
const t = to.trim();
|
|
142
|
+
if (t.startsWith("cloak:")) {
|
|
143
|
+
parseCloakAddress(t);
|
|
144
|
+
return "cloak";
|
|
145
|
+
}
|
|
146
|
+
if (isAddress(t))
|
|
147
|
+
return "wallet";
|
|
148
|
+
throw new Error("Pay to a cloak: address or a 0x wallet address.");
|
|
149
|
+
}
|
|
150
|
+
export function buildRequestLink(r, origin = LINK_ORIGIN) {
|
|
151
|
+
checkPayee(r.to);
|
|
152
|
+
if (!LINK_TOKENS.includes(r.token))
|
|
153
|
+
throw new Error(`Requests take ${LINK_TOKENS.join(" or ")}.`);
|
|
154
|
+
const p = new URLSearchParams();
|
|
155
|
+
p.set("to", r.to.trim());
|
|
156
|
+
if (r.amount)
|
|
157
|
+
p.set("amount", normalizeLinkAmount(r.amount, r.token === "USDC" ? 6 : 18));
|
|
158
|
+
p.set("token", r.token);
|
|
159
|
+
const note = cleanLinkText(r.note, LINK_MESSAGE_MAX);
|
|
160
|
+
if (note)
|
|
161
|
+
p.set("note", note);
|
|
162
|
+
const name = cleanLinkText(r.name, LINK_NAME_MAX);
|
|
163
|
+
if (name)
|
|
164
|
+
p.set("name", name);
|
|
165
|
+
return `${origin.replace(/\/+$/, "")}/pay#${p.toString()}`;
|
|
166
|
+
}
|
|
167
|
+
/** Reads a request link (fragment first, then the query string, so hand-made `?to=…` links work too). */
|
|
168
|
+
export function parseRequestLink(link) {
|
|
169
|
+
const url = new URL(link, LINK_ORIGIN);
|
|
170
|
+
const p = new URLSearchParams(url.search);
|
|
171
|
+
const frag = url.hash.replace(/^#/, "");
|
|
172
|
+
for (const [k, v] of new URLSearchParams(frag))
|
|
173
|
+
p.set(k, v);
|
|
174
|
+
const to = (p.get("to") ?? "").trim();
|
|
175
|
+
checkPayee(to);
|
|
176
|
+
const token = ((p.get("token") ?? "HYPE").toUpperCase() === "USDC" ? "USDC" : "HYPE");
|
|
177
|
+
const raw = p.get("amount") ?? "";
|
|
178
|
+
let amount;
|
|
179
|
+
try {
|
|
180
|
+
amount = normalizeLinkAmount(raw, token === "USDC" ? 6 : 18) || undefined;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
amount = undefined;
|
|
184
|
+
}
|
|
185
|
+
return { to, token, amount, note: cleanLinkText(p.get("note"), LINK_MESSAGE_MAX) || undefined, name: cleanLinkText(p.get("name"), LINK_NAME_MAX) || undefined };
|
|
186
|
+
}
|
package/dist/private/shared.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export declare const SHARED_SWAP_DATA_ABI: readonly [{
|
|
|
39
39
|
readonly type: "uint64";
|
|
40
40
|
}];
|
|
41
41
|
}];
|
|
42
|
-
export type SharedRelayKind = "sharedTransact" | "sharedBuy" | "sharedSell" | "sharedSend";
|
|
42
|
+
export type SharedRelayKind = "sharedTransact" | "sharedBuy" | "sharedSell" | "sharedSend" | "sharedOrderBuy" | "sharedOrderSell";
|
|
43
43
|
export interface SharedRelayCall {
|
|
44
44
|
kind: SharedRelayKind;
|
|
45
45
|
/** The shared-vault proof (or, for sharedBuy, the HYPE vault proof). */
|
|
@@ -56,7 +56,7 @@ export type SharedSubmitter = ((call: SharedRelayCall) => Promise<Hex>) & {
|
|
|
56
56
|
/** POST shared-vault calls to a Cloak relayer (`services/keeper` serves `/relay-shared` and `/quote-shared`). */
|
|
57
57
|
export declare function sharedRelayerSubmitter(url: string): SharedSubmitter;
|
|
58
58
|
/** Submit shared-vault calls from a wallet (that wallet pays gas and appears as the sender). For tests and relayers. */
|
|
59
|
-
export declare function sharedWalletSubmitter(priv: CloakPrivate, wallet: Wallet, vault: Address, router: Address): SharedSubmitter;
|
|
59
|
+
export declare function sharedWalletSubmitter(priv: CloakPrivate, wallet: Wallet, vault: Address, router: Address, batcher?: Address): SharedSubmitter;
|
|
60
60
|
export interface SharedToken {
|
|
61
61
|
token: Address;
|
|
62
62
|
market: Address;
|
|
@@ -69,12 +69,16 @@ export declare class SharedVaultClient {
|
|
|
69
69
|
readonly prover: () => Promise<Prover>;
|
|
70
70
|
readonly submit: SharedSubmitter;
|
|
71
71
|
readonly fromBlock: bigint;
|
|
72
|
+
/** SharedBatcher: batch rounds that hide trade size for these tokens. */
|
|
73
|
+
readonly batcher?: Address | undefined;
|
|
72
74
|
tokens: SharedToken[];
|
|
73
75
|
private leaves;
|
|
74
76
|
private scannedUpTo;
|
|
75
77
|
private notes;
|
|
76
78
|
private nextBlock;
|
|
77
|
-
constructor(priv: CloakPrivate, vault: Address, router: Address, prover: () => Promise<Prover>, submit: SharedSubmitter, fromBlock?: bigint
|
|
79
|
+
constructor(priv: CloakPrivate, vault: Address, router: Address, prover: () => Promise<Prover>, submit: SharedSubmitter, fromBlock?: bigint,
|
|
80
|
+
/** SharedBatcher: batch rounds that hide trade size for these tokens. */
|
|
81
|
+
batcher?: Address | undefined);
|
|
78
82
|
private get pub();
|
|
79
83
|
/** Every token admitted to the vault, with its Uniswap market. */
|
|
80
84
|
loadTokens(): Promise<SharedToken[]>;
|
|
@@ -124,4 +128,27 @@ export declare class SharedVaultClient {
|
|
|
124
128
|
send(token: Address, amount: bigint, to: string, onStatus?: Status): Promise<Hex>;
|
|
125
129
|
/** Private `token` → `to` (any address). Names the token, as any withdrawal must. */
|
|
126
130
|
unshield(token: Address, amount: bigint, to: Address, onStatus?: Status): Promise<Hex>;
|
|
131
|
+
roundInfo(): Promise<{
|
|
132
|
+
round: bigint;
|
|
133
|
+
endsAt: number;
|
|
134
|
+
roundSecs: number;
|
|
135
|
+
}>;
|
|
136
|
+
/**
|
|
137
|
+
* Batched private buy or sell of a shared-vault token: the amount is split into standard sizes (1, 2, 5 × 10ⁿ), each
|
|
138
|
+
* placed by the relayer into the current round. When the round ends, buys and sells cross at the pool's average price
|
|
139
|
+
* and only the net trades on Uniswap; outputs arrive as private notes. Nobody can tell how much any one trader moved.
|
|
140
|
+
* Any remainder that isn't a standard size stays in the private balance.
|
|
141
|
+
*/
|
|
142
|
+
batch(token: Address, side: "buy" | "sell", amount: bigint, opts?: {
|
|
143
|
+
maxOrders?: number;
|
|
144
|
+
onStatus?: Status;
|
|
145
|
+
}): Promise<{
|
|
146
|
+
orders: {
|
|
147
|
+
id: bigint;
|
|
148
|
+
size: bigint;
|
|
149
|
+
round: bigint;
|
|
150
|
+
txHash: Hex;
|
|
151
|
+
}[];
|
|
152
|
+
rest: bigint;
|
|
153
|
+
}>;
|
|
127
154
|
}
|
package/dist/private/shared.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { encodeAbiParameters, formatEther, keccak256, zeroAddress } from "viem";
|
|
2
|
-
import { cloakAuctionAbi, cloakPairAbi, sharedRouterAbi, sharedVaultAbi } from "../abis.js";
|
|
1
|
+
import { encodeAbiParameters, formatEther, keccak256, parseEventLogs, zeroAddress } from "viem";
|
|
2
|
+
import { cloakAuctionAbi, cloakPairAbi, sharedBatcherAbi, sharedRouterAbi, sharedVaultAbi } from "../abis.js";
|
|
3
3
|
import { Cloak } from "../cloak.js";
|
|
4
4
|
import { MAIN_KEY, encryptFullMemo, encryptRouterMemo, noteCommitment, nullifierOf, parseCloakAddress, randomField, randomKeyIndex, readMemo } from "./crypto.js";
|
|
5
5
|
import { MerkleTree } from "./tree.js";
|
|
6
|
-
import { buildTransaction, selectNotes } from "./tx.js";
|
|
6
|
+
import { ORDER_DATA_ABI, buildTransaction, selectNotes, splitStandard } from "./tx.js";
|
|
7
7
|
/**
|
|
8
8
|
* The shared launch-token vault (testnet experiment): one private vault for every Cloak launch token that opens on
|
|
9
9
|
* Uniswap from now on, next to the single-token vaults of HYPE and USDC.
|
|
@@ -36,14 +36,16 @@ const json = (v) => JSON.stringify(v, (_, x) => (typeof x === "bigint" ? x.toStr
|
|
|
36
36
|
export function sharedRelayerSubmitter(url) {
|
|
37
37
|
const base = url.replace(/\/$/, "");
|
|
38
38
|
const submit = async (call) => {
|
|
39
|
-
const res = await fetch(`${base}/relay-shared`, { method: "POST", headers: { "content-type": "application/json" }, body: json(call) })
|
|
39
|
+
const res = await fetch(`${base}/relay-shared`, { method: "POST", headers: { "content-type": "application/json" }, body: json(call), signal: AbortSignal.timeout(120_000) }).catch((e) => {
|
|
40
|
+
throw new Error(e?.name === "TimeoutError" ? "The relayer didn't answer. Check your balance in a minute before trying again." : `Couldn't reach the relayer: ${e?.message ?? e}`);
|
|
41
|
+
});
|
|
40
42
|
const out = await res.json().catch(() => ({}));
|
|
41
43
|
if (!res.ok)
|
|
42
44
|
throw new Error(out.error ?? `relay failed (${res.status})`);
|
|
43
45
|
return out.txHash;
|
|
44
46
|
};
|
|
45
47
|
submit.quote = async (kind, token) => {
|
|
46
|
-
const res = await fetch(`${base}/quote-shared?kind=${kind}&token=${token}
|
|
48
|
+
const res = await fetch(`${base}/quote-shared?kind=${kind}&token=${token}`, { signal: AbortSignal.timeout(30_000) });
|
|
47
49
|
const out = await res.json().catch(() => ({}));
|
|
48
50
|
if (!res.ok)
|
|
49
51
|
throw new Error(out.error ?? `relayer quote failed (${res.status})`);
|
|
@@ -52,15 +54,21 @@ export function sharedRelayerSubmitter(url) {
|
|
|
52
54
|
return submit;
|
|
53
55
|
}
|
|
54
56
|
/** Submit shared-vault calls from a wallet (that wallet pays gas and appears as the sender). For tests and relayers. */
|
|
55
|
-
export function sharedWalletSubmitter(priv, wallet, vault, router) {
|
|
57
|
+
export function sharedWalletSubmitter(priv, wallet, vault, router, batcher) {
|
|
56
58
|
return async (call) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
if ((call.kind === "sharedOrderBuy" || call.kind === "sharedOrderSell") && !batcher)
|
|
60
|
+
throw new Error("no shared batcher configured");
|
|
61
|
+
const req = call.kind === "sharedOrderBuy"
|
|
62
|
+
? { address: batcher, abi: sharedBatcherAbi, functionName: "placeBuy", args: [call.proof, call.extData] }
|
|
63
|
+
: call.kind === "sharedOrderSell"
|
|
64
|
+
? { address: batcher, abi: sharedBatcherAbi, functionName: "placeSell", args: [call.proof, call.extData] }
|
|
65
|
+
: call.kind === "sharedTransact"
|
|
66
|
+
? { address: vault, abi: sharedVaultAbi, functionName: "transact", args: [call.proof, call.extData] }
|
|
67
|
+
: call.kind === "sharedBuy"
|
|
68
|
+
? { address: router, abi: sharedRouterAbi, functionName: "buy", args: [call.proof, call.extData] }
|
|
69
|
+
: call.kind === "sharedSell"
|
|
70
|
+
? { address: router, abi: sharedRouterAbi, functionName: "sell", args: [call.proof, call.extData] }
|
|
71
|
+
: { address: router, abi: sharedRouterAbi, functionName: "send", args: [call.proof, call.extData, call.feeProof, call.feeExtData] };
|
|
64
72
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
73
|
const est = await priv.publicClient.estimateContractGas({ ...req, account: wallet.account.address });
|
|
66
74
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -86,18 +94,22 @@ export class SharedVaultClient {
|
|
|
86
94
|
prover;
|
|
87
95
|
submit;
|
|
88
96
|
fromBlock;
|
|
97
|
+
batcher;
|
|
89
98
|
tokens = [];
|
|
90
99
|
leaves = [];
|
|
91
100
|
scannedUpTo = 0;
|
|
92
101
|
notes = [];
|
|
93
102
|
nextBlock;
|
|
94
|
-
constructor(priv, vault, router, prover, submit, fromBlock = 0n
|
|
103
|
+
constructor(priv, vault, router, prover, submit, fromBlock = 0n,
|
|
104
|
+
/** SharedBatcher: batch rounds that hide trade size for these tokens. */
|
|
105
|
+
batcher) {
|
|
95
106
|
this.priv = priv;
|
|
96
107
|
this.vault = vault;
|
|
97
108
|
this.router = router;
|
|
98
109
|
this.prover = prover;
|
|
99
110
|
this.submit = submit;
|
|
100
111
|
this.fromBlock = fromBlock;
|
|
112
|
+
this.batcher = batcher;
|
|
101
113
|
this.nextBlock = fromBlock;
|
|
102
114
|
}
|
|
103
115
|
get pub() {
|
|
@@ -456,4 +468,93 @@ export class SharedVaultClient {
|
|
|
456
468
|
onStatus("Relaying…");
|
|
457
469
|
return this.submit({ kind: "sharedTransact", proof, extData });
|
|
458
470
|
}
|
|
471
|
+
// ───────────── batch rounds (hide trade size) ─────────────
|
|
472
|
+
async roundInfo() {
|
|
473
|
+
if (!this.batcher)
|
|
474
|
+
throw new Error("no shared batcher configured");
|
|
475
|
+
const c = { address: this.batcher, abi: sharedBatcherAbi };
|
|
476
|
+
const [round, roundSecs] = await Promise.all([this.pub.readContract({ ...c, functionName: "currentRound" }), this.pub.readContract({ ...c, functionName: "roundSecs" })]);
|
|
477
|
+
return { round, roundSecs: Number(roundSecs), endsAt: Number((round + 1n) * roundSecs) };
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Batched private buy or sell of a shared-vault token: the amount is split into standard sizes (1, 2, 5 × 10ⁿ), each
|
|
481
|
+
* placed by the relayer into the current round. When the round ends, buys and sells cross at the pool's average price
|
|
482
|
+
* and only the net trades on Uniswap; outputs arrive as private notes. Nobody can tell how much any one trader moved.
|
|
483
|
+
* Any remainder that isn't a standard size stays in the private balance.
|
|
484
|
+
*/
|
|
485
|
+
async batch(token, side, amount, opts = {}) {
|
|
486
|
+
if (!this.batcher)
|
|
487
|
+
throw new Error("no shared batcher configured");
|
|
488
|
+
if (!this.tokens.length)
|
|
489
|
+
await this.loadTokens();
|
|
490
|
+
this.info(token);
|
|
491
|
+
const onStatus = opts.onStatus ?? (() => { });
|
|
492
|
+
const asset = asNum(token);
|
|
493
|
+
const kind = side === "buy" ? "sharedOrderBuy" : "sharedOrderSell";
|
|
494
|
+
const hypePool = this.priv.hypeMarket.pool;
|
|
495
|
+
const maxOrders = opts.maxOrders ?? 6;
|
|
496
|
+
let { sizes, rest } = splitStandard(amount, maxOrders);
|
|
497
|
+
const fee = (await this.quote(kind, token)).fee;
|
|
498
|
+
if (fee > 0n) {
|
|
499
|
+
const have = side === "buy" ? await this.priv.balance(hypePool) : await this.balance(token);
|
|
500
|
+
const reserve = (n) => (fee * BigInt(n) * 5n) / 4n + fee;
|
|
501
|
+
if (sizes.reduce((a, x) => a + x, 0n) + reserve(sizes.length) > have) {
|
|
502
|
+
// every order pays its own relayer fee: use fewer orders when that's all the balance can pay for
|
|
503
|
+
let fit = false;
|
|
504
|
+
for (let n = maxOrders; n >= 1 && !fit; n--) {
|
|
505
|
+
const budget = have - reserve(n);
|
|
506
|
+
if (budget <= 0n)
|
|
507
|
+
continue;
|
|
508
|
+
({ sizes } = splitStandard(budget < amount ? budget : amount, n));
|
|
509
|
+
fit = sizes.length > 0;
|
|
510
|
+
}
|
|
511
|
+
if (!fit)
|
|
512
|
+
throw new Error("insufficient private balance");
|
|
513
|
+
rest = amount - sizes.reduce((a, x) => a + x, 0n);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (sizes.length === 0)
|
|
517
|
+
throw new Error("amount too small for a standard-size order");
|
|
518
|
+
const placed = [];
|
|
519
|
+
for (let i = 0; i < sizes.length; i++) {
|
|
520
|
+
const size = sizes[i];
|
|
521
|
+
onStatus(`Order ${i + 1}/${sizes.length}: syncing…`);
|
|
522
|
+
const q = await this.quote(kind, token);
|
|
523
|
+
let inputs;
|
|
524
|
+
try {
|
|
525
|
+
inputs = side === "buy" ? await this.priv.inputsFor(hypePool, size + q.fee, onStatus) : await this.inputsFor(token, size + q.fee, onStatus);
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
if (!placed.length || !/insufficient private balance/.test(String(e.message)))
|
|
529
|
+
throw e;
|
|
530
|
+
rest += sizes.slice(i).reduce((a, x) => a + x, 0n);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
const keyIndex = randomKeyIndex();
|
|
534
|
+
const outKey = await this.priv.keys.key(keyIndex);
|
|
535
|
+
const outBlinding = randomField();
|
|
536
|
+
const data = encodeAbiParameters(ORDER_DATA_ABI, [{ token, outPubKey: outKey.pubKey, outBlinding, outMemo: encryptRouterMemo(this.priv.keys.encPub, outBlinding, keyIndex) }]);
|
|
537
|
+
const change = inputs.reduce((a, x) => a + x.amount, 0n) - size - q.fee;
|
|
538
|
+
onStatus(`Order ${i + 1}/${sizes.length}: proving on this device…`);
|
|
539
|
+
const common = {
|
|
540
|
+
inputs,
|
|
541
|
+
outputs: change > 0n ? [await this.priv.changeOutput(change)] : [],
|
|
542
|
+
extAmount: -size,
|
|
543
|
+
fee: q.fee,
|
|
544
|
+
relayer: q.relayer,
|
|
545
|
+
recipient: this.batcher,
|
|
546
|
+
data,
|
|
547
|
+
...(await this.priv.dummy()),
|
|
548
|
+
};
|
|
549
|
+
const { proof, extData } = side === "buy"
|
|
550
|
+
? await buildTransaction({ ...common, tree: await this.priv.treeFor(hypePool, inputs), prover: await this.priv.prover() })
|
|
551
|
+
: await buildTransaction({ ...common, tree: await this.tree(inputs[0].epoch ?? 0), asset, publicAsset: asset, prover: await this.prover() });
|
|
552
|
+
onStatus(`Order ${i + 1}/${sizes.length}: relaying…`);
|
|
553
|
+
const txHash = await this.submit({ kind, proof, extData });
|
|
554
|
+
const rc = await this.pub.waitForTransactionReceipt({ hash: txHash });
|
|
555
|
+
const ev = parseEventLogs({ abi: sharedBatcherAbi, logs: rc.logs, eventName: "OrderPlaced" })[0];
|
|
556
|
+
placed.push({ id: ev.args.id, size, round: ev.args.round, txHash });
|
|
557
|
+
}
|
|
558
|
+
return { orders: placed, rest };
|
|
559
|
+
}
|
|
459
560
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface CloakRegistry {
|
|
|
42
42
|
sharedBlock?: bigint;
|
|
43
43
|
/** SharedRouter: private buys, sells and hidden sends for the shared vault. */
|
|
44
44
|
sharedRouter?: Address;
|
|
45
|
+
/** SharedBatcher: batch rounds for the shared vault's tokens. */
|
|
46
|
+
sharedBatcher?: Address;
|
|
45
47
|
factory: Address;
|
|
46
48
|
factoryBlock: bigint;
|
|
47
49
|
/** Earlier factories of the current version (a factory added next to the first), newest first. */
|