@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 +237 -69
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -12
- package/dist/index.d.ts +28 -12
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
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 {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
]);
|
|
37
|
+
import {
|
|
38
|
+
getBase58Decoder,
|
|
39
|
+
generateExtractableKeyPairSigner,
|
|
40
|
+
extractBytesFromKeyPairSigner,
|
|
41
|
+
} from "gill";
|
|
43
42
|
|
|
44
|
-
const
|
|
45
|
-
|
|
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(
|
|
51
|
-
|
|
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
|
-
- **`
|
|
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
|
-
- **`
|
|
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`**
|
|
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 {
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
process.env.RPC_URL
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
221
|
+
// await waitForYourApprovalSystem(...);
|
|
134
222
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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 `
|
|
315
|
+
`verifyTransaction` returns a `VerifyTransactionResult` object with:
|
|
161
316
|
|
|
162
|
-
- **`
|
|
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 {
|
|
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(
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
if (
|
|
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(
|
|
397
|
+
- **`verifyTransaction(transactionManagerConfig, payload, getClientDetails?)`**
|
|
237
398
|
- Decodes and verifies a serialized Solana transaction.
|
|
238
|
-
- Returns a `
|
|
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
|
-
- **`
|
|
245
|
-
- `
|
|
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
|
---
|