@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,36 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * Load packages/relay/.env if present. Never commit that file.
6
+ */
7
+ export function loadRelayEnv() {
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ const envPath = resolve(here, '../.env');
10
+ if (!existsSync(envPath))
11
+ return;
12
+ const content = parseEnvFile(readFileSync(envPath, 'utf8'));
13
+ for (const [key, value] of Object.entries(content)) {
14
+ if (process.env[key] === undefined) {
15
+ process.env[key] = value;
16
+ }
17
+ }
18
+ }
19
+ function parseEnvFile(text) {
20
+ const out = {};
21
+ for (const line of text.split('\n')) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed || trimmed.startsWith('#'))
24
+ continue;
25
+ const eq = trimmed.indexOf('=');
26
+ if (eq <= 0)
27
+ continue;
28
+ const key = trimmed.slice(0, eq).trim();
29
+ let value = trimmed.slice(eq + 1).trim();
30
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
31
+ value = value.slice(1, -1);
32
+ }
33
+ out[key] = value;
34
+ }
35
+ return out;
36
+ }
@@ -0,0 +1,4 @@
1
+ import type { RelayConfig } from './types.js';
2
+ export declare function fetchStxBalanceMicro(address: string, networkName: RelayConfig['network']): Promise<bigint>;
3
+ /** @internal test helper */
4
+ export declare function clearStxBalanceCache(): void;
@@ -0,0 +1,60 @@
1
+ import { STACKS_MAINNET, STACKS_TESTNET } from '@stacks/network';
2
+ function getNetwork(name) {
3
+ return name === 'mainnet' ? STACKS_MAINNET : STACKS_TESTNET;
4
+ }
5
+ const CACHE_TTL_MS = 15_000;
6
+ const balanceCache = new Map();
7
+ function cacheKey(address, networkName) {
8
+ return `${networkName}:${address}`;
9
+ }
10
+ function readCachedBalance(address, networkName) {
11
+ const entry = balanceCache.get(cacheKey(address, networkName));
12
+ if (!entry)
13
+ return null;
14
+ if (Date.now() - entry.fetchedAt > CACHE_TTL_MS)
15
+ return null;
16
+ return entry.balance;
17
+ }
18
+ function readStaleBalance(address, networkName) {
19
+ return balanceCache.get(cacheKey(address, networkName))?.balance ?? null;
20
+ }
21
+ function writeCachedBalance(address, networkName, balance) {
22
+ balanceCache.set(cacheKey(address, networkName), { balance, fetchedAt: Date.now() });
23
+ }
24
+ async function fetchBalanceFromApi(address, networkName) {
25
+ const network = getNetwork(networkName);
26
+ return fetch(`${network.client.baseUrl}/extended/v1/address/${address}/stx`);
27
+ }
28
+ export async function fetchStxBalanceMicro(address, networkName) {
29
+ const cached = readCachedBalance(address, networkName);
30
+ if (cached !== null)
31
+ return cached;
32
+ let lastStatus = 0;
33
+ for (let attempt = 0; attempt < 3; attempt++) {
34
+ if (attempt > 0) {
35
+ await new Promise((r) => setTimeout(r, 400 * attempt));
36
+ }
37
+ const res = await fetchBalanceFromApi(address, networkName);
38
+ lastStatus = res.status;
39
+ if (res.status === 429)
40
+ continue;
41
+ if (!res.ok) {
42
+ const stale = readStaleBalance(address, networkName);
43
+ if (stale !== null)
44
+ return stale;
45
+ throw new Error(`Unable to fetch STX balance for ${address}: ${res.status}`);
46
+ }
47
+ const data = (await res.json());
48
+ const balance = BigInt(data.balance ?? '0');
49
+ writeCachedBalance(address, networkName, balance);
50
+ return balance;
51
+ }
52
+ const stale = readStaleBalance(address, networkName);
53
+ if (stale !== null)
54
+ return stale;
55
+ throw new Error(`Unable to fetch STX balance for ${address}: ${lastStatus}`);
56
+ }
57
+ /** @internal test helper */
58
+ export function clearStxBalanceCache() {
59
+ balanceCache.clear();
60
+ }
@@ -0,0 +1,9 @@
1
+ import type { RelayPolicy } from './types.js';
2
+ export declare class RateLimiter {
3
+ private buckets;
4
+ private readonly windowMs;
5
+ private readonly maxRequests;
6
+ constructor(policy: RelayPolicy);
7
+ check(key: string): boolean;
8
+ }
9
+ export declare function isContractAllowed(contractId: string, policy: RelayPolicy): boolean;
@@ -0,0 +1,29 @@
1
+ export class RateLimiter {
2
+ buckets = new Map();
3
+ windowMs;
4
+ maxRequests;
5
+ constructor(policy) {
6
+ this.windowMs = policy.rateLimit.windowMs;
7
+ this.maxRequests = policy.rateLimit.maxRequests;
8
+ }
9
+ check(key) {
10
+ const now = Date.now();
11
+ const bucket = this.buckets.get(key);
12
+ if (!bucket || now >= bucket.resetAt) {
13
+ this.buckets.set(key, { count: 1, resetAt: now + this.windowMs });
14
+ return true;
15
+ }
16
+ if (bucket.count >= this.maxRequests)
17
+ return false;
18
+ bucket.count += 1;
19
+ return true;
20
+ }
21
+ }
22
+ export function isContractAllowed(contractId, policy) {
23
+ if (!policy.allowedContracts || policy.allowedContracts.length === 0)
24
+ return true;
25
+ const smartAccountName = process.env.PASSKEY_SMART_ACCOUNT_NAME ?? 'smart-account';
26
+ if (contractId.endsWith(`.${smartAccountName}`))
27
+ return true;
28
+ return policy.allowedContracts.some((allowed) => contractId.startsWith(allowed));
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { isContractAllowed } from './rate-limit.js';
3
+ describe('isContractAllowed', () => {
4
+ const policy = {
5
+ allowedContracts: ['ST3XHHZ1CXVCNYXK3FQ1FDGJ9NK6YBJBJK3FVY5KQ'],
6
+ maxFeeMicroStx: 50000n,
7
+ rateLimit: { windowMs: 60_000, maxRequests: 100 },
8
+ };
9
+ afterEach(() => {
10
+ delete process.env.PASSKEY_SMART_ACCOUNT_NAME;
11
+ });
12
+ it('allows platform deployer contracts', () => {
13
+ expect(isContractAllowed('ST3XHHZ1CXVCNYXK3FQ1FDGJ9NK6YBJBJK3FVY5KQ.passkey-adapter', policy)).toBe(true);
14
+ });
15
+ it('allows self-deployed smart-account contracts on any origin address', () => {
16
+ expect(isContractAllowed('ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG.smart-account', policy)).toBe(true);
17
+ });
18
+ it('rejects unrelated contracts', () => {
19
+ expect(isContractAllowed('ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG.other-contract', policy)).toBe(false);
20
+ });
21
+ });
@@ -0,0 +1,14 @@
1
+ /** Serialize all txs from the deployer/sponsor key to avoid BadNonce races. */
2
+ export declare function runWithDeployerLock<T>(task: () => Promise<T>): Promise<T>;
3
+ export declare function isBadNonceResult(result: {
4
+ error?: string;
5
+ reason?: string;
6
+ }): boolean;
7
+ export declare function isBadNonceError(error: unknown): boolean;
8
+ export declare function broadcastWithNonceRetry(buildTx: () => Promise<{
9
+ transaction: unknown;
10
+ }>, broadcast: (tx: unknown) => Promise<{
11
+ txid?: string;
12
+ error?: string;
13
+ reason?: string;
14
+ }>, maxAttempts?: number): Promise<string>;
@@ -0,0 +1,30 @@
1
+ let deployerChain = Promise.resolve();
2
+ /** Serialize all txs from the deployer/sponsor key to avoid BadNonce races. */
3
+ export function runWithDeployerLock(task) {
4
+ const next = deployerChain.then(task, task);
5
+ deployerChain = next.then(() => undefined, () => undefined);
6
+ return next;
7
+ }
8
+ export function isBadNonceResult(result) {
9
+ const detail = `${result.error ?? ''} ${result.reason ?? ''}`;
10
+ return /BadNonce/i.test(detail);
11
+ }
12
+ export function isBadNonceError(error) {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ return /BadNonce/i.test(message);
15
+ }
16
+ export async function broadcastWithNonceRetry(buildTx, broadcast, maxAttempts = 4) {
17
+ let lastError = 'BadNonce';
18
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
19
+ const { transaction: tx } = await buildTx();
20
+ const result = await broadcast(tx);
21
+ if (!result.error && result.txid) {
22
+ return result.txid.startsWith('0x') ? result.txid.slice(2) : result.txid;
23
+ }
24
+ lastError = result.reason ?? result.error ?? lastError;
25
+ if (!isBadNonceResult(result) || attempt === maxAttempts - 1) {
26
+ throw new Error(lastError);
27
+ }
28
+ }
29
+ throw new Error(lastError);
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { runWithDeployerLock, isBadNonceResult } from './registrar-queue.js';
3
+ describe('registrar-queue', () => {
4
+ it('detects BadNonce in broadcast results', () => {
5
+ expect(isBadNonceResult({ reason: 'BadNonce' })).toBe(true);
6
+ expect(isBadNonceResult({ error: 'transaction rejected' })).toBe(false);
7
+ });
8
+ it('runs deployer tasks serially', async () => {
9
+ const order = [];
10
+ await Promise.all([
11
+ runWithDeployerLock(async () => {
12
+ order.push(1);
13
+ await new Promise((r) => setTimeout(r, 20));
14
+ order.push(2);
15
+ }),
16
+ runWithDeployerLock(async () => {
17
+ order.push(3);
18
+ }),
19
+ ]);
20
+ expect(order).toEqual([1, 2, 3]);
21
+ });
22
+ });
@@ -0,0 +1,4 @@
1
+ import type { RelayConfig } from './types.js';
2
+ /** Platform deployer key — required sender for passkey-factory and passkey-adapter registrar functions. */
3
+ export declare function platformRegistrarPrivateKey(config: RelayConfig): string;
4
+ export declare function platformRegistrarAddress(config: RelayConfig): string;
@@ -0,0 +1,9 @@
1
+ import { getAddressFromPrivateKey } from '@stacks/transactions';
2
+ /** Platform deployer key — required sender for passkey-factory and passkey-adapter registrar functions. */
3
+ export function platformRegistrarPrivateKey(config) {
4
+ return config.registrarPrivateKey ?? config.sponsorPrivateKey;
5
+ }
6
+ export function platformRegistrarAddress(config) {
7
+ const network = config.network === 'mainnet' ? 'mainnet' : 'testnet';
8
+ return getAddressFromPrivateKey(platformRegistrarPrivateKey(config), network);
9
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Load sponsor private key from a restricted file (preferred) or env var.
3
+ * Never log or return this value outside the relay process.
4
+ */
5
+ export declare function loadSponsorPrivateKey(): string;
6
+ export declare function assertRelayAuthConfigured(network: string): void;
@@ -0,0 +1,59 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ const PRIVATE_KEY_HEX = /^[0-9a-fA-F]{64}(01)?$/;
4
+ function assertSafeKeyFile(path) {
5
+ const absolute = resolve(path);
6
+ const stat = statSync(absolute, { throwIfNoEntry: true });
7
+ if (!stat.isFile()) {
8
+ throw new Error(`SPONSOR_PRIVATE_KEY_FILE must point to a regular file: ${absolute}`);
9
+ }
10
+ // Require owner-only permissions on Unix (skip on Windows).
11
+ if (process.platform !== 'win32') {
12
+ const mode = stat.mode & 0o777;
13
+ if (mode & 0o077) {
14
+ throw new Error(`Insecure permissions on ${absolute} (${mode.toString(8)}). ` +
15
+ 'Run: chmod 600 <keyfile>');
16
+ }
17
+ }
18
+ }
19
+ function normalizePrivateKey(raw, source) {
20
+ const key = raw.trim();
21
+ if (!PRIVATE_KEY_HEX.test(key)) {
22
+ throw new Error(`Invalid sponsor private key format from ${source}`);
23
+ }
24
+ return key.toLowerCase();
25
+ }
26
+ /**
27
+ * Load sponsor private key from a restricted file (preferred) or env var.
28
+ * Never log or return this value outside the relay process.
29
+ */
30
+ export function loadSponsorPrivateKey() {
31
+ const keyFile = process.env.SPONSOR_PRIVATE_KEY_FILE;
32
+ if (keyFile) {
33
+ assertSafeKeyFile(keyFile);
34
+ return normalizePrivateKey(readFileSync(resolve(keyFile), 'utf8'), 'SPONSOR_PRIVATE_KEY_FILE');
35
+ }
36
+ const inline = process.env.SPONSOR_PRIVATE_KEY;
37
+ if (inline) {
38
+ if (process.env.NODE_ENV === 'production') {
39
+ throw new Error('SPONSOR_PRIVATE_KEY inline env var is disabled in production. ' +
40
+ 'Use SPONSOR_PRIVATE_KEY_FILE pointing to a chmod 600 key file.');
41
+ }
42
+ console.warn('[relay] Warning: SPONSOR_PRIVATE_KEY is set inline. Prefer SPONSOR_PRIVATE_KEY_FILE to avoid shell history leaks.');
43
+ return normalizePrivateKey(inline, 'SPONSOR_PRIVATE_KEY');
44
+ }
45
+ throw new Error('Sponsor key not configured. Set SPONSOR_PRIVATE_KEY_FILE (recommended) or SPONSOR_PRIVATE_KEY (local dev only).');
46
+ }
47
+ export function assertRelayAuthConfigured(network) {
48
+ const hasApiKey = Boolean(process.env.RELAY_API_KEY?.trim());
49
+ const insecureLocal = process.env.RELAY_ALLOW_INSECURE_LOCAL === 'true';
50
+ if (network === 'mainnet' && !hasApiKey) {
51
+ throw new Error('RELAY_API_KEY is required when STACKS_NETWORK=mainnet');
52
+ }
53
+ if (!hasApiKey && !insecureLocal) {
54
+ throw new Error('RELAY_API_KEY is required. For local development only, set RELAY_ALLOW_INSECURE_LOCAL=true');
55
+ }
56
+ if (!hasApiKey && insecureLocal) {
57
+ console.warn('[relay] Warning: running without RELAY_API_KEY — local development only');
58
+ }
59
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ const VALID_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
6
+ describe('loadSponsorPrivateKey', () => {
7
+ it('loads from a restricted key file', async () => {
8
+ const dir = mkdtempSync(join(tmpdir(), 'relay-key-'));
9
+ const file = join(dir, 'sponsor.key');
10
+ writeFileSync(file, VALID_KEY);
11
+ chmodSync(file, 0o600);
12
+ process.env.SPONSOR_PRIVATE_KEY_FILE = file;
13
+ delete process.env.SPONSOR_PRIVATE_KEY;
14
+ const { loadSponsorPrivateKey } = await import('./secrets.js');
15
+ expect(loadSponsorPrivateKey()).toBe(VALID_KEY);
16
+ delete process.env.SPONSOR_PRIVATE_KEY_FILE;
17
+ });
18
+ it('rejects world-readable key files on unix', async () => {
19
+ if (process.platform === 'win32')
20
+ return;
21
+ const dir = mkdtempSync(join(tmpdir(), 'relay-key-'));
22
+ const file = join(dir, 'sponsor.key');
23
+ writeFileSync(file, VALID_KEY);
24
+ chmodSync(file, 0o644);
25
+ process.env.SPONSOR_PRIVATE_KEY_FILE = file;
26
+ delete process.env.SPONSOR_PRIVATE_KEY;
27
+ const { loadSponsorPrivateKey } = await import('./secrets.js');
28
+ expect(() => loadSponsorPrivateKey()).toThrow(/chmod 600/);
29
+ delete process.env.SPONSOR_PRIVATE_KEY_FILE;
30
+ });
31
+ });
32
+ describe('assertRelayAuthConfigured', () => {
33
+ it('requires api key on mainnet', async () => {
34
+ delete process.env.RELAY_API_KEY;
35
+ delete process.env.RELAY_ALLOW_INSECURE_LOCAL;
36
+ const { assertRelayAuthConfigured } = await import('./secrets.js');
37
+ expect(() => assertRelayAuthConfigured('mainnet')).toThrow(/RELAY_API_KEY/);
38
+ });
39
+ });
@@ -0,0 +1,3 @@
1
+ /** Deterministic sponsor private key per wallet owner (compressed hex). */
2
+ export declare function deriveSponsorPrivateKey(masterSecret: string, ownerAddress: string): string;
3
+ export declare function deriveSponsorAddress(masterSecret: string, ownerAddress: string, network: 'mainnet' | 'testnet'): string;
@@ -0,0 +1,25 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { getAddressFromPrivateKey } from '@stacks/transactions';
3
+ const DERIVATION_LABEL = 'stacks-passkey-sponsor-v1';
4
+ /** Deterministic sponsor private key per wallet owner (compressed hex). */
5
+ export function deriveSponsorPrivateKey(masterSecret, ownerAddress) {
6
+ for (let counter = 0; counter < 256; counter++) {
7
+ const digest = createHmac('sha256', masterSecret)
8
+ .update(DERIVATION_LABEL)
9
+ .update(ownerAddress)
10
+ .update(Buffer.from([counter]))
11
+ .digest('hex');
12
+ const candidate = `${digest}01`;
13
+ try {
14
+ getAddressFromPrivateKey(candidate, 'testnet');
15
+ return candidate;
16
+ }
17
+ catch {
18
+ // try next counter
19
+ }
20
+ }
21
+ throw new Error('Unable to derive sponsor private key');
22
+ }
23
+ export function deriveSponsorAddress(masterSecret, ownerAddress, network) {
24
+ return getAddressFromPrivateKey(deriveSponsorPrivateKey(masterSecret, ownerAddress), network);
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { deriveSponsorAddress, deriveSponsorPrivateKey } from './sponsor-derivation.js';
3
+ describe('sponsor derivation', () => {
4
+ const master = 'master-secret-v1';
5
+ const owner = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM';
6
+ it('is deterministic per owner address', () => {
7
+ const a = deriveSponsorAddress(master, owner, 'testnet');
8
+ const b = deriveSponsorAddress(master, owner, 'testnet');
9
+ expect(a).toBe(b);
10
+ });
11
+ it('differs across owners', () => {
12
+ const a = deriveSponsorAddress(master, owner, 'testnet');
13
+ const b = deriveSponsorAddress(master, 'ST2OTHER', 'testnet');
14
+ expect(a).not.toBe(b);
15
+ });
16
+ it('returns compressed private keys', () => {
17
+ const key = deriveSponsorPrivateKey(master, owner);
18
+ expect(key.endsWith('01')).toBe(true);
19
+ expect(key.length).toBeGreaterThan(64);
20
+ });
21
+ });
@@ -0,0 +1,2 @@
1
+ /** Serialize txs per sponsor address to avoid BadNonce races across tenants. */
2
+ export declare function runWithSponsorLock<T>(sponsorAddress: string, task: () => Promise<T>): Promise<T>;
@@ -0,0 +1,8 @@
1
+ const sponsorChains = new Map();
2
+ /** Serialize txs per sponsor address to avoid BadNonce races across tenants. */
3
+ export function runWithSponsorLock(sponsorAddress, task) {
4
+ const prev = sponsorChains.get(sponsorAddress) ?? Promise.resolve();
5
+ const next = prev.then(task, task);
6
+ sponsorChains.set(sponsorAddress, next.then(() => undefined, () => undefined));
7
+ return next;
8
+ }
@@ -0,0 +1,24 @@
1
+ import type { RelayConfig, SponsorResult } from './types.js';
2
+ import type { GasTankStore } from './gas-tank.js';
3
+ export interface SponsorContext {
4
+ walletId?: string;
5
+ apiKeyId?: string;
6
+ sponsorPrivateKey?: string;
7
+ sponsorAddress?: string;
8
+ billingMode: 'gasless' | 'account-pay';
9
+ estimatedFeeMicroStx?: bigint;
10
+ }
11
+ export declare class SponsorService {
12
+ private readonly config;
13
+ private readonly network;
14
+ private readonly gasTank?;
15
+ constructor(config: RelayConfig, gasTank?: GasTankStore);
16
+ getRegistrarAddress(): string;
17
+ /** Platform registrar / legacy sponsor address */
18
+ getSponsorAddress(): string;
19
+ sponsorAndBroadcast(txHex: string, context?: SponsorContext): Promise<SponsorResult & {
20
+ feeChargedMicroStx?: string;
21
+ gasBalanceMicroStx?: string;
22
+ }>;
23
+ private sponsorAndBroadcastInner;
24
+ }
@@ -0,0 +1,129 @@
1
+ import { extractContractCallId } from './contract-id.js';
2
+ import { isContractAllowed } from './rate-limit.js';
3
+ import { broadcastTransaction, deserializeTransaction, getAddressFromPrivateKey, sponsorTransaction, AuthType, isSmartContractPayload, } from '@stacks/transactions';
4
+ import { STACKS_MAINNET, STACKS_TESTNET } from '@stacks/network';
5
+ import { isBadNonceResult } from './registrar-queue.js';
6
+ import { runWithSponsorLock } from './sponsor-lock.js';
7
+ import { fetchStxBalanceMicro } from './on-chain-balance.js';
8
+ function normalizeTxId(txid) {
9
+ return txid.startsWith('0x') ? txid.slice(2) : txid;
10
+ }
11
+ function getNetwork(name) {
12
+ switch (name) {
13
+ case 'mainnet':
14
+ return STACKS_MAINNET;
15
+ case 'testnet':
16
+ return STACKS_TESTNET;
17
+ default:
18
+ return { ...STACKS_TESTNET, client: { ...STACKS_TESTNET.client, baseUrl: 'http://localhost:3999' } };
19
+ }
20
+ }
21
+ export class SponsorService {
22
+ config;
23
+ network;
24
+ gasTank;
25
+ constructor(config, gasTank) {
26
+ this.config = config;
27
+ this.network = getNetwork(config.network);
28
+ this.gasTank = gasTank;
29
+ }
30
+ getRegistrarAddress() {
31
+ const network = this.config.network === 'mainnet' ? 'mainnet' : 'testnet';
32
+ const key = this.config.registrarPrivateKey ?? this.config.sponsorPrivateKey;
33
+ return getAddressFromPrivateKey(key, network);
34
+ }
35
+ /** Platform registrar / legacy sponsor address */
36
+ getSponsorAddress() {
37
+ return this.getRegistrarAddress();
38
+ }
39
+ async sponsorAndBroadcast(txHex, context = { billingMode: 'gasless' }) {
40
+ const sponsorPrivateKey = context.sponsorPrivateKey ?? this.config.sponsorPrivateKey;
41
+ const sponsorAddress = context.sponsorAddress ??
42
+ getAddressFromPrivateKey(sponsorPrivateKey, this.config.network === 'mainnet' ? 'mainnet' : 'testnet');
43
+ return runWithSponsorLock(sponsorAddress, () => this.sponsorAndBroadcastInner(txHex, { ...context, sponsorPrivateKey, sponsorAddress }));
44
+ }
45
+ async sponsorAndBroadcastInner(txHex, context) {
46
+ const hex = txHex.startsWith('0x') ? txHex.slice(2) : txHex;
47
+ const deployFeeMultiplier = 4n;
48
+ const transactionPreview = deserializeTransaction(Buffer.from(hex, 'hex'));
49
+ const configuredFee = isSmartContractPayload(transactionPreview.payload)
50
+ ? this.config.policy.maxFeeMicroStx * deployFeeMultiplier
51
+ : this.config.policy.maxFeeMicroStx;
52
+ const actualFee = context.billingMode === 'account-pay'
53
+ ? configuredFee
54
+ : context.estimatedFeeMicroStx && context.estimatedFeeMicroStx > configuredFee
55
+ ? context.estimatedFeeMicroStx
56
+ : configuredFee;
57
+ let reserved = false;
58
+ if (this.gasTank && context.walletId && context.billingMode === 'gasless') {
59
+ const wallet = this.gasTank.getWalletById(context.walletId);
60
+ if (!wallet) {
61
+ return { txid: '', status: 'rejected', reason: 'Wallet not found' };
62
+ }
63
+ const onChain = await fetchStxBalanceMicro(context.sponsorAddress, this.config.network);
64
+ const available = this.gasTank.availableBalance(onChain, wallet);
65
+ if (available < actualFee) {
66
+ return {
67
+ txid: '',
68
+ status: 'rejected',
69
+ reason: `Insufficient gas tank balance at ${context.sponsorAddress}`,
70
+ };
71
+ }
72
+ this.gasTank.reserveGas(context.walletId, actualFee);
73
+ reserved = true;
74
+ }
75
+ let lastError = 'Transaction broadcast failed: BadNonce';
76
+ try {
77
+ for (let attempt = 0; attempt < 3; attempt++) {
78
+ const transaction = deserializeTransaction(Buffer.from(hex, 'hex'));
79
+ if (transaction.auth.authType !== AuthType.Sponsored) {
80
+ return { txid: '', status: 'rejected', reason: 'Transaction is not marked as sponsored' };
81
+ }
82
+ const contractId = extractContractCallId(transaction.payload);
83
+ if (contractId && !isContractAllowed(contractId, this.config.policy)) {
84
+ return { txid: '', status: 'rejected', reason: `Contract not allowlisted: ${contractId}` };
85
+ }
86
+ const sponsoredTx = await sponsorTransaction({
87
+ transaction,
88
+ sponsorPrivateKey: context.sponsorPrivateKey,
89
+ fee: actualFee,
90
+ network: this.network,
91
+ });
92
+ const response = (await broadcastTransaction({
93
+ transaction: sponsoredTx,
94
+ network: this.network,
95
+ }));
96
+ if (!response.error && response.txid) {
97
+ const txid = normalizeTxId(response.txid);
98
+ let gasBalanceMicroStx;
99
+ if (this.gasTank && context.walletId) {
100
+ const wallet = this.gasTank.recordSponsor(context.walletId, BigInt(actualFee), txid, context.billingMode, context.apiKeyId, reserved ? actualFee : undefined);
101
+ reserved = false;
102
+ const onChain = await fetchStxBalanceMicro(context.sponsorAddress, this.config.network);
103
+ gasBalanceMicroStx = this.gasTank.availableBalance(onChain, wallet).toString();
104
+ }
105
+ return {
106
+ txid,
107
+ status: 'accepted',
108
+ feeChargedMicroStx: actualFee.toString(),
109
+ gasBalanceMicroStx,
110
+ };
111
+ }
112
+ lastError = response.reason ?? response.error ?? lastError;
113
+ if (!isBadNonceResult(response) || attempt === 2) {
114
+ throw new Error(`Transaction broadcast failed: ${lastError}`);
115
+ }
116
+ }
117
+ }
118
+ catch (error) {
119
+ if (reserved && this.gasTank && context.walletId) {
120
+ this.gasTank.releaseReservation(context.walletId, actualFee);
121
+ }
122
+ throw error;
123
+ }
124
+ if (reserved && this.gasTank && context.walletId) {
125
+ this.gasTank.releaseReservation(context.walletId, actualFee);
126
+ }
127
+ throw new Error(`Transaction broadcast failed: ${lastError}`);
128
+ }
129
+ }
@@ -0,0 +1,4 @@
1
+ import type { STACKS_MAINNET, STACKS_TESTNET } from '@stacks/network';
2
+ type Network = typeof STACKS_MAINNET | typeof STACKS_TESTNET;
3
+ export declare function waitForTx(network: Network, txid: string, maxAttempts?: number, intervalMs?: number): Promise<void>;
4
+ export {};
@@ -0,0 +1,18 @@
1
+ export async function waitForTx(network, txid, maxAttempts = 60, intervalMs = 2000) {
2
+ const normalized = txid.startsWith('0x') ? txid.slice(2) : txid;
3
+ for (let i = 0; i < maxAttempts; i++) {
4
+ if (i > 0)
5
+ await new Promise((r) => setTimeout(r, intervalMs));
6
+ const res = await fetch(`${network.client.baseUrl}/extended/v1/tx/${normalized}`);
7
+ if (!res.ok)
8
+ continue;
9
+ const data = (await res.json());
10
+ if (data.tx_status === 'success')
11
+ return;
12
+ if (data.tx_status === 'abort_by_response' || data.tx_status === 'failed') {
13
+ const detail = data.tx_result?.repr ?? data.tx_status;
14
+ throw new Error(`Transaction failed on chain: ${detail}`);
15
+ }
16
+ }
17
+ throw new Error(`Transaction ${normalized} was not confirmed on chain`);
18
+ }
@@ -0,0 +1,30 @@
1
+ export interface RateLimitConfig {
2
+ windowMs: number;
3
+ maxRequests: number;
4
+ }
5
+ export interface RelayPolicy {
6
+ allowedContracts?: string[];
7
+ maxFeeMicroStx: bigint;
8
+ rateLimit: RateLimitConfig;
9
+ }
10
+ export interface RelayConfig {
11
+ sponsorPrivateKey: string;
12
+ registrarPrivateKey?: string;
13
+ masterSecret: string;
14
+ sessionSecret: string;
15
+ network: 'mainnet' | 'testnet' | 'devnet';
16
+ port: number;
17
+ host: string;
18
+ apiKey?: string;
19
+ adminApiKey?: string;
20
+ gasTankPath?: string;
21
+ policy: RelayPolicy;
22
+ }
23
+ export interface SponsorRequest {
24
+ txHex: string;
25
+ }
26
+ export interface SponsorResult {
27
+ txid: string;
28
+ status: 'accepted' | 'rejected';
29
+ reason?: string;
30
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};