@stacks-passkey/relay 0.1.0

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.
Files changed (63) hide show
  1. package/dist/account-service.d.ts +39 -0
  2. package/dist/account-service.js +223 -0
  3. package/dist/account-service.test.d.ts +1 -0
  4. package/dist/account-service.test.js +44 -0
  5. package/dist/accounts.d.ts +20 -0
  6. package/dist/accounts.js +34 -0
  7. package/dist/app.d.ts +4 -0
  8. package/dist/app.js +412 -0
  9. package/dist/catalog-service.d.ts +20 -0
  10. package/dist/catalog-service.js +165 -0
  11. package/dist/catalog.d.ts +21 -0
  12. package/dist/catalog.js +60 -0
  13. package/dist/catalog.test.d.ts +1 -0
  14. package/dist/catalog.test.js +39 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +41 -0
  17. package/dist/contract-id.d.ts +2 -0
  18. package/dist/contract-id.js +6 -0
  19. package/dist/contract-id.test.d.ts +1 -0
  20. package/dist/contract-id.test.js +25 -0
  21. package/dist/crypto.d.ts +9 -0
  22. package/dist/crypto.js +64 -0
  23. package/dist/gas-tank.d.ts +89 -0
  24. package/dist/gas-tank.js +286 -0
  25. package/dist/gas-tank.test.d.ts +1 -0
  26. package/dist/gas-tank.test.js +60 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.js +4 -0
  29. package/dist/load-env.d.ts +4 -0
  30. package/dist/load-env.js +36 -0
  31. package/dist/on-chain-balance.d.ts +4 -0
  32. package/dist/on-chain-balance.js +60 -0
  33. package/dist/rate-limit.d.ts +9 -0
  34. package/dist/rate-limit.js +29 -0
  35. package/dist/rate-limit.test.d.ts +1 -0
  36. package/dist/rate-limit.test.js +21 -0
  37. package/dist/registrar-queue.d.ts +14 -0
  38. package/dist/registrar-queue.js +30 -0
  39. package/dist/registrar-queue.test.d.ts +1 -0
  40. package/dist/registrar-queue.test.js +22 -0
  41. package/dist/registrar.d.ts +4 -0
  42. package/dist/registrar.js +9 -0
  43. package/dist/secrets.d.ts +6 -0
  44. package/dist/secrets.js +59 -0
  45. package/dist/secrets.test.d.ts +1 -0
  46. package/dist/secrets.test.js +39 -0
  47. package/dist/sponsor-derivation.d.ts +3 -0
  48. package/dist/sponsor-derivation.js +25 -0
  49. package/dist/sponsor-derivation.test.d.ts +1 -0
  50. package/dist/sponsor-derivation.test.js +21 -0
  51. package/dist/sponsor-lock.d.ts +2 -0
  52. package/dist/sponsor-lock.js +8 -0
  53. package/dist/sponsor.d.ts +24 -0
  54. package/dist/sponsor.js +129 -0
  55. package/dist/tx-wait.d.ts +4 -0
  56. package/dist/tx-wait.js +18 -0
  57. package/dist/types.d.ts +30 -0
  58. package/dist/types.js +1 -0
  59. package/dist/wallet-auth.d.ts +43 -0
  60. package/dist/wallet-auth.js +145 -0
  61. package/dist/wallet-auth.test.d.ts +1 -0
  62. package/dist/wallet-auth.test.js +30 -0
  63. package/package.json +54 -0
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { CatalogStore } from './catalog.js';
6
+ describe('CatalogStore', () => {
7
+ let dir;
8
+ let store;
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), 'spk-catalog-'));
11
+ store = new CatalogStore(join(dir, 'catalog.json'));
12
+ });
13
+ afterEach(() => {
14
+ rmSync(dir, { recursive: true, force: true });
15
+ });
16
+ it('saves and lists contracts per project', () => {
17
+ store.save({
18
+ contractId: 'ST1PQ.my-app',
19
+ projectId: 'proj-1',
20
+ functions: ['passkey-exec', 'set-score'],
21
+ registeredAt: new Date().toISOString(),
22
+ registrationTxid: 'abc123',
23
+ });
24
+ const list = store.listForProject('proj-1');
25
+ expect(list).toHaveLength(1);
26
+ expect(list[0]?.contractId).toBe('ST1PQ.my-app');
27
+ });
28
+ it('finds cached registration', () => {
29
+ store.save({
30
+ contractId: 'ST1PQ.my-app',
31
+ projectId: 'proj-1',
32
+ functions: ['passkey-exec'],
33
+ registeredAt: new Date().toISOString(),
34
+ registrationTxid: 'abc123',
35
+ });
36
+ const found = store.find('proj-1', 'ST1PQ.my-app');
37
+ expect(found?.registrationTxid).toBe('abc123');
38
+ });
39
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { serve } from '@hono/node-server';
3
+ import { createRelayApp } from './app.js';
4
+ import { loadRelayEnv } from './load-env.js';
5
+ import { assertRelayAuthConfigured, loadSponsorPrivateKey } from './secrets.js';
6
+ import { GasTankStore, defaultStorePath } from './gas-tank.js';
7
+ import { loadMasterSecret, loadSessionSecret } from './crypto.js';
8
+ loadRelayEnv();
9
+ function loadConfig() {
10
+ const network = process.env.STACKS_NETWORK ?? 'testnet';
11
+ assertRelayAuthConfigured(network);
12
+ return {
13
+ sponsorPrivateKey: loadSponsorPrivateKey(),
14
+ registrarPrivateKey: process.env.REGISTRAR_PRIVATE_KEY,
15
+ masterSecret: loadMasterSecret(),
16
+ sessionSecret: loadSessionSecret(),
17
+ network,
18
+ port: Number(process.env.PORT ?? 8787),
19
+ host: process.env.HOST ?? '127.0.0.1',
20
+ apiKey: process.env.RELAY_API_KEY,
21
+ adminApiKey: process.env.RELAY_ADMIN_API_KEY,
22
+ gasTankPath: process.env.GAS_TANK_PATH ?? defaultStorePath(),
23
+ policy: {
24
+ allowedContracts: process.env.ALLOWED_CONTRACTS?.split(',').filter(Boolean),
25
+ maxFeeMicroStx: BigInt(process.env.MAX_FEE_MICRO_STX ?? '100000'),
26
+ rateLimit: {
27
+ windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS ?? '60000'),
28
+ maxRequests: Number(process.env.RATE_LIMIT_MAX ?? '30'),
29
+ },
30
+ },
31
+ };
32
+ }
33
+ const config = loadConfig();
34
+ const sponsorNetwork = config.network === 'mainnet' ? 'mainnet' : 'testnet';
35
+ const gasTank = new GasTankStore(config.gasTankPath, config.masterSecret, sponsorNetwork);
36
+ const app = createRelayApp(config, gasTank);
37
+ serve({ fetch: app.fetch, port: config.port, hostname: config.host }, () => {
38
+ console.log(`Stacks Passkey relay listening on http://${config.host}:${config.port}`);
39
+ console.log(`Relay store: ${config.gasTankPath}`);
40
+ console.log(`Registrar: ${config.network} (platform key — per-wallet sponsors derived from RELAY_MASTER_SECRET)`);
41
+ });
@@ -0,0 +1,2 @@
1
+ import { type PayloadWire } from '@stacks/transactions';
2
+ export declare function extractContractCallId(payload: PayloadWire): string | null;
@@ -0,0 +1,6 @@
1
+ import { addressToString, isContractCallPayload, } from '@stacks/transactions';
2
+ export function extractContractCallId(payload) {
3
+ if (!isContractCallPayload(payload))
4
+ return null;
5
+ return `${addressToString(payload.contractAddress)}.${payload.contractName.content}`;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Cl, makeContractCall, randomPrivateKey } from '@stacks/transactions';
3
+ import { extractContractCallId } from './contract-id.js';
4
+ import { isContractAllowed } from './rate-limit.js';
5
+ describe('extractContractCallId', () => {
6
+ it('extracts a string contract id from deserialized contract-call payloads', async () => {
7
+ const tx = await makeContractCall({
8
+ contractAddress: 'ST3XHHZ1CXVCNYXK3FQ1FDGJ9NK6YBJBJK3FVY5KQ',
9
+ contractName: 'passkey-account-v2',
10
+ functionName: 'register',
11
+ functionArgs: [Cl.buffer(new Uint8Array(33).fill(2))],
12
+ senderKey: randomPrivateKey(),
13
+ sponsored: true,
14
+ fee: 0n,
15
+ network: 'testnet',
16
+ });
17
+ const contractId = extractContractCallId(tx.payload);
18
+ expect(contractId).toBe('ST3XHHZ1CXVCNYXK3FQ1FDGJ9NK6YBJBJK3FVY5KQ.passkey-account-v2');
19
+ expect(isContractAllowed(contractId, {
20
+ allowedContracts: ['ST3XHHZ1CXVCNYXK3FQ1FDGJ9NK6YBJBJK3FVY5KQ'],
21
+ maxFeeMicroStx: 50000n,
22
+ rateLimit: { windowMs: 60_000, maxRequests: 100 },
23
+ })).toBe(true);
24
+ });
25
+ });
@@ -0,0 +1,9 @@
1
+ export declare function hashApiKey(apiKey: string): string;
2
+ export declare function generateApiKey(): string;
3
+ export declare function apiKeyPrefix(apiKey: string): string;
4
+ export declare function createSessionToken(address: string, secret: string, ttlMs?: number): string;
5
+ export declare function verifySessionToken(token: string, secret: string): {
6
+ address: string;
7
+ } | null;
8
+ export declare function loadMasterSecret(): string;
9
+ export declare function loadSessionSecret(): string;
package/dist/crypto.js ADDED
@@ -0,0 +1,64 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ export function hashApiKey(apiKey) {
3
+ return createHash('sha256').update(apiKey).digest('hex');
4
+ }
5
+ export function generateApiKey() {
6
+ return `spk_${randomBytes(24).toString('hex')}`;
7
+ }
8
+ export function apiKeyPrefix(apiKey) {
9
+ return apiKey.slice(0, 12);
10
+ }
11
+ export function createSessionToken(address, secret, ttlMs = 24 * 60 * 60 * 1000) {
12
+ const exp = Date.now() + ttlMs;
13
+ const payload = JSON.stringify({ address, exp });
14
+ const payloadB64 = Buffer.from(payload).toString('base64url');
15
+ const sig = createHmac('sha256', secret).update(payloadB64).digest('base64url');
16
+ return `${payloadB64}.${sig}`;
17
+ }
18
+ export function verifySessionToken(token, secret) {
19
+ const dot = token.indexOf('.');
20
+ if (dot === -1)
21
+ return null;
22
+ const payloadB64 = token.slice(0, dot);
23
+ const sig = token.slice(dot + 1);
24
+ const expected = createHmac('sha256', secret).update(payloadB64).digest('base64url');
25
+ try {
26
+ if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
27
+ return null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ try {
33
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
34
+ if (!payload.address || typeof payload.exp !== 'number')
35
+ return null;
36
+ if (Date.now() > payload.exp)
37
+ return null;
38
+ return { address: payload.address };
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ export function loadMasterSecret() {
45
+ const explicit = process.env.RELAY_MASTER_SECRET?.trim();
46
+ if (explicit)
47
+ return explicit;
48
+ if (process.env.NODE_ENV === 'production') {
49
+ throw new Error('RELAY_MASTER_SECRET is required in production for per-wallet sponsor derivation');
50
+ }
51
+ const fallback = process.env.RELAY_SESSION_SECRET?.trim() ?? 'dev-master-secret-change-me';
52
+ console.warn('[relay] Warning: RELAY_MASTER_SECRET not set — using dev fallback (local only)');
53
+ return fallback;
54
+ }
55
+ export function loadSessionSecret() {
56
+ const secret = process.env.RELAY_SESSION_SECRET?.trim();
57
+ if (secret)
58
+ return secret;
59
+ if (process.env.NODE_ENV === 'production') {
60
+ throw new Error('RELAY_SESSION_SECRET is required in production');
61
+ }
62
+ console.warn('[relay] Warning: RELAY_SESSION_SECRET not set — using dev fallback (local only)');
63
+ return 'dev-session-secret-change-me';
64
+ }
@@ -0,0 +1,89 @@
1
+ export interface WalletRecord {
2
+ id: string;
3
+ ownerAddress: string;
4
+ sponsorAddress: string;
5
+ totalSpentMicroStx: bigint;
6
+ reservedMicroStx: bigint;
7
+ txCount: number;
8
+ createdAt: string;
9
+ }
10
+ export interface ApiKeyRecord {
11
+ id: string;
12
+ walletId: string;
13
+ name: string;
14
+ keyHash: string;
15
+ keyPrefix: string;
16
+ revokedAt?: string;
17
+ createdAt: string;
18
+ /** Legacy v1 migration — plaintext lookup only */
19
+ legacyPlaintext?: string;
20
+ }
21
+ export interface SponsorLogRecord {
22
+ id: string;
23
+ walletId: string;
24
+ apiKeyId?: string;
25
+ txid: string;
26
+ feeMicroStx: bigint;
27
+ billingMode: 'gasless' | 'account-pay';
28
+ at: string;
29
+ }
30
+ export interface ResolvedApiKey {
31
+ wallet: WalletRecord;
32
+ apiKey: ApiKeyRecord;
33
+ sponsorPrivateKey: string;
34
+ }
35
+ /** @deprecated alias — GasTankStore is now wallet-scoped relay storage */
36
+ export type ProjectRecord = WalletRecord & {
37
+ name?: string;
38
+ apiKey?: string;
39
+ gasTankAddress: string;
40
+ gasBalanceMicroStx: bigint;
41
+ };
42
+ /** Local dev: keep plaintext so wallet owners can reveal keys in admin UI. */
43
+ export declare function devStoreApiKeys(): boolean;
44
+ export declare class GasTankStore {
45
+ private readonly filePath;
46
+ private data;
47
+ private readonly masterSecret;
48
+ private readonly network;
49
+ constructor(filePath: string, masterSecret: string, network?: 'mainnet' | 'testnet');
50
+ private load;
51
+ private migrateFromV1;
52
+ private persist;
53
+ private toWallet;
54
+ private toApiKey;
55
+ ensureWallet(ownerAddress: string): WalletRecord;
56
+ getWalletByOwner(ownerAddress: string): WalletRecord | null;
57
+ /** @deprecated use getWalletById */
58
+ getById(id: string): WalletRecord | null;
59
+ getWalletById(id: string): WalletRecord | null;
60
+ listWallets(): WalletRecord[];
61
+ /** @deprecated use listWallets */
62
+ listProjects(): WalletRecord[];
63
+ listApiKeys(walletId: string): ApiKeyRecord[];
64
+ createApiKey(walletId: string, name: string): {
65
+ apiKey: string;
66
+ record: ApiKeyRecord;
67
+ };
68
+ canRevealApiKey(walletId: string, keyId: string): boolean;
69
+ revealApiKey(walletId: string, keyId: string): string;
70
+ revokeApiKey(walletId: string, keyId: string): ApiKeyRecord;
71
+ resolveApiKey(apiKey: string): ResolvedApiKey | null;
72
+ /** @deprecated use resolveApiKey */
73
+ getByApiKey(apiKey: string): ProjectRecord | null;
74
+ reserveGas(walletId: string, amountMicroStx: bigint): WalletRecord;
75
+ releaseReservation(walletId: string, amountMicroStx: bigint): WalletRecord;
76
+ recordSponsor(walletId: string, feeMicroStx: bigint, txid: string, billingMode: 'gasless' | 'account-pay', apiKeyId?: string, reservedMicroStx?: bigint): WalletRecord;
77
+ /** @deprecated virtual refill removed — deposit STX to sponsor address */
78
+ refill(_walletId: string, _amountMicroStx: bigint): WalletRecord;
79
+ /** @deprecated use ensureWallet + createApiKey */
80
+ createProject(name: string, _initialGasMicroStx?: bigint, _gasTankAddress?: string): ProjectRecord;
81
+ getLogs(walletId?: string): SponsorLogRecord[];
82
+ availableBalance(onChainMicroStx: bigint, wallet: WalletRecord): bigint;
83
+ getSponsorCredentials(walletId: string): {
84
+ wallet: WalletRecord;
85
+ sponsorPrivateKey: string;
86
+ sponsorAddress: string;
87
+ } | null;
88
+ }
89
+ export declare function defaultStorePath(): string;
@@ -0,0 +1,286 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { apiKeyPrefix, generateApiKey, hashApiKey } from './crypto.js';
5
+ import { deriveSponsorAddress, deriveSponsorPrivateKey } from './sponsor-derivation.js';
6
+ /** Local dev: keep plaintext so wallet owners can reveal keys in admin UI. */
7
+ export function devStoreApiKeys() {
8
+ const flag = process.env.RELAY_DEV_STORE_API_KEYS?.trim().toLowerCase();
9
+ if (flag === 'true' || flag === '1')
10
+ return true;
11
+ if (flag === 'false' || flag === '0')
12
+ return false;
13
+ return process.env.NODE_ENV !== 'production';
14
+ }
15
+ export class GasTankStore {
16
+ filePath;
17
+ data;
18
+ masterSecret;
19
+ network;
20
+ constructor(filePath, masterSecret, network = 'testnet') {
21
+ this.filePath = filePath;
22
+ this.masterSecret = masterSecret;
23
+ this.network = network;
24
+ mkdirSync(dirname(filePath), { recursive: true });
25
+ this.data = this.load();
26
+ }
27
+ load() {
28
+ if (!existsSync(this.filePath)) {
29
+ return { version: 2, wallets: [], apiKeys: [], logs: [] };
30
+ }
31
+ const raw = JSON.parse(readFileSync(this.filePath, 'utf8'));
32
+ if (raw.version === 2)
33
+ return raw;
34
+ return this.migrateFromV1(raw);
35
+ }
36
+ migrateFromV1(legacy) {
37
+ const data = { version: 2, wallets: [], apiKeys: [], logs: [] };
38
+ for (const project of legacy.projects ?? []) {
39
+ const ownerAddress = `legacy:${project.id}`;
40
+ const sponsorAddress = project.gasTankAddress ||
41
+ deriveSponsorAddress(this.masterSecret, ownerAddress, this.network);
42
+ const walletId = project.id;
43
+ data.wallets.push({
44
+ id: walletId,
45
+ ownerAddress,
46
+ sponsorAddress,
47
+ totalSpentMicroStx: project.totalSpentMicroStx ?? '0',
48
+ reservedMicroStx: '0',
49
+ txCount: project.txCount ?? 0,
50
+ createdAt: project.createdAt ?? new Date().toISOString(),
51
+ });
52
+ data.apiKeys.push({
53
+ id: randomBytes(6).toString('hex'),
54
+ walletId,
55
+ name: project.name ?? 'Legacy project',
56
+ keyHash: hashApiKey(project.apiKey),
57
+ keyPrefix: apiKeyPrefix(project.apiKey),
58
+ createdAt: project.createdAt ?? new Date().toISOString(),
59
+ legacyPlaintext: project.apiKey,
60
+ });
61
+ }
62
+ for (const log of legacy.logs ?? []) {
63
+ data.logs.push({
64
+ id: log.id,
65
+ walletId: log.projectId,
66
+ apiKeyId: undefined,
67
+ txid: log.txid,
68
+ feeMicroStx: log.feeMicroStx,
69
+ billingMode: log.billingMode,
70
+ at: log.at,
71
+ });
72
+ }
73
+ this.data = data;
74
+ this.persist();
75
+ console.log(`[relay] Migrated ${data.wallets.length} legacy project(s) to wallet model`);
76
+ return data;
77
+ }
78
+ persist() {
79
+ writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
80
+ }
81
+ toWallet(row) {
82
+ return {
83
+ ...row,
84
+ totalSpentMicroStx: BigInt(row.totalSpentMicroStx),
85
+ reservedMicroStx: BigInt(row.reservedMicroStx),
86
+ };
87
+ }
88
+ toApiKey(row) {
89
+ return { ...row };
90
+ }
91
+ ensureWallet(ownerAddress) {
92
+ const existing = this.data.wallets.find((w) => w.ownerAddress.toLowerCase() === ownerAddress.toLowerCase());
93
+ if (existing)
94
+ return this.toWallet(existing);
95
+ const sponsorAddress = deriveSponsorAddress(this.masterSecret, ownerAddress, this.network);
96
+ const wallet = {
97
+ id: randomBytes(8).toString('hex'),
98
+ ownerAddress,
99
+ sponsorAddress,
100
+ totalSpentMicroStx: '0',
101
+ reservedMicroStx: '0',
102
+ txCount: 0,
103
+ createdAt: new Date().toISOString(),
104
+ };
105
+ this.data.wallets.push(wallet);
106
+ this.persist();
107
+ return this.toWallet(wallet);
108
+ }
109
+ getWalletByOwner(ownerAddress) {
110
+ const row = this.data.wallets.find((w) => w.ownerAddress.toLowerCase() === ownerAddress.toLowerCase());
111
+ return row ? this.toWallet(row) : null;
112
+ }
113
+ /** @deprecated use getWalletById */
114
+ getById(id) {
115
+ return this.getWalletById(id);
116
+ }
117
+ getWalletById(id) {
118
+ const row = this.data.wallets.find((w) => w.id === id);
119
+ return row ? this.toWallet(row) : null;
120
+ }
121
+ listWallets() {
122
+ return this.data.wallets.map((w) => this.toWallet(w));
123
+ }
124
+ /** @deprecated use listWallets */
125
+ listProjects() {
126
+ return this.listWallets();
127
+ }
128
+ listApiKeys(walletId) {
129
+ return this.data.apiKeys
130
+ .filter((k) => k.walletId === walletId && !k.revokedAt)
131
+ .map((k) => this.toApiKey(k));
132
+ }
133
+ createApiKey(walletId, name) {
134
+ const wallet = this.getWalletById(walletId);
135
+ if (!wallet)
136
+ throw new Error('Wallet not found');
137
+ const apiKey = generateApiKey();
138
+ const record = {
139
+ id: randomBytes(6).toString('hex'),
140
+ walletId,
141
+ name,
142
+ keyHash: hashApiKey(apiKey),
143
+ keyPrefix: apiKeyPrefix(apiKey),
144
+ createdAt: new Date().toISOString(),
145
+ ...(devStoreApiKeys() ? { legacyPlaintext: apiKey } : {}),
146
+ };
147
+ this.data.apiKeys.push(record);
148
+ this.persist();
149
+ return { apiKey, record: this.toApiKey(record) };
150
+ }
151
+ canRevealApiKey(walletId, keyId) {
152
+ const row = this.data.apiKeys.find((k) => k.id === keyId && k.walletId === walletId && !k.revokedAt);
153
+ return Boolean(row?.legacyPlaintext);
154
+ }
155
+ revealApiKey(walletId, keyId) {
156
+ const row = this.data.apiKeys.find((k) => k.id === keyId && k.walletId === walletId && !k.revokedAt);
157
+ if (!row?.legacyPlaintext) {
158
+ throw new Error('API key not retrievable — create a new key or use a copy saved at creation time');
159
+ }
160
+ return row.legacyPlaintext;
161
+ }
162
+ revokeApiKey(walletId, keyId) {
163
+ const row = this.data.apiKeys.find((k) => k.id === keyId && k.walletId === walletId);
164
+ if (!row)
165
+ throw new Error('API key not found');
166
+ if (row.revokedAt)
167
+ throw new Error('API key already revoked');
168
+ row.revokedAt = new Date().toISOString();
169
+ this.persist();
170
+ return this.toApiKey(row);
171
+ }
172
+ resolveApiKey(apiKey) {
173
+ const hash = hashApiKey(apiKey);
174
+ const row = this.data.apiKeys.find((k) => k.keyHash === hash && !k.revokedAt) ??
175
+ this.data.apiKeys.find((k) => k.legacyPlaintext === apiKey && !k.revokedAt);
176
+ if (!row)
177
+ return null;
178
+ const walletRow = this.data.wallets.find((w) => w.id === row.walletId);
179
+ if (!walletRow)
180
+ return null;
181
+ const wallet = this.toWallet(walletRow);
182
+ return {
183
+ wallet,
184
+ apiKey: this.toApiKey(row),
185
+ sponsorPrivateKey: deriveSponsorPrivateKey(this.masterSecret, wallet.ownerAddress),
186
+ };
187
+ }
188
+ /** @deprecated use resolveApiKey */
189
+ getByApiKey(apiKey) {
190
+ const resolved = this.resolveApiKey(apiKey);
191
+ if (!resolved)
192
+ return null;
193
+ return {
194
+ ...resolved.wallet,
195
+ id: resolved.wallet.id,
196
+ name: resolved.apiKey.name,
197
+ apiKey,
198
+ gasTankAddress: resolved.wallet.sponsorAddress,
199
+ gasBalanceMicroStx: 0n,
200
+ };
201
+ }
202
+ reserveGas(walletId, amountMicroStx) {
203
+ const row = this.data.wallets.find((w) => w.id === walletId);
204
+ if (!row)
205
+ throw new Error('Wallet not found');
206
+ row.reservedMicroStx = (BigInt(row.reservedMicroStx) + amountMicroStx).toString();
207
+ this.persist();
208
+ return this.toWallet(row);
209
+ }
210
+ releaseReservation(walletId, amountMicroStx) {
211
+ const row = this.data.wallets.find((w) => w.id === walletId);
212
+ if (!row)
213
+ throw new Error('Wallet not found');
214
+ const next = BigInt(row.reservedMicroStx) - amountMicroStx;
215
+ row.reservedMicroStx = (next > 0n ? next : 0n).toString();
216
+ this.persist();
217
+ return this.toWallet(row);
218
+ }
219
+ recordSponsor(walletId, feeMicroStx, txid, billingMode, apiKeyId, reservedMicroStx) {
220
+ const row = this.data.wallets.find((w) => w.id === walletId);
221
+ if (!row)
222
+ throw new Error('Wallet not found');
223
+ if (reservedMicroStx && reservedMicroStx > 0n) {
224
+ const next = BigInt(row.reservedMicroStx) - reservedMicroStx;
225
+ row.reservedMicroStx = (next > 0n ? next : 0n).toString();
226
+ }
227
+ if (billingMode === 'gasless') {
228
+ row.totalSpentMicroStx = (BigInt(row.totalSpentMicroStx) + feeMicroStx).toString();
229
+ }
230
+ row.txCount += 1;
231
+ this.data.logs.unshift({
232
+ id: randomBytes(6).toString('hex'),
233
+ walletId,
234
+ apiKeyId,
235
+ txid,
236
+ feeMicroStx: feeMicroStx.toString(),
237
+ billingMode,
238
+ at: new Date().toISOString(),
239
+ });
240
+ this.data.logs = this.data.logs.slice(0, 500);
241
+ this.persist();
242
+ return this.toWallet(row);
243
+ }
244
+ /** @deprecated virtual refill removed — deposit STX to sponsor address */
245
+ refill(_walletId, _amountMicroStx) {
246
+ throw new Error('Virtual refill is disabled. Deposit STX to your sponsor gas tank address on-chain.');
247
+ }
248
+ /** @deprecated use ensureWallet + createApiKey */
249
+ createProject(name, _initialGasMicroStx = 0n, _gasTankAddress = '') {
250
+ const ownerAddress = `legacy:${randomBytes(8).toString('hex')}`;
251
+ const wallet = this.ensureWallet(ownerAddress);
252
+ const { apiKey } = this.createApiKey(wallet.id, name);
253
+ return {
254
+ ...wallet,
255
+ name,
256
+ apiKey,
257
+ gasTankAddress: wallet.sponsorAddress,
258
+ gasBalanceMicroStx: 0n,
259
+ };
260
+ }
261
+ getLogs(walletId) {
262
+ return this.data.logs
263
+ .filter((l) => !walletId || l.walletId === walletId)
264
+ .map((l) => ({
265
+ ...l,
266
+ feeMicroStx: BigInt(l.feeMicroStx),
267
+ }));
268
+ }
269
+ availableBalance(onChainMicroStx, wallet) {
270
+ const available = onChainMicroStx - wallet.reservedMicroStx;
271
+ return available > 0n ? available : 0n;
272
+ }
273
+ getSponsorCredentials(walletId) {
274
+ const wallet = this.getWalletById(walletId);
275
+ if (!wallet)
276
+ return null;
277
+ return {
278
+ wallet,
279
+ sponsorPrivateKey: deriveSponsorPrivateKey(this.masterSecret, wallet.ownerAddress),
280
+ sponsorAddress: wallet.sponsorAddress,
281
+ };
282
+ }
283
+ }
284
+ export function defaultStorePath() {
285
+ return join(process.cwd(), 'data', 'gas-tank.json');
286
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { GasTankStore } from './gas-tank.js';
6
+ import { deriveSponsorAddress } from './sponsor-derivation.js';
7
+ import { hashApiKey } from './crypto.js';
8
+ const MASTER = 'test-master-secret';
9
+ const OWNER = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM';
10
+ describe('GasTankStore', () => {
11
+ let dir;
12
+ let store;
13
+ beforeEach(() => {
14
+ dir = mkdtempSync(join(tmpdir(), 'gas-tank-'));
15
+ store = new GasTankStore(join(dir, 'tank.json'), MASTER, 'testnet');
16
+ });
17
+ afterEach(() => {
18
+ rmSync(dir, { recursive: true, force: true });
19
+ });
20
+ it('creates wallet with derived sponsor address', () => {
21
+ const wallet = store.ensureWallet(OWNER);
22
+ expect(wallet.ownerAddress).toBe(OWNER);
23
+ expect(wallet.sponsorAddress).toBe(deriveSponsorAddress(MASTER, OWNER, 'testnet'));
24
+ });
25
+ it('creates hashed API keys under unified wallet tank', () => {
26
+ const wallet = store.ensureWallet(OWNER);
27
+ const { apiKey, record } = store.createApiKey(wallet.id, 'Demo app');
28
+ expect(apiKey.startsWith('spk_')).toBe(true);
29
+ expect(record.keyHash).toBe(hashApiKey(apiKey));
30
+ expect(record.keyPrefix).toBe(apiKey.slice(0, 12));
31
+ const resolved = store.resolveApiKey(apiKey);
32
+ expect(resolved?.wallet.id).toBe(wallet.id);
33
+ expect(resolved?.sponsorPrivateKey.endsWith('01')).toBe(true);
34
+ });
35
+ it('reveals API keys in non-production dev mode', () => {
36
+ const wallet = store.ensureWallet(OWNER);
37
+ const { apiKey, record } = store.createApiKey(wallet.id, 'Reveal me');
38
+ expect(store.canRevealApiKey(wallet.id, record.id)).toBe(true);
39
+ expect(store.revealApiKey(wallet.id, record.id)).toBe(apiKey);
40
+ });
41
+ it('revokes API keys', () => {
42
+ const wallet = store.ensureWallet(OWNER);
43
+ const { apiKey, record } = store.createApiKey(wallet.id, 'Temp');
44
+ expect(store.resolveApiKey(apiKey)).not.toBeNull();
45
+ store.revokeApiKey(wallet.id, record.id);
46
+ expect(store.resolveApiKey(apiKey)).toBeNull();
47
+ });
48
+ it('tracks reservations and sponsor records', () => {
49
+ const wallet = store.ensureWallet(OWNER);
50
+ store.reserveGas(wallet.id, 50000n);
51
+ const updated = store.recordSponsor(wallet.id, 50000n, 'abc123', 'gasless', undefined, 50000n);
52
+ expect(updated.totalSpentMicroStx).toBe(50000n);
53
+ expect(updated.reservedMicroStx).toBe(0n);
54
+ expect(updated.txCount).toBe(1);
55
+ });
56
+ it('rejects virtual refill', () => {
57
+ const wallet = store.ensureWallet(OWNER);
58
+ expect(() => store.refill(wallet.id, 100n)).toThrow(/Deposit STX/);
59
+ });
60
+ });
@@ -0,0 +1,6 @@
1
+ export { createRelayApp } from './app.js';
2
+ export { SponsorService } from './sponsor.js';
3
+ export { AccountService } from './account-service.js';
4
+ export { RateLimiter, isContractAllowed } from './rate-limit.js';
5
+ export type { RelayConfig, RelayPolicy, SponsorRequest, SponsorResult } from './types.js';
6
+ export type { EnsureAccountResult } from './account-service.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createRelayApp } from './app.js';
2
+ export { SponsorService } from './sponsor.js';
3
+ export { AccountService } from './account-service.js';
4
+ export { RateLimiter, isContractAllowed } from './rate-limit.js';
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Load packages/relay/.env if present. Never commit that file.
3
+ */
4
+ export declare function loadRelayEnv(): void;