|
| 1 | +--- |
| 2 | +title: Tutorial |
| 3 | +slug: tutorial |
| 4 | +sidebar_position: 3 |
| 5 | +--- |
| 6 | + |
| 7 | +## Introduction |
| 8 | + |
| 9 | +In this section, we’ll look at an example of how you can integrate GoiPay into your project. |
| 10 | + |
| 11 | +The showcase project itself you can run using [folowing guide](https://github.com/goipay/example#getting-started). |
| 12 | + |
| 13 | + |
| 14 | +## Basic Workflow |
| 15 | + |
| 16 | +The interaction workflow between the external service and GoiPay is described in the diagram below. |
| 17 | + |
| 18 | + |
| 19 | + |
| 20 | +All these aspects will be explained further with code examples. |
| 21 | + |
| 22 | + |
| 23 | +## User Registration |
| 24 | + |
| 25 | +Before creating an invoice, we need to register a user and set the user's private view and public spend XMR keys. |
| 26 | +This is necessary to generate subaddresses for payments and track the transaction state on the blockchain to verify the payment. |
| 27 | + |
| 28 | +Although GoiPay is designed to support multiple users, for simplicity, we will use only one predefined user and it's keys in the `.env` file. |
| 29 | + |
| 30 | +```ini title=".env" |
| 31 | +USER_ID=de92d9a9-9e2e-4456-ba0b-20424c97fde2 # UUID |
| 32 | + |
| 33 | +# XMR KEYS |
| 34 | +XMR_PRIV_VIEW=8aa763d1c8d9da4ca75cb6ca22a021b5cca376c1367be8d62bcc9cdf4b926009 |
| 35 | +XMR_PUB_SPEND=38e9908d33d034de0ba1281aa7afe3907b795cea14852b3d8fe276e8931cb130 |
| 36 | + |
| 37 | +GOIPAY_ADDRESS=localhost:3001 |
| 38 | +``` |
| 39 | + |
| 40 | +### User Registration and Keys Setup |
| 41 | + |
| 42 | +Here is the startup script that runs on server initialization. |
| 43 | +It uses the gRPC client to create a user and set up XMR keys. |
| 44 | + |
| 45 | +```ts title="startup.ts" |
| 46 | +import { RegisterUserRequest, RegisterUserResponse, UpdateCryptoKeysRequest, UpdateCryptoKeysResponse } from '@/generated/goipay/user_pb' |
| 47 | +import { userGrpcClient } from '@/lib/grpc-clients' |
| 48 | +import { getEnvOrThrow } from '@/lib/utils' |
| 49 | +import { XmrKeysUpdateRequest } from '@/generated/goipay/crypto_pb' |
| 50 | +import { promisify } from 'util' |
| 51 | +import { userId } from '@/lib/const' |
| 52 | + |
| 53 | +(async () => { |
| 54 | + // User Registration |
| 55 | + const registerUserPromise = promisify(userGrpcClient.registerUser.bind(userGrpcClient)) as ( |
| 56 | + request: RegisterUserRequest |
| 57 | + ) => Promise<RegisterUserResponse> |
| 58 | + |
| 59 | + try { |
| 60 | + const res = await registerUserPromise(new RegisterUserRequest().setUserid(userId)) |
| 61 | + console.log(res.toObject()) |
| 62 | + } catch (err) { |
| 63 | + console.error(err) |
| 64 | + } |
| 65 | + |
| 66 | + // Keys Update |
| 67 | + const updateKeysPromise = promisify(userGrpcClient.updateCryptoKeys.bind(userGrpcClient)) as ( |
| 68 | + request: UpdateCryptoKeysRequest |
| 69 | + ) => Promise<UpdateCryptoKeysResponse> |
| 70 | + |
| 71 | + try { |
| 72 | + await updateKeysPromise( |
| 73 | + new UpdateCryptoKeysRequest() |
| 74 | + .setUserid(userId) |
| 75 | + .setXmrreq(new XmrKeysUpdateRequest().setPrivviewkey(getEnvOrThrow('XMR_PRIV_VIEW')).setPubspendkey(getEnvOrThrow('XMR_PUB_SPEND'))) |
| 76 | + ) |
| 77 | + console.log('Keys Updated') |
| 78 | + } catch (err) { |
| 79 | + console.error(err) |
| 80 | + } |
| 81 | +})() |
| 82 | +``` |
| 83 | + |
| 84 | +:::note |
| 85 | +In the [DB Schema](/docs/development/architecture#db-schema), crypto keys have a unique constraint, meaning each user must have unique keys. |
| 86 | +::: |
| 87 | + |
| 88 | + |
| 89 | +## Subscribe to Invoice Status Notification Stream |
| 90 | + |
| 91 | +In this API route, Server-Sent Events (SSE) are used as the transport mechanism between the client and server to receive updated invoices from the gRPC Invoice Status Stream. |
| 92 | + |
| 93 | +```ts title="app/api/socket/route.ts" |
| 94 | +import { InvoiceStatusStreamRequest, InvoiceStatusStreamResponse } from '@/generated/goipay/invoice_pb' |
| 95 | +import { invoiceGrpcClient } from '@/lib/grpc-clients' |
| 96 | + |
| 97 | +const encoder = new TextEncoder() |
| 98 | +// The collection of subscribed clients. |
| 99 | +const connectedClients = new Set<WritableStreamDefaultWriter>() |
| 100 | + |
| 101 | +// Helper function to broadcast SSE messages to all subscribed clients. |
| 102 | +function broadcast(message: string) { |
| 103 | + Array.from(connectedClients).forEach((client) => { |
| 104 | + client.write(encoder.encode(message)).catch((err) => { |
| 105 | + console.error('Error writing to client:', err) |
| 106 | + connectedClients.delete(client) |
| 107 | + }) |
| 108 | + }) |
| 109 | +} |
| 110 | + |
| 111 | +// The gRPC Invoice Status Stream that broadcasts each updated invoice to all SSE clients. |
| 112 | +const stream = invoiceGrpcClient.invoiceStatusStream(new InvoiceStatusStreamRequest()) |
| 113 | +stream.on('data', (invRes: InvoiceStatusStreamResponse) => { |
| 114 | + broadcast(`event: new-invoice\ndata: ${JSON.stringify(invRes.getInvoice()?.toObject())}\n\n`) |
| 115 | +}) |
| 116 | + |
| 117 | +// HTTP handler for establishing an SSE connection. |
| 118 | +export async function GET() { |
| 119 | + const clientStream = new TransformStream() |
| 120 | + const writer = clientStream.writable.getWriter() |
| 121 | + connectedClients.add(writer) |
| 122 | + |
| 123 | + writer.write(encoder.encode('event: connected\ndata: keepalive\n\n')).catch(() => { |
| 124 | + connectedClients.delete(writer) |
| 125 | + }) |
| 126 | + |
| 127 | + return new Response(clientStream.readable, { |
| 128 | + headers: { |
| 129 | + 'Content-Type': 'text/event-stream', |
| 130 | + Connection: 'keep-alive', |
| 131 | + 'Cache-Control': 'no-cache, no-transform', |
| 132 | + }, |
| 133 | + }) |
| 134 | +} |
| 135 | +``` |
| 136 | + |
| 137 | +So, we can establish an SSE connection on the client-side, subscribe to the `new-invoice` event, and render the received invoices on the frontend. |
| 138 | + |
| 139 | +```tsx title="app/components/main.tsx" |
| 140 | +// Client-side subscription to the SSE connection. |
| 141 | +useEffect(() => { |
| 142 | + const sse = new EventSource('/api/socket') |
| 143 | + |
| 144 | + sse.onopen = () => { |
| 145 | + console.log('Connected to SSE') |
| 146 | + } |
| 147 | + sse.addEventListener('new-invoice', (e) => { |
| 148 | + const invoice = JSON.parse(e.data) as InvoiceGrpc.AsObject |
| 149 | + setInvoices((prev) => [mapInvoiceAsObjectToInvoice(invoice), ...prev.filter((i) => i.id !== invoice.id)]) |
| 150 | + }) |
| 151 | + sse.onerror = (e) => { |
| 152 | + console.error('SSE connection error:', e) |
| 153 | + } |
| 154 | + |
| 155 | + return () => { |
| 156 | + sse.close() |
| 157 | + console.log('SSE connection closed') |
| 158 | + } |
| 159 | +}, []) |
| 160 | +``` |
| 161 | + |
| 162 | +```tsx title="app/components/main.tsx" |
| 163 | +// Rendering the content of each tab, displaying invoices filtered by their status. |
| 164 | +{tabs.map((t) => ( |
| 165 | + <TabsContent key={t.val} value={t.val} className="h-[calc(100vh-250px)] overflow-auto"> |
| 166 | + <InvoicesPageTab header={t.header} invoices={invoices.filter((i) => i.status === t.status)} /> |
| 167 | + </TabsContent> |
| 168 | +))} |
| 169 | +``` |
| 170 | + |
| 171 | +## Create Invoice |
| 172 | + |
| 173 | +:::note |
| 174 | +Partial payments are not supported. |
| 175 | +If you pay less than the required amount, you will need to pay the full amount again. |
| 176 | +::: |
| 177 | + |
| 178 | +```tsx title="app/components/footer.tsx" |
| 179 | +// Creating a new invoice via the gRPC client. |
| 180 | +async function handleNewInvoiceSubmit(formData: FormData) { |
| 181 | + 'use server' |
| 182 | + const createInvoicePromise = promisify(invoiceGrpcClient.createInvoice.bind(invoiceGrpcClient)) as ( |
| 183 | + request: CreateInvoiceRequest |
| 184 | + ) => Promise<CreateInvoiceResponse> |
| 185 | + |
| 186 | + const userId = formData.get('userId') as string |
| 187 | + const amount = formData.get('amount') as string |
| 188 | + const confirmations = formData.get('confirmations') as string |
| 189 | + const timeout = formData.get('timeout') as string |
| 190 | + if (!amount || !confirmations || !userId || !timeout) return |
| 191 | + |
| 192 | + try { |
| 193 | + await createInvoicePromise( |
| 194 | + new CreateInvoiceRequest() |
| 195 | + .setUserid(userId) |
| 196 | + .setCoin(CoinType.XMR) |
| 197 | + .setAmount(parseFloat(amount)) |
| 198 | + .setConfirmations(parseInt(confirmations)) |
| 199 | + .setTimeout(parseInt(timeout)) |
| 200 | + ) |
| 201 | + } catch (err) { |
| 202 | + console.error(err) |
| 203 | + } |
| 204 | +} |
| 205 | +``` |
| 206 | + |
| 207 | +:::note |
| 208 | +The timeout option in the request applies to all conditions, including amount and confirmations. |
| 209 | +If the required amount or more is paid, but the required number of confirmations hasn't been met by the time the timeout expires, the invoice will be considered expired. |
| 210 | +::: |
0 commit comments