@spectratools/tx-shared 0.4.1 → 0.4.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
@@ -56,7 +56,7 @@ export PRIVY_WALLET_ID="..."
56
56
  export PRIVY_AUTHORIZATION_KEY="..."
57
57
  ```
58
58
 
59
- > Privy signer execution is intentionally stubbed right now and throws `PRIVY_AUTH_FAILED` with a deterministic message. Full implementation is tracked in [issue #117](https://github.com/spectra-the-bot/spectra-tools/issues/117).
59
+ > Privy signer resolution now performs wallet address lookup and exposes a Privy-backed account helper for `eth_sendTransaction` intents. Additional signing methods (for example `personal_sign` and typed-data) are tracked in [issue #117](https://github.com/spectra-the-bot/spectra-tools/issues/117).
60
60
 
61
61
  ## `resolveSigner()` usage
62
62
 
@@ -185,7 +185,7 @@ try {
185
185
  - ensure keystore is valid V3 JSON
186
186
  - **`PRIVY_AUTH_FAILED`**
187
187
  - verify all `PRIVY_*` variables are set
188
- - note: provider is not live yet (see #117)
188
+ - check signer/owner policy constraints for the Privy wallet
189
189
  - **`GAS_ESTIMATION_FAILED` / `TX_REVERTED`**
190
190
  - validate function args and `value`
191
191
  - run with `dryRun: true` first
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- type TxErrorCode = 'INSUFFICIENT_FUNDS' | 'NONCE_CONFLICT' | 'TX_REVERTED' | 'GAS_ESTIMATION_FAILED' | 'SIGNER_NOT_CONFIGURED' | 'KEYSTORE_DECRYPT_FAILED' | 'PRIVY_AUTH_FAILED';
1
+ type TxErrorCode = 'INSUFFICIENT_FUNDS' | 'NONCE_CONFLICT' | 'TX_REVERTED' | 'GAS_ESTIMATION_FAILED' | 'SIGNER_NOT_CONFIGURED' | 'KEYSTORE_DECRYPT_FAILED' | 'PRIVY_AUTH_FAILED' | 'PRIVY_TRANSPORT_FAILED';
2
2
  declare class TxError extends Error {
3
3
  readonly code: TxErrorCode;
4
4
  constructor(code: TxErrorCode, message: string, cause?: unknown);
package/dist/index.d.ts CHANGED
@@ -4,7 +4,8 @@ export { TxError, TxErrorCode, toTxError } from './errors.js';
4
4
  export { abstractMainnet, createAbstractClient } from './chain.js';
5
5
  export { DryRunResult, ExecuteTxOptions, executeTx } from './execute-tx.js';
6
6
  import { z } from 'incur';
7
- import 'viem';
7
+ import { Account, Address, Hex, Hash } from 'viem';
8
+ import { KeyObject } from 'node:crypto';
8
9
 
9
10
  /**
10
11
  * Resolve the active signer provider using deterministic precedence:
@@ -18,6 +19,7 @@ declare const signerFlagSchema: z.ZodObject<{
18
19
  keystore: z.ZodOptional<z.ZodString>;
19
20
  password: z.ZodOptional<z.ZodString>;
20
21
  privy: z.ZodDefault<z.ZodBoolean>;
22
+ 'privy-api-url': z.ZodOptional<z.ZodString>;
21
23
  }, z.core.$strip>;
22
24
  /** Shared signer-related environment variables for write-capable commands. */
23
25
  declare const signerEnvSchema: z.ZodObject<{
@@ -26,6 +28,7 @@ declare const signerEnvSchema: z.ZodObject<{
26
28
  PRIVY_APP_ID: z.ZodOptional<z.ZodString>;
27
29
  PRIVY_WALLET_ID: z.ZodOptional<z.ZodString>;
28
30
  PRIVY_AUTHORIZATION_KEY: z.ZodOptional<z.ZodString>;
31
+ PRIVY_API_URL: z.ZodOptional<z.ZodString>;
29
32
  }, z.core.$strip>;
30
33
  type SignerFlags = z.infer<typeof signerFlagSchema>;
31
34
  type SignerEnv = z.infer<typeof signerEnvSchema>;
@@ -57,17 +60,128 @@ interface KeystoreSignerOptions {
57
60
  */
58
61
  declare function createKeystoreSigner(options: KeystoreSignerOptions): TxSigner;
59
62
 
63
+ interface PrivyRpcIntentRequest {
64
+ method: string;
65
+ params: Record<string, unknown>;
66
+ [key: string]: unknown;
67
+ }
68
+ type PrivyIntentStatus = 'pending' | 'executed' | 'failed' | 'expired' | 'rejected' | 'dismissed' | string;
69
+ interface PrivyRpcIntentResponse {
70
+ intent_id: string;
71
+ status: PrivyIntentStatus;
72
+ resource_id?: string;
73
+ request_details?: Record<string, unknown>;
74
+ dismissal_reason?: string;
75
+ action_result?: {
76
+ response_body?: Record<string, unknown>;
77
+ [key: string]: unknown;
78
+ };
79
+ [key: string]: unknown;
80
+ }
81
+ interface PrivyWalletResponse {
82
+ id: string;
83
+ address: string;
84
+ owner_id: string | null;
85
+ policy_ids: string[];
86
+ [key: string]: unknown;
87
+ }
88
+ interface PrivyPolicyResponse {
89
+ id: string;
90
+ owner_id: string | null;
91
+ rules: unknown[];
92
+ [key: string]: unknown;
93
+ }
94
+ interface CreatePrivyClientOptions {
95
+ appId: string;
96
+ walletId: string;
97
+ authorizationKey: string;
98
+ apiUrl?: string;
99
+ fetchImplementation?: typeof fetch;
100
+ }
101
+ interface PrivyRequestOptions {
102
+ idempotencyKey?: string;
103
+ }
104
+ interface PrivyClient {
105
+ readonly appId: string;
106
+ readonly walletId: string;
107
+ readonly apiUrl: string;
108
+ createRpcIntent(request: PrivyRpcIntentRequest, options?: PrivyRequestOptions): Promise<PrivyRpcIntentResponse>;
109
+ getWallet(): Promise<PrivyWalletResponse>;
110
+ getPolicy(policyId: string): Promise<PrivyPolicyResponse>;
111
+ }
112
+ declare function createPrivyClient(options: CreatePrivyClientOptions): PrivyClient;
113
+
114
+ type PrivyNumberish = bigint | number | string;
115
+ interface PrivySendTransactionRequest {
116
+ to?: Address;
117
+ data?: Hex;
118
+ value?: PrivyNumberish;
119
+ nonce?: PrivyNumberish;
120
+ gas?: PrivyNumberish;
121
+ gasPrice?: PrivyNumberish;
122
+ maxFeePerGas?: PrivyNumberish;
123
+ maxPriorityFeePerGas?: PrivyNumberish;
124
+ chainId?: PrivyNumberish;
125
+ type?: number;
126
+ }
127
+ type PrivyAccount = Extract<Account, {
128
+ type: 'json-rpc';
129
+ }> & {
130
+ sendTransaction: (request: PrivySendTransactionRequest) => Promise<Hash>;
131
+ };
132
+ interface CreatePrivyAccountOptions {
133
+ client: PrivyClient;
134
+ chainId?: number;
135
+ }
136
+ declare function createPrivyAccount(options: CreatePrivyAccountOptions): Promise<PrivyAccount>;
137
+
138
+ type PrivyAuthorizationMethod = 'POST' | 'PUT' | 'PATCH' | 'DELETE';
139
+ interface PrivyAuthorizationPayloadHeaders {
140
+ 'privy-app-id': string;
141
+ 'privy-idempotency-key'?: string;
142
+ }
143
+ interface PrivyAuthorizationPayload<TBody extends Record<string, unknown>> {
144
+ version: 1;
145
+ method: PrivyAuthorizationMethod;
146
+ url: string;
147
+ headers: PrivyAuthorizationPayloadHeaders;
148
+ body: TBody;
149
+ }
150
+ interface CreatePrivyAuthorizationPayloadOptions<TBody extends Record<string, unknown>> {
151
+ appId: string;
152
+ method: PrivyAuthorizationMethod;
153
+ url: string;
154
+ body: TBody;
155
+ idempotencyKey?: string;
156
+ }
157
+ declare function normalizePrivyApiUrl(apiUrl?: string): string;
158
+ declare function parsePrivyAuthorizationKey(authorizationKey: string): KeyObject;
159
+ declare function createPrivyAuthorizationPayload<TBody extends Record<string, unknown>>(options: CreatePrivyAuthorizationPayloadOptions<TBody>): PrivyAuthorizationPayload<TBody>;
160
+ declare function serializePrivyAuthorizationPayload(payload: PrivyAuthorizationPayload<Record<string, unknown>>): string;
161
+ declare function generatePrivyAuthorizationSignature(payload: PrivyAuthorizationPayload<Record<string, unknown>>, authorizationKey: string): string;
162
+
60
163
  interface PrivySignerOptions {
61
164
  privyAppId?: string;
62
165
  privyWalletId?: string;
63
166
  privyAuthorizationKey?: string;
167
+ privyApiUrl?: string;
168
+ }
169
+ interface PrivySigner extends TxSigner {
170
+ account: PrivyAccount;
171
+ provider: 'privy';
172
+ privy: {
173
+ appId: string;
174
+ walletId: string;
175
+ apiUrl: string;
176
+ client: PrivyClient;
177
+ };
64
178
  }
65
179
  /**
66
- * Privy signer adapter entrypoint.
180
+ * Create a Privy signer envelope with reusable transport and request-signing primitives.
67
181
  *
68
- * Full Privy integration is tracked in issue #117. Until that lands,
69
- * this adapter provides deterministic, structured failures for callers.
182
+ * The resolved account is backed by Privy RPC intents and includes a `sendTransaction`
183
+ * helper that submits `eth_sendTransaction` intents and returns the resulting tx hash.
70
184
  */
71
- declare function createPrivySigner(options: PrivySignerOptions): Promise<TxSigner>;
185
+ declare function createPrivySigner(options: PrivySignerOptions): Promise<PrivySigner>;
72
186
 
73
- export { type KeystoreSignerOptions, type PrivySignerOptions, type SignerEnv, type SignerFlags, SignerOptions, TxSigner, createKeystoreSigner, createPrivateKeySigner, createPrivySigner, resolveSigner, signerEnvSchema, signerFlagSchema, toSignerOptions };
187
+ export { type KeystoreSignerOptions, type PrivyAccount, type PrivyAuthorizationPayload, type PrivyAuthorizationPayloadHeaders, type PrivyClient, type PrivySendTransactionRequest, type PrivySigner, type PrivySignerOptions, type SignerEnv, type SignerFlags, SignerOptions, TxSigner, createKeystoreSigner, createPrivateKeySigner, createPrivyAccount, createPrivyAuthorizationPayload, createPrivyClient, createPrivySigner, generatePrivyAuthorizationSignature, normalizePrivyApiUrl, parsePrivyAuthorizationKey, resolveSigner, serializePrivyAuthorizationPayload, signerEnvSchema, signerFlagSchema, toSignerOptions };
package/dist/index.js CHANGED
@@ -57,14 +57,545 @@ function createKeystoreSigner(options) {
57
57
  return { account, address: account.address, provider: "keystore" };
58
58
  }
59
59
 
60
+ // src/signers/privy-account.ts
61
+ import { isAddress } from "viem";
62
+ var DEFAULT_CHAIN_ID = 2741;
63
+ var HASH_REGEX = /^0x[a-fA-F0-9]{64}$/;
64
+ async function createPrivyAccount(options) {
65
+ const wallet = await options.client.getWallet();
66
+ if (typeof wallet.address !== "string" || !isAddress(wallet.address)) {
67
+ throw new TxError(
68
+ "PRIVY_TRANSPORT_FAILED",
69
+ "Privy get wallet request failed: wallet address is missing or invalid"
70
+ );
71
+ }
72
+ const address = wallet.address;
73
+ const defaultChainId = options.chainId ?? DEFAULT_CHAIN_ID;
74
+ return {
75
+ address,
76
+ type: "json-rpc",
77
+ async sendTransaction(request) {
78
+ const chainId = normalizeChainId(request.chainId ?? defaultChainId);
79
+ const rpcRequest = createSendTransactionRpcRequest(address, chainId, request);
80
+ const response = await options.client.createRpcIntent(rpcRequest);
81
+ return parsePrivyTransactionHash(response);
82
+ }
83
+ };
84
+ }
85
+ function createSendTransactionRpcRequest(from, chainId, request) {
86
+ const transaction = {
87
+ from
88
+ };
89
+ if (request.to !== void 0) {
90
+ transaction.to = request.to;
91
+ }
92
+ if (request.data !== void 0) {
93
+ transaction.data = request.data;
94
+ }
95
+ if (request.value !== void 0) {
96
+ transaction.value = normalizeNumberish(request.value, "value");
97
+ }
98
+ if (request.nonce !== void 0) {
99
+ transaction.nonce = normalizeNumberish(request.nonce, "nonce");
100
+ }
101
+ if (request.gas !== void 0) {
102
+ transaction.gas_limit = normalizeNumberish(request.gas, "gas");
103
+ }
104
+ if (request.gasPrice !== void 0) {
105
+ transaction.gas_price = normalizeNumberish(request.gasPrice, "gasPrice");
106
+ }
107
+ if (request.maxFeePerGas !== void 0) {
108
+ transaction.max_fee_per_gas = normalizeNumberish(request.maxFeePerGas, "maxFeePerGas");
109
+ }
110
+ if (request.maxPriorityFeePerGas !== void 0) {
111
+ transaction.max_priority_fee_per_gas = normalizeNumberish(
112
+ request.maxPriorityFeePerGas,
113
+ "maxPriorityFeePerGas"
114
+ );
115
+ }
116
+ if (request.type !== void 0) {
117
+ transaction.type = request.type;
118
+ }
119
+ transaction.chain_id = chainId;
120
+ return {
121
+ method: "eth_sendTransaction",
122
+ caip2: `eip155:${chainId}`,
123
+ params: {
124
+ transaction
125
+ }
126
+ };
127
+ }
128
+ function parsePrivyTransactionHash(response) {
129
+ const intentId = response.intent_id;
130
+ if (response.status !== "executed") {
131
+ const reason = extractIntentReason(response) ?? "no reason provided";
132
+ if (response.status === "failed") {
133
+ throw new TxError("TX_REVERTED", `Privy rpc intent ${intentId} failed: ${reason}`);
134
+ }
135
+ if (response.status === "rejected" || response.status === "dismissed" || response.status === "expired") {
136
+ throw new TxError(
137
+ "PRIVY_AUTH_FAILED",
138
+ `Privy rpc intent ${intentId} ${response.status}: ${reason}`
139
+ );
140
+ }
141
+ throw new TxError(
142
+ "PRIVY_TRANSPORT_FAILED",
143
+ `Privy rpc intent ${intentId} did not execute (status: ${response.status})`
144
+ );
145
+ }
146
+ const hash = extractIntentHash(response);
147
+ if (hash === void 0 || !HASH_REGEX.test(hash)) {
148
+ throw new TxError(
149
+ "PRIVY_TRANSPORT_FAILED",
150
+ `Privy rpc intent ${intentId} executed without a valid transaction hash`
151
+ );
152
+ }
153
+ return hash;
154
+ }
155
+ function extractIntentHash(response) {
156
+ if (!isRecord(response.action_result)) {
157
+ return void 0;
158
+ }
159
+ const responseBody = response.action_result.response_body;
160
+ if (!isRecord(responseBody)) {
161
+ return void 0;
162
+ }
163
+ const data = responseBody.data;
164
+ if (!isRecord(data)) {
165
+ return void 0;
166
+ }
167
+ return typeof data.hash === "string" ? data.hash : void 0;
168
+ }
169
+ function extractIntentReason(response) {
170
+ if (typeof response.dismissal_reason === "string" && response.dismissal_reason.length > 0) {
171
+ return response.dismissal_reason;
172
+ }
173
+ if (isRecord(response.action_result)) {
174
+ const responseBody = response.action_result.response_body;
175
+ if (isRecord(responseBody)) {
176
+ const error = responseBody.error;
177
+ if (typeof error === "string" && error.length > 0) {
178
+ return error;
179
+ }
180
+ if (isRecord(error)) {
181
+ const errorMessage = error.message;
182
+ if (typeof errorMessage === "string" && errorMessage.length > 0) {
183
+ return errorMessage;
184
+ }
185
+ }
186
+ const message = responseBody.message;
187
+ if (typeof message === "string" && message.length > 0) {
188
+ return message;
189
+ }
190
+ }
191
+ }
192
+ return void 0;
193
+ }
194
+ function normalizeChainId(chainId) {
195
+ if (typeof chainId === "number") {
196
+ if (!Number.isInteger(chainId) || chainId <= 0) {
197
+ throw new TxError(
198
+ "PRIVY_TRANSPORT_FAILED",
199
+ "Failed to build Privy eth_sendTransaction payload: chainId must be a positive integer"
200
+ );
201
+ }
202
+ return chainId;
203
+ }
204
+ if (typeof chainId === "bigint") {
205
+ if (chainId <= 0n || chainId > BigInt(Number.MAX_SAFE_INTEGER)) {
206
+ throw new TxError(
207
+ "PRIVY_TRANSPORT_FAILED",
208
+ "Failed to build Privy eth_sendTransaction payload: chainId must be a positive safe integer"
209
+ );
210
+ }
211
+ return Number(chainId);
212
+ }
213
+ const trimmed = chainId.trim();
214
+ if (trimmed.length === 0) {
215
+ throw new TxError(
216
+ "PRIVY_TRANSPORT_FAILED",
217
+ "Failed to build Privy eth_sendTransaction payload: chainId is empty"
218
+ );
219
+ }
220
+ if (/^0x[0-9a-fA-F]+$/.test(trimmed)) {
221
+ const parsed2 = Number.parseInt(trimmed.slice(2), 16);
222
+ if (!Number.isSafeInteger(parsed2) || parsed2 <= 0) {
223
+ throw new TxError(
224
+ "PRIVY_TRANSPORT_FAILED",
225
+ "Failed to build Privy eth_sendTransaction payload: chainId must be a positive safe integer"
226
+ );
227
+ }
228
+ return parsed2;
229
+ }
230
+ if (!/^[0-9]+$/.test(trimmed)) {
231
+ throw new TxError(
232
+ "PRIVY_TRANSPORT_FAILED",
233
+ "Failed to build Privy eth_sendTransaction payload: chainId must be a positive safe integer"
234
+ );
235
+ }
236
+ const parsed = Number(trimmed);
237
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
238
+ throw new TxError(
239
+ "PRIVY_TRANSPORT_FAILED",
240
+ "Failed to build Privy eth_sendTransaction payload: chainId must be a positive safe integer"
241
+ );
242
+ }
243
+ return parsed;
244
+ }
245
+ function normalizeNumberish(value, fieldName) {
246
+ if (typeof value === "bigint") {
247
+ if (value < 0n) {
248
+ throw new TxError(
249
+ "PRIVY_TRANSPORT_FAILED",
250
+ `Failed to build Privy eth_sendTransaction payload: ${fieldName} cannot be negative`
251
+ );
252
+ }
253
+ return value.toString(10);
254
+ }
255
+ if (typeof value === "number") {
256
+ if (!Number.isFinite(value) || value < 0) {
257
+ throw new TxError(
258
+ "PRIVY_TRANSPORT_FAILED",
259
+ `Failed to build Privy eth_sendTransaction payload: ${fieldName} must be a non-negative number`
260
+ );
261
+ }
262
+ return Number.isInteger(value) ? value : value.toString();
263
+ }
264
+ const trimmed = value.trim();
265
+ if (trimmed.length === 0) {
266
+ throw new TxError(
267
+ "PRIVY_TRANSPORT_FAILED",
268
+ `Failed to build Privy eth_sendTransaction payload: ${fieldName} is empty`
269
+ );
270
+ }
271
+ return trimmed;
272
+ }
273
+ function isRecord(value) {
274
+ return typeof value === "object" && value !== null;
275
+ }
276
+
277
+ // src/signers/privy-signature.ts
278
+ import { createPrivateKey, sign as signWithCrypto } from "crypto";
279
+ var PRIVY_AUTHORIZATION_KEY_PREFIX = "wallet-auth:";
280
+ var PRIVY_AUTHORIZATION_KEY_REGEX = /^wallet-auth:[A-Za-z0-9+/]+={0,2}$/;
281
+ var DEFAULT_PRIVY_API_URL = "https://api.privy.io";
282
+ function normalizePrivyApiUrl(apiUrl) {
283
+ const value = (apiUrl ?? DEFAULT_PRIVY_API_URL).trim();
284
+ if (value.length === 0) {
285
+ throw new TxError(
286
+ "PRIVY_AUTH_FAILED",
287
+ "Invalid PRIVY_API_URL format: expected a non-empty http(s) URL"
288
+ );
289
+ }
290
+ let parsed;
291
+ try {
292
+ parsed = new URL(value);
293
+ } catch (cause) {
294
+ throw new TxError(
295
+ "PRIVY_AUTH_FAILED",
296
+ "Invalid PRIVY_API_URL format: expected a non-empty http(s) URL",
297
+ cause
298
+ );
299
+ }
300
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
301
+ throw new TxError(
302
+ "PRIVY_AUTH_FAILED",
303
+ "Invalid PRIVY_API_URL format: expected a non-empty http(s) URL"
304
+ );
305
+ }
306
+ return parsed.toString().replace(/\/+$/, "");
307
+ }
308
+ function parsePrivyAuthorizationKey(authorizationKey) {
309
+ const normalizedKey = authorizationKey.trim();
310
+ if (!PRIVY_AUTHORIZATION_KEY_REGEX.test(normalizedKey)) {
311
+ throw new TxError(
312
+ "PRIVY_AUTH_FAILED",
313
+ "Invalid PRIVY_AUTHORIZATION_KEY format: expected wallet-auth:<base64-pkcs8-p256-private-key>"
314
+ );
315
+ }
316
+ const rawPrivateKey = normalizedKey.slice(PRIVY_AUTHORIZATION_KEY_PREFIX.length);
317
+ let derKey;
318
+ try {
319
+ derKey = Buffer.from(rawPrivateKey, "base64");
320
+ } catch (cause) {
321
+ throw new TxError(
322
+ "PRIVY_AUTH_FAILED",
323
+ "Invalid PRIVY_AUTHORIZATION_KEY format: expected wallet-auth:<base64-pkcs8-p256-private-key>",
324
+ cause
325
+ );
326
+ }
327
+ let keyObject;
328
+ try {
329
+ keyObject = createPrivateKey({
330
+ key: derKey,
331
+ format: "der",
332
+ type: "pkcs8"
333
+ });
334
+ } catch (cause) {
335
+ throw new TxError(
336
+ "PRIVY_AUTH_FAILED",
337
+ "Invalid PRIVY_AUTHORIZATION_KEY format: expected wallet-auth:<base64-pkcs8-p256-private-key>",
338
+ cause
339
+ );
340
+ }
341
+ if (keyObject.asymmetricKeyType !== "ec") {
342
+ throw new TxError(
343
+ "PRIVY_AUTH_FAILED",
344
+ "Invalid PRIVY_AUTHORIZATION_KEY format: expected wallet-auth:<base64-pkcs8-p256-private-key>"
345
+ );
346
+ }
347
+ const details = keyObject.asymmetricKeyDetails;
348
+ const namedCurve = details !== void 0 && "namedCurve" in details ? details.namedCurve : void 0;
349
+ if (namedCurve !== void 0 && namedCurve !== "prime256v1") {
350
+ throw new TxError(
351
+ "PRIVY_AUTH_FAILED",
352
+ "Invalid PRIVY_AUTHORIZATION_KEY format: expected wallet-auth:<base64-pkcs8-p256-private-key>"
353
+ );
354
+ }
355
+ return keyObject;
356
+ }
357
+ function createPrivyAuthorizationPayload(options) {
358
+ const appId = options.appId.trim();
359
+ if (appId.length === 0) {
360
+ throw new TxError(
361
+ "PRIVY_AUTH_FAILED",
362
+ "Invalid PRIVY_APP_ID format: expected non-empty string"
363
+ );
364
+ }
365
+ const url = options.url.trim().replace(/\/+$/, "");
366
+ if (url.length === 0) {
367
+ throw new TxError(
368
+ "PRIVY_TRANSPORT_FAILED",
369
+ "Failed to build Privy authorization payload: request URL is empty"
370
+ );
371
+ }
372
+ return {
373
+ version: 1,
374
+ method: options.method,
375
+ url,
376
+ headers: {
377
+ "privy-app-id": appId,
378
+ ...options.idempotencyKey !== void 0 ? { "privy-idempotency-key": options.idempotencyKey } : {}
379
+ },
380
+ body: options.body
381
+ };
382
+ }
383
+ function serializePrivyAuthorizationPayload(payload) {
384
+ return canonicalizeJson(payload);
385
+ }
386
+ function generatePrivyAuthorizationSignature(payload, authorizationKey) {
387
+ const privateKey = parsePrivyAuthorizationKey(authorizationKey);
388
+ const serializedPayload = serializePrivyAuthorizationPayload(payload);
389
+ const signature = signWithCrypto("sha256", Buffer.from(serializedPayload), privateKey);
390
+ return signature.toString("base64");
391
+ }
392
+ function canonicalizeJson(value) {
393
+ if (value === null) {
394
+ return "null";
395
+ }
396
+ if (typeof value === "string" || typeof value === "boolean") {
397
+ return JSON.stringify(value);
398
+ }
399
+ if (typeof value === "number") {
400
+ if (!Number.isFinite(value)) {
401
+ throw new TxError(
402
+ "PRIVY_TRANSPORT_FAILED",
403
+ "Failed to build Privy authorization payload: JSON payload contains a non-finite number"
404
+ );
405
+ }
406
+ return JSON.stringify(value);
407
+ }
408
+ if (Array.isArray(value)) {
409
+ return `[${value.map((item) => canonicalizeJson(item)).join(",")}]`;
410
+ }
411
+ if (typeof value === "object") {
412
+ const record = value;
413
+ const keys = Object.keys(record).sort((left, right) => left.localeCompare(right));
414
+ const entries = [];
415
+ for (const key of keys) {
416
+ const entryValue = record[key];
417
+ if (entryValue === void 0) {
418
+ continue;
419
+ }
420
+ entries.push(`${JSON.stringify(key)}:${canonicalizeJson(entryValue)}`);
421
+ }
422
+ return `{${entries.join(",")}}`;
423
+ }
424
+ throw new TxError(
425
+ "PRIVY_TRANSPORT_FAILED",
426
+ "Failed to build Privy authorization payload: JSON payload contains unsupported value type"
427
+ );
428
+ }
429
+
430
+ // src/signers/privy-client.ts
431
+ function createPrivyClient(options) {
432
+ const appId = options.appId.trim();
433
+ const walletId = options.walletId.trim();
434
+ if (appId.length === 0) {
435
+ throw new TxError(
436
+ "PRIVY_AUTH_FAILED",
437
+ "Invalid PRIVY_APP_ID format: expected non-empty string"
438
+ );
439
+ }
440
+ if (walletId.length === 0) {
441
+ throw new TxError(
442
+ "PRIVY_AUTH_FAILED",
443
+ "Invalid PRIVY_WALLET_ID format: expected non-empty string"
444
+ );
445
+ }
446
+ const apiUrl = normalizePrivyApiUrl(options.apiUrl);
447
+ const fetchImplementation = options.fetchImplementation ?? fetch;
448
+ return {
449
+ appId,
450
+ walletId,
451
+ apiUrl,
452
+ async createRpcIntent(request, requestOptions) {
453
+ const url = `${apiUrl}/v1/intents/wallets/${walletId}/rpc`;
454
+ const payload = createPrivyAuthorizationPayload({
455
+ appId,
456
+ method: "POST",
457
+ url,
458
+ body: request,
459
+ ...requestOptions?.idempotencyKey !== void 0 ? { idempotencyKey: requestOptions.idempotencyKey } : {}
460
+ });
461
+ const signature = generatePrivyAuthorizationSignature(payload, options.authorizationKey);
462
+ return sendPrivyRequest({
463
+ fetchImplementation,
464
+ method: "POST",
465
+ url,
466
+ body: request,
467
+ operation: "create rpc intent",
468
+ headers: {
469
+ "privy-app-id": appId,
470
+ "privy-authorization-signature": signature,
471
+ ...requestOptions?.idempotencyKey !== void 0 ? { "privy-idempotency-key": requestOptions.idempotencyKey } : {}
472
+ }
473
+ });
474
+ },
475
+ async getWallet() {
476
+ const url = `${apiUrl}/v1/wallets/${walletId}`;
477
+ return sendPrivyRequest({
478
+ fetchImplementation,
479
+ method: "GET",
480
+ url,
481
+ operation: "get wallet",
482
+ headers: {
483
+ "privy-app-id": appId
484
+ }
485
+ });
486
+ },
487
+ async getPolicy(policyId) {
488
+ if (policyId.trim().length === 0) {
489
+ throw new TxError(
490
+ "PRIVY_TRANSPORT_FAILED",
491
+ "Failed to build Privy policy lookup request: policy id is empty"
492
+ );
493
+ }
494
+ const url = `${apiUrl}/v1/policies/${policyId}`;
495
+ return sendPrivyRequest({
496
+ fetchImplementation,
497
+ method: "GET",
498
+ url,
499
+ operation: "get policy",
500
+ headers: {
501
+ "privy-app-id": appId
502
+ }
503
+ });
504
+ }
505
+ };
506
+ }
507
+ async function sendPrivyRequest(options) {
508
+ let response;
509
+ try {
510
+ response = await options.fetchImplementation(options.url, {
511
+ method: options.method,
512
+ headers: {
513
+ ...options.headers,
514
+ ...options.body !== void 0 ? { "content-type": "application/json" } : {}
515
+ },
516
+ ...options.body !== void 0 ? { body: JSON.stringify(options.body) } : {}
517
+ });
518
+ } catch (cause) {
519
+ throw new TxError(
520
+ "PRIVY_TRANSPORT_FAILED",
521
+ `Privy ${options.operation} request failed: network error`,
522
+ cause
523
+ );
524
+ }
525
+ const payload = await parseJsonResponse(response, options.operation);
526
+ if (!response.ok) {
527
+ const message = extractPrivyErrorMessage(payload) ?? `HTTP ${response.status}`;
528
+ if (response.status === 401 || response.status === 403) {
529
+ throw new TxError(
530
+ "PRIVY_AUTH_FAILED",
531
+ `Privy authentication failed (${response.status}): ${message}`
532
+ );
533
+ }
534
+ throw new TxError(
535
+ "PRIVY_TRANSPORT_FAILED",
536
+ `Privy ${options.operation} request failed (${response.status}): ${message}`
537
+ );
538
+ }
539
+ if (!isRecord2(payload)) {
540
+ throw new TxError(
541
+ "PRIVY_TRANSPORT_FAILED",
542
+ `Privy ${options.operation} request failed: invalid JSON response shape`
543
+ );
544
+ }
545
+ return payload;
546
+ }
547
+ async function parseJsonResponse(response, operation) {
548
+ const text = await response.text();
549
+ if (text.trim().length === 0) {
550
+ return void 0;
551
+ }
552
+ try {
553
+ return JSON.parse(text);
554
+ } catch (cause) {
555
+ throw new TxError(
556
+ "PRIVY_TRANSPORT_FAILED",
557
+ `Privy ${operation} request failed: invalid JSON response`,
558
+ cause
559
+ );
560
+ }
561
+ }
562
+ function isRecord2(value) {
563
+ return typeof value === "object" && value !== null;
564
+ }
565
+ function extractPrivyErrorMessage(payload) {
566
+ if (!isRecord2(payload)) {
567
+ return void 0;
568
+ }
569
+ const directMessage = payload.message;
570
+ if (typeof directMessage === "string" && directMessage.length > 0) {
571
+ return directMessage;
572
+ }
573
+ const errorValue = payload.error;
574
+ if (typeof errorValue === "string" && errorValue.length > 0) {
575
+ return errorValue;
576
+ }
577
+ if (isRecord2(errorValue)) {
578
+ const nestedMessage = errorValue.message;
579
+ if (typeof nestedMessage === "string" && nestedMessage.length > 0) {
580
+ return nestedMessage;
581
+ }
582
+ }
583
+ return void 0;
584
+ }
585
+
60
586
  // src/signers/privy.ts
61
587
  var REQUIRED_FIELDS = [
62
588
  "privyAppId",
63
589
  "privyWalletId",
64
590
  "privyAuthorizationKey"
65
591
  ];
592
+ var APP_ID_REGEX = /^[A-Za-z0-9_-]{8,128}$/;
593
+ var WALLET_ID_REGEX = /^[A-Za-z0-9_-]{8,128}$/;
66
594
  async function createPrivySigner(options) {
67
- const missing = REQUIRED_FIELDS.filter((field) => options[field] === void 0);
595
+ const missing = REQUIRED_FIELDS.filter((field) => {
596
+ const value = options[field];
597
+ return typeof value !== "string" || value.trim().length === 0;
598
+ });
68
599
  if (missing.length > 0) {
69
600
  const missingLabels = missing.map((field) => {
70
601
  if (field === "privyAppId") {
@@ -80,10 +611,43 @@ async function createPrivySigner(options) {
80
611
  `Privy signer requires configuration: missing ${missingLabels}`
81
612
  );
82
613
  }
83
- throw new TxError(
84
- "PRIVY_AUTH_FAILED",
85
- "Privy signer is not yet available in tx-shared. Track progress in issue #117."
86
- );
614
+ const appId = options.privyAppId?.trim() ?? "";
615
+ const walletId = options.privyWalletId?.trim() ?? "";
616
+ const authorizationKey = options.privyAuthorizationKey?.trim() ?? "";
617
+ if (!APP_ID_REGEX.test(appId)) {
618
+ throw new TxError(
619
+ "PRIVY_AUTH_FAILED",
620
+ "Invalid PRIVY_APP_ID format: expected 8-128 chars using letters, numbers, hyphen, or underscore"
621
+ );
622
+ }
623
+ if (!WALLET_ID_REGEX.test(walletId)) {
624
+ throw new TxError(
625
+ "PRIVY_AUTH_FAILED",
626
+ "Invalid PRIVY_WALLET_ID format: expected 8-128 chars using letters, numbers, hyphen, or underscore"
627
+ );
628
+ }
629
+ parsePrivyAuthorizationKey(authorizationKey);
630
+ const apiUrl = normalizePrivyApiUrl(options.privyApiUrl);
631
+ const client = createPrivyClient({
632
+ appId,
633
+ walletId,
634
+ authorizationKey,
635
+ apiUrl
636
+ });
637
+ const account = await createPrivyAccount({
638
+ client
639
+ });
640
+ return {
641
+ provider: "privy",
642
+ account,
643
+ address: account.address,
644
+ privy: {
645
+ appId,
646
+ walletId,
647
+ apiUrl,
648
+ client
649
+ }
650
+ };
87
651
  }
88
652
 
89
653
  // src/resolve-signer.ts
@@ -110,7 +674,8 @@ async function resolveSigner(opts) {
110
674
  return createPrivySigner({
111
675
  ...opts.privyAppId !== void 0 ? { privyAppId: opts.privyAppId } : {},
112
676
  ...opts.privyWalletId !== void 0 ? { privyWalletId: opts.privyWalletId } : {},
113
- ...opts.privyAuthorizationKey !== void 0 ? { privyAuthorizationKey: opts.privyAuthorizationKey } : {}
677
+ ...opts.privyAuthorizationKey !== void 0 ? { privyAuthorizationKey: opts.privyAuthorizationKey } : {},
678
+ ...opts.privyApiUrl !== void 0 ? { privyApiUrl: opts.privyApiUrl } : {}
114
679
  });
115
680
  }
116
681
  throw new TxError(
@@ -125,18 +690,21 @@ var signerFlagSchema = z.object({
125
690
  "private-key": z.string().optional().describe("Raw private key (0x-prefixed 32-byte hex) for signing transactions"),
126
691
  keystore: z.string().optional().describe("Path to an encrypted V3 keystore JSON file"),
127
692
  password: z.string().optional().describe("Keystore password (non-interactive mode)"),
128
- privy: z.boolean().default(false).describe("Use Privy server wallet signer mode")
693
+ privy: z.boolean().default(false).describe("Use Privy server wallet signer mode"),
694
+ "privy-api-url": z.string().optional().describe("Override Privy API base URL (defaults to https://api.privy.io)")
129
695
  });
130
696
  var signerEnvSchema = z.object({
131
697
  PRIVATE_KEY: z.string().optional().describe("Raw private key (0x-prefixed 32-byte hex)"),
132
698
  KEYSTORE_PASSWORD: z.string().optional().describe("Password for decrypting --keystore"),
133
699
  PRIVY_APP_ID: z.string().optional().describe("Privy app id used to authorize wallet intents"),
134
700
  PRIVY_WALLET_ID: z.string().optional().describe("Privy wallet id used for transaction intents"),
135
- PRIVY_AUTHORIZATION_KEY: z.string().optional().describe("Privy authorization private key used to sign intent requests")
701
+ PRIVY_AUTHORIZATION_KEY: z.string().optional().describe("Privy authorization private key used to sign intent requests"),
702
+ PRIVY_API_URL: z.string().optional().describe("Optional Privy API base URL override (default https://api.privy.io)")
136
703
  });
137
704
  function toSignerOptions(flags, env) {
138
705
  const privateKey = flags["private-key"] ?? env.PRIVATE_KEY;
139
706
  const keystorePassword = flags.password ?? env.KEYSTORE_PASSWORD;
707
+ const privyApiUrl = flags["privy-api-url"] ?? env.PRIVY_API_URL;
140
708
  return {
141
709
  ...privateKey !== void 0 ? { privateKey } : {},
142
710
  ...flags.keystore !== void 0 ? { keystorePath: flags.keystore } : {},
@@ -144,7 +712,8 @@ function toSignerOptions(flags, env) {
144
712
  ...flags.privy ? { privy: true } : {},
145
713
  ...env.PRIVY_APP_ID !== void 0 ? { privyAppId: env.PRIVY_APP_ID } : {},
146
714
  ...env.PRIVY_WALLET_ID !== void 0 ? { privyWalletId: env.PRIVY_WALLET_ID } : {},
147
- ...env.PRIVY_AUTHORIZATION_KEY !== void 0 ? { privyAuthorizationKey: env.PRIVY_AUTHORIZATION_KEY } : {}
715
+ ...env.PRIVY_AUTHORIZATION_KEY !== void 0 ? { privyAuthorizationKey: env.PRIVY_AUTHORIZATION_KEY } : {},
716
+ ...privyApiUrl !== void 0 ? { privyApiUrl } : {}
148
717
  };
149
718
  }
150
719
  export {
@@ -153,9 +722,16 @@ export {
153
722
  createAbstractClient,
154
723
  createKeystoreSigner,
155
724
  createPrivateKeySigner,
725
+ createPrivyAccount,
726
+ createPrivyAuthorizationPayload,
727
+ createPrivyClient,
156
728
  createPrivySigner,
157
729
  executeTx,
730
+ generatePrivyAuthorizationSignature,
731
+ normalizePrivyApiUrl,
732
+ parsePrivyAuthorizationKey,
158
733
  resolveSigner,
734
+ serializePrivyAuthorizationPayload,
159
735
  signerEnvSchema,
160
736
  signerFlagSchema,
161
737
  toSignerOptions,
package/dist/types.d.ts CHANGED
@@ -23,6 +23,7 @@ interface SignerOptions {
23
23
  privyAppId?: string;
24
24
  privyWalletId?: string;
25
25
  privyAuthorizationKey?: string;
26
+ privyApiUrl?: string;
26
27
  }
27
28
 
28
29
  export type { SignerOptions, SignerProvider, TxResult, TxSigner };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectratools/tx-shared",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Shared transaction primitives, signer types, and chain config for spectra tools",
5
5
  "type": "module",
6
6
  "license": "MIT",