feat: finish alby wallet
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Kieran 2024-01-04 12:05:13 +00:00
parent 0442c3512c
commit 9f88b44b91
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
16 changed files with 341 additions and 109 deletions

View File

@ -40,5 +40,9 @@
"wss://nostr.wine/": { "read": true, "write": false },
"wss://eden.nostr.land/": { "read": true, "write": false }
},
"useIndexedDBEvents": false
"useIndexedDBEvents": false,
"alby": {
"clientId": "pohiJjPhQR",
"clientSecret": "GAl1YKLA3FveK1gLBYok"
}
}

View File

@ -85,6 +85,11 @@ declare const CONFIG: {
profileLinkPrefix: NostrPrefix;
defaultRelays: Record<string, RelaySettings>;
useIndexedDBEvents: boolean;
// Alby wallet oAuth config
alby?: {
clientId: string;
clientSecret: string;
};
};
/**

View File

@ -354,18 +354,18 @@ export function NoteCreator() {
onChange={e => {
note.update(
v =>
(v.selectedCustomRelays =
// set false if all relays selected
e.target.checked &&
(v.selectedCustomRelays =
// set false if all relays selected
e.target.checked &&
note.selectedCustomRelays &&
note.selectedCustomRelays.length == a.length - 1
? undefined
: // otherwise return selectedCustomRelays with target relay added / removed
a.filter(el =>
el === r
? e.target.checked
: !note.selectedCustomRelays || note.selectedCustomRelays.includes(el),
)),
? undefined
: // otherwise return selectedCustomRelays with target relay added / removed
a.filter(el =>
el === r
? e.target.checked
: !note.selectedCustomRelays || note.selectedCustomRelays.includes(el),
)),
);
}}
/>
@ -434,9 +434,9 @@ export function NoteCreator() {
onChange={e =>
note.update(
v =>
(v.zapSplits = arr.map((vv, ii) =>
ii === i ? { ...vv, weight: Number(e.target.value) } : vv,
)),
(v.zapSplits = arr.map((vv, ii) =>
ii === i ? { ...vv, weight: Number(e.target.value) } : vv,
)),
)
}
/>

View File

@ -130,4 +130,4 @@ export const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: Markdo
</div>
);
});
Markdown.displayName = "Markdown";
Markdown.displayName = "Markdown";

View File

@ -325,4 +325,4 @@ const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, r
</AsyncIcon>
);
});
AsyncFooterIcon.displayName = "AsyncFooterIcon";
AsyncFooterIcon.displayName = "AsyncFooterIcon";

View File

@ -71,7 +71,7 @@ export default function NoteReaction(props: NoteReactionProps) {
const opt = {
showHeader: ev?.kind === EventKind.Repost || ev?.kind === EventKind.TextNote,
showFooter: false,
truncate: true
truncate: true,
};
return shouldNotBeRendered ? null : (

View File

@ -11,55 +11,56 @@ type ProxyImgProps = HTMLProps<HTMLImageElement> & {
missingImageElement?: ReactNode;
};
export const ProxyImg = forwardRef<HTMLImageElement, ProxyImgProps>(
function ProxyImg({ size, className, promptToLoadDirectly, missingImageElement, sha256, ...props }: ProxyImgProps, ref) {
const { proxy } = useImgProxy();
const [loadFailed, setLoadFailed] = useState(false);
const [bypass, setBypass] = useState(CONFIG.media.bypassImgProxyError);
const proxiedSrc = useMemo(() => proxy(props.src ?? "", size, sha256), [props.src, size, sha256]);
const [src, setSrc] = useState(proxiedSrc);
export const ProxyImg = forwardRef<HTMLImageElement, ProxyImgProps>(function ProxyImg(
{ size, className, promptToLoadDirectly, missingImageElement, sha256, ...props }: ProxyImgProps,
ref,
) {
const { proxy } = useImgProxy();
const [loadFailed, setLoadFailed] = useState(false);
const [bypass, setBypass] = useState(CONFIG.media.bypassImgProxyError);
const proxiedSrc = useMemo(() => proxy(props.src ?? "", size, sha256), [props.src, size, sha256]);
const [src, setSrc] = useState(proxiedSrc);
useEffect(() => {
setLoadFailed(false);
setSrc(proxy(props.src, size, sha256));
}, [props.src, size, sha256]);
if (loadFailed && !bypass && (promptToLoadDirectly ?? true)) {
return (
<div
className="note-invoice error"
onClick={e => {
e.stopPropagation();
setBypass(true);
}}>
<FormattedMessage
defaultMessage="Failed to proxy image from {host}, click here to load directly"
id="65BmHb"
values={{
host: getUrlHostname(props.src),
}}
/>
</div>
);
}
const handleImageError = e => {
if (props.onError) {
props.onError(e);
} else {
console.error("Failed to load image: ", props.src, e);
if (bypass && src === proxiedSrc) {
setSrc(props.src ?? "");
} else {
setLoadFailed(true);
}
}
};
if (!src || loadFailed) return missingImageElement ?? <div>Image not available</div>;
useEffect(() => {
setLoadFailed(false);
setSrc(proxy(props.src, size, sha256));
}, [props.src, size, sha256]);
if (loadFailed && !bypass && (promptToLoadDirectly ?? true)) {
return (
<img {...props} ref={ref} src={src} width={size} height={size} className={className} onError={handleImageError} />
<div
className="note-invoice error"
onClick={e => {
e.stopPropagation();
setBypass(true);
}}>
<FormattedMessage
defaultMessage="Failed to proxy image from {host}, click here to load directly"
id="65BmHb"
values={{
host: getUrlHostname(props.src),
}}
/>
</div>
);
},
);
}
const handleImageError = e => {
if (props.onError) {
props.onError(e);
} else {
console.error("Failed to load image: ", props.src, e);
if (bypass && src === proxiedSrc) {
setSrc(props.src ?? "");
} else {
setLoadFailed(true);
}
}
};
if (!src || loadFailed) return missingImageElement ?? <div>Image not available</div>;
return (
<img {...props} ref={ref} src={src} width={size} height={size} className={className} onError={handleImageError} />
);
});

View File

@ -42,7 +42,14 @@ export default function TrendingHashtags({
</div>
);
} else {
return <HashTagHeader key={a.hashtag} tag={a.hashtag} events={a.posts} className={classNames("bb", { p: !short })} />;
return (
<HashTagHeader
key={a.hashtag}
tag={a.hashtag}
events={a.posts}
className={classNames("bb", { p: !short })}
/>
);
}
})}
</>

View File

@ -18,7 +18,7 @@ import useCachedFetch from "@/Hooks/useCachedFetch";
import { System } from "@/index";
import { removeUndefined } from "@snort/shared";
export default function TrendingNotes({ count = Infinity, small = false }: { count: number, small: boolean }) {
export default function TrendingNotes({ count = Infinity, small = false }: { count: number; small: boolean }) {
const api = new NostrBandApi();
const { lang } = useLocale();
const trendingNotesUrl = api.trendingNotesUrl(lang);
@ -29,15 +29,17 @@ export default function TrendingNotes({ count = Infinity, small = false }: { cou
isLoading,
error,
} = useCachedFetch<{ notes: Array<{ event: NostrEvent }> }, Array<NostrEvent>>(trendingNotesUrl, storageKey, data => {
return removeUndefined(data.notes.map(a => {
const ev = a.event;
if (!System.Optimizer.schnorrVerify(ev)) {
console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`);
return;
}
System.HandleEvent(ev as TaggedNostrEvent);
return ev;
}));
return removeUndefined(
data.notes.map(a => {
const ev = a.event;
if (!System.Optimizer.schnorrVerify(ev)) {
console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`);
return;
}
System.HandleEvent(ev as TaggedNostrEvent);
return ev;
}),
);
});
const login = useLogin();

View File

@ -105,7 +105,11 @@ export default function WalletPage(props: { showHistory: boolean }) {
<div>
<select className="w-max" onChange={e => Wallets.switch(e.target.value)} value={walletState.config?.id}>
{Wallets.list().map(a => {
return <option value={a.id} key={a.id}>{a.info.alias}</option>;
return (
<option value={a.id} key={a.id}>
{a.info.alias}
</option>
);
})}
</select>
</div>

View File

@ -10,16 +10,26 @@ import AlbyIcon from "@/Icons/Alby";
import Icon from "@/Icons/Icon";
import { getAlbyOAuth } from "./wallet/Alby";
const WalletRow = (props: { logo: ReactNode; name: ReactNode; url: string; desc?: ReactNode }) => {
const WalletRow = (props: {
logo: ReactNode;
name: ReactNode;
url: string;
desc?: ReactNode;
onClick?: () => void;
}) => {
const navigate = useNavigate();
return (
<div
className="flex items-center gap-4 px-4 py-2 bg-[--gray-superdark] rounded-xl hover:bg-[--gray-ultradark]"
onClick={() => {
if (props.url.startsWith("http")) {
window.location.href = props.url;
if (props.onClick) {
props.onClick();
} else {
navigate(props.url);
if (props.url.startsWith("http")) {
window.location.href = props.url;
} else {
navigate(props.url);
}
}
}}>
<div className="rounded-xl aspect-square h-[4rem] bg-[--gray-dark] p-3 flex items-center justify-center">
@ -35,7 +45,6 @@ const WalletRow = (props: { logo: ReactNode; name: ReactNode; url: string; desc?
};
const WalletSettings = () => {
const alby = getAlbyOAuth();
return (
<>
<h3>
@ -68,12 +77,18 @@ const WalletSettings = () => {
url="/settings/wallet/cashu"
desc={<FormattedMessage defaultMessage="Cashu mint wallet" id="3natuV" />}
/>
<WalletRow
logo={<AlbyIcon size={64} />}
name="Alby"
url={alby.authUrl}
desc={<FormattedMessage defaultMessage="Alby wallet connection" id="XPB8VV" />}
/>
{CONFIG.alby && (
<WalletRow
logo={<AlbyIcon size={64} />}
name="Alby"
url={""}
onClick={() => {
const alby = getAlbyOAuth();
window.location.href = alby.getAuthUrl();
}}
desc={<FormattedMessage defaultMessage="Alby wallet connection" id="XPB8VV" />}
/>
)}
</div>
</>
);

View File

@ -1,18 +1,40 @@
import PageSpinner from "@/Element/PageSpinner";
import { WalletConfig, WalletKind, Wallets } from "@/Wallet";
import AlbyWallet from "@/Wallet/AlbyWallet";
import { sha256 } from "@noble/hashes/sha256";
import { randomBytes } from "@noble/hashes/utils";
import { base64, base64urlnopad, hex } from "@scure/base";
import { unixNow } from "@snort/shared";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { v4 as uuid } from "uuid";
export default function AlbyOAuth() {
const navigate = useNavigate();
const location = useLocation();
const alby = getAlbyOAuth();
const [error, setError] = useState("");
async function setupWallet(token: string) {
const auth = await alby.getToken(token);
console.debug(auth);
try {
const auth = await alby.getToken(token);
console.debug(auth);
const connection = new AlbyWallet(auth, () => {});
const info = await connection.getInfo();
const newWallet = {
id: uuid(),
kind: WalletKind.Alby,
active: true,
info,
data: JSON.stringify(auth),
} as WalletConfig;
Wallets.add(newWallet);
navigate("/settings/wallet");
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => {
@ -38,29 +60,30 @@ export default function AlbyOAuth() {
}
export function getAlbyOAuth() {
const clientId = "35EQp6crss";
const clientSecret = "DTUPIqOjsjwxZXcJwF5C";
const clientId = CONFIG.alby?.clientId ?? "";
const clientSecret = CONFIG.alby?.clientSecret ?? "";
const redirectUrl = `${window.location.protocol}//${window.location.host}/settings/wallet/alby`;
const scopes = ["invoices:create", "invoices:read", "transactions:read", "balance:read", "payments:send"];
const ec = new TextEncoder();
const code_verifier = hex.encode(randomBytes(64));
window.sessionStorage.setItem("alby-code", code_verifier);
const params = new URLSearchParams();
params.set("client_id", clientId);
params.set("response_type", "code");
params.set("code_challenge", base64urlnopad.encode(sha256(code_verifier)));
params.set("code_challenge_method", "S256");
params.set("redirect_uri", redirectUrl);
params.set("scope", scopes.join(" "));
const tokenUrl = "https://api.getalby.com/oauth/token";
const authUrl = `https://getalby.com/oauth?${params}`;
return {
tokenUrl,
authUrl,
getAuthUrl: () => {
const code_verifier = hex.encode(randomBytes(64));
window.sessionStorage.setItem("alby-code", code_verifier);
const params = new URLSearchParams();
params.set("client_id", clientId);
params.set("response_type", "code");
params.set("code_challenge", base64urlnopad.encode(sha256(code_verifier)));
params.set("code_challenge_method", "S256");
params.set("redirect_uri", redirectUrl);
params.set("scope", scopes.join(" "));
return `https://getalby.com/oauth?${params}`;
},
getToken: async (token: string) => {
const code = window.sessionStorage.getItem("alby-code");
if (!code) throw new Error("Alby code is missing!");
@ -85,10 +108,19 @@ export function getAlbyOAuth() {
const data = await req.json();
if (req.ok) {
return data.access_token as string;
return { ...data, created_at: unixNow() } as OAuthToken;
} else {
throw new Error(data.error_description as string);
}
},
};
}
export interface OAuthToken {
access_token: string;
created_at: number;
expires_in: number;
refresh_token: string;
scope: string;
token_type: string;
}

View File

@ -16,7 +16,7 @@ const ConnectLNDHub = () => {
async function tryConnect(config: string) {
try {
const connection = new LNDHubWallet(config);
const connection = new LNDHubWallet(config, () => {});
await connection.login();
const info = await connection.getInfo();

View File

@ -16,7 +16,7 @@ const ConnectNostrWallet = () => {
async function tryConnect(config: string) {
try {
const connection = new NostrConnectWallet(config);
const connection = new NostrConnectWallet(config, () => {});
await connection.login();
const info = await connection.getInfo();

View File

@ -0,0 +1,157 @@
import { OAuthToken } from "@/Pages/settings/wallet/Alby";
import {
InvoiceRequest,
LNWallet,
WalletError,
WalletErrorCode,
WalletInfo,
WalletInvoice,
WalletInvoiceState,
prToWalletInvoice,
} from ".";
import { unixNow, unwrap } from "@snort/shared";
export default class AlbyWallet implements LNWallet {
#token: OAuthToken;
constructor(
token: OAuthToken,
readonly onChange: () => void,
) {
this.#token = token;
}
isReady() {
return true;
}
canAutoLogin() {
return true;
}
canGetInvoices() {
return this.#token.scope.includes("invoices:read");
}
canGetBalance() {
return this.#token.scope.includes("balance:read");
}
async getInfo() {
const me = await this.#fetch<GetUserResponse>("/user/me");
return { alias: me.lightning_address } as WalletInfo;
}
async login() {
await this.#refreshToken();
return true;
}
close() {
return Promise.resolve(true);
}
async getBalance() {
const bal = await this.#fetch<GetBalanceResponse>("/balance");
return bal.balance;
}
async createInvoice(req: InvoiceRequest) {
const inv = await this.#fetch<CreateInvoiceResponse>("/invoices", "POST", {
amount: req.amount,
memo: req.memo,
});
return unwrap(prToWalletInvoice(inv.payment_request));
}
async payInvoice(pr: string) {
const pay = await this.#fetch<PayInvoiceResponse>("/payments/bolt11", "POST", {
invoice: pr,
});
return {
...prToWalletInvoice(pay.payment_request),
fees: pay.fee,
preimage: pay.payment_preimage,
state: WalletInvoiceState.Paid,
direction: "out",
} as WalletInvoice;
}
async getInvoices() {
const invoices = await this.#fetch<Array<GetInvoiceResponse>>("/invoices?page=1&items=20");
return invoices.map(a => {
return {
...prToWalletInvoice(a.payment_request),
memo: a.comment,
preimage: a.preimage,
state: a.settled ? WalletInvoiceState.Paid : WalletInvoiceState.Pending,
direction: a.type === "incoming" ? "in" : "out",
} as WalletInvoice;
});
}
async #fetch<T>(path: string, method: "GET" | "POST" = "GET", body?: object) {
const req = await fetch(`https://api.getalby.com${path}`, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
accept: "application/json",
authorization: `Bearer ${this.#token.access_token}`,
...(body ? { "content-type": "application/json" } : {}),
},
});
const json = await req.text();
if (req.ok) {
return JSON.parse(json) as T;
} else {
if (json.length > 0) {
throw new WalletError(WalletErrorCode.GeneralError, JSON.parse(json).message as string);
} else {
throw new WalletError(WalletErrorCode.GeneralError, `Error: ${json} (${req.status})`);
}
}
}
async #refreshToken() {
if (this.#token.created_at + this.#token.expires_in < unixNow()) {
// refresh
}
}
}
interface GetBalanceResponse {
balance: number;
currency: string;
unit: string;
}
interface CreateInvoiceResponse {
expires_at: string;
payment_hash: string;
payment_request: string;
}
interface PayInvoiceResponse {
amount: number;
description?: string;
destination: string;
fee: number;
payment_hash: string;
payment_preimage: string;
payment_request: string;
}
interface GetInvoiceResponse {
amount: number;
comment?: string;
created_at: string;
creation_date: number;
currency: string;
expires_at: string;
preimage: string;
payment_request: string;
settled: boolean;
settled_at: string;
type: "incoming" | "outgoing";
}
interface GetUserResponse {
lightning_address: string;
}

View File

@ -5,6 +5,7 @@ import { unwrap } from "@/SnortUtils";
import LNDHubWallet from "./LNDHub";
import { NostrConnectWallet } from "./NostrWalletConnect";
import { WebLNWallet } from "./WebLN";
import AlbyWallet from "./AlbyWallet";
export enum WalletKind {
LNDHub = 1,
@ -12,6 +13,7 @@ export enum WalletKind {
WebLN = 3,
NWC = 4,
Cashu = 5,
Alby = 6,
}
export enum WalletErrorCode {
@ -240,6 +242,9 @@ export class WalletStore extends ExternalStore<WalletStoreSnapshot> {
case WalletKind.NWC: {
return new NostrConnectWallet(unwrap(cfg.data), () => this.notifyChange());
}
case WalletKind.Alby: {
return new AlbyWallet(JSON.parse(unwrap(cfg.data)), () => this.notifyChange());
}
}
}
}