@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 type { RelayConfig } from './types.js';
2
+ import type { AccountStore } from './accounts.js';
3
+ import type { GasTankStore } from './gas-tank.js';
4
+ export interface EnsureAccountResult {
5
+ contractAddress: string;
6
+ contractName: string;
7
+ contractId: string;
8
+ alreadyRegistered: boolean;
9
+ originAddress: string;
10
+ registerTxid?: string;
11
+ factoryTxid?: string;
12
+ }
13
+ export declare const DEFAULT_SELF_DEPLOY_ACCOUNT_NAME: string;
14
+ export interface EnsureAccountOptions {
15
+ originAddress?: string;
16
+ contractName?: string;
17
+ }
18
+ export declare class AccountService {
19
+ private readonly config;
20
+ private readonly store;
21
+ private readonly gasTank?;
22
+ constructor(config: RelayConfig, store: AccountStore, gasTank?: GasTankStore | undefined);
23
+ ensureAccount(projectId: string, publicKeyHex: string, options?: EnsureAccountOptions): Promise<EnsureAccountResult>;
24
+ getAccountContractTemplate(): {
25
+ contractName: string;
26
+ source: string;
27
+ clarityVersion: number;
28
+ };
29
+ /** Inject fully-qualified passkey-adapter references for user-origin deploys. */
30
+ buildAccountContractSource(): string;
31
+ private ensureAccountInner;
32
+ private lookupFactoryAccount;
33
+ private isContractDeployed;
34
+ private isKeyRegistered;
35
+ private readAccountContractSource;
36
+ private submitFactoryRegister;
37
+ private assertGas;
38
+ private broadcast;
39
+ }
@@ -0,0 +1,223 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { Cl, broadcastTransaction, fetchCallReadOnlyFunction, makeContractCall, cvToValue, } from '@stacks/transactions';
5
+ import { STACKS_MAINNET, STACKS_TESTNET } from '@stacks/network';
6
+ import { waitForTx } from './tx-wait.js';
7
+ import { broadcastWithNonceRetry, runWithDeployerLock, } from './registrar-queue.js';
8
+ import { runWithSponsorLock } from './sponsor-lock.js';
9
+ import { fetchStxBalanceMicro } from './on-chain-balance.js';
10
+ import { platformRegistrarAddress, platformRegistrarPrivateKey } from './registrar.js';
11
+ function getNetwork(name) {
12
+ return name === 'mainnet' ? STACKS_MAINNET : STACKS_TESTNET;
13
+ }
14
+ function defaultAccountContractPath() {
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ return join(here, '../../../contracts/contracts/passkey-account.clar');
17
+ }
18
+ export const DEFAULT_SELF_DEPLOY_ACCOUNT_NAME = process.env.PASSKEY_SMART_ACCOUNT_NAME ?? 'smart-account';
19
+ export class AccountService {
20
+ config;
21
+ store;
22
+ gasTank;
23
+ constructor(config, store, gasTank) {
24
+ this.config = config;
25
+ this.store = store;
26
+ this.gasTank = gasTank;
27
+ }
28
+ async ensureAccount(projectId, publicKeyHex, options = {}) {
29
+ return runWithDeployerLock(() => this.ensureAccountInner(projectId, publicKeyHex, options));
30
+ }
31
+ getAccountContractTemplate() {
32
+ return {
33
+ contractName: DEFAULT_SELF_DEPLOY_ACCOUNT_NAME,
34
+ source: this.buildAccountContractSource(),
35
+ clarityVersion: 5,
36
+ };
37
+ }
38
+ /** Inject fully-qualified passkey-adapter references for user-origin deploys. */
39
+ buildAccountContractSource() {
40
+ const adapterAddress = process.env.PASSKEY_ADAPTER_ADDRESS ?? process.env.PASSKEY_DEPLOYER_ADDRESS ?? '';
41
+ const adapterName = process.env.PASSKEY_ADAPTER_NAME ?? 'passkey-adapter';
42
+ if (!adapterAddress) {
43
+ throw new Error('PASSKEY_ADAPTER_ADDRESS or PASSKEY_DEPLOYER_ADDRESS must be configured');
44
+ }
45
+ const adapterContract = `'${adapterAddress}.${adapterName}`;
46
+ return this.readAccountContractSource()
47
+ .replaceAll('(use-trait exec-trait .passkey-adapter.passkey-exec-trait)', `(use-trait exec-trait ${adapterContract}.passkey-exec-trait)`)
48
+ .replaceAll('(contract-call? .passkey-adapter forward-invoke', `(contract-call? ${adapterContract} forward-invoke`);
49
+ }
50
+ async ensureAccountInner(projectId, publicKeyHex, options) {
51
+ const normalized = publicKeyHex.toLowerCase().replace(/^0x/, '');
52
+ if (normalized.length !== 66) {
53
+ throw new Error('publicKeyHex must be 33-byte compressed key (66 hex chars)');
54
+ }
55
+ const originAddress = options.originAddress;
56
+ if (!originAddress) {
57
+ throw new Error('originAddress is required');
58
+ }
59
+ const contractName = options.contractName ?? DEFAULT_SELF_DEPLOY_ACCOUNT_NAME;
60
+ const contractAddress = originAddress;
61
+ const contractId = `${contractAddress}.${contractName}`;
62
+ const cached = this.store.find(projectId, normalized);
63
+ if (cached?.registerTxid && cached.contractName === contractName && cached.contractAddress === contractAddress) {
64
+ return {
65
+ contractAddress,
66
+ contractName,
67
+ contractId,
68
+ originAddress,
69
+ alreadyRegistered: true,
70
+ registerTxid: cached.registerTxid,
71
+ factoryTxid: cached.factoryTxid,
72
+ };
73
+ }
74
+ const deployerAddress = process.env.PASSKEY_DEPLOYER_ADDRESS ?? process.env.PASSKEY_ADAPTER_ADDRESS;
75
+ const factoryAddress = process.env.PASSKEY_FACTORY_ADDRESS ?? deployerAddress;
76
+ const factoryName = process.env.PASSKEY_FACTORY_NAME ?? 'passkey-factory';
77
+ if (!deployerAddress || !factoryAddress) {
78
+ throw new Error('PASSKEY_DEPLOYER_ADDRESS or PASSKEY_FACTORY_ADDRESS must be configured');
79
+ }
80
+ const maxAccounts = Number(process.env.ACCOUNTS_MAX_PER_PROJECT ?? '500');
81
+ if (this.gasTank && this.store.count(projectId) >= maxAccounts && !cached) {
82
+ throw new Error(`Project exceeded max passkey accounts (${maxAccounts})`);
83
+ }
84
+ const network = getNetwork(this.config.network);
85
+ const pubkeyBuffer = Buffer.from(normalized, 'hex');
86
+ const deployed = await this.isContractDeployed(contractAddress, contractName, network);
87
+ if (!deployed) {
88
+ throw new Error(`Smart account ${contractId} is not deployed on chain yet`);
89
+ }
90
+ const registered = await this.isKeyRegistered(contractAddress, contractName, pubkeyBuffer, network, contractAddress);
91
+ if (!registered) {
92
+ throw new Error(`Passkey is not registered on ${contractId}`);
93
+ }
94
+ const onChain = await this.lookupFactoryAccount(factoryAddress, factoryName, pubkeyBuffer, deployerAddress);
95
+ let factoryTxid = cached?.factoryTxid;
96
+ if (!onChain) {
97
+ factoryTxid = await this.submitFactoryRegister(factoryAddress, factoryName, pubkeyBuffer, contractAddress, contractName, projectId);
98
+ await waitForTx(network, factoryTxid);
99
+ }
100
+ this.store.save({
101
+ publicKeyHex: normalized,
102
+ contractAddress,
103
+ contractName,
104
+ contractId,
105
+ projectId,
106
+ registerTxid: cached?.registerTxid ?? 'client-registered',
107
+ factoryTxid,
108
+ createdAt: new Date().toISOString(),
109
+ });
110
+ return {
111
+ contractAddress,
112
+ contractName,
113
+ contractId,
114
+ originAddress,
115
+ alreadyRegistered: Boolean(onChain && registered),
116
+ factoryTxid,
117
+ };
118
+ }
119
+ async lookupFactoryAccount(factoryAddress, factoryName, pubkey, sender) {
120
+ const network = getNetwork(this.config.network);
121
+ try {
122
+ const result = await fetchCallReadOnlyFunction({
123
+ contractAddress: factoryAddress,
124
+ contractName: factoryName,
125
+ functionName: 'lookup-account',
126
+ functionArgs: [Cl.buffer(pubkey)],
127
+ network,
128
+ senderAddress: sender,
129
+ });
130
+ const parsed = cvToValue(result);
131
+ if (parsed && typeof parsed === 'object' && parsed.type === 'optional') {
132
+ return parsed.value != null;
133
+ }
134
+ if (typeof parsed === 'string')
135
+ return true;
136
+ if (parsed && typeof parsed === 'object' && 'value' in parsed && parsed.value)
137
+ return true;
138
+ return false;
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ async isContractDeployed(address, name, network) {
145
+ const res = await fetch(`${network.client.baseUrl}/v2/contracts/interface/${address}/${name}`);
146
+ return res.ok;
147
+ }
148
+ async isKeyRegistered(address, name, pubkey, network, sender) {
149
+ try {
150
+ const result = await fetchCallReadOnlyFunction({
151
+ contractAddress: address,
152
+ contractName: name,
153
+ functionName: 'is-key-authorized',
154
+ functionArgs: [Cl.buffer(pubkey)],
155
+ network,
156
+ senderAddress: sender,
157
+ });
158
+ const parsed = cvToValue(result);
159
+ return parsed === true || (typeof parsed === 'object' && parsed?.value === true);
160
+ }
161
+ catch {
162
+ return false;
163
+ }
164
+ }
165
+ readAccountContractSource() {
166
+ const path = process.env.PASSKEY_ACCOUNT_CONTRACT_PATH ?? defaultAccountContractPath();
167
+ return readFileSync(path, 'utf8');
168
+ }
169
+ async submitFactoryRegister(factoryAddress, factoryName, pubkey, accountAddress, accountName, projectId) {
170
+ const network = getNetwork(this.config.network);
171
+ const fee = this.config.policy.maxFeeMicroStx;
172
+ const creds = await this.assertGas(projectId, fee);
173
+ const senderKey = platformRegistrarPrivateKey(this.config);
174
+ const lockAddress = platformRegistrarAddress(this.config);
175
+ const broadcastTask = () => this.broadcast(projectId, fee, async () => makeContractCall({
176
+ contractAddress: factoryAddress,
177
+ contractName: factoryName,
178
+ functionName: 'register-account',
179
+ functionArgs: [Cl.buffer(pubkey), Cl.contractPrincipal(accountAddress, accountName)],
180
+ senderKey,
181
+ network,
182
+ fee,
183
+ }));
184
+ if (creds) {
185
+ return runWithSponsorLock(lockAddress, broadcastTask);
186
+ }
187
+ return broadcastTask();
188
+ }
189
+ async assertGas(walletId, fee) {
190
+ if (!this.gasTank)
191
+ return null;
192
+ const creds = this.gasTank.getSponsorCredentials(walletId);
193
+ if (!creds)
194
+ throw new Error('Wallet not found');
195
+ const onChain = await fetchStxBalanceMicro(creds.sponsorAddress, this.config.network);
196
+ const available = this.gasTank.availableBalance(onChain, creds.wallet);
197
+ if (available < fee) {
198
+ throw new Error(`Insufficient gas tank balance for account operation. Deposit STX to ${creds.sponsorAddress}`);
199
+ }
200
+ this.gasTank.reserveGas(walletId, fee);
201
+ return creds;
202
+ }
203
+ async broadcast(projectId, fee, buildTx) {
204
+ const network = getNetwork(this.config.network);
205
+ let reserved = fee;
206
+ try {
207
+ const txid = await broadcastWithNonceRetry(async () => ({ transaction: await buildTx() }), async (tx) => broadcastTransaction({
208
+ transaction: tx,
209
+ network,
210
+ }));
211
+ if (this.gasTank) {
212
+ this.gasTank.recordSponsor(projectId, fee, txid, 'gasless', undefined, reserved);
213
+ reserved = 0n;
214
+ }
215
+ return txid;
216
+ }
217
+ finally {
218
+ if (reserved > 0n && this.gasTank) {
219
+ this.gasTank.releaseReservation(projectId, reserved);
220
+ }
221
+ }
222
+ }
223
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { AccountService, DEFAULT_SELF_DEPLOY_ACCOUNT_NAME } from './account-service.js';
6
+ import { AccountStore } from './accounts.js';
7
+ describe('AccountService', () => {
8
+ let dir;
9
+ let service;
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), 'account-service-'));
12
+ process.env.PASSKEY_ADAPTER_ADDRESS = 'ST3XHHZ1CXVCNYXK3FQ1FDGJ9NK6YBJBJK3FVY5KQ';
13
+ process.env.PASSKEY_ADAPTER_NAME = 'passkey-adapter';
14
+ const clarPath = join(dir, 'passkey-account.clar');
15
+ writeFileSync(clarPath, `(use-trait exec-trait .passkey-adapter.passkey-exec-trait)
16
+ (contract-call? .passkey-adapter forward-invoke target)
17
+ `);
18
+ process.env.PASSKEY_ACCOUNT_CONTRACT_PATH = clarPath;
19
+ const config = {
20
+ sponsorPrivateKey: '753b7cc01a1855527860d90776314512f5f16cc592133f14327856e06653810e01',
21
+ masterSecret: 'test-master',
22
+ sessionSecret: 'test-session',
23
+ network: 'testnet',
24
+ port: 8787,
25
+ host: '127.0.0.1',
26
+ policy: { maxFeeMicroStx: 50000n, rateLimit: { windowMs: 60_000, maxRequests: 100 } },
27
+ };
28
+ service = new AccountService(config, new AccountStore(join(dir, 'accounts.json')));
29
+ });
30
+ afterEach(() => {
31
+ rmSync(dir, { recursive: true, force: true });
32
+ delete process.env.PASSKEY_ACCOUNT_CONTRACT_PATH;
33
+ });
34
+ it('uses smart-account as default self-deploy contract name', () => {
35
+ expect(DEFAULT_SELF_DEPLOY_ACCOUNT_NAME).toBe('smart-account');
36
+ });
37
+ it('injects fully-qualified passkey-adapter into contract source for user-origin deploys', () => {
38
+ const source = service.buildAccountContractSource();
39
+ expect(source).toContain("(use-trait exec-trait 'ST3XHHZ1CXVCNYXK3FQ1FDGJ9NK6YBJBJK3FVY5KQ.passkey-adapter.passkey-exec-trait)");
40
+ expect(source).toContain("(contract-call? 'ST3XHHZ1CXVCNYXK3FQ1FDGJ9NK6YBJBJK3FVY5KQ.passkey-adapter forward-invoke");
41
+ expect(source).not.toContain('(use-trait exec-trait .passkey-adapter');
42
+ expect(source).not.toContain('(contract-call? .passkey-adapter');
43
+ });
44
+ });
@@ -0,0 +1,20 @@
1
+ export interface AccountRecord {
2
+ publicKeyHex: string;
3
+ contractAddress: string;
4
+ contractName: string;
5
+ contractId: string;
6
+ projectId: string;
7
+ registerTxid?: string;
8
+ factoryTxid?: string;
9
+ createdAt: string;
10
+ }
11
+ export declare function defaultAccountsPath(): string;
12
+ export declare class AccountStore {
13
+ private readonly path;
14
+ constructor(path: string);
15
+ private load;
16
+ private persist;
17
+ find(projectId: string, publicKeyHex: string): AccountRecord | undefined;
18
+ save(record: AccountRecord): void;
19
+ count(projectId: string): number;
20
+ }
@@ -0,0 +1,34 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ export function defaultAccountsPath() {
4
+ return join(process.cwd(), 'data', 'accounts.json');
5
+ }
6
+ export class AccountStore {
7
+ path;
8
+ constructor(path) {
9
+ this.path = path;
10
+ }
11
+ load() {
12
+ if (!existsSync(this.path))
13
+ return { accounts: [] };
14
+ return JSON.parse(readFileSync(this.path, 'utf8'));
15
+ }
16
+ persist(data) {
17
+ mkdirSync(dirname(this.path), { recursive: true });
18
+ writeFileSync(this.path, `${JSON.stringify(data, null, 2)}\n`);
19
+ }
20
+ find(projectId, publicKeyHex) {
21
+ const normalized = publicKeyHex.toLowerCase().replace(/^0x/, '');
22
+ return this.load().accounts.find((a) => a.projectId === projectId && a.publicKeyHex.toLowerCase() === normalized);
23
+ }
24
+ save(record) {
25
+ const data = this.load();
26
+ const normalized = record.publicKeyHex.toLowerCase().replace(/^0x/, '');
27
+ const next = data.accounts.filter((a) => !(a.projectId === record.projectId && a.publicKeyHex.toLowerCase() === normalized));
28
+ next.push({ ...record, publicKeyHex: normalized });
29
+ this.persist({ accounts: next });
30
+ }
31
+ count(projectId) {
32
+ return this.load().accounts.filter((a) => a.projectId === projectId).length;
33
+ }
34
+ }
package/dist/app.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { Hono } from 'hono';
2
+ import type { RelayConfig } from './types.js';
3
+ import { GasTankStore } from './gas-tank.js';
4
+ export declare function createRelayApp(config: RelayConfig, gasTank?: GasTankStore): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;