Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: zaps #7

Merged
merged 3 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ BASE_URL="http://localhost:3000"
DAILY_WALLET_LIMIT=10
#APP_NAME_PREFIX = "Alby Jim "
#PASSWORD="My super secret password"
NOSTR_NIP57_PRIVATE_KEY=""
HTTP_NOSTR_URL="https://api.getalby.com/nwc"

# Info
NAME="Uncle Jim Demo Server"
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ returns:
}
```

`GET /api/info`
## Nostr Zaps

Zaps are supported by receiving payment notifications through [http-nostr](https://github.com/getAlby/http-nostr)

## Development

Expand Down
20 changes: 20 additions & 0 deletions app/.well-known/lnurlp/[username]/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextRequest } from "next/server";
import { findWalletConnection } from "@/app/db";
import { nwc } from "@getalby/sdk";
import { validateZapRequest } from "nostr-tools/nip57";
import { NWC_POOL } from "@/app/nwc/nwcPool";

export async function GET(
request: NextRequest,
Expand All @@ -13,6 +15,7 @@ export async function GET(
const searchParams = request.nextUrl.searchParams;
const amount = searchParams.get("amount");
const comment = searchParams.get("comment") || "";
const nostr = searchParams.get("nostr") || "";

if (!amount) {
throw new Error("No amount provided");
Expand All @@ -22,11 +25,28 @@ export async function GET(
username: params.username,
});

// subscribe to existing lightning addresses
if (!connection.subscribed) {
await NWC_POOL.subscribeUser(connection);
}

const nwcClient = new nwc.NWCClient({ nostrWalletConnectUrl: connection.id });

let zapRequest: Event | undefined;
if (nostr) {
const zapValidationError = validateZapRequest(nostr);
if (zapValidationError) {
throw new Error(zapValidationError);
}
zapRequest = JSON.parse(nostr);
}

const transaction = await nwcClient.makeInvoice({
amount: +amount,
description: comment,
metadata: {
nostr: zapRequest,
},
});

return Response.json({
Expand Down
9 changes: 9 additions & 0 deletions app/.well-known/lnurlp/[username]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getDomain, getBaseUrl } from "@/app/utils";
import { getPublicKey } from "nostr-tools";
import { hexToBytes } from "@noble/hashes/utils";

export async function GET(
request: Request,
Expand All @@ -8,6 +10,7 @@ export async function GET(
throw new Error("No username provided");
}
const domain = getDomain();
const NOSTR_NIP57_PRIVATE_KEY = process.env.NOSTR_NIP57_PRIVATE_KEY;

return Response.json({
status: "OK",
Expand All @@ -17,5 +20,11 @@ export async function GET(
minSendable: 1000,
maxSendable: 10000000000,
metadata: `[["text/identifier","${params.username}@${domain}"],["text/plain","Sats for Alby Jim user ${params.username}"]]`,
...(NOSTR_NIP57_PRIVATE_KEY
? {
allowsNostr: true,
nostrPubkey: getPublicKey(hexToBytes(NOSTR_NIP57_PRIVATE_KEY)),
}
: {}),
});
}
10 changes: 7 additions & 3 deletions app/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use server";

import { saveConnectionSecret, UsernameTakenError } from "./db";
import { NWC_POOL } from "./nwc/nwcPool";
import { getAlbyHubUrl, getDailyWalletLimit, getDomain } from "./utils";

export type Reserves = {
Expand Down Expand Up @@ -116,10 +117,13 @@ export async function createWallet(
}
appId = appInfo.id;

const { username } = await saveConnectionSecret(
const connectionSecret = await saveConnectionSecret(
request?.username,
newApp.pairingUri
);
await NWC_POOL.subscribeUser(connectionSecret);

const { username } = connectionSecret;

const domain = getDomain();
const lightningAddress = username + "@" + domain;
Expand All @@ -135,7 +139,7 @@ export async function createWallet(
error: undefined,
};
} catch (error) {
console.error(error);
console.error("failed to create wallet", { error });

// only expose known errors
if (error instanceof UsernameTakenError) {
Expand Down Expand Up @@ -184,7 +188,7 @@ export async function getReserves(): Promise<Reserves | undefined> {
totalChannelCapacity,
};
} catch (error) {
console.error(error);
console.error("failed to get reserves", { error });
return undefined;
}
}
Expand Down
52 changes: 52 additions & 0 deletions app/api/http_nostr/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { findWalletConnection } from "@/app/db";
import { Event } from "nostr-tools";
import { decrypt } from "nostr-tools/nip04";
import { nwc } from "@getalby/sdk";
import { NWC_POOL } from "@/app/nwc/nwcPool";

export async function POST(request: Request) {
let body: Event | undefined;
try {
body = await request.json();
if (!body) {
throw new Error("no body in request");
}

// get wallet
const pTagValue = body.tags.find((tag) => tag[0] === "p")?.[1];
if (!pTagValue) {
throw new Error("p tag not found");
}
const wallet = await findWalletConnection({ pubkey: pTagValue });
if (!wallet) {
throw new Error("could not find wallet by pubkey");
}

// deserialize content
const parsedNWCUrl = nwc.NWCClient.parseWalletConnectUrl(wallet.id);
const secretKey = parsedNWCUrl.secret;
if (!secretKey) {
throw new Error("no secret key");
}
// TODO: update to NIP-44
const decryptedContent = await decrypt(
secretKey,
parsedNWCUrl.walletPubkey,
body.content
);

const notification = JSON.parse(decryptedContent) as nwc.Nip47Notification;
if (notification.notification_type !== "payment_received") {
console.debug("skipping notification that is not payment_received");
return Response.json({});
}

// console.debug("publishing zap", { notification });

await NWC_POOL.publishZap(wallet, notification.notification);
} catch (error) {
console.error("failed to process http-nostr request", { body });
}

return Response.json({});
}
28 changes: 22 additions & 6 deletions app/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PrismaClient } from "@prisma/client";
import { nwc } from "@getalby/sdk";
import { getPublicKey } from "nostr-tools";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { hexToBytes } from "@noble/hashes/utils";

const prisma = new PrismaClient();

Expand All @@ -14,12 +15,12 @@ export class UsernameTakenError extends Error {
export async function saveConnectionSecret(
username: string | undefined,
connectionSecret: string
): Promise<{ username: string }> {
) {
const parsed = nwc.NWCClient.parseWalletConnectUrl(connectionSecret);
if (!parsed.secret) {
throw new Error("no secret found in connection secret");
}
const pubkey = getPublicKey(parsed.secret);
const pubkey = getPublicKey(hexToBytes(parsed.secret));
username = username || pubkey.substring(0, 6);

try {
Expand All @@ -30,9 +31,9 @@ export async function saveConnectionSecret(
pubkey,
},
});
return { username: result.username };
return result;
} catch (error) {
console.error("failed to save wallet", error);
console.error("failed to save wallet", { error });
if (
error instanceof PrismaClientKnownRequestError &&
error.code === "P2002" // unique constraint
Expand All @@ -43,10 +44,25 @@ export async function saveConnectionSecret(
}
}

export async function findWalletConnection(query: { username: string }) {
export async function findWalletConnection(
query: { username: string } | { pubkey: string }
) {
return prisma.connectionSecret.findUniqueOrThrow({
where: query,
});
}

export async function markConnectionSecretSubscribed(id: string) {
return prisma.connectionSecret.update({
where: {
username: query.username,
id,
},
data: {
subscribed: true,
},
});
}

export async function getAllConnections() {
return prisma.connectionSecret.findMany();
}
134 changes: 134 additions & 0 deletions app/nwc/nwcPool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { Event, finalizeEvent, getPublicKey, SimplePool } from "nostr-tools";
import { makeZapReceipt } from "nostr-tools/nip57";
import { nwc } from "@getalby/sdk";
import { hexToBytes } from "@noble/hashes/utils";
import { markConnectionSecretSubscribed } from "../db";
import { ConnectionSecret } from "@prisma/client";
import { getBaseUrl } from "../utils";

class NWCPool {
private readonly pool: SimplePool;

constructor() {
this.pool = new SimplePool();
}

async subscribeUser(connectionSecret: ConnectionSecret) {
try {
const HTTP_NOSTR_URL = process.env.HTTP_NOSTR_URL;
if (!HTTP_NOSTR_URL) {
throw new Error("No HTTP_NOSTR_URL set");
}

console.debug("subscribing to user", {
username: connectionSecret.username,
});

const parsedNwcUrl = nwc.NWCClient.parseWalletConnectUrl(
connectionSecret.id
);
if (!parsedNwcUrl.secret) {
throw new Error("no secret in NWC URL");
}

const result = await fetch(`${HTTP_NOSTR_URL}/nip47/notifications`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
webhookUrl: `${getBaseUrl()}/api/http_nostr`,
walletPubkey: parsedNwcUrl.walletPubkey,
connectionPubkey: getPublicKey(hexToBytes(parsedNwcUrl.secret)),
}),
});
if (!result.ok) {
throw new Error(
"Failed to subscribe to http-nostr notifications: " +
result.status +
" " +
(await result.text())
);
}

await markConnectionSecretSubscribed(connectionSecret.id);
} catch (error) {
console.error("failed to subscribe user", { error });
}
}

async publishZap(
connectionSecret: ConnectionSecret,
transaction: nwc.Nip47Transaction
) {
const metadata = transaction.metadata;
const requestEvent = metadata?.nostr as Event;

if (!requestEvent) {
return;
}

const NOSTR_NIP57_PRIVATE_KEY = process.env.NOSTR_NIP57_PRIVATE_KEY;

if (!NOSTR_NIP57_PRIVATE_KEY) {
throw new Error("no zapper private key set");
}

const zapReceipt = makeZapReceipt({
zapRequest: JSON.stringify(requestEvent),
preimage: transaction.preimage,
bolt11: transaction.invoice,
paidAt: new Date(transaction.settled_at * 1000),
});
const relays = requestEvent.tags
.find((tag) => tag[0] === "relays")
?.slice(1);
if (!relays || !relays.length) {
console.error("no relays specified in zap request", {
username: connectionSecret.username,
transaction,
});
return;
}

const signedEvent = finalizeEvent(
zapReceipt,
hexToBytes(NOSTR_NIP57_PRIVATE_KEY)
);

const results = await Promise.allSettled(
this.pool.publish(relays, signedEvent)
);

const successfulRelays: string[] = [];
const failedRelays: string[] = [];

results.forEach((result, index) => {
const relay = relays[index];
if (result.status === "fulfilled") {
successfulRelays.push(relay);
} else {
failedRelays.push(relay);
}
});

if (failedRelays.length === relays.length) {
console.error("failed to publish zap", {
username: connectionSecret.username,
event_id: signedEvent.id,
payment_hash: transaction.payment_hash,
failed_relays: relays,
});
return;
}

console.debug("published zap", {
username: connectionSecret.username,
event_id: signedEvent.id,
payment_hash: transaction.payment_hash,
successful_relays: successfulRelays,
failed_relays: failedRelays,
});
}
}
export const NWC_POOL = new NWCPool();
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default function Home() {
window.localStorage.setItem("wallet", JSON.stringify(wallet));
}
} catch (error) {
console.error(error);
console.error("failed to create wallet", { error });
alert("Something went wrong: " + error);
}
setLoading(false);
Expand Down
Loading
Loading