Skip to content

Commit

Permalink
Merge pull request #7 from getAlby/feat/zaps
Browse files Browse the repository at this point in the history
feat: zaps
  • Loading branch information
rolznz authored Jan 16, 2025
2 parents c230d6e + aff00eb commit 2b681dd
Show file tree
Hide file tree
Showing 13 changed files with 336 additions and 61 deletions.
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

0 comments on commit 2b681dd

Please sign in to comment.