@bufinance/web3-signin 0.1.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/README.md +65 -0
- package/package.json +43 -0
- package/src/eip6963.test.ts +68 -0
- package/src/eip6963.ts +78 -0
- package/src/index.ts +32 -0
- package/src/react/index.ts +13 -0
- package/src/react/turnstile.tsx +114 -0
- package/src/react/use-injected-wallets.ts +39 -0
- package/src/react/use-web3-signin.ts +67 -0
- package/src/react/wallet-dropdown.tsx +145 -0
- package/src/registry.ts +32 -0
- package/src/sign-in.ts +82 -0
- package/src/types.ts +115 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @bufinance/web3-signin
|
|
2
|
+
|
|
3
|
+
Headless, cross-app **Web3 wallet sign-in** for BUFI. One source of truth, consumed by both **desk-v1** and **defi-web-app** so the login surface stays DRY.
|
|
4
|
+
|
|
5
|
+
It wraps three things:
|
|
6
|
+
|
|
7
|
+
1. **EIP-6963 wallet discovery** — lists the browser wallets the user actually has (MetaMask, Coinbase Wallet, Brave, …). Each wallet ships its own name + icon; nothing is hardcoded.
|
|
8
|
+
2. **Supabase Web3 auth** — `signInWithWeb3` (EIP-4361 "Sign in with Ethereum"). Supabase builds the SIWE message, the wallet signs, GoTrue verifies server-side and mints the session. No custom SIWE backend.
|
|
9
|
+
3. **Turnstile CAPTCHA** (wallet logins only) — renders the Cloudflare Turnstile widget and hands the token to `signInWithWeb3` as `captchaToken`. **No siteverify Worker** — Supabase verifies the token with the secret set in its dashboard.
|
|
10
|
+
|
|
11
|
+
## Install (GitHub Packages)
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
# .npmrc
|
|
15
|
+
@bufinance:registry=https://npm.pkg.github.com
|
|
16
|
+
|
|
17
|
+
bun add @bufinance/web3-signin
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The package ships TypeScript source; consuming Next.js apps add it to `transpilePackages`.
|
|
21
|
+
|
|
22
|
+
## Headless core
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { subscribeWallets, getWallets, signInWithWallet } from "@bufinance/web3-signin";
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## React
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { useWeb3SignIn, Turnstile } from "@bufinance/web3-signin/react";
|
|
32
|
+
|
|
33
|
+
function WalletButtons({ supabase }) {
|
|
34
|
+
const [token, setToken] = useState<string>();
|
|
35
|
+
const { wallets, signIn, status, error, pendingRdns } = useWeb3SignIn({
|
|
36
|
+
supabase,
|
|
37
|
+
statement: "I accept the BUFI Terms of Service at https://bu.finance/terms",
|
|
38
|
+
onSignedIn: () => location.assign("/"),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<Turnstile sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY!} onToken={setToken} />
|
|
44
|
+
{wallets.map((w) => (
|
|
45
|
+
<button key={w.info.rdns} disabled={!token || pendingRdns === w.info.rdns}
|
|
46
|
+
onClick={() => signIn(w, token)}>
|
|
47
|
+
<img src={w.info.icon} width={20} height={20} alt="" /> {w.info.name}
|
|
48
|
+
</button>
|
|
49
|
+
))}
|
|
50
|
+
{error && <p>{error}</p>}
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## One-time project setup
|
|
57
|
+
|
|
58
|
+
- Supabase dashboard → **Authentication → Providers → Web3 Wallet** → enable Ethereum.
|
|
59
|
+
- Supabase dashboard → **Auth → Bot & Abuse Protection** → enable CAPTCHA → Turnstile → paste the **Secret key**.
|
|
60
|
+
- Cloudflare → create a **Turnstile widget** for `bu.finance` (+ `localhost`); put the **Sitekey** in `NEXT_PUBLIC_TURNSTILE_SITEKEY` and the **Secret** in Supabase (above).
|
|
61
|
+
- Both apps must point at the **same** Supabase project (`cmrpdkvogpxyneidmtnu` in prod).
|
|
62
|
+
|
|
63
|
+
## Releasing
|
|
64
|
+
|
|
65
|
+
This repo is the home. Bump `version`, commit, tag, and publish to GitHub Packages (`npm publish`). Both apps pick it up via their lockfile.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bufinance/web3-signin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless cross-app Web3 wallet sign-in for BUFI: EIP-6963 wallet discovery + Supabase signInWithWeb3 (EIP-4361) + Turnstile captcha. Shared by desk-v1 and defi-web-app. Source of truth lives here; both apps consume it.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./react": "./src/react/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": ["src"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "bun test"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@supabase/supabase-js": ">=2.62.0",
|
|
19
|
+
"react": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"peerDependenciesMeta": {
|
|
22
|
+
"react": {
|
|
23
|
+
"optional": true
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@supabase/supabase-js": "^2.108.2",
|
|
28
|
+
"@types/react": "^18",
|
|
29
|
+
"bun-types": "^1.1",
|
|
30
|
+
"react": "^18",
|
|
31
|
+
"typescript": "^5",
|
|
32
|
+
"viem": "2.45.3"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"registry": "https://registry.npmjs.org",
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/BuFi007/bufi-sign-in-web.git"
|
|
41
|
+
},
|
|
42
|
+
"license": "UNLICENSED"
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { __test, getWallets, getWalletByRdns } from "./eip6963";
|
|
3
|
+
import { walletSortKey } from "./registry";
|
|
4
|
+
import type { DiscoveredWallet, Eip1193Provider } from "./types";
|
|
5
|
+
|
|
6
|
+
/** A tagged provider so tests can assert which instance won a de-dupe race. */
|
|
7
|
+
type TaggedProvider = Eip1193Provider & { _tag: string };
|
|
8
|
+
|
|
9
|
+
function fakeWallet(rdns: string, name: string, providerTag = "p"): DiscoveredWallet {
|
|
10
|
+
const provider: TaggedProvider = { request: async () => null, _tag: providerTag };
|
|
11
|
+
return {
|
|
12
|
+
info: { uuid: `${rdns}-${providerTag}`, name, icon: "data:image/svg+xml,x", rdns },
|
|
13
|
+
provider,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => __test.reset());
|
|
18
|
+
|
|
19
|
+
describe("eip6963 discovery store", () => {
|
|
20
|
+
test("accumulates announced wallets", () => {
|
|
21
|
+
__test.announce(fakeWallet("io.metamask", "MetaMask"));
|
|
22
|
+
__test.announce(fakeWallet("com.coinbase.wallet", "Coinbase Wallet"));
|
|
23
|
+
expect(__test.count()).toBe(2);
|
|
24
|
+
expect(getWalletByRdns("io.metamask")?.info.name).toBe("MetaMask");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("de-dupes by rdns; identical re-announce is a no-op", () => {
|
|
28
|
+
const w = fakeWallet("io.metamask", "MetaMask", "same");
|
|
29
|
+
__test.announce(w);
|
|
30
|
+
__test.announce(w); // same provider ref → ignored
|
|
31
|
+
expect(__test.count()).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("newer provider for same rdns replaces (extension reload)", () => {
|
|
35
|
+
__test.announce(fakeWallet("io.metamask", "MetaMask", "old"));
|
|
36
|
+
__test.announce(fakeWallet("io.metamask", "MetaMask", "new"));
|
|
37
|
+
expect(__test.count()).toBe(1);
|
|
38
|
+
const provider = getWalletByRdns("io.metamask")?.provider as TaggedProvider | undefined;
|
|
39
|
+
expect(provider?._tag).toBe("new");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("getWallets returns name-sorted list", () => {
|
|
43
|
+
__test.announce(fakeWallet("com.brave.wallet", "Brave"));
|
|
44
|
+
__test.announce(fakeWallet("io.metamask", "MetaMask"));
|
|
45
|
+
expect(getWallets().map((w) => w.info.name)).toEqual(["Brave", "MetaMask"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ignores announces with empty rdns", () => {
|
|
49
|
+
__test.announce({
|
|
50
|
+
info: { uuid: "", name: "X", icon: "", rdns: "" },
|
|
51
|
+
provider: { request: async () => null },
|
|
52
|
+
});
|
|
53
|
+
expect(__test.count()).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("walletSortKey — preferred wallets float to top", () => {
|
|
58
|
+
test("metamask before an unknown wallet", () => {
|
|
59
|
+
const mm = walletSortKey("io.metamask", "MetaMask");
|
|
60
|
+
const other = walletSortKey("xyz.unknown", "Zzz");
|
|
61
|
+
expect(mm[0]).toBeLessThan(other[0]);
|
|
62
|
+
});
|
|
63
|
+
test("coinbase before brave (preferred order)", () => {
|
|
64
|
+
expect(walletSortKey("com.coinbase.wallet", "Coinbase")[0]).toBeLessThan(
|
|
65
|
+
walletSortKey("com.brave.wallet", "Brave")[0],
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/eip6963.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EIP-6963 multi-injected-provider discovery — the standard way to list the
|
|
3
|
+
* browser wallets a user actually has (MetaMask, Coinbase Wallet, Brave, …)
|
|
4
|
+
* WITHOUT hardcoding any of them. Each wallet ships its own name + icon in the
|
|
5
|
+
* announce event, so the UI renders whatever is installed.
|
|
6
|
+
*
|
|
7
|
+
* Framework-agnostic store: call `subscribeWallets` (returns an unsubscribe) or
|
|
8
|
+
* read `getWallets()`. The React hook in ./react wraps this with useSyncExternalStore.
|
|
9
|
+
*
|
|
10
|
+
* Why a module-level store: EIP-6963 is request/announce. We dispatch
|
|
11
|
+
* `eip6963:requestProvider` once and accumulate `eip6963:announceProvider`
|
|
12
|
+
* events; wallets injected later (extension wakes up) announce again and we
|
|
13
|
+
* de-dupe by `rdns` (newest provider wins — handles extension reloads).
|
|
14
|
+
*/
|
|
15
|
+
import type { DiscoveredWallet, Eip6963AnnounceDetail } from "./types";
|
|
16
|
+
|
|
17
|
+
const byRdns = new Map<string, DiscoveredWallet>();
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
let started = false;
|
|
20
|
+
|
|
21
|
+
function emit(): void {
|
|
22
|
+
for (const l of listeners) l();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function onAnnounce(event: Event): void {
|
|
26
|
+
const { detail } = event as CustomEvent<Eip6963AnnounceDetail>;
|
|
27
|
+
if (!detail?.info?.rdns || !detail.provider) return;
|
|
28
|
+
const prev = byRdns.get(detail.info.rdns);
|
|
29
|
+
// Replace if new (extension reload) or first sight; skip identical re-announce.
|
|
30
|
+
if (prev && prev.provider === detail.provider) return;
|
|
31
|
+
byRdns.set(detail.info.rdns, detail);
|
|
32
|
+
emit();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Begin listening + ask installed wallets to announce. Idempotent + SSR-safe. */
|
|
36
|
+
export function startDiscovery(): void {
|
|
37
|
+
if (started || typeof window === "undefined") return;
|
|
38
|
+
started = true;
|
|
39
|
+
window.addEventListener("eip6963:announceProvider", onAnnounce as EventListener);
|
|
40
|
+
window.dispatchEvent(new Event("eip6963:requestProvider"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Current discovered wallets, sorted by name (stable for rendering). */
|
|
44
|
+
export function getWallets(): DiscoveredWallet[] {
|
|
45
|
+
return [...byRdns.values()].sort((a, b) => a.info.name.localeCompare(b.info.name));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Subscribe to discovery changes. Auto-starts discovery. Returns unsubscribe. */
|
|
49
|
+
export function subscribeWallets(cb: () => void): () => void {
|
|
50
|
+
startDiscovery();
|
|
51
|
+
listeners.add(cb);
|
|
52
|
+
// Re-request: a wallet that already announced before we subscribed will
|
|
53
|
+
// re-announce on request, so late subscribers still get the full set.
|
|
54
|
+
if (typeof window !== "undefined") window.dispatchEvent(new Event("eip6963:requestProvider"));
|
|
55
|
+
return () => {
|
|
56
|
+
listeners.delete(cb);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Look up a discovered wallet by reverse-DNS id (e.g. "io.metamask"). */
|
|
61
|
+
export function getWalletByRdns(rdns: string): DiscoveredWallet | undefined {
|
|
62
|
+
return byRdns.get(rdns);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Test-only: inject an announce event + reset. Not exported from the package root. */
|
|
66
|
+
export const __test = {
|
|
67
|
+
reset(): void {
|
|
68
|
+
byRdns.clear();
|
|
69
|
+
listeners.clear();
|
|
70
|
+
started = false;
|
|
71
|
+
},
|
|
72
|
+
announce(detail: DiscoveredWallet): void {
|
|
73
|
+
onAnnounce(new CustomEvent("eip6963:announceProvider", { detail }));
|
|
74
|
+
},
|
|
75
|
+
count(): number {
|
|
76
|
+
return byRdns.size;
|
|
77
|
+
},
|
|
78
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Headless core — framework-agnostic. React bindings live in "@bufi/web3-signin/react".
|
|
2
|
+
export type {
|
|
3
|
+
Address,
|
|
4
|
+
BrandWallet,
|
|
5
|
+
BufiBrandWalletId,
|
|
6
|
+
DiscoveredWallet,
|
|
7
|
+
Eip1193EventMap,
|
|
8
|
+
Eip1193Provider,
|
|
9
|
+
Eip1193RequestArgs,
|
|
10
|
+
Eip6963ProviderInfo,
|
|
11
|
+
JsonValue,
|
|
12
|
+
ProviderRpcError,
|
|
13
|
+
TokenBalance,
|
|
14
|
+
WalletAccount,
|
|
15
|
+
WalletScope,
|
|
16
|
+
} from "./types";
|
|
17
|
+
export {
|
|
18
|
+
getWallets,
|
|
19
|
+
getWalletByRdns,
|
|
20
|
+
startDiscovery,
|
|
21
|
+
subscribeWallets,
|
|
22
|
+
} from "./eip6963";
|
|
23
|
+
export {
|
|
24
|
+
signInWithWallet,
|
|
25
|
+
type WalletSignInArgs,
|
|
26
|
+
type WalletSignInResult,
|
|
27
|
+
} from "./sign-in";
|
|
28
|
+
export {
|
|
29
|
+
BRAND_WALLETS,
|
|
30
|
+
PREFERRED_RDNS_ORDER,
|
|
31
|
+
walletSortKey,
|
|
32
|
+
} from "./registry";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { useInjectedWallets } from "./use-injected-wallets";
|
|
2
|
+
export {
|
|
3
|
+
useWeb3SignIn,
|
|
4
|
+
type UseWeb3SignIn,
|
|
5
|
+
type UseWeb3SignInArgs,
|
|
6
|
+
type Web3SignInStatus,
|
|
7
|
+
} from "./use-web3-signin";
|
|
8
|
+
export { Turnstile, resetTurnstile, type TurnstileProps } from "./turnstile";
|
|
9
|
+
export {
|
|
10
|
+
WalletDropdown,
|
|
11
|
+
type WalletDropdownProps,
|
|
12
|
+
type WalletDropdownClassNames,
|
|
13
|
+
} from "./wallet-dropdown";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare Turnstile widget for the wallet-login CAPTCHA.
|
|
5
|
+
*
|
|
6
|
+
* IMPORTANT: for Supabase auth there is NO siteverify Worker. Supabase's GoTrue
|
|
7
|
+
* verifies this token server-side using the secret set in the Supabase
|
|
8
|
+
* dashboard. This component only renders the widget and hands the resulting
|
|
9
|
+
* token up via `onToken`, which the caller passes as `captchaToken` to
|
|
10
|
+
* `signInWithWeb3`. Reset it after each auth attempt (tokens are single-use).
|
|
11
|
+
*/
|
|
12
|
+
declare global {
|
|
13
|
+
interface Window {
|
|
14
|
+
turnstile?: {
|
|
15
|
+
render: (el: HTMLElement, opts: TurnstileRenderOpts) => string;
|
|
16
|
+
reset: (id?: string) => void;
|
|
17
|
+
remove: (id?: string) => void;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TurnstileRenderOpts {
|
|
23
|
+
sitekey: string;
|
|
24
|
+
callback: (token: string) => void;
|
|
25
|
+
"error-callback"?: () => void;
|
|
26
|
+
"expired-callback"?: () => void;
|
|
27
|
+
action?: string;
|
|
28
|
+
theme?: "auto" | "light" | "dark";
|
|
29
|
+
size?: "normal" | "flexible" | "compact";
|
|
30
|
+
appearance?: "always" | "execute" | "interaction-only";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
|
34
|
+
let scriptPromise: Promise<void> | null = null;
|
|
35
|
+
|
|
36
|
+
function loadScript(): Promise<void> {
|
|
37
|
+
if (typeof window === "undefined") return Promise.resolve();
|
|
38
|
+
if (window.turnstile) return Promise.resolve();
|
|
39
|
+
if (scriptPromise) return scriptPromise;
|
|
40
|
+
scriptPromise = new Promise<void>((resolve, reject) => {
|
|
41
|
+
const s = document.createElement("script");
|
|
42
|
+
s.src = SCRIPT_SRC;
|
|
43
|
+
s.async = true;
|
|
44
|
+
s.defer = true;
|
|
45
|
+
s.onload = () => resolve();
|
|
46
|
+
s.onerror = () => reject(new Error("turnstile script failed to load"));
|
|
47
|
+
document.head.appendChild(s);
|
|
48
|
+
});
|
|
49
|
+
return scriptPromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TurnstileProps {
|
|
53
|
+
sitekey: string;
|
|
54
|
+
onToken: (token: string) => void;
|
|
55
|
+
onExpire?: () => void;
|
|
56
|
+
onError?: () => void;
|
|
57
|
+
theme?: "auto" | "light" | "dark";
|
|
58
|
+
appearance?: "always" | "execute" | "interaction-only";
|
|
59
|
+
className?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Turnstile({
|
|
63
|
+
sitekey,
|
|
64
|
+
onToken,
|
|
65
|
+
onExpire,
|
|
66
|
+
onError,
|
|
67
|
+
theme = "auto",
|
|
68
|
+
appearance = "interaction-only",
|
|
69
|
+
className,
|
|
70
|
+
}: TurnstileProps) {
|
|
71
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
72
|
+
const widgetId = useRef<string | null>(null);
|
|
73
|
+
// Keep latest callbacks without re-rendering the widget.
|
|
74
|
+
const cb = useRef({ onToken, onExpire, onError });
|
|
75
|
+
cb.current = { onToken, onExpire, onError };
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
let cancelled = false;
|
|
79
|
+
if (!sitekey) return;
|
|
80
|
+
loadScript()
|
|
81
|
+
.then(() => {
|
|
82
|
+
if (cancelled || !ref.current || !window.turnstile) return;
|
|
83
|
+
widgetId.current = window.turnstile.render(ref.current, {
|
|
84
|
+
sitekey,
|
|
85
|
+
action: "bufi-wallet-login",
|
|
86
|
+
theme,
|
|
87
|
+
appearance,
|
|
88
|
+
callback: (t) => cb.current.onToken(t),
|
|
89
|
+
"expired-callback": () => cb.current.onExpire?.(),
|
|
90
|
+
"error-callback": () => cb.current.onError?.(),
|
|
91
|
+
});
|
|
92
|
+
})
|
|
93
|
+
.catch(() => cb.current.onError?.());
|
|
94
|
+
return () => {
|
|
95
|
+
cancelled = true;
|
|
96
|
+
if (widgetId.current && window.turnstile) {
|
|
97
|
+
try {
|
|
98
|
+
window.turnstile.remove(widgetId.current);
|
|
99
|
+
} catch {
|
|
100
|
+
/* widget already gone */
|
|
101
|
+
}
|
|
102
|
+
widgetId.current = null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}, [sitekey, theme, appearance]);
|
|
106
|
+
|
|
107
|
+
return <div ref={ref} className={className} />;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Imperatively reset the rendered widget (tokens are single-use; reset after
|
|
111
|
+
* each auth attempt). Pass the same sitekey instance's container. */
|
|
112
|
+
export function resetTurnstile(): void {
|
|
113
|
+
if (typeof window !== "undefined") window.turnstile?.reset();
|
|
114
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { getWallets, subscribeWallets } from "../eip6963";
|
|
3
|
+
import { walletSortKey } from "../registry";
|
|
4
|
+
import type { DiscoveredWallet } from "../types";
|
|
5
|
+
|
|
6
|
+
const EMPTY: DiscoveredWallet[] = [];
|
|
7
|
+
let cache: DiscoveredWallet[] = EMPTY;
|
|
8
|
+
let cacheKey = "";
|
|
9
|
+
|
|
10
|
+
/** Stable snapshot: useSyncExternalStore needs referential stability between
|
|
11
|
+
* renders when nothing changed, or React loops. We memo on a cheap key. */
|
|
12
|
+
function snapshot(): DiscoveredWallet[] {
|
|
13
|
+
const wallets = getWallets();
|
|
14
|
+
const key = wallets.map((w) => w.info.rdns).join("|");
|
|
15
|
+
if (key !== cacheKey) {
|
|
16
|
+
cacheKey = key;
|
|
17
|
+
cache = wallets
|
|
18
|
+
.slice()
|
|
19
|
+
.sort((a, b) => {
|
|
20
|
+
const [ai, an] = walletSortKey(a.info.rdns, a.info.name);
|
|
21
|
+
const [bi, bn] = walletSortKey(b.info.rdns, b.info.name);
|
|
22
|
+
return ai - bi || an.localeCompare(bn);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return cache;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Live list of EIP-6963-discovered browser wallets (MetaMask, Coinbase, Brave,
|
|
30
|
+
* …), preferred-ordered. Each carries `.info.name`, `.info.icon` (the wallet's
|
|
31
|
+
* own icon), `.info.rdns`, and `.provider`. SSR-safe (empty on the server).
|
|
32
|
+
*/
|
|
33
|
+
export function useInjectedWallets(): DiscoveredWallet[] {
|
|
34
|
+
return useSyncExternalStore(
|
|
35
|
+
subscribeWallets,
|
|
36
|
+
snapshot,
|
|
37
|
+
() => EMPTY,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
3
|
+
import { signInWithWallet, type WalletSignInResult } from "../sign-in";
|
|
4
|
+
import type { DiscoveredWallet } from "../types";
|
|
5
|
+
import { useInjectedWallets } from "./use-injected-wallets";
|
|
6
|
+
|
|
7
|
+
export type Web3SignInStatus = "idle" | "signing" | "success" | "error";
|
|
8
|
+
|
|
9
|
+
export interface UseWeb3SignInArgs {
|
|
10
|
+
supabase: SupabaseClient | null;
|
|
11
|
+
/** SIWE consent statement shown in the wallet prompt. */
|
|
12
|
+
statement: string;
|
|
13
|
+
/** Called once a session is established. */
|
|
14
|
+
onSignedIn?: (result: WalletSignInResult) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseWeb3SignIn {
|
|
18
|
+
/** EIP-6963-discovered wallets (preferred-ordered). */
|
|
19
|
+
wallets: DiscoveredWallet[];
|
|
20
|
+
status: Web3SignInStatus;
|
|
21
|
+
error: string | null;
|
|
22
|
+
/** The rdns currently mid-sign, for per-button spinners. */
|
|
23
|
+
pendingRdns: string | null;
|
|
24
|
+
/** Trigger sign-in for a discovered wallet. `captchaToken` is required when
|
|
25
|
+
* the project enforces CAPTCHA on Web3 sign-in (wallet logins do). */
|
|
26
|
+
signIn: (wallet: DiscoveredWallet, captchaToken?: string) => Promise<WalletSignInResult>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One hook for the whole wallet-login surface: the discovered wallet list +
|
|
31
|
+
* a `signIn` that runs Supabase Web3 auth and tracks status. UI is the app's;
|
|
32
|
+
* this owns the logic so desk + defi-web stay DRY.
|
|
33
|
+
*/
|
|
34
|
+
export function useWeb3SignIn(args: UseWeb3SignInArgs): UseWeb3SignIn {
|
|
35
|
+
const { supabase, statement, onSignedIn } = args;
|
|
36
|
+
const wallets = useInjectedWallets();
|
|
37
|
+
const [status, setStatus] = useState<Web3SignInStatus>("idle");
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
const [pendingRdns, setPendingRdns] = useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
const signIn = useCallback(
|
|
42
|
+
async (wallet: DiscoveredWallet, captchaToken?: string): Promise<WalletSignInResult> => {
|
|
43
|
+
if (!supabase) {
|
|
44
|
+
const r = { ok: false, error: "auth not configured" };
|
|
45
|
+
setStatus("error");
|
|
46
|
+
setError(r.error);
|
|
47
|
+
return r;
|
|
48
|
+
}
|
|
49
|
+
setStatus("signing");
|
|
50
|
+
setError(null);
|
|
51
|
+
setPendingRdns(wallet.info.rdns);
|
|
52
|
+
const result = await signInWithWallet({ supabase, wallet, statement, captchaToken });
|
|
53
|
+
setPendingRdns(null);
|
|
54
|
+
if (result.ok) {
|
|
55
|
+
setStatus("success");
|
|
56
|
+
onSignedIn?.(result);
|
|
57
|
+
} else {
|
|
58
|
+
setStatus("error");
|
|
59
|
+
setError(result.error ?? "sign-in failed");
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
},
|
|
63
|
+
[supabase, statement, onSignedIn],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return { wallets, status, error, pendingRdns, signIn };
|
|
67
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { type ReactNode, useMemo, useState } from "react";
|
|
2
|
+
import type { TokenBalance, WalletAccount, WalletScope } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unified Stablecoin FX wallet pill + dropdown — one instance shared by
|
|
6
|
+
* defi-web (scope="personal") and desk (scope="all": Operations/Treasury/
|
|
7
|
+
* Personal). Presentational + auth-gated: when `authed` is false it renders
|
|
8
|
+
* NOTHING (no pill, no balances, no tabs). Balances are app-supplied (fx reads
|
|
9
|
+
* via wagmi, desk via its wallet context) and passed in as `accounts`, so this
|
|
10
|
+
* stays DRY and free of data-fetching.
|
|
11
|
+
*
|
|
12
|
+
* Theming is per-app via `classNames` (both apps' design tokens differ); the
|
|
13
|
+
* defaults are minimal and responsive.
|
|
14
|
+
*/
|
|
15
|
+
export interface WalletDropdownClassNames {
|
|
16
|
+
root?: string;
|
|
17
|
+
pill?: string;
|
|
18
|
+
panel?: string;
|
|
19
|
+
tabRow?: string;
|
|
20
|
+
tab?: string;
|
|
21
|
+
tabActive?: string;
|
|
22
|
+
accountRow?: string;
|
|
23
|
+
balanceRow?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WalletDropdownProps {
|
|
27
|
+
/** Auth gate. False → component renders nothing. */
|
|
28
|
+
authed: boolean;
|
|
29
|
+
/** App-supplied accounts (with their balances). */
|
|
30
|
+
accounts: WalletAccount[];
|
|
31
|
+
/** "personal" (defi-web) | "team" | "all" (desk shows every scope). */
|
|
32
|
+
scope?: WalletScope | "all";
|
|
33
|
+
/** Currency formatter for the USD total; defaults to en-US $. */
|
|
34
|
+
formatUsd?: (n: number) => string;
|
|
35
|
+
/** Extra content inside the open panel — desk injects gateway/hinkal/multisig. */
|
|
36
|
+
children?: ReactNode;
|
|
37
|
+
/** Shown when authed but no accounts yet (provisioning). */
|
|
38
|
+
emptyState?: ReactNode;
|
|
39
|
+
onSelectAccount?: (account: WalletAccount) => void;
|
|
40
|
+
classNames?: WalletDropdownClassNames;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const defaultFormatUsd = (n: number): string =>
|
|
44
|
+
`$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
45
|
+
|
|
46
|
+
function totalUsd(balances: TokenBalance[]): number {
|
|
47
|
+
return balances.reduce((sum, b) => sum + (Number.isFinite(b.usd) ? b.usd : 0), 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function WalletDropdown(props: WalletDropdownProps): ReactNode {
|
|
51
|
+
const {
|
|
52
|
+
authed,
|
|
53
|
+
accounts,
|
|
54
|
+
scope = "all",
|
|
55
|
+
formatUsd = defaultFormatUsd,
|
|
56
|
+
children,
|
|
57
|
+
emptyState,
|
|
58
|
+
onSelectAccount,
|
|
59
|
+
classNames = {},
|
|
60
|
+
} = props;
|
|
61
|
+
|
|
62
|
+
const visible = useMemo(
|
|
63
|
+
() => (scope === "all" ? accounts : accounts.filter((a) => a.scope === scope)),
|
|
64
|
+
[accounts, scope],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const [open, setOpen] = useState(false);
|
|
68
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
69
|
+
|
|
70
|
+
// Auth gate — the whole surface is hidden until sign-in passes.
|
|
71
|
+
if (!authed) return null;
|
|
72
|
+
|
|
73
|
+
const active = visible.find((a) => a.id === activeId) ?? visible[0];
|
|
74
|
+
const pillTotal = active ? totalUsd(active.balances) : 0;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className={classNames.root} data-bufi-wallet>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
aria-label="Open Stablecoin FX Wallet"
|
|
81
|
+
aria-expanded={open}
|
|
82
|
+
className={classNames.pill ?? "acct-mini"}
|
|
83
|
+
onClick={() => setOpen((v) => !v)}
|
|
84
|
+
>
|
|
85
|
+
{formatUsd(pillTotal)}
|
|
86
|
+
</button>
|
|
87
|
+
|
|
88
|
+
{open && (
|
|
89
|
+
<div role="menu" className={classNames.panel} data-bufi-wallet-panel>
|
|
90
|
+
{visible.length === 0
|
|
91
|
+
? (emptyState ?? null)
|
|
92
|
+
: (
|
|
93
|
+
<>
|
|
94
|
+
{scope !== "personal" && visible.length > 1 && (
|
|
95
|
+
<div className={classNames.tabRow} role="tablist">
|
|
96
|
+
{visible.map((a) => {
|
|
97
|
+
const isActive = a.id === active?.id;
|
|
98
|
+
return (
|
|
99
|
+
<button
|
|
100
|
+
key={a.id}
|
|
101
|
+
type="button"
|
|
102
|
+
role="tab"
|
|
103
|
+
aria-selected={isActive}
|
|
104
|
+
className={`${classNames.tab ?? ""} ${isActive ? (classNames.tabActive ?? "") : ""}`.trim()}
|
|
105
|
+
onClick={() => {
|
|
106
|
+
setActiveId(a.id);
|
|
107
|
+
onSelectAccount?.(a);
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{a.label}
|
|
111
|
+
</button>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{active && (
|
|
118
|
+
<div className={classNames.accountRow}>
|
|
119
|
+
{active.balances.length === 0
|
|
120
|
+
? (emptyState ?? null)
|
|
121
|
+
: active.balances
|
|
122
|
+
.slice()
|
|
123
|
+
.sort((x, y) => y.usd - x.usd)
|
|
124
|
+
.map((b) => (
|
|
125
|
+
<div
|
|
126
|
+
key={`${b.chainId}:${b.symbol}`}
|
|
127
|
+
className={classNames.balanceRow}
|
|
128
|
+
data-bufi-balance
|
|
129
|
+
>
|
|
130
|
+
<span>{b.symbol}</span>
|
|
131
|
+
<span>{b.amount}</span>
|
|
132
|
+
<span>{b.chainName}</span>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{children}
|
|
139
|
+
</>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet ordering + BUFI-brand entries.
|
|
3
|
+
*
|
|
4
|
+
* Injected wallets (MetaMask, Coinbase, Brave, …) come from EIP-6963 with their
|
|
5
|
+
* own icons — never hardcoded. This module only adds the two BUFI-brand options
|
|
6
|
+
* that aren't browser extensions (the embedded Circle UCW and ghost-mode), and
|
|
7
|
+
* a preferred display order so the common wallets float to the top.
|
|
8
|
+
*/
|
|
9
|
+
import type { BrandWallet } from "./types";
|
|
10
|
+
|
|
11
|
+
/** Preferred top-of-list order by reverse-DNS id; everything else follows A-Z. */
|
|
12
|
+
export const PREFERRED_RDNS_ORDER: readonly string[] = [
|
|
13
|
+
"io.metamask",
|
|
14
|
+
"com.coinbase.wallet",
|
|
15
|
+
"com.brave.wallet",
|
|
16
|
+
"app.phantom",
|
|
17
|
+
"com.okex.wallet",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Stable sort key: preferred wallets first (in PREFERRED order), then by name. */
|
|
21
|
+
export function walletSortKey(rdns: string, name: string): [number, string] {
|
|
22
|
+
const i = PREFERRED_RDNS_ORDER.indexOf(rdns);
|
|
23
|
+
return [i === -1 ? PREFERRED_RDNS_ORDER.length : i, name.toLowerCase()];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The BUFI-brand non-injected options. Icons are supplied by the host app
|
|
27
|
+
* (rabbit = embedded UCW, ghost = ghost-mode private routing) since they're
|
|
28
|
+
* app-owned brand assets, not EIP-6963 data URIs. */
|
|
29
|
+
export const BRAND_WALLETS: readonly BrandWallet[] = [
|
|
30
|
+
{ id: "bufi-embedded", name: "BUFI Wallet", rdns: "finance.bu.embedded" },
|
|
31
|
+
{ id: "bufi-ghost", name: "Ghost Mode", rdns: "finance.bu.ghost" },
|
|
32
|
+
];
|
package/src/sign-in.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase Web3 wallet sign-in (EIP-4361 "Sign in with Ethereum").
|
|
3
|
+
*
|
|
4
|
+
* Supabase does the whole protocol natively: given a discovered wallet's
|
|
5
|
+
* provider, `signInWithWeb3` builds the SIWE message, asks the wallet to sign,
|
|
6
|
+
* and verifies the signature server-side (10-minute timestamp window) before
|
|
7
|
+
* minting the session. We do NOT build or verify SIWE ourselves, and we do NOT
|
|
8
|
+
* run a siteverify Worker for the captcha — GoTrue verifies the Turnstile token
|
|
9
|
+
* using the secret configured in the Supabase dashboard.
|
|
10
|
+
*
|
|
11
|
+
* Requirements (one-time, project side):
|
|
12
|
+
* - Supabase dashboard → Auth providers → enable Web3 Wallet (Ethereum)
|
|
13
|
+
* - Auth → Bot & Abuse Protection → enable CAPTCHA (Turnstile) + secret
|
|
14
|
+
*/
|
|
15
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
16
|
+
import type { DiscoveredWallet } from "./types";
|
|
17
|
+
|
|
18
|
+
/** The exact credentials type Supabase's signInWithWeb3 accepts (derived from
|
|
19
|
+
* the installed client — stays correct across supabase-js upgrades). */
|
|
20
|
+
type Web3Credentials = Parameters<SupabaseClient["auth"]["signInWithWeb3"]>[0];
|
|
21
|
+
|
|
22
|
+
export interface WalletSignInArgs {
|
|
23
|
+
supabase: SupabaseClient;
|
|
24
|
+
/** A wallet from EIP-6963 discovery. Omit to use the default window provider. */
|
|
25
|
+
wallet?: DiscoveredWallet;
|
|
26
|
+
/** Consent line shown in the SIWE message (e.g. "I accept the BUFI Terms…").
|
|
27
|
+
* Must not contain newlines (SIWE statement rule). */
|
|
28
|
+
statement: string;
|
|
29
|
+
/** Cloudflare Turnstile token. Required when the project enforces CAPTCHA on
|
|
30
|
+
* Web3 sign-in (we do, for wallet logins). */
|
|
31
|
+
captchaToken?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WalletSignInResult {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
/** Supabase session user id (auth.users.id) on success. */
|
|
37
|
+
userId?: string;
|
|
38
|
+
/** The address that signed, lowercased. */
|
|
39
|
+
address?: string;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sign in with a browser wallet via Supabase Web3 auth. One call: prompts the
|
|
45
|
+
* wallet to sign the SIWE message and returns the established session.
|
|
46
|
+
*/
|
|
47
|
+
export async function signInWithWallet(args: WalletSignInArgs): Promise<WalletSignInResult> {
|
|
48
|
+
const { supabase, wallet, statement, captchaToken } = args;
|
|
49
|
+
try {
|
|
50
|
+
// Built to match EthereumWeb3Credentials. The lone cast bridges Supabase's
|
|
51
|
+
// EthereumWallet type, which (as published) requires an `address` field that
|
|
52
|
+
// no real EIP-6963 / window.ethereum provider exposes — so the provider is
|
|
53
|
+
// adapted here, at the single library boundary, with no `unknown`/`any`.
|
|
54
|
+
const credentials = {
|
|
55
|
+
chain: "ethereum",
|
|
56
|
+
statement,
|
|
57
|
+
...(wallet ? { wallet: wallet.provider } : {}),
|
|
58
|
+
...(captchaToken ? { options: { captchaToken } } : {}),
|
|
59
|
+
} as Web3Credentials;
|
|
60
|
+
|
|
61
|
+
const { data, error } = await supabase.auth.signInWithWeb3(credentials);
|
|
62
|
+
if (error) return { ok: false, error: error.message };
|
|
63
|
+
|
|
64
|
+
const user = data.user;
|
|
65
|
+
const meta = user.user_metadata as Record<string, JsonLike> | undefined;
|
|
66
|
+
const rawAddress =
|
|
67
|
+
pickString(meta?.address) ?? pickString(meta?.wallet_address) ?? pickString(meta?.custom_claims);
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
userId: user.id,
|
|
71
|
+
address: rawAddress ? rawAddress.toLowerCase() : undefined,
|
|
72
|
+
};
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** user_metadata is loosely shaped by Supabase; read fields defensively without `any`. */
|
|
79
|
+
type JsonLike = string | number | boolean | null | JsonLike[] | { [k: string]: JsonLike };
|
|
80
|
+
function pickString(v: JsonLike | undefined): string | undefined {
|
|
81
|
+
return typeof v === "string" ? v : undefined;
|
|
82
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict types — no `any`, no naked `unknown` in the public surface. Arbitrary
|
|
3
|
+
* JSON-RPC payloads are modeled as `JsonValue` (the honest type for "any JSON"),
|
|
4
|
+
* not `unknown`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Any JSON-serializable value — the precise type for EIP-1193 params/results. */
|
|
8
|
+
export type JsonValue =
|
|
9
|
+
| string
|
|
10
|
+
| number
|
|
11
|
+
| boolean
|
|
12
|
+
| null
|
|
13
|
+
| JsonValue[]
|
|
14
|
+
| { [key: string]: JsonValue };
|
|
15
|
+
|
|
16
|
+
/** EIP-1193 request envelope. */
|
|
17
|
+
export interface Eip1193RequestArgs {
|
|
18
|
+
readonly method: string;
|
|
19
|
+
readonly params?: readonly JsonValue[] | Record<string, JsonValue>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** EIP-1193 provider error (EIP-1193 §5). */
|
|
23
|
+
export interface ProviderRpcError extends Error {
|
|
24
|
+
code: number;
|
|
25
|
+
data?: JsonValue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Typed EIP-1193 event map (the events wallets actually emit). */
|
|
29
|
+
export interface Eip1193EventMap {
|
|
30
|
+
accountsChanged: [accounts: string[]];
|
|
31
|
+
chainChanged: [chainId: string];
|
|
32
|
+
connect: [connectInfo: { chainId: string }];
|
|
33
|
+
disconnect: [error: ProviderRpcError];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Minimal, fully-typed EIP-1193 provider. The discovery store treats this
|
|
38
|
+
* opaquely (stores + compares by reference); only the Supabase sign-in bridge
|
|
39
|
+
* invokes `request`, and it does so through Supabase's own typed call.
|
|
40
|
+
*/
|
|
41
|
+
export interface Eip1193Provider {
|
|
42
|
+
request(args: Eip1193RequestArgs): Promise<JsonValue>;
|
|
43
|
+
on?<E extends keyof Eip1193EventMap>(event: E, listener: (...args: Eip1193EventMap[E]) => void): void;
|
|
44
|
+
removeListener?<E extends keyof Eip1193EventMap>(
|
|
45
|
+
event: E,
|
|
46
|
+
listener: (...args: Eip1193EventMap[E]) => void,
|
|
47
|
+
): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** EIP-6963 provider info, as announced by the wallet. */
|
|
51
|
+
export interface Eip6963ProviderInfo {
|
|
52
|
+
/** Globally-unique per page load. */
|
|
53
|
+
uuid: string;
|
|
54
|
+
/** Human name, e.g. "MetaMask". */
|
|
55
|
+
name: string;
|
|
56
|
+
/** data: URI — the wallet ships its own icon; we never hardcode these. */
|
|
57
|
+
icon: string;
|
|
58
|
+
/** Reverse-DNS id, e.g. "io.metamask" — stable, used for de-dupe + ordering. */
|
|
59
|
+
rdns: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A discovered injected wallet: its announced info + the live provider. */
|
|
63
|
+
export interface DiscoveredWallet {
|
|
64
|
+
info: Eip6963ProviderInfo;
|
|
65
|
+
provider: Eip1193Provider;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** EIP-6963 announce event detail. */
|
|
69
|
+
export type Eip6963AnnounceDetail = DiscoveredWallet;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* BUFI-brand non-injected options shown alongside discovered wallets: the
|
|
73
|
+
* embedded Circle UCW (rabbit) and ghost-mode private routing (ghost). Icons
|
|
74
|
+
* are app-owned brand assets, supplied by the host (not EIP-6963 data URIs).
|
|
75
|
+
*/
|
|
76
|
+
export type BufiBrandWalletId = "bufi-embedded" | "bufi-ghost";
|
|
77
|
+
|
|
78
|
+
export interface BrandWallet {
|
|
79
|
+
id: BufiBrandWalletId;
|
|
80
|
+
name: string;
|
|
81
|
+
rdns: string; // synthetic, for stable keys/ordering
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Which wallet surface to render (cross-repo: defi-web = personal; desk = team+personal). */
|
|
85
|
+
export type WalletScope = "personal" | "team";
|
|
86
|
+
|
|
87
|
+
// ── Unified wallet dropdown contract ────────────────────────────────────────
|
|
88
|
+
// The pill + dropdown shell is shared; balances are app-supplied (fx reads via
|
|
89
|
+
// wagmi, desk via its wallet context + gateway/hinkal), passed in as props.
|
|
90
|
+
|
|
91
|
+
/** Address string (lowercased 0x). */
|
|
92
|
+
export type Address = `0x${string}`;
|
|
93
|
+
|
|
94
|
+
/** One stablecoin balance row on one chain. */
|
|
95
|
+
export interface TokenBalance {
|
|
96
|
+
symbol: string; // "USDC", "EURC", …
|
|
97
|
+
chainId: number;
|
|
98
|
+
chainName: string;
|
|
99
|
+
/** Human-readable amount, already decimal-formatted by the host. */
|
|
100
|
+
amount: string;
|
|
101
|
+
/** USD-equivalent value (for the pill total + sorting). */
|
|
102
|
+
usd: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A selectable wallet within a scope. desk: Operations/Treasury/Personal;
|
|
106
|
+
* defi-web: a single Personal account. */
|
|
107
|
+
export interface WalletAccount {
|
|
108
|
+
id: string;
|
|
109
|
+
scope: WalletScope;
|
|
110
|
+
/** "Personal" | "Operations" | "Treasury" — shown as the row/tab label. */
|
|
111
|
+
label: string;
|
|
112
|
+
address: Address;
|
|
113
|
+
balances: TokenBalance[];
|
|
114
|
+
}
|
|
115
|
+
|