Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Private payments use the following environment variables:
- `PAYMENTS_API_BASE_URL`: base URL for the payments API.
- `CLUSTER`: cluster name passed to the payments API and used for Solana Explorer links. Supported values are `devnet`, `testnet`, and `mainnet-beta`.
- `PAYMENTS_CLUSTER`: legacy fallback for the payments API cluster. `CLUSTER` takes precedence if both are set. If this value is an RPC URL containing `devnet`, `testnet`, or `mainnet`, the app infers the corresponding cluster name.
- `PAYMENTS_EPHEMERAL_RPC_URL` or `EPHEMERAL_RPC_URL`: ephemeral RPC used when signed transactions must be submitted to ER.
Comment thread
snawaz marked this conversation as resolved.
- `NEXT_PUBLIC_PAYMENTS_TEST_USDC_MINT`: overrides the default payment mint in the UI.

Example:
Expand All @@ -39,13 +40,16 @@ Example:
PAYMENTS_API_BASE_URL=http://localhost:8787 \
CLUSTER=devnet \
SOLANA_RPC_URL=https://rpc.magicblock.app/devnet \
PAYMENTS_EPHEMERAL_RPC_URL=https://devnet.magicblock.app \
NEXT_PUBLIC_SOLANA_RPC_URL=https://rpc.magicblock.app/devnet \
NEXT_PUBLIC_PAYMENTS_TEST_USDC_MINT=4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \
yarn dev -p 3002
```

With `CLUSTER=devnet`, the app sends `cluster=devnet` to the payments API and opens transactions on `https://explorer.solana.com` with the `devnet` cluster selected.

The Handle tab creates or updates `.block` stealth pools. Payment recipients can now be a wallet address, a `.sol` name, or a `.block` handle; `.block` recipients are sent through the private stealth-transfer route.

Comment thread
snawaz marked this conversation as resolved.
## Learn More

To learn more, take a look at the following resources:
Expand Down
195 changes: 195 additions & 0 deletions app/api/payments/send/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { NextRequest, NextResponse } from "next/server";
import {
Connection,
PublicKey,
SendTransactionError,
Transaction,
VersionedTransaction,
} from "@solana/web3.js";
import {
createPaymentsEphemeralConnection,
createServerSolanaConnection,
} from "@/lib/solana-rpc";

function base64ToUint8Array(base64: string) {
const buffer = Buffer.from(base64, "base64");
return new Uint8Array(buffer);
}

class FeePayerFundingError extends Error {
constructor(message: string) {
super(message);
this.name = "FeePayerFundingError";
}
}

function getFeePayer(rawTransaction: Uint8Array) {
try {
const transaction = Transaction.from(rawTransaction);
return transaction.feePayer ?? null;
} catch {
try {
const transaction = VersionedTransaction.deserialize(rawTransaction);
return transaction.message.staticAccountKeys[0] ?? null;
} catch {
return null;
}
}
}

async function requireFundedBaseFeePayer(
connection: Connection,
feePayer: PublicKey | null
) {
if (!feePayer) {
return;
}

const lamports = await connection.getBalance(feePayer, "confirmed");
if (lamports > 0) {
return;
}

const feePayerAddress = feePayer.toBase58();
throw new FeePayerFundingError(
`Base fee payer ${feePayerAddress} has no SOL on the configured base RPC`
);
}

async function getSendTransactionLogs(
error: SendTransactionError,
connection: Connection
) {
if (error.logs?.length) {
return error.logs;
}

try {
return await error.getLogs(connection);
} catch {
return [];
}
}

export async function POST(request: NextRequest) {
let connection: Connection | null = null;

try {
const body = await request.json();
const {
signedTransaction,
blockhash,
lastValidBlockHeight,
sendTo,
} = body as {
signedTransaction?: string;
blockhash?: string;
lastValidBlockHeight?: number;
sendTo?: "base" | "ephemeral";
};
Comment thread
snawaz marked this conversation as resolved.

if (
typeof signedTransaction !== "string" ||
!signedTransaction ||
typeof blockhash !== "string" ||
!blockhash ||
typeof lastValidBlockHeight !== "number" ||
(sendTo !== "base" && sendTo !== "ephemeral")
) {
return NextResponse.json(
{
error:
"Missing signedTransaction, blockhash, lastValidBlockHeight, or sendTo",
},
{ status: 400 }
);
}

const authHeader = request.headers.get("authorization") ?? "";
const authToken = authHeader.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
if (sendTo === "ephemeral" && !authToken) {
return NextResponse.json(
{ error: "Authentication is required for ephemeral submission" },
{ status: 401 }
);
}

connection =
sendTo === "ephemeral"
? createPaymentsEphemeralConnection(authToken)
: createServerSolanaConnection();
const rawTransaction = base64ToUint8Array(signedTransaction);
if (sendTo === "base") {
await requireFundedBaseFeePayer(connection, getFeePayer(rawTransaction));
}

const signature = await connection.sendRawTransaction(rawTransaction, {
skipPreflight: sendTo === "ephemeral",
preflightCommitment: "confirmed",
maxRetries: 10,
});

const confirmation = await connection.confirmTransaction(
{
signature,
blockhash,
lastValidBlockHeight,
},
"confirmed"
);

if (confirmation.value.err) {
return NextResponse.json(
{
error: "Transaction failed on-chain",
details: JSON.stringify(confirmation.value.err),
signature,
},
{ status: 400 }
);
}

return NextResponse.json({ signature });
} catch (error) {
if (error instanceof FeePayerFundingError) {
return NextResponse.json(
{
error: error.message,
details:
"Fund this wallet on the same base RPC used by the Pay server, or point SOLANA_RPC_URL at the chain where the wallet is funded.",
logs: [],
},
{ status: 400 }
);
}

if (error instanceof SendTransactionError && connection) {
const logs = await getSendTransactionLogs(error, connection);
const transactionError = error.transactionError;
const message = transactionError.message || error.message;

console.error("Payments send transaction error:", {
message,
logs,
});

return NextResponse.json(
{
error: message,
details: logs.length > 0 ? logs.join("\n") : error.message,
logs,
},
{ status: 400 }
);
}

console.error("Payments send error:", error);
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Failed to send transaction",
},
{ status: 502 }
);
}
}
162 changes: 162 additions & 0 deletions app/api/payments/stealth-pool/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from "next/server";
import { PublicKey } from "@solana/web3.js";
import {
PAYMENTS_CLUSTER,
PAYMENTS_ENDPOINTS,
getPaymentsApiUrl,
getPaymentsTimeoutSignal,
} from "@/lib/payments";
import { getPaymentsErrorMessage } from "@/lib/payments-errors";
import {
STEALTH_POOL_MAX_DESTINATIONS,
getExactStealthHandleInput,
isStealthHandleInput,
} from "@/lib/stealth-handles";

interface StealthPoolBuildRequest {
payer?: string;
authority?: string;
handle?: string;
destinations?: string[];
splitAcrossKeys?: boolean;
}

export async function GET(request: NextRequest) {
try {
const handle = getExactStealthHandleInput(
request.nextUrl.searchParams.get("handle") ?? ""
);
if (!handle || !isStealthHandleInput(handle)) {
return NextResponse.json(
{ error: "Missing or invalid .block handle" },
{ status: 400 }
);
}

const upstreamUrl = new URL(getPaymentsApiUrl(PAYMENTS_ENDPOINTS.stealthPool));
upstreamUrl.searchParams.set("handle", handle);
if (PAYMENTS_CLUSTER) {
upstreamUrl.searchParams.set("cluster", PAYMENTS_CLUSTER);
}

const upstreamRes = await fetch(upstreamUrl, {
method: "GET",
signal: getPaymentsTimeoutSignal(),
cache: "no-store",
});

const responseBody = await upstreamRes.json().catch(() => null);
if (!upstreamRes.ok) {
return NextResponse.json(
{
error: getPaymentsErrorMessage(upstreamRes.status, responseBody),
details: responseBody,
},
{ status: upstreamRes.status }
);
}

return NextResponse.json(responseBody);
} catch (error) {
console.error("Payments stealth pool status error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to fetch stealth pool status",
},
{ status: 500 }
);
}
}

export async function POST(request: NextRequest) {
try {
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return NextResponse.json(
{ error: "Missing private-session auth token" },
{ status: 401 }
);
}
Comment thread
snawaz marked this conversation as resolved.

const body = (await request.json()) as StealthPoolBuildRequest;
const handle =
typeof body.handle === "string"
? getExactStealthHandleInput(body.handle)
: "";

if (
typeof body.payer !== "string" ||
typeof body.authority !== "string" ||
!handle ||
!isStealthHandleInput(handle) ||
!Array.isArray(body.destinations) ||
body.destinations.length < 1 ||
body.destinations.length > STEALTH_POOL_MAX_DESTINATIONS ||
(body.splitAcrossKeys !== undefined &&
typeof body.splitAcrossKeys !== "boolean")
) {
return NextResponse.json(
{ error: "Missing or invalid stealth pool parameters" },
{ status: 400 }
);
}

try {
new PublicKey(body.payer);
new PublicKey(body.authority);
body.destinations.forEach((destination) => new PublicKey(destination));
} catch {
return NextResponse.json(
{ error: "Invalid payer, authority, or destination public key" },
{ status: 400 }
);
}

const upstreamRes = await fetch(getPaymentsApiUrl(PAYMENTS_ENDPOINTS.stealthPool), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
body: JSON.stringify({
payer: body.payer,
authority: body.authority,
handle,
destinations: body.destinations,
...(body.splitAcrossKeys !== undefined
? { splitAcrossKeys: body.splitAcrossKeys }
: {}),
...(PAYMENTS_CLUSTER ? { cluster: PAYMENTS_CLUSTER } : {}),
}),
signal: getPaymentsTimeoutSignal(),
cache: "no-store",
});

const responseBody = await upstreamRes.json().catch(() => null);
if (!upstreamRes.ok) {
return NextResponse.json(
{
error: getPaymentsErrorMessage(upstreamRes.status, responseBody),
details: responseBody,
},
{ status: upstreamRes.status }
);
}

return NextResponse.json(responseBody);
} catch (error) {
console.error("Payments stealth pool build error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to build stealth pool transaction",
},
{ status: 500 }
);
}
}
Loading
Loading