Files
flotilla/src/app/core/commands.ts
T
Tyson Lupul 4dfbb437f9 Wallet receive flow (#15) (#52)
* Pin sharp via pnpm override, add wallet receive

* Revert toast success styling

* Route receive through wallet connect

* Simplify receive invoice validation

* Polish receive modal layout

* Clarify NWC client config

* Adjust wallet action layout on mobile
2026-02-05 20:51:59 +00:00

678 lines
18 KiB
TypeScript

import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19"
import {get, derived} from "svelte/store"
import {
first,
sha256,
append,
remove,
poll,
uniq,
equals,
parseJson,
last,
simpleCache,
normalizeUrl,
nthNe,
} from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile} from "@welshman/util"
import {
DELETE,
REPORT,
PROFILE,
MESSAGING_RELAYS,
RELAYS,
FOLLOWS,
REACTION,
RELAY_JOIN,
RELAY_LEAVE,
ROOMS,
COMMENT,
APP_DATA,
isSignedEvent,
makeEvent,
normalizeRelayUrl,
makeList,
addToListPublicly,
removeFromListByPredicate,
getTag,
getListTags,
getRelayTagValues,
toNostrURI,
RelayMode,
getTagValues,
uploadBlob,
canUploadBlob,
encryptFile,
makeBlossomAuthEvent,
isPublishedProfile,
editProfile,
createProfile,
uniqTags,
} from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
sign,
signer,
session,
repository,
publishThunk,
tagEvent,
tagPubkey,
tagEventForReaction,
nip44EncryptToSelf,
tagEventForComment,
tagEventForQuote,
waitForThunkError,
getPubkeyRelays,
userBlossomServerList,
getThunkError,
} from "@welshman/app"
import {compressFile} from "@lib/html"
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
import {
SETTINGS,
PROTECTED,
INDEXER_RELAYS,
DEFAULT_BLOSSOM_SERVERS,
userSpaceUrls,
userSettingsValues,
getSetting,
getSettings,
userGroupList,
shouldIgnoreError,
stripPrefix,
relaysMostlyRestricted,
deriveSocket,
} from "@app/core/state"
// Utils
export const getPubkeyHints = (pubkey: string) => {
const relays = getPubkeyRelays(pubkey, RelayMode.Write)
const hints = relays.length ? relays : INDEXER_RELAYS
return hints
}
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
const nevent = nip19.neventEncode({
id: parent.id,
kind: parent.kind,
author: parent.pubkey,
relays: Router.get().Event(parent).limit(3).getUrls(),
})
tags = [...tags, tagEventForQuote(parent), tagPubkey(parent.pubkey)]
content = toNostrURI(nevent) + "\n\n" + content
}
return {content, tags}
}
// Synchronization
export const broadcastUserData = async (relays: string[]) => {
const authors = [pubkey.get()!]
const kinds = [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE]
const events = repository.query([{kinds, authors}])
for (const event of events) {
if (isSignedEvent(event)) {
await publishThunk({event, relays}).complete
}
}
}
// List updates
export const addSpaceMembership = async (url: string) => {
const list = get(userGroupList) || makeList({kind: ROOMS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const removeSpaceMembership = async (url: string) => {
const list = get(userGroupList) || makeList({kind: ROOMS})
const pred = (t: string[]) => normalizeRelayUrl(t[t[0] === "r" ? 1 : 2]) === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const addRoomMembership = async (url: string, h: string) => {
const list = get(userGroupList) || makeList({kind: ROOMS})
const newTags = [
["r", url],
["group", h, url],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const removeRoomMembership = async (url: string, h: string) => {
const list = get(userGroupList) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", h, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
// Relay access
export const canEnforceNip70 = async (url: string) => {
const socket = Pool.get().get(url)
await socket.auth.attemptAuth(sign)
return socket.auth.status !== AuthStatus.None
}
export const attemptRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url)
socket.attemptToOpen()
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
await socket.auth.attemptAuth(sign)
// Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay
if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
if (socket.auth.details) {
return `Failed to authenticate (${socket.auth.details})`
} else {
return `Failed to authenticate (${last(socket.auth.status.split(":"))})`
}
}
const thunk = publishJoinRequest({url, claim})
const error = await waitForThunkError(thunk)
if (shouldIgnoreError(error)) return
if (error.includes("invite code")) return "join request rejected"
return stripPrefix(error)
}
export const deriveRelayAuthError = (url: string, claim = "") => {
// Kick off the auth process
Pool.get().get(url).auth.attemptAuth(sign)
// Attempt to join the relay
const thunk = publishJoinRequest({url, claim})
return derived(
[thunk, relaysMostlyRestricted, deriveSocket(url)],
([$thunk, $relaysMostlyRestricted, $socket]) => {
if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) {
return stripPrefix($socket.auth.details)
}
if ($relaysMostlyRestricted[url]) {
return stripPrefix($relaysMostlyRestricted[url])
}
const error = getThunkError($thunk)
if (error) {
const isEmptyInvite = !claim && error.includes("invite code")
if (!shouldIgnoreError(error) && !isEmptyInvite) {
return stripPrefix(error) || "join request rejected"
}
}
},
)
}
// Deletions
export type DeleteParams = {
protect: boolean
event: TrustedEvent
tags?: string[][]
}
export const makeDelete = ({protect, event, tags = []}: DeleteParams) => {
const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
const groupTag = getTag("h", event.tags)
if (groupTag) {
thisTags.push(groupTag)
}
if (protect) {
thisTags.push(PROTECTED)
}
return makeEvent(DELETE, {tags: thisTags})
}
export const publishDelete = ({relays, ...params}: DeleteParams & {relays: string[]}) =>
publishThunk({event: makeDelete(params), relays})
// Reports
export type ReportParams = {
event: TrustedEvent
content: string
reason: string
}
export const makeReport = ({event, reason, content}: ReportParams) => {
const tags = [
["p", event.pubkey],
["e", event.id, reason],
]
return makeEvent(REPORT, {content, tags})
}
export const publishReport = ({
relays,
event,
reason,
content,
}: ReportParams & {relays: string[]}) =>
publishThunk({event: makeReport({event, reason, content}), relays})
// Reactions
export type ReactionParams = {
protect: boolean
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeReaction = ({protect, content, event, tags: paramTags = []}: ReactionParams) => {
const tags = [...paramTags, ...tagEventForReaction(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(groupTag)
}
if (protect) {
tags.push(PROTECTED)
}
return makeEvent(REACTION, {content, tags})
}
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
publishThunk({event: makeReaction(params), relays})
// Comments
export type CommentParams = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeComment = ({event, content, tags = []}: CommentParams) =>
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays})
// Settings
export const makeSettings = async (params: Partial<SettingsValues>) => {
const json = JSON.stringify({...get(userSettingsValues), ...params})
const content = await signer.get().nip44.encrypt(pubkey.get()!, json)
const tags = [["d", SETTINGS]]
return makeEvent(APP_DATA, {content, tags})
}
export const publishSettings = async (params: Partial<SettingsValues>) =>
publishThunk({event: await makeSettings(params), relays: Router.get().FromUser().getUrls()})
export const addTrustedRelay = async (url: string) =>
publishSettings({trusted_relays: append(url, getSetting<string[]>("trusted_relays"))})
export const removeTrustedRelay = async (url: string) =>
publishSettings({trusted_relays: remove(url, getSetting<string[]>("trusted_relays"))})
// Space and room notification settings
export const setSpaceNotifications = async (url: string, notify: boolean) => {
const {alerts} = getSettings()
const existing = alerts.find((s: SpaceNotificationSettings) => s.url === url)
let updated: typeof alerts
if (existing) {
// Clear exceptions when changing the space notification setting
updated = alerts.map((s: SpaceNotificationSettings) =>
s.url === url ? {...s, notify, exceptions: []} : s,
)
} else {
updated = [...alerts, {url, notify, exceptions: []}]
}
return publishSettings({alerts: updated})
}
export const toggleRoomNotifications = async (url: string, h: string) => {
const {alerts} = getSettings()
const existing = alerts.find((s: SpaceNotificationSettings) => s.url === url)
let updated: typeof alerts
if (!existing) {
// No space settings yet, create one with this room as an exception (default is notify: true)
updated = [...alerts, {url, notify: true, exceptions: [h]}]
} else {
// Toggle exception status
const hasException = existing.exceptions.includes(h)
const exceptions = hasException
? remove(h, existing.exceptions)
: append(h, existing.exceptions)
updated = alerts.map((s: SpaceNotificationSettings) => (s.url === url ? {...s, exceptions} : s))
}
return publishSettings({alerts: updated})
}
// Join request
export type JoinRequestParams = {
url: string
claim: string
}
export const makeJoinRequest = (params: JoinRequestParams) =>
makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]})
export const publishJoinRequest = (params: JoinRequestParams) =>
publishThunk({event: makeJoinRequest(params), relays: [params.url]})
// Leave request
export type LeaveRequestParams = {
url: string
}
export const publishLeaveRequest = (params: LeaveRequestParams) =>
publishThunk({event: makeEvent(RELAY_LEAVE), relays: [params.url]})
// Lightning
export const getWebLn = () => (window as any).webln
export const getNwcClient = () => {
const $session = session.get()
if (!$session?.wallet || $session.wallet.type !== "nwc") {
throw new Error("No NWC wallet is connected")
}
const {info} = $session.wallet
if (info.nostrWalletConnectUrl) {
return new nwc.NWCClient({nostrWalletConnectUrl: info.nostrWalletConnectUrl})
}
return new nwc.NWCClient(info)
}
export const payInvoice = async (invoice: string, msats?: number) => {
const $session = session.get()
if (!$session?.wallet) {
throw new Error("No wallet is connected")
}
if ($session.wallet.type === "nwc") {
const params: {invoice: string; amount?: number} = {invoice}
if (msats) params.amount = msats
return getNwcClient().payInvoice(params)
} else if ($session.wallet.type === "webln") {
if (msats) throw new Error("Unable to pay zero invoices with webln")
return getWebLn()
.enable()
.then(() => getWebLn().sendPayment(invoice))
}
}
export type CreateInvoiceParams = {
sats: number
description?: string
}
export const createInvoice = async ({
sats,
description = "Receive via lightning",
}: CreateInvoiceParams) => {
const $session = session.get()
if (!$session?.wallet) {
throw new Error("No wallet is connected")
}
const satAmount = Math.floor(sats)
if (!Number.isFinite(satAmount) || satAmount <= 0) {
throw new Error("Invalid satoshi amount")
}
if ($session.wallet.type === "nwc") {
const createdInvoice = await getNwcClient().makeInvoice({
amount: satAmount * 1000,
description,
})
if (!createdInvoice.invoice) {
throw new Error("NWC wallet failed to return an invoice")
}
return createdInvoice.invoice
}
if ($session.wallet.type === "webln") {
const webLn = getWebLn()
if (!webLn) {
throw new Error("WebLN not available")
}
await webLn.enable()
const response = await webLn.makeInvoice({
amount: satAmount,
defaultMemo: description,
})
const paymentRequest =
typeof response === "string" ? response : response?.paymentRequest || response?.pr || ""
if (!paymentRequest) {
throw new Error("Invalid payment request returned from WebLN")
}
return paymentRequest
}
throw new Error("Unsupported wallet type")
}
// File upload
export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http"))
export const fetchHasBlossomSupport = async (url: string) => {
const server = normalizeBlossomUrl(url)
const $signer = signer.get() || Nip01Signer.ephemeral()
const headers: Record<string, string> = {
"X-Content-Type": "text/plain",
"X-Content-Length": "1",
"X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac",
}
try {
const authEvent = await $signer.sign(makeBlossomAuthEvent({action: "upload", server}))
const res = await canUploadBlob(server, {authEvent, headers})
return res.status === 200
} catch (e) {
if (!String(e).match(/Failed to fetch|NetworkError/)) {
console.error(e)
}
}
return false
}
export const hasBlossomSupport = simpleCache(([url]: [string]) => fetchHasBlossomSupport(url))
export type GetBlossomServerOptions = {
url?: string
}
export const getBlossomServer = async (options: GetBlossomServerOptions = {}) => {
if (options.url) {
if (await hasBlossomSupport(options.url)) {
return normalizeBlossomUrl(options.url)
}
}
const userUrls = getTagValues("server", getListTags(get(userBlossomServerList)))
for (const url of userUrls) {
return normalizeBlossomUrl(url)
}
return first(DEFAULT_BLOSSOM_SERVERS)!
}
export type UploadFileOptions = {
url?: string
encrypt?: boolean
maxWidth?: number
maxHeight?: number
}
export type UploadFileResult = {
error?: string
result?: UploadTask
}
export const uploadFile = async (file: File, options: UploadFileOptions = {}) => {
try {
const {name, type} = file
if (!type.match("image/(webp|gif|svg)")) {
file = await compressFile(file, options)
}
const tags: string[][] = []
if (options.encrypt) {
const {ciphertext, key, nonce, algorithm} = await encryptFile(file)
tags.push(
["decryption-key", key],
["decryption-nonce", nonce],
["encryption-algorithm", algorithm],
)
file = new File([new Uint8Array(ciphertext)], name, {
type: "application/octet-stream",
})
}
const ext = "." + type.split("/")[1]
const server = await getBlossomServer(options)
const hashes = [await sha256(await file.arrayBuffer())]
const $signer = signer.get() || Nip01Signer.ephemeral()
const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes})
const authEvent = await $signer.sign(authTemplate)
const res = await uploadBlob(server, file, {authEvent})
const text = await res.text()
let {uploaded, url, ...task} = parseJson(text) || {}
if (!uploaded) {
return {error: text || `Failed to upload file (HTTP ${res.status})`}
}
// Always append correct file extension if we encrypted the file, or if it's missing
if (options.encrypt) {
url = url.replace(/\.\w+$/, "") + ext
} else if (new URL(url).pathname.split(".").length === 1) {
url += ext
}
const result = {...task, tags, url}
return {result}
} catch (e: any) {
console.error("Error caught when uploading file:", e)
return {error: e.toString()}
}
}
// Update Profile
export const initProfile = (profile: Profile) => {
const template = createProfile(profile)
// Start out protected by default
template.tags.push(PROTECTED)
const event = makeEvent(PROFILE, template)
// Don't publish anywhere yet, wait until they join a space
return publishThunk({event, relays: []})
}
export const updateProfile = async ({
profile,
shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || []),
}: {
profile: Profile
shouldBroadcast?: boolean
}) => {
const router = Router.get()
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const scenarios = [router.FromRelays(get(userSpaceUrls))]
if (shouldBroadcast) {
scenarios.push(router.FromUser(), router.Index())
template.tags = template.tags.filter(nthNe(0, "-"))
} else {
template.tags = uniqTags([...template.tags, PROTECTED])
}
const event = makeEvent(template.kind, template)
const relays = router.merge(scenarios).getUrls()
await publishThunk({event, relays}).complete
}