@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.
- package/dist/account-service.d.ts +39 -0
- package/dist/account-service.js +223 -0
- package/dist/account-service.test.d.ts +1 -0
- package/dist/account-service.test.js +44 -0
- package/dist/accounts.d.ts +20 -0
- package/dist/accounts.js +34 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.js +412 -0
- package/dist/catalog-service.d.ts +20 -0
- package/dist/catalog-service.js +165 -0
- package/dist/catalog.d.ts +21 -0
- package/dist/catalog.js +60 -0
- package/dist/catalog.test.d.ts +1 -0
- package/dist/catalog.test.js +39 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +41 -0
- package/dist/contract-id.d.ts +2 -0
- package/dist/contract-id.js +6 -0
- package/dist/contract-id.test.d.ts +1 -0
- package/dist/contract-id.test.js +25 -0
- package/dist/crypto.d.ts +9 -0
- package/dist/crypto.js +64 -0
- package/dist/gas-tank.d.ts +89 -0
- package/dist/gas-tank.js +286 -0
- package/dist/gas-tank.test.d.ts +1 -0
- package/dist/gas-tank.test.js +60 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/load-env.d.ts +4 -0
- package/dist/load-env.js +36 -0
- package/dist/on-chain-balance.d.ts +4 -0
- package/dist/on-chain-balance.js +60 -0
- package/dist/rate-limit.d.ts +9 -0
- package/dist/rate-limit.js +29 -0
- package/dist/rate-limit.test.d.ts +1 -0
- package/dist/rate-limit.test.js +21 -0
- package/dist/registrar-queue.d.ts +14 -0
- package/dist/registrar-queue.js +30 -0
- package/dist/registrar-queue.test.d.ts +1 -0
- package/dist/registrar-queue.test.js +22 -0
- package/dist/registrar.d.ts +4 -0
- package/dist/registrar.js +9 -0
- package/dist/secrets.d.ts +6 -0
- package/dist/secrets.js +59 -0
- package/dist/secrets.test.d.ts +1 -0
- package/dist/secrets.test.js +39 -0
- package/dist/sponsor-derivation.d.ts +3 -0
- package/dist/sponsor-derivation.js +25 -0
- package/dist/sponsor-derivation.test.d.ts +1 -0
- package/dist/sponsor-derivation.test.js +21 -0
- package/dist/sponsor-lock.d.ts +2 -0
- package/dist/sponsor-lock.js +8 -0
- package/dist/sponsor.d.ts +24 -0
- package/dist/sponsor.js +129 -0
- package/dist/tx-wait.d.ts +4 -0
- package/dist/tx-wait.js +18 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +1 -0
- package/dist/wallet-auth.d.ts +43 -0
- package/dist/wallet-auth.js +145 -0
- package/dist/wallet-auth.test.d.ts +1 -0
- package/dist/wallet-auth.test.js +30 -0
- package/package.json +54 -0
package/dist/app.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { cors } from 'hono/cors';
|
|
3
|
+
import { RateLimiter } from './rate-limit.js';
|
|
4
|
+
import { SponsorService } from './sponsor.js';
|
|
5
|
+
import { CatalogStore, defaultCatalogPath } from './catalog.js';
|
|
6
|
+
import { CatalogService } from './catalog-service.js';
|
|
7
|
+
import { AccountStore, defaultAccountsPath } from './accounts.js';
|
|
8
|
+
import { AccountService } from './account-service.js';
|
|
9
|
+
import { verifySessionToken } from './crypto.js';
|
|
10
|
+
import { createWalletSession, issueAuthChallengeForNetwork, verifyAuthSignatureWithReason, } from './wallet-auth.js';
|
|
11
|
+
import { fetchStxBalanceMicro } from './on-chain-balance.js';
|
|
12
|
+
function resolveSessionAddress(c, config) {
|
|
13
|
+
const auth = c.req.header('Authorization');
|
|
14
|
+
const token = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
15
|
+
if (!token || token.startsWith('spk_'))
|
|
16
|
+
return null;
|
|
17
|
+
return verifySessionToken(token, config.sessionSecret)?.address ?? null;
|
|
18
|
+
}
|
|
19
|
+
function isAdminKey(c, config) {
|
|
20
|
+
if (!config.adminApiKey)
|
|
21
|
+
return true;
|
|
22
|
+
return c.req.header('X-Admin-Key') === config.adminApiKey;
|
|
23
|
+
}
|
|
24
|
+
export function createRelayApp(config, gasTank) {
|
|
25
|
+
const app = new Hono();
|
|
26
|
+
const sponsor = new SponsorService(config, gasTank);
|
|
27
|
+
const limiter = new RateLimiter(config.policy);
|
|
28
|
+
const catalog = new CatalogStore(process.env.CATALOG_PATH ?? defaultCatalogPath());
|
|
29
|
+
const catalogService = new CatalogService(config, catalog, gasTank);
|
|
30
|
+
const accountStore = new AccountStore(process.env.ACCOUNTS_PATH ?? defaultAccountsPath());
|
|
31
|
+
const accountService = new AccountService(config, accountStore, gasTank);
|
|
32
|
+
app.use('*', cors({
|
|
33
|
+
origin: ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:3001', 'http://127.0.0.1:3001'],
|
|
34
|
+
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
35
|
+
allowHeaders: ['Content-Type', 'Authorization', 'X-Admin-Key'],
|
|
36
|
+
}));
|
|
37
|
+
app.get('/health', (c) => {
|
|
38
|
+
const registrarAddress = sponsor.getRegistrarAddress();
|
|
39
|
+
return c.json({
|
|
40
|
+
ok: true,
|
|
41
|
+
registrarAddress,
|
|
42
|
+
sponsorAddress: registrarAddress,
|
|
43
|
+
sponsorFeeMicroStx: config.policy.maxFeeMicroStx.toString(),
|
|
44
|
+
network: config.network,
|
|
45
|
+
auth: 'wallet-signature',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
app.get('/v1/accounts/template', (c) => c.json(accountService.getAccountContractTemplate()));
|
|
49
|
+
// --- Wallet signature auth ---
|
|
50
|
+
app.get('/v1/auth/challenge', (c) => {
|
|
51
|
+
const address = c.req.query('address');
|
|
52
|
+
if (!address)
|
|
53
|
+
return c.json({ error: 'Missing address query param' }, 400);
|
|
54
|
+
const challenge = issueAuthChallengeForNetwork(address, config.network);
|
|
55
|
+
return c.json({
|
|
56
|
+
address,
|
|
57
|
+
nonce: challenge.nonce,
|
|
58
|
+
expiresAt: challenge.expiresAt,
|
|
59
|
+
plainMessage: challenge.plainMessage,
|
|
60
|
+
domain: {
|
|
61
|
+
name: process.env.RELAY_AUTH_DOMAIN ?? 'localhost',
|
|
62
|
+
version: '1.0.0',
|
|
63
|
+
'chain-id': config.network === 'mainnet' ? 1 : 0x80000000,
|
|
64
|
+
},
|
|
65
|
+
message: {
|
|
66
|
+
action: 'authenticate',
|
|
67
|
+
address,
|
|
68
|
+
nonce: challenge.nonce,
|
|
69
|
+
expires: challenge.expiresAt,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
app.post('/v1/auth/verify', async (c) => {
|
|
74
|
+
if (!gasTank)
|
|
75
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
76
|
+
const body = await c.req.json();
|
|
77
|
+
if (!body.address || !body.signature || !body.publicKey || !body.nonce || !body.expiresAt) {
|
|
78
|
+
return c.json({ error: 'Missing auth fields' }, 400);
|
|
79
|
+
}
|
|
80
|
+
const verified = verifyAuthSignatureWithReason({
|
|
81
|
+
address: body.address,
|
|
82
|
+
signature: body.signature,
|
|
83
|
+
publicKey: body.publicKey,
|
|
84
|
+
nonce: body.nonce,
|
|
85
|
+
expiresAt: body.expiresAt,
|
|
86
|
+
network: config.network,
|
|
87
|
+
mode: body.mode,
|
|
88
|
+
plainMessage: body.plainMessage,
|
|
89
|
+
});
|
|
90
|
+
if (!verified.ok) {
|
|
91
|
+
const messages = {
|
|
92
|
+
missing_challenge: 'Sign-in expired or was replaced. Click Sign in again.',
|
|
93
|
+
nonce_mismatch: 'Sign-in challenge changed while signing. Click Sign in again.',
|
|
94
|
+
expires_mismatch: 'Sign-in challenge changed while signing. Click Sign in again.',
|
|
95
|
+
expired: 'Sign-in challenge expired. Click Sign in again.',
|
|
96
|
+
bad_signature: 'Signature did not match the connected wallet. Try Sign in again.',
|
|
97
|
+
};
|
|
98
|
+
return c.json({ error: messages[verified.reason] ?? 'Invalid or expired signature' }, 401);
|
|
99
|
+
}
|
|
100
|
+
gasTank.ensureWallet(body.address);
|
|
101
|
+
const session = createWalletSession(body.address, config.sessionSecret);
|
|
102
|
+
return c.json({
|
|
103
|
+
sessionToken: session.sessionToken,
|
|
104
|
+
expiresAt: session.expiresAt,
|
|
105
|
+
address: body.address,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
// --- Wallet dashboard (session auth) ---
|
|
109
|
+
app.get('/v1/wallet/me', async (c) => {
|
|
110
|
+
if (!gasTank)
|
|
111
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
112
|
+
const address = resolveSessionAddress(c, config);
|
|
113
|
+
if (!address)
|
|
114
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
115
|
+
const wallet = gasTank.ensureWallet(address);
|
|
116
|
+
const onChain = await fetchStxBalanceMicro(wallet.sponsorAddress, config.network);
|
|
117
|
+
const available = gasTank.availableBalance(onChain, wallet);
|
|
118
|
+
return c.json({
|
|
119
|
+
walletId: wallet.id,
|
|
120
|
+
ownerAddress: wallet.ownerAddress,
|
|
121
|
+
sponsorAddress: wallet.sponsorAddress,
|
|
122
|
+
gasBalanceMicroStx: onChain.toString(),
|
|
123
|
+
availableMicroStx: available.toString(),
|
|
124
|
+
reservedMicroStx: wallet.reservedMicroStx.toString(),
|
|
125
|
+
totalSpentMicroStx: wallet.totalSpentMicroStx.toString(),
|
|
126
|
+
txCount: wallet.txCount,
|
|
127
|
+
createdAt: wallet.createdAt,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
app.get('/v1/wallet/keys', (c) => {
|
|
131
|
+
if (!gasTank)
|
|
132
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
133
|
+
const address = resolveSessionAddress(c, config);
|
|
134
|
+
if (!address)
|
|
135
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
136
|
+
const wallet = gasTank.getWalletByOwner(address);
|
|
137
|
+
if (!wallet)
|
|
138
|
+
return c.json({ keys: [] });
|
|
139
|
+
return c.json({
|
|
140
|
+
keys: gasTank.listApiKeys(wallet.id).map((k) => ({
|
|
141
|
+
id: k.id,
|
|
142
|
+
name: k.name,
|
|
143
|
+
keyPrefix: k.keyPrefix,
|
|
144
|
+
createdAt: k.createdAt,
|
|
145
|
+
retrievable: gasTank.canRevealApiKey(wallet.id, k.id),
|
|
146
|
+
})),
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
app.get('/v1/wallet/keys/:id/reveal', (c) => {
|
|
150
|
+
if (!gasTank)
|
|
151
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
152
|
+
const address = resolveSessionAddress(c, config);
|
|
153
|
+
if (!address)
|
|
154
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
155
|
+
const wallet = gasTank.getWalletByOwner(address);
|
|
156
|
+
if (!wallet)
|
|
157
|
+
return c.json({ error: 'Wallet not registered' }, 404);
|
|
158
|
+
try {
|
|
159
|
+
const apiKey = gasTank.revealApiKey(wallet.id, c.req.param('id'));
|
|
160
|
+
return c.json({ apiKey });
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
const message = error instanceof Error ? error.message : 'Reveal failed';
|
|
164
|
+
return c.json({ error: message }, 404);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
app.post('/v1/wallet/keys', async (c) => {
|
|
168
|
+
if (!gasTank)
|
|
169
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
170
|
+
const address = resolveSessionAddress(c, config);
|
|
171
|
+
if (!address)
|
|
172
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
173
|
+
const body = await c.req.json();
|
|
174
|
+
if (!body.name?.trim())
|
|
175
|
+
return c.json({ error: 'Missing name' }, 400);
|
|
176
|
+
const wallet = gasTank.ensureWallet(address);
|
|
177
|
+
const { apiKey, record } = gasTank.createApiKey(wallet.id, body.name.trim());
|
|
178
|
+
return c.json({
|
|
179
|
+
id: record.id,
|
|
180
|
+
name: record.name,
|
|
181
|
+
apiKey,
|
|
182
|
+
keyPrefix: record.keyPrefix,
|
|
183
|
+
createdAt: record.createdAt,
|
|
184
|
+
warning: 'Store this API key securely — it is shown only once.',
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
app.delete('/v1/wallet/keys/:id', (c) => {
|
|
188
|
+
if (!gasTank)
|
|
189
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
190
|
+
const address = resolveSessionAddress(c, config);
|
|
191
|
+
if (!address)
|
|
192
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
193
|
+
const wallet = gasTank.getWalletByOwner(address);
|
|
194
|
+
if (!wallet)
|
|
195
|
+
return c.json({ error: 'Wallet not registered' }, 404);
|
|
196
|
+
try {
|
|
197
|
+
const revoked = gasTank.revokeApiKey(wallet.id, c.req.param('id'));
|
|
198
|
+
return c.json({ id: revoked.id, revokedAt: revoked.revokedAt });
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const message = error instanceof Error ? error.message : 'Revoke failed';
|
|
202
|
+
return c.json({ error: message }, 400);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
app.get('/v1/wallet/logs', (c) => {
|
|
206
|
+
if (!gasTank)
|
|
207
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
208
|
+
const address = resolveSessionAddress(c, config);
|
|
209
|
+
if (!address)
|
|
210
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
211
|
+
const wallet = gasTank.getWalletByOwner(address);
|
|
212
|
+
if (!wallet)
|
|
213
|
+
return c.json({ logs: [] });
|
|
214
|
+
return c.json({
|
|
215
|
+
logs: gasTank.getLogs(wallet.id).map((l) => ({
|
|
216
|
+
...l,
|
|
217
|
+
feeMicroStx: l.feeMicroStx.toString(),
|
|
218
|
+
})),
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
// --- SDK project info (API key auth) ---
|
|
222
|
+
app.get('/v1/project', async (c) => {
|
|
223
|
+
if (!gasTank)
|
|
224
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
225
|
+
const auth = c.req.header('Authorization');
|
|
226
|
+
const apiKey = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
227
|
+
if (!apiKey)
|
|
228
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
229
|
+
const resolved = gasTank.resolveApiKey(apiKey);
|
|
230
|
+
if (!resolved)
|
|
231
|
+
return c.json({ error: 'Invalid or revoked API key' }, 401);
|
|
232
|
+
try {
|
|
233
|
+
const onChain = await fetchStxBalanceMicro(resolved.wallet.sponsorAddress, config.network);
|
|
234
|
+
const available = gasTank.availableBalance(onChain, resolved.wallet);
|
|
235
|
+
return c.json({
|
|
236
|
+
walletId: resolved.wallet.id,
|
|
237
|
+
projectId: resolved.wallet.id,
|
|
238
|
+
projectName: resolved.apiKey.name,
|
|
239
|
+
gasTankAddress: resolved.wallet.sponsorAddress,
|
|
240
|
+
gasBalanceMicroStx: onChain.toString(),
|
|
241
|
+
availableMicroStx: available.toString(),
|
|
242
|
+
totalSpentMicroStx: resolved.wallet.totalSpentMicroStx.toString(),
|
|
243
|
+
txCount: resolved.wallet.txCount,
|
|
244
|
+
sponsorFeeMicroStx: config.policy.maxFeeMicroStx.toString(),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
const message = error instanceof Error ? error.message : 'Balance lookup failed';
|
|
249
|
+
return c.json({ error: message }, 503);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
// --- Legacy admin (X-Admin-Key) — list only ---
|
|
253
|
+
app.get('/v1/admin/projects', async (c) => {
|
|
254
|
+
if (!gasTank)
|
|
255
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
256
|
+
if (!isAdminKey(c, config))
|
|
257
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
258
|
+
const wallets = await Promise.all(gasTank.listWallets().map(async (w) => {
|
|
259
|
+
const onChain = await fetchStxBalanceMicro(w.sponsorAddress, config.network);
|
|
260
|
+
return {
|
|
261
|
+
id: w.id,
|
|
262
|
+
name: w.ownerAddress,
|
|
263
|
+
ownerAddress: w.ownerAddress,
|
|
264
|
+
gasTankAddress: w.sponsorAddress,
|
|
265
|
+
gasBalanceMicroStx: onChain.toString(),
|
|
266
|
+
availableMicroStx: gasTank.availableBalance(onChain, w).toString(),
|
|
267
|
+
totalSpentMicroStx: w.totalSpentMicroStx.toString(),
|
|
268
|
+
txCount: w.txCount,
|
|
269
|
+
createdAt: w.createdAt,
|
|
270
|
+
};
|
|
271
|
+
}));
|
|
272
|
+
return c.json({ projects: wallets });
|
|
273
|
+
});
|
|
274
|
+
app.get('/v1/admin/projects/:id/logs', (c) => {
|
|
275
|
+
if (!gasTank)
|
|
276
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
277
|
+
if (!isAdminKey(c, config))
|
|
278
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
279
|
+
return c.json({
|
|
280
|
+
logs: gasTank.getLogs(c.req.param('id')).map((l) => ({
|
|
281
|
+
...l,
|
|
282
|
+
projectId: l.walletId,
|
|
283
|
+
feeMicroStx: l.feeMicroStx.toString(),
|
|
284
|
+
})),
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
app.get('/v1/catalog', (c) => {
|
|
288
|
+
if (!gasTank)
|
|
289
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
290
|
+
const auth = c.req.header('Authorization');
|
|
291
|
+
const apiKey = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
292
|
+
if (!apiKey)
|
|
293
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
294
|
+
const resolved = gasTank.resolveApiKey(apiKey);
|
|
295
|
+
if (!resolved)
|
|
296
|
+
return c.json({ error: 'Invalid API key' }, 401);
|
|
297
|
+
return c.json({ contracts: catalog.listForProject(resolved.wallet.id) });
|
|
298
|
+
});
|
|
299
|
+
app.post('/v1/catalog/ensure', async (c) => {
|
|
300
|
+
if (!gasTank)
|
|
301
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
302
|
+
const auth = c.req.header('Authorization');
|
|
303
|
+
const apiKey = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
304
|
+
if (!apiKey)
|
|
305
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
306
|
+
const resolved = gasTank.resolveApiKey(apiKey);
|
|
307
|
+
if (!resolved)
|
|
308
|
+
return c.json({ error: 'Invalid API key' }, 401);
|
|
309
|
+
const body = await c.req.json();
|
|
310
|
+
if (!body.contractId)
|
|
311
|
+
return c.json({ error: 'Missing contractId' }, 400);
|
|
312
|
+
try {
|
|
313
|
+
const result = await catalogService.ensureContract(resolved.wallet.id, body.contractId);
|
|
314
|
+
return c.json(result);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
const message = error instanceof Error ? error.message : 'Catalog ensure failed';
|
|
318
|
+
return c.json({ error: message }, 400);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
app.post('/v1/accounts/ensure', async (c) => {
|
|
322
|
+
if (!gasTank)
|
|
323
|
+
return c.json({ error: 'Gas tank not enabled' }, 503);
|
|
324
|
+
const auth = c.req.header('Authorization');
|
|
325
|
+
const apiKey = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
326
|
+
if (!apiKey)
|
|
327
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
328
|
+
const resolved = gasTank.resolveApiKey(apiKey);
|
|
329
|
+
if (!resolved)
|
|
330
|
+
return c.json({ error: 'Invalid API key' }, 401);
|
|
331
|
+
const body = await c.req.json();
|
|
332
|
+
if (!body.publicKeyHex)
|
|
333
|
+
return c.json({ error: 'Missing publicKeyHex' }, 400);
|
|
334
|
+
if (!body.originAddress)
|
|
335
|
+
return c.json({ error: 'Missing originAddress' }, 400);
|
|
336
|
+
try {
|
|
337
|
+
const result = await accountService.ensureAccount(resolved.wallet.id, body.publicKeyHex, {
|
|
338
|
+
originAddress: body.originAddress,
|
|
339
|
+
contractName: body.contractName,
|
|
340
|
+
});
|
|
341
|
+
return c.json(result);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
const message = error instanceof Error ? error.message : 'Account ensure failed';
|
|
345
|
+
return c.json({ error: message }, 400);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
app.post('/sponsor', async (c) => {
|
|
349
|
+
const auth = c.req.header('Authorization');
|
|
350
|
+
const apiKey = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
351
|
+
let walletId;
|
|
352
|
+
let apiKeyId;
|
|
353
|
+
let sponsorPrivateKey;
|
|
354
|
+
let sponsorAddress;
|
|
355
|
+
if (gasTank && apiKey) {
|
|
356
|
+
const resolved = gasTank.resolveApiKey(apiKey);
|
|
357
|
+
if (!resolved) {
|
|
358
|
+
return c.json({ status: 'rejected', reason: 'Invalid or revoked API key' }, 401);
|
|
359
|
+
}
|
|
360
|
+
walletId = resolved.wallet.id;
|
|
361
|
+
apiKeyId = resolved.apiKey.id;
|
|
362
|
+
sponsorPrivateKey = resolved.sponsorPrivateKey;
|
|
363
|
+
sponsorAddress = resolved.wallet.sponsorAddress;
|
|
364
|
+
}
|
|
365
|
+
else if (config.apiKey) {
|
|
366
|
+
if (auth !== `Bearer ${config.apiKey}`) {
|
|
367
|
+
return c.json({ status: 'rejected', reason: 'Unauthorized' }, 401);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const clientIp = c.req.header('x-forwarded-for') ?? 'local';
|
|
371
|
+
if (!limiter.check(clientIp)) {
|
|
372
|
+
return c.json({ status: 'rejected', reason: 'Rate limit exceeded' }, 429);
|
|
373
|
+
}
|
|
374
|
+
const body = await c.req.json();
|
|
375
|
+
if (!body.txHex) {
|
|
376
|
+
return c.json({ status: 'rejected', reason: 'Missing txHex' }, 400);
|
|
377
|
+
}
|
|
378
|
+
const billingMode = body.billingMode ?? 'gasless';
|
|
379
|
+
if (gasTank && walletId && billingMode === 'gasless' && sponsorAddress) {
|
|
380
|
+
const wallet = gasTank.getWalletById(walletId);
|
|
381
|
+
const minFee = config.policy.maxFeeMicroStx;
|
|
382
|
+
if (wallet) {
|
|
383
|
+
const onChain = await fetchStxBalanceMicro(sponsorAddress, config.network);
|
|
384
|
+
if (gasTank.availableBalance(onChain, wallet) < minFee) {
|
|
385
|
+
return c.json({
|
|
386
|
+
status: 'rejected',
|
|
387
|
+
reason: `Insufficient gas tank balance. Deposit STX to ${sponsorAddress}`,
|
|
388
|
+
}, 402);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const result = await sponsor.sponsorAndBroadcast(body.txHex, {
|
|
394
|
+
walletId,
|
|
395
|
+
apiKeyId,
|
|
396
|
+
sponsorPrivateKey,
|
|
397
|
+
sponsorAddress,
|
|
398
|
+
billingMode,
|
|
399
|
+
estimatedFeeMicroStx: body.estimatedFeeMicroStx ? BigInt(body.estimatedFeeMicroStx) : undefined,
|
|
400
|
+
});
|
|
401
|
+
if (result.status === 'rejected') {
|
|
402
|
+
return c.json(result, 400);
|
|
403
|
+
}
|
|
404
|
+
return c.json(result);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
const message = error instanceof Error ? error.message : 'Unknown relay error';
|
|
408
|
+
return c.json({ status: 'rejected', reason: message }, 500);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
return app;
|
|
412
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RelayConfig } from './types.js';
|
|
2
|
+
import type { CatalogStore } from './catalog.js';
|
|
3
|
+
import type { GasTankStore } from './gas-tank.js';
|
|
4
|
+
export interface EnsureResult {
|
|
5
|
+
contractId: string;
|
|
6
|
+
registered: boolean;
|
|
7
|
+
alreadyRegistered: boolean;
|
|
8
|
+
registrationTxid?: string;
|
|
9
|
+
functions: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare class CatalogService {
|
|
12
|
+
private readonly config;
|
|
13
|
+
private readonly catalog;
|
|
14
|
+
private readonly gasTank?;
|
|
15
|
+
constructor(config: RelayConfig, catalog: CatalogStore, gasTank?: GasTankStore | undefined);
|
|
16
|
+
ensureContract(projectId: string, contractId: string): Promise<EnsureResult>;
|
|
17
|
+
private fetchPublicFunctions;
|
|
18
|
+
private isRegisteredOnChain;
|
|
19
|
+
private submitRegistration;
|
|
20
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Cl, broadcastTransaction, fetchCallReadOnlyFunction, makeContractCall, cvToValue, } from '@stacks/transactions';
|
|
2
|
+
import { STACKS_MAINNET, STACKS_TESTNET } from '@stacks/network';
|
|
3
|
+
import { broadcastWithNonceRetry, runWithDeployerLock } from './registrar-queue.js';
|
|
4
|
+
import { runWithSponsorLock } from './sponsor-lock.js';
|
|
5
|
+
import { fetchStxBalanceMicro } from './on-chain-balance.js';
|
|
6
|
+
import { platformRegistrarAddress, platformRegistrarPrivateKey } from './registrar.js';
|
|
7
|
+
const REGISTRATION_FEE_MULTIPLIER = 3n;
|
|
8
|
+
function getNetwork(name) {
|
|
9
|
+
return name === 'mainnet' ? STACKS_MAINNET : STACKS_TESTNET;
|
|
10
|
+
}
|
|
11
|
+
function parseContractId(contractId) {
|
|
12
|
+
const dot = contractId.indexOf('.');
|
|
13
|
+
if (dot === -1)
|
|
14
|
+
throw new Error('contractId must be address.name');
|
|
15
|
+
return { address: contractId.slice(0, dot), name: contractId.slice(dot + 1) };
|
|
16
|
+
}
|
|
17
|
+
export class CatalogService {
|
|
18
|
+
config;
|
|
19
|
+
catalog;
|
|
20
|
+
gasTank;
|
|
21
|
+
constructor(config, catalog, gasTank) {
|
|
22
|
+
this.config = config;
|
|
23
|
+
this.catalog = catalog;
|
|
24
|
+
this.gasTank = gasTank;
|
|
25
|
+
}
|
|
26
|
+
async ensureContract(projectId, contractId) {
|
|
27
|
+
const limits = {
|
|
28
|
+
maxContracts: Number(process.env.CATALOG_MAX_CONTRACTS_PER_PROJECT ?? '50'),
|
|
29
|
+
maxRegistrationsPerDay: Number(process.env.CATALOG_MAX_REGISTRATIONS_PER_DAY ?? '5'),
|
|
30
|
+
};
|
|
31
|
+
const cached = this.catalog.find(projectId, contractId);
|
|
32
|
+
if (cached?.registrationTxid) {
|
|
33
|
+
return {
|
|
34
|
+
contractId,
|
|
35
|
+
registered: true,
|
|
36
|
+
alreadyRegistered: true,
|
|
37
|
+
registrationTxid: cached.registrationTxid,
|
|
38
|
+
functions: cached.functions,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (this.gasTank) {
|
|
42
|
+
if (this.catalog.countContracts(projectId) >= limits.maxContracts && !cached) {
|
|
43
|
+
throw new Error(`Project exceeded max registered contracts (${limits.maxContracts})`);
|
|
44
|
+
}
|
|
45
|
+
if (this.catalog.countRegistrationsToday(projectId) >= limits.maxRegistrationsPerDay) {
|
|
46
|
+
throw new Error(`Project exceeded daily registration limit (${limits.maxRegistrationsPerDay})`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const functions = await this.fetchPublicFunctions(contractId);
|
|
50
|
+
if (!functions.includes('passkey-exec')) {
|
|
51
|
+
throw new Error('Contract must implement public passkey-exec (passkey-exec-trait)');
|
|
52
|
+
}
|
|
53
|
+
const adapterAddress = process.env.PASSKEY_ADAPTER_ADDRESS;
|
|
54
|
+
const adapterName = process.env.PASSKEY_ADAPTER_NAME ?? 'passkey-adapter';
|
|
55
|
+
if (!adapterAddress) {
|
|
56
|
+
throw new Error('PASSKEY_ADAPTER_ADDRESS is not configured on relay');
|
|
57
|
+
}
|
|
58
|
+
const network = getNetwork(this.config.network);
|
|
59
|
+
const alreadyOnChain = await this.isRegisteredOnChain(adapterAddress, adapterName, contractId);
|
|
60
|
+
let registrationTxid = cached?.registrationTxid;
|
|
61
|
+
if (!alreadyOnChain) {
|
|
62
|
+
registrationTxid = await runWithDeployerLock(() => this.submitRegistration(projectId, adapterAddress, adapterName, contractId));
|
|
63
|
+
this.catalog.incrementRegistrationsToday(projectId);
|
|
64
|
+
}
|
|
65
|
+
this.catalog.save({
|
|
66
|
+
contractId,
|
|
67
|
+
projectId,
|
|
68
|
+
functions,
|
|
69
|
+
registeredAt: new Date().toISOString(),
|
|
70
|
+
registrationTxid,
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
contractId,
|
|
74
|
+
registered: true,
|
|
75
|
+
alreadyRegistered: alreadyOnChain,
|
|
76
|
+
registrationTxid,
|
|
77
|
+
functions,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async fetchPublicFunctions(contractId) {
|
|
81
|
+
const network = getNetwork(this.config.network);
|
|
82
|
+
const { address, name } = parseContractId(contractId);
|
|
83
|
+
const url = `${network.client.baseUrl}/v2/contracts/interface/${address}/${name}`;
|
|
84
|
+
const res = await fetch(url);
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
throw new Error(`Unable to fetch contract interface for ${contractId}: ${res.status}`);
|
|
87
|
+
}
|
|
88
|
+
const body = (await res.json());
|
|
89
|
+
return (body.functions ?? [])
|
|
90
|
+
.filter((f) => f.access === 'public')
|
|
91
|
+
.map((f) => f.name);
|
|
92
|
+
}
|
|
93
|
+
async isRegisteredOnChain(adapterAddress, adapterName, contractId) {
|
|
94
|
+
const network = getNetwork(this.config.network);
|
|
95
|
+
const { address, name } = parseContractId(contractId);
|
|
96
|
+
try {
|
|
97
|
+
const result = await fetchCallReadOnlyFunction({
|
|
98
|
+
contractAddress: adapterAddress,
|
|
99
|
+
contractName: adapterName,
|
|
100
|
+
functionName: 'is-registered',
|
|
101
|
+
functionArgs: [Cl.contractPrincipal(address, name)],
|
|
102
|
+
network,
|
|
103
|
+
senderAddress: adapterAddress,
|
|
104
|
+
});
|
|
105
|
+
const parsed = cvToValue(result);
|
|
106
|
+
return parsed === true || (typeof parsed === 'object' && parsed?.value === true);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async submitRegistration(projectId, adapterAddress, adapterName, contractId) {
|
|
113
|
+
const network = getNetwork(this.config.network);
|
|
114
|
+
const { address, name } = parseContractId(contractId);
|
|
115
|
+
const fee = this.config.policy.maxFeeMicroStx * REGISTRATION_FEE_MULTIPLIER;
|
|
116
|
+
let creds = null;
|
|
117
|
+
if (this.gasTank) {
|
|
118
|
+
const sponsor = this.gasTank.getSponsorCredentials(projectId);
|
|
119
|
+
if (!sponsor)
|
|
120
|
+
throw new Error('Wallet not found');
|
|
121
|
+
const onChain = await fetchStxBalanceMicro(sponsor.sponsorAddress, this.config.network);
|
|
122
|
+
const available = this.gasTank.availableBalance(onChain, sponsor.wallet);
|
|
123
|
+
if (available < fee) {
|
|
124
|
+
throw new Error(`Insufficient gas tank balance for contract registration. Deposit STX to ${sponsor.sponsorAddress}`);
|
|
125
|
+
}
|
|
126
|
+
this.gasTank.reserveGas(projectId, fee);
|
|
127
|
+
creds = sponsor;
|
|
128
|
+
}
|
|
129
|
+
const senderKey = platformRegistrarPrivateKey(this.config);
|
|
130
|
+
const lockAddress = platformRegistrarAddress(this.config);
|
|
131
|
+
const submit = async () => {
|
|
132
|
+
let reserved = fee;
|
|
133
|
+
try {
|
|
134
|
+
const txid = await broadcastWithNonceRetry(async () => ({
|
|
135
|
+
transaction: await makeContractCall({
|
|
136
|
+
contractAddress: adapterAddress,
|
|
137
|
+
contractName: adapterName,
|
|
138
|
+
functionName: 'register-contract',
|
|
139
|
+
functionArgs: [Cl.contractPrincipal(address, name)],
|
|
140
|
+
senderKey,
|
|
141
|
+
network,
|
|
142
|
+
fee,
|
|
143
|
+
}),
|
|
144
|
+
}), async (tx) => broadcastTransaction({
|
|
145
|
+
transaction: tx,
|
|
146
|
+
network,
|
|
147
|
+
}));
|
|
148
|
+
if (this.gasTank) {
|
|
149
|
+
this.gasTank.recordSponsor(projectId, fee, txid, 'gasless', undefined, reserved);
|
|
150
|
+
reserved = 0n;
|
|
151
|
+
}
|
|
152
|
+
return txid;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
if (reserved > 0n && this.gasTank) {
|
|
156
|
+
this.gasTank.releaseReservation(projectId, reserved);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
if (this.gasTank) {
|
|
161
|
+
return runWithSponsorLock(lockAddress, submit);
|
|
162
|
+
}
|
|
163
|
+
return submit();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface CatalogEntry {
|
|
2
|
+
contractId: string;
|
|
3
|
+
projectId: string;
|
|
4
|
+
functions: string[];
|
|
5
|
+
registeredAt: string;
|
|
6
|
+
registrationTxid?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class CatalogStore {
|
|
9
|
+
private readonly filePath;
|
|
10
|
+
private data;
|
|
11
|
+
constructor(filePath: string);
|
|
12
|
+
private load;
|
|
13
|
+
private persist;
|
|
14
|
+
find(projectId: string, contractId: string): CatalogEntry | null;
|
|
15
|
+
listForProject(projectId: string): CatalogEntry[];
|
|
16
|
+
save(entry: CatalogEntry): CatalogEntry;
|
|
17
|
+
countRegistrationsToday(projectId: string): number;
|
|
18
|
+
incrementRegistrationsToday(projectId: string): number;
|
|
19
|
+
countContracts(projectId: string): number;
|
|
20
|
+
}
|
|
21
|
+
export declare function defaultCatalogPath(): string;
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export class CatalogStore {
|
|
4
|
+
filePath;
|
|
5
|
+
data;
|
|
6
|
+
constructor(filePath) {
|
|
7
|
+
this.filePath = filePath;
|
|
8
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
9
|
+
this.data = this.load();
|
|
10
|
+
}
|
|
11
|
+
load() {
|
|
12
|
+
if (!existsSync(this.filePath)) {
|
|
13
|
+
return { entries: [], registrationCounts: {} };
|
|
14
|
+
}
|
|
15
|
+
return JSON.parse(readFileSync(this.filePath, 'utf8'));
|
|
16
|
+
}
|
|
17
|
+
persist() {
|
|
18
|
+
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
|
|
19
|
+
}
|
|
20
|
+
find(projectId, contractId) {
|
|
21
|
+
return (this.data.entries.find((e) => e.projectId === projectId && e.contractId === contractId) ?? null);
|
|
22
|
+
}
|
|
23
|
+
listForProject(projectId) {
|
|
24
|
+
return this.data.entries.filter((e) => e.projectId === projectId);
|
|
25
|
+
}
|
|
26
|
+
save(entry) {
|
|
27
|
+
const idx = this.data.entries.findIndex((e) => e.projectId === entry.projectId && e.contractId === entry.contractId);
|
|
28
|
+
if (idx >= 0)
|
|
29
|
+
this.data.entries[idx] = entry;
|
|
30
|
+
else
|
|
31
|
+
this.data.entries.push(entry);
|
|
32
|
+
this.persist();
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
countRegistrationsToday(projectId) {
|
|
36
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
37
|
+
const row = this.data.registrationCounts[projectId];
|
|
38
|
+
if (!row || row.day !== day)
|
|
39
|
+
return 0;
|
|
40
|
+
return row.count;
|
|
41
|
+
}
|
|
42
|
+
incrementRegistrationsToday(projectId) {
|
|
43
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
44
|
+
const row = this.data.registrationCounts[projectId];
|
|
45
|
+
if (!row || row.day !== day) {
|
|
46
|
+
this.data.registrationCounts[projectId] = { day, count: 1 };
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
row.count += 1;
|
|
50
|
+
}
|
|
51
|
+
this.persist();
|
|
52
|
+
return this.data.registrationCounts[projectId].count;
|
|
53
|
+
}
|
|
54
|
+
countContracts(projectId) {
|
|
55
|
+
return this.data.entries.filter((e) => e.projectId === projectId).length;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function defaultCatalogPath() {
|
|
59
|
+
return join(process.cwd(), 'data', 'catalog.json');
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|