@revibase/transaction-manager 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,37 +34,34 @@ Use this package when you want a single, auditable place in your backend where e
34
34
  Generate one keypair for the manager and keep the private key server-side only.
35
35
 
36
36
  ```ts
37
- import { getBase58Decoder } from "gill";
38
-
39
- const keyPair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [
40
- "sign",
41
- "verify",
42
- ]);
37
+ import {
38
+ getBase58Decoder,
39
+ generateExtractableKeyPairSigner,
40
+ extractBytesFromKeyPairSigner,
41
+ } from "gill";
43
42
 
44
- const [pubRaw, privJwk] = await Promise.all([
45
- crypto.subtle.exportKey("raw", keyPair.publicKey),
46
- crypto.subtle.exportKey("jwk", keyPair.privateKey),
47
- ]);
43
+ const keypair = await generateExtractableKeyPairSigner();
44
+ const secretKey = await extractBytesFromKeyPairSigner(keypair);
48
45
 
49
46
  console.log({
50
- publicKey: getBase58Decoder().decode(new Uint8Array(pubRaw)),
51
- privateKey: JSON.stringify(privJwk),
47
+ publicKey: getBase58Decoder().decode(secretKey.slice(32)),
48
+ secretKey: getBase58Decoder().decode(secretKey),
52
49
  });
53
50
  ```
54
51
 
55
52
  Store:
56
53
 
57
54
  - **`publicKey`**: as the transaction manager public key (base58).
58
- - **`privateKey`**: as a JWK JSON string in a secure secret store.
55
+ - **`secretKey`**: as a base58 string in a secure secret store.
59
56
 
60
57
  ### 2. Configure environment
61
58
 
62
59
  Set the following environment variables for your signing service:
63
60
 
64
- - **`TX_MANAGER_PRIVATE_KEY`**: Manager private key (JWK JSON string).
61
+ - **`TX_MANAGER_SECRET_KEY`**: Manager secret key (base58 string).
65
62
  - **`TX_MANAGER_PUBLIC_KEY`**: Manager public key (base58).
66
- - **`TX_MANAGER_URL`**: Public HTTPS URL of your signing endpoint.
67
- - **`RPC_URL`** (optional): Solana RPC URL. Defaults to `https://api.mainnet-beta.solana.com`.
63
+ - **`TX_MANAGER_URL`**: Public HTTPS URL of your signing endpoint (the client will connect via WebSocket at the same URL).
64
+ - **`RPC_URL`** Solana RPC URL.
68
65
 
69
66
  ### 3. Implement a basic signing endpoint
70
67
 
@@ -72,6 +69,12 @@ Expose a public HTTPS endpoint, for example:
72
69
 
73
70
  `https://your-transaction-manager.com/sign`
74
71
 
72
+ This section uses the [`ws`](https://www.npmjs.com/package/ws) package for the WebSocket server:
73
+
74
+ ```bash
75
+ npm install ws
76
+ ```
77
+
75
78
  This endpoint:
76
79
 
77
80
  - Verifies that the request is intended for this transaction manager.
@@ -81,73 +84,225 @@ This endpoint:
81
84
  - Returns base58-encoded signatures.
82
85
 
83
86
  ```ts
84
- import { verifyTransaction } from "@revibase/transaction-manager";
85
- import { createSolanaRpc, getBase58Decoder } from "gill";
87
+ import {
88
+ initialize,
89
+ type CompleteMessageRequest,
90
+ type TransactionAuthDetails,
91
+ verifyMessage,
92
+ verifyTransaction,
93
+ } from "@revibase/transaction-manager";
94
+ import {
95
+ getBase58Decoder,
96
+ createKeypairSignerFromBase58,
97
+ signBytes,
98
+ } from "gill";
86
99
  import { enforcePolicies } from "@/lib/policy";
100
+ import http from "node:http";
101
+ import { WebSocketServer } from "ws";
87
102
 
88
- const rpc = createSolanaRpc(
89
- process.env.RPC_URL ?? "https://api.mainnet-beta.solana.com",
90
- );
103
+ initialize({
104
+ rpcEndpoint: process.env.RPC_URL,
105
+ });
91
106
 
92
107
  const transactionManagerConfig = {
93
108
  publicKey: process.env.TX_MANAGER_PUBLIC_KEY!, // base58
94
109
  url: process.env.TX_MANAGER_URL!, // public HTTPS URL of this endpoint
95
110
  };
96
111
 
97
- export async function POST(req: Request) {
112
+ /**
113
+ * The @revibase/core client connects using WebSocket (wss://...) and sends one JSON message.
114
+ * This is the exact shape produced by createTransactionManagerSigner():
115
+ *
116
+ * Transaction signing:
117
+ * {
118
+ * type: "transaction",
119
+ * data: {
120
+ * publicKey: string,
121
+ * payload: Array<{
122
+ * transaction: string,
123
+ * transactionMessageBytes?: string,
124
+ * authResponses?: TransactionAuthDetails[],
125
+ * }>,
126
+ * }
127
+ * }
128
+ *
129
+ * Message signing:
130
+ * {
131
+ * type: "message",
132
+ * data: {
133
+ * publicKey: string,
134
+ * payload: CompleteMessageRequest
135
+ * }
136
+ * }
137
+ *
138
+ * Your service should respond with JSON events:
139
+ * - { event: "signatures", data: { signatures: string[] } } // base58 signatures
140
+ * - { event: "error", data: { error: string } }
141
+ *
142
+ * (Optional) approval UX:
143
+ * - { event: "pending_transaction_approval", data: { validTill: number } }
144
+ * - { event: "transaction_approved", data: {} }
145
+ */
146
+ const server = http.createServer();
147
+
148
+ // Route upgrade requests for "/sign" to WebSocket.
149
+ const wss = new WebSocketServer({ noServer: true });
150
+ server.on("upgrade", (req, socket, head) => {
98
151
  try {
99
- const { publicKey, payload } = (await req.json()) as {
100
- publicKey: string;
101
- payload: {
102
- transaction: string;
103
- transactionMessageBytes?: string;
104
- authResponses?: unknown[];
105
- }[];
106
- };
152
+ const url = new URL(req.url ?? "/", "http://localhost");
153
+ if (url.pathname !== "/sign") {
154
+ socket.destroy();
155
+ return;
156
+ }
157
+ wss.handleUpgrade(req, socket, head, (ws) => {
158
+ wss.emit("connection", ws, req);
159
+ });
160
+ } catch {
161
+ socket.destroy();
162
+ }
163
+ });
107
164
 
108
- if (publicKey !== transactionManagerConfig.publicKey) {
109
- return Response.json(
110
- { error: "Invalid transaction manager public key" },
111
- { status: 400 },
165
+ wss.on("connection", async (ws) => {
166
+ try {
167
+ // Read exactly one request message from the client.
168
+ const msg = (await readJsonOnce(ws)) as {
169
+ type: string;
170
+ data?: unknown;
171
+ };
172
+ if (msg.type !== "transaction" && msg.type !== "message") {
173
+ ws.send(
174
+ JSON.stringify({
175
+ event: "error",
176
+ data: { error: `Unsupported request type: ${msg.type}` },
177
+ }),
112
178
  );
179
+ ws.close();
180
+ return;
113
181
  }
114
182
 
115
- const jwk = JSON.parse(process.env.TX_MANAGER_PRIVATE_KEY!);
116
- const privateKey = await crypto.subtle.importKey(
117
- "jwk",
118
- jwk,
119
- { name: "Ed25519" },
120
- false,
121
- ["sign"],
183
+ // Shared: load signer key once per connection.
184
+ const { keyPair } = await createKeypairSignerFromBase58(
185
+ process.env.TX_MANAGER_SECRET_KEY!,
122
186
  );
123
187
 
124
- const signatures: string[] = [];
188
+ if (msg.type === "transaction") {
189
+ const { publicKey, payload } = (msg.data ?? {}) as {
190
+ publicKey: string;
191
+ payload: {
192
+ transaction: string;
193
+ transactionMessageBytes?: string;
194
+ authResponses?: TransactionAuthDetails[];
195
+ }[];
196
+ };
197
+
198
+ if (publicKey !== transactionManagerConfig.publicKey) {
199
+ ws.send(
200
+ JSON.stringify({
201
+ event: "error",
202
+ data: { error: "Invalid transaction manager public key" },
203
+ }),
204
+ );
205
+ ws.close();
206
+ return;
207
+ }
125
208
 
126
- for (const payloadItem of payload) {
127
- const result = await verifyTransaction(
128
- rpc,
129
- transactionManagerConfig,
130
- payloadItem,
131
- );
209
+ const signatures: string[] = [];
210
+ for (const payloadItem of payload) {
211
+ const { messageBytes, verificationResults } = await verifyTransaction(
212
+ transactionManagerConfig,
213
+ payloadItem,
214
+ );
215
+
216
+ await enforcePolicies(verificationResults);
217
+
218
+ // (Optional) if your policy requires an out-of-band human approval:
219
+ // ws.send(JSON.stringify({ event: "pending_transaction_approval", data: { validTill: Date.now() + 60_000 } }));
132
220
 
133
- await enforcePolicies(result);
221
+ // await waitForYourApprovalSystem(...);
134
222
 
135
- const signatureBytes = await crypto.subtle.sign(
136
- { name: "Ed25519" },
137
- privateKey,
138
- result.transactionMessage,
223
+ // ws.send(JSON.stringify({ event: "transaction_approved", data: {} }));
224
+
225
+ const signatureBytes = await signBytes(
226
+ keyPair.privateKey,
227
+ messageBytes,
228
+ );
229
+
230
+ signatures.push(getBase58Decoder().decode(signatureBytes));
231
+ }
232
+
233
+ ws.send(JSON.stringify({ event: "signatures", data: { signatures } }));
234
+ ws.close();
235
+ } else {
236
+ // msg.type === "message"
237
+ const { publicKey, payload } = (msg.data ?? {}) as {
238
+ publicKey: string;
239
+ payload: CompleteMessageRequest;
240
+ };
241
+
242
+ if (publicKey !== transactionManagerConfig.publicKey) {
243
+ ws.send(
244
+ JSON.stringify({
245
+ event: "error",
246
+ data: { error: "Invalid transaction manager public key" },
247
+ }),
248
+ );
249
+ ws.close();
250
+ return;
251
+ }
252
+
253
+ const { messageBytes, verificationResults } = await verifyMessage(
254
+ publicKey,
255
+ payload,
139
256
  );
140
257
 
141
- signatures.push(
142
- getBase58Decoder().decode(new Uint8Array(signatureBytes)),
258
+ // (Optional) if your policy requires an out-of-band human approval:
259
+
260
+ // ws.send(JSON.stringify({ event: "pending_transaction_approval", data: { validTill: Date.now() + 60_000 } }));
261
+
262
+ // await waitForYourApprovalSystem(...);
263
+
264
+ // ws.send(JSON.stringify({ event: "transaction_approved", data: {} }));
265
+
266
+ const signatureBytes = await signBytes(keyPair.privateKey, messageBytes);
267
+
268
+ ws.send(
269
+ JSON.stringify({
270
+ event: "signatures",
271
+ data: {
272
+ signatures: [getBase58Decoder().decode(signatureBytes)],
273
+ },
274
+ }),
143
275
  );
276
+ ws.close();
144
277
  }
145
-
146
- return Response.json({ signatures });
147
278
  } catch (e) {
148
279
  const msg = e instanceof Error ? e.message : String(e);
149
- return Response.json({ error: msg }, { status: 500 });
280
+ try {
281
+ ws.send(JSON.stringify({ event: "error", data: { error: msg } }));
282
+ } finally {
283
+ ws.close();
284
+ }
150
285
  }
286
+ });
287
+
288
+ server.listen(3000, () => {
289
+ console.log("Transaction manager listening on http://localhost:3000/sign");
290
+ });
291
+
292
+ function readJsonOnce(ws: import("ws").WebSocket): Promise<unknown> {
293
+ return new Promise((resolve, reject) => {
294
+ const onMessage = (data: import("ws").RawData) => {
295
+ try {
296
+ const text = typeof data === "string" ? data : data.toString("utf8");
297
+ resolve(JSON.parse(text));
298
+ } catch (e) {
299
+ reject(e);
300
+ } finally {
301
+ ws.off("message", onMessage);
302
+ }
303
+ };
304
+ ws.on("message", onMessage);
305
+ });
151
306
  }
152
307
  ```
153
308
 
@@ -157,9 +312,9 @@ export async function POST(req: Request) {
157
312
 
158
313
  Your transaction manager should express your security model in one place.
159
314
 
160
- `verifyTransaction` returns a `VerificationResults` object with:
315
+ `verifyTransaction` returns a `VerifyTransactionResult` object with:
161
316
 
162
- - **`transactionMessage`**: Raw transaction message bytes to sign.
317
+ - **`messageBytes`**: Raw transaction message bytes to sign.
163
318
  - **`verificationResults`**: An array of batches, where each batch contains:
164
319
  - **`instructions`**: Decoded on-chain instructions.
165
320
  - **`signers`**: The signers that successfully passed verification for those instructions.
@@ -172,7 +327,8 @@ The example below:
172
327
  - Caps each transfer at **1 SOL**.
173
328
 
174
329
  ```ts
175
- import type { VerificationResults } from "@revibase/transaction-manager";
330
+ import type { ExpectedTransactionSigner } from "@revibase/transaction-manager";
331
+ import type { Instruction } from "gill";
176
332
  import {
177
333
  SYSTEM_PROGRAM_ADDRESS,
178
334
  identifySystemInstruction,
@@ -184,13 +340,18 @@ import {
184
340
  const ALLOWED_ORIGINS = new Set(["https://app.revibase.com"]);
185
341
  const MAX_TRANSFER_LAMPORTS = 1_000_000_000n; // 1 SOL
186
342
 
187
- export async function enforcePolicies(results: VerificationResults) {
188
- for (const batch of results.verificationResults) {
343
+ export async function enforcePolicies(
344
+ verificationResults: {
345
+ instructions: Instruction[];
346
+ signers: ExpectedTransactionSigner[];
347
+ }[],
348
+ ) {
349
+ for (const batch of verificationResults) {
189
350
  const { signers, instructions } = batch;
190
351
 
191
352
  for (const signer of signers) {
192
- const origin = "client" in signer ? signer.client?.origin : undefined;
193
- if (origin && !ALLOWED_ORIGINS.has(origin)) {
353
+ // Only secp256r1/passkey signers include `client` metadata (origin + client JWK).
354
+ if ("client" in signer && !ALLOWED_ORIGINS.has(signer.client.origin)) {
194
355
  throw new Error("Unauthorized app origin");
195
356
  }
196
357
  }
@@ -233,16 +394,23 @@ export async function enforcePolicies(results: VerificationResults) {
233
394
 
234
395
  This package exports the following public API:
235
396
 
236
- - **`verifyTransaction(rpc, transactionManagerConfig, payload, wellKnownProxyUrl?)`**
397
+ - **`verifyTransaction(transactionManagerConfig, payload, getClientDetails?)`**
237
398
  - Decodes and verifies a serialized Solana transaction.
238
- - Returns a `VerificationResults` object with the transaction message bytes and verification batches.
399
+ - Returns a `VerifyTransactionResult` object with the transaction message bytes and verification batches.
400
+
401
+ - **`verifyMessage(publicKey, payload, getClientDetails?)`**
402
+ - Verifies a sign-in / message authorization payload (`CompleteMessageRequest`).
403
+ - Returns a `VerifyMessageResult` containing:
404
+ - `messageBytes`: the message bytes to sign (Ed25519).
405
+ - `verificationResults`: the verified payload plus the extracted signer metadata.
406
+ - Uses `payload.data.payload.startRequest.rpId` and `payload.data.payload.startRequest.clientOrigin` for WebAuthn verification (RP ID + expected origin).
239
407
 
240
408
  - **`TransactionManagerConfig`**
241
409
  - `publicKey`: Transaction manager public key (base58 string).
242
410
  - `url`: Public URL of your transaction manager endpoint.
243
411
 
244
- - **`VerificationResults`**
245
- - `transactionMessage`: Transaction message bytes to sign.
412
+ - **`VerifyTransactionResult`**
413
+ - `messageBytes`: Transaction message bytes to sign.
246
414
  - `verificationResults`: Array of `{ instructions, signers }` batches used by your policy.
247
415
 
248
416
  ---