@rotateprotocol/sdk 1.0.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 (74) hide show
  1. package/README.md +453 -0
  2. package/dist/catalog.d.ts +112 -0
  3. package/dist/catalog.d.ts.map +1 -0
  4. package/dist/catalog.js +210 -0
  5. package/dist/catalog.js.map +1 -0
  6. package/dist/components/CheckoutForm.d.ts +86 -0
  7. package/dist/components/CheckoutForm.d.ts.map +1 -0
  8. package/dist/components/CheckoutForm.js +332 -0
  9. package/dist/components/CheckoutForm.js.map +1 -0
  10. package/dist/components/HostedCheckout.d.ts +57 -0
  11. package/dist/components/HostedCheckout.d.ts.map +1 -0
  12. package/dist/components/HostedCheckout.js +414 -0
  13. package/dist/components/HostedCheckout.js.map +1 -0
  14. package/dist/components/PaymentButton.d.ts +80 -0
  15. package/dist/components/PaymentButton.d.ts.map +1 -0
  16. package/dist/components/PaymentButton.js +210 -0
  17. package/dist/components/PaymentButton.js.map +1 -0
  18. package/dist/components/RotateProvider.d.ts +115 -0
  19. package/dist/components/RotateProvider.d.ts.map +1 -0
  20. package/dist/components/RotateProvider.js +264 -0
  21. package/dist/components/RotateProvider.js.map +1 -0
  22. package/dist/components/index.d.ts +17 -0
  23. package/dist/components/index.d.ts.map +1 -0
  24. package/dist/components/index.js +27 -0
  25. package/dist/components/index.js.map +1 -0
  26. package/dist/embed.d.ts +85 -0
  27. package/dist/embed.d.ts.map +1 -0
  28. package/dist/embed.js +313 -0
  29. package/dist/embed.js.map +1 -0
  30. package/dist/hooks.d.ts +156 -0
  31. package/dist/hooks.d.ts.map +1 -0
  32. package/dist/hooks.js +280 -0
  33. package/dist/hooks.js.map +1 -0
  34. package/dist/idl/rotate_connect.json +2572 -0
  35. package/dist/index.d.ts +505 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +1197 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/marketplace.d.ts +257 -0
  40. package/dist/marketplace.d.ts.map +1 -0
  41. package/dist/marketplace.js +433 -0
  42. package/dist/marketplace.js.map +1 -0
  43. package/dist/platform.d.ts +234 -0
  44. package/dist/platform.d.ts.map +1 -0
  45. package/dist/platform.js +268 -0
  46. package/dist/platform.js.map +1 -0
  47. package/dist/react.d.ts +140 -0
  48. package/dist/react.d.ts.map +1 -0
  49. package/dist/react.js +429 -0
  50. package/dist/react.js.map +1 -0
  51. package/dist/store.d.ts +213 -0
  52. package/dist/store.d.ts.map +1 -0
  53. package/dist/store.js +404 -0
  54. package/dist/store.js.map +1 -0
  55. package/dist/webhooks.d.ts +149 -0
  56. package/dist/webhooks.d.ts.map +1 -0
  57. package/dist/webhooks.js +371 -0
  58. package/dist/webhooks.js.map +1 -0
  59. package/package.json +114 -0
  60. package/src/catalog.ts +299 -0
  61. package/src/components/CheckoutForm.tsx +608 -0
  62. package/src/components/HostedCheckout.tsx +675 -0
  63. package/src/components/PaymentButton.tsx +348 -0
  64. package/src/components/RotateProvider.tsx +370 -0
  65. package/src/components/index.ts +26 -0
  66. package/src/embed.ts +408 -0
  67. package/src/hooks.ts +518 -0
  68. package/src/idl/rotate_connect.json +2572 -0
  69. package/src/index.ts +1538 -0
  70. package/src/marketplace.ts +642 -0
  71. package/src/platform.ts +403 -0
  72. package/src/react.ts +459 -0
  73. package/src/store.ts +577 -0
  74. package/src/webhooks.ts +506 -0
package/src/index.ts ADDED
@@ -0,0 +1,1538 @@
1
+ /**
2
+ * Rotate Protocol SDK
3
+ *
4
+ * Non-custodial P2P payment protocol for Solana.
5
+ * Your keys, your money. We never touch your funds.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import {
11
+ Connection,
12
+ PublicKey,
13
+ TransactionInstruction,
14
+ SystemProgram,
15
+ LAMPORTS_PER_SOL,
16
+ } from '@solana/web3.js';
17
+ import {
18
+ TOKEN_PROGRAM_ID,
19
+ getAssociatedTokenAddress,
20
+ } from '@solana/spl-token';
21
+ import * as anchor from '@coral-xyz/anchor';
22
+ import { BN } from '@coral-xyz/anchor';
23
+
24
+ // Import IDL
25
+ import IDL from './idl/rotate_connect.json';
26
+
27
+ // ==================== CONSTANTS ====================
28
+
29
+ /** Rotate Protocol Program ID */
30
+ export const PROGRAM_ID = new PublicKey('ELBYdNeCGeMThC2ccckw3fyAt77SniV3gPTo1fuFcxDg');
31
+
32
+ /** Protocol fee in basis points (3%) */
33
+ export const PROTOCOL_FEE_BPS = 300;
34
+
35
+ /** Maximum platform fee in basis points (6%) */
36
+ export const MAX_PLATFORM_FEE_BPS = 600;
37
+
38
+ /** Minimum payment in USD (micro-USD, 6 decimals) - $5.00 */
39
+ export const MIN_PAYMENT_USD = 5_000_000;
40
+
41
+ /** Minimum payment in lamports (~$5 at ~$100/SOL) */
42
+ export const MIN_PAYMENT_LAMPORTS = 50_000_000;
43
+
44
+ /** Minimum payment in tokens (USDC/USDT) - $5.00 */
45
+ export const MIN_PAYMENT_TOKENS = 5_000_000;
46
+
47
+ /** Basis points denominator */
48
+ export const BPS = 10000;
49
+
50
+ /** Solana Memo Program ID */
51
+ export const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr');
52
+
53
+ /** 7-digit ID range for platforms and merchants (sequential starting at 1000000) */
54
+ export const MIN_RANDOM_ID = 1000000;
55
+ export const MAX_RANDOM_ID = 9999999;
56
+
57
+ /** PDA Seeds */
58
+ export const SEEDS = {
59
+ PROTOCOL: Buffer.from('protocol'),
60
+ PLATFORM: Buffer.from('platform'),
61
+ MERCHANT: Buffer.from('merchant'),
62
+ LINK: Buffer.from('link'),
63
+ } as const;
64
+
65
+ /** Token mint addresses by network */
66
+ export const TOKEN_MINTS = {
67
+ devnet: {
68
+ USDC: new PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'),
69
+ USDT: new PublicKey('EJwZgeZrdC8TXTQbQBoL6bfuAnFUUy1PVCMB4DYPzVaS'),
70
+ },
71
+ 'mainnet-beta': {
72
+ USDC: new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'),
73
+ USDT: new PublicKey('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'),
74
+ },
75
+ } as const;
76
+
77
+ // ==================== TYPES ====================
78
+
79
+ export type Network = 'devnet' | 'mainnet-beta';
80
+ export type PaymentCurrency = 'SOL' | 'USDC' | 'USDT';
81
+
82
+ export interface RotateConfig {
83
+ /** Solana network */
84
+ network: Network;
85
+ /** Custom RPC endpoint (optional) */
86
+ rpcEndpoint?: string;
87
+ /** Program ID override (optional) */
88
+ programId?: PublicKey;
89
+ /** Custom payment page base URL (default: https://rotate.app) */
90
+ paymentBaseUrl?: string;
91
+ /** Custom price API URL for SOL/USD conversion (default: CoinGecko) */
92
+ priceApiUrl?: string;
93
+ /**
94
+ * Custom QR code API URL template (optional).
95
+ * Use `{url}` as placeholder for the encoded payment URL, and `{size}` for the pixel dimension.
96
+ * Default: `https://api.qrserver.com/v1/create-qr-code/?size={size}x{size}&data={url}`
97
+ */
98
+ qrApiUrl?: string;
99
+ /**
100
+ * Number of retry attempts for link creation when a PDA collision occurs
101
+ * due to concurrent sequential ID assignment. Each retry re-fetches the
102
+ * protocol to get the latest `link_count`. Default: 3.
103
+ */
104
+ linkCreationRetries?: number;
105
+ }
106
+
107
+ export interface Protocol {
108
+ authority: PublicKey;
109
+ treasury: PublicKey;
110
+ usdcMint: PublicKey;
111
+ usdtMint: PublicKey;
112
+ platformCount: number;
113
+ merchantCount: number;
114
+ linkCount: number;
115
+ bump: number;
116
+ }
117
+
118
+ export interface Platform {
119
+ id: number;
120
+ admin: PublicKey;
121
+ wallet: PublicKey;
122
+ feeBps: number;
123
+ active: boolean;
124
+ bump: number;
125
+ }
126
+
127
+ export interface Merchant {
128
+ id: number;
129
+ platformId: number;
130
+ wallet: PublicKey;
131
+ active: boolean;
132
+ bump: number;
133
+ }
134
+
135
+ export interface PaymentLink {
136
+ id: number;
137
+ merchantId: number;
138
+ tokenType: 'Sol' | 'Usdc' | 'Usdt' | 'Usd';
139
+ tokenMint: PublicKey;
140
+ status: 'Pending' | 'PartiallyPaid' | 'Paid' | 'Cancelled';
141
+ allowTips: boolean;
142
+ allowPartial: boolean;
143
+ amount: bigint;
144
+ amountPaid: bigint;
145
+ expiresAt: number;
146
+ orderRef: string;
147
+ bump: number;
148
+ }
149
+
150
+ export interface CreatePlatformParams {
151
+ /** Platform fee in basis points (max 600 = 6%) */
152
+ feeBps: number;
153
+ /** Platform wallet address */
154
+ wallet: PublicKey;
155
+ }
156
+
157
+ export interface CreateMerchantParams {
158
+ /** Platform ID the merchant belongs to */
159
+ platformId: number;
160
+ /** Merchant's wallet address */
161
+ wallet: PublicKey;
162
+ }
163
+
164
+ export interface CreateLinkParams {
165
+ merchantId: number;
166
+ platformId: number;
167
+ /** Amount in lamports (SOL) or micro-units (tokens/USD) */
168
+ amount: bigint;
169
+ /** Expiration timestamp (0 = no expiration) */
170
+ expiresAt?: number;
171
+ allowTips?: boolean;
172
+ allowPartial?: boolean;
173
+ orderRef?: string;
174
+ /** Optional description stored as a memo on-chain (recoverable after cache clear) */
175
+ description?: string;
176
+ }
177
+
178
+ export interface PayLinkParams {
179
+ linkId: number;
180
+ merchantId: number;
181
+ platformId: number;
182
+ amount: bigint;
183
+ tip?: bigint;
184
+ }
185
+
186
+ export interface DirectPayParams {
187
+ merchantId: number;
188
+ platformId: number;
189
+ /** Amount in lamports (SOL) or micro-units (tokens) */
190
+ amount: bigint;
191
+ /** Your order reference ID */
192
+ orderRef?: string;
193
+ }
194
+
195
+ export interface CreateLinkTokenParams extends CreateLinkParams {
196
+ /** Token currency for the link */
197
+ currency: 'USDC' | 'USDT';
198
+ }
199
+
200
+ export interface UpdatePlatformParams {
201
+ platformId: number;
202
+ feeBps: number;
203
+ active: boolean;
204
+ /** New wallet address for fee collection */
205
+ newWallet: PublicKey;
206
+ }
207
+
208
+ export interface UpdateMerchantParams {
209
+ merchantId: number;
210
+ platformId: number;
211
+ active: boolean;
212
+ /** New wallet address */
213
+ newWallet: PublicKey;
214
+ }
215
+
216
+ // ==================== PDA HELPERS ====================
217
+
218
+ export function getProtocolPda(programId: PublicKey = PROGRAM_ID): [PublicKey, number] {
219
+ return PublicKey.findProgramAddressSync([SEEDS.PROTOCOL], programId);
220
+ }
221
+
222
+ export function getPlatformPda(platformId: number, programId: PublicKey = PROGRAM_ID): [PublicKey, number] {
223
+ const idBuffer = Buffer.alloc(4);
224
+ idBuffer.writeUInt32LE(platformId);
225
+ return PublicKey.findProgramAddressSync([SEEDS.PLATFORM, idBuffer], programId);
226
+ }
227
+
228
+ export function getMerchantPda(merchantId: number, programId: PublicKey = PROGRAM_ID): [PublicKey, number] {
229
+ const idBuffer = Buffer.alloc(4);
230
+ idBuffer.writeUInt32LE(merchantId);
231
+ return PublicKey.findProgramAddressSync([SEEDS.MERCHANT, idBuffer], programId);
232
+ }
233
+
234
+ export function getLinkPda(linkId: number, programId: PublicKey = PROGRAM_ID): [PublicKey, number] {
235
+ const idBuffer = Buffer.alloc(4);
236
+ idBuffer.writeUInt32LE(linkId);
237
+ return PublicKey.findProgramAddressSync([SEEDS.LINK, idBuffer], programId);
238
+ }
239
+
240
+ // ==================== ID GENERATION ====================
241
+
242
+ /**
243
+ * Generate a sample 7-digit ID within the valid range.
244
+ * For testing and display only — on-chain IDs are assigned
245
+ * automatically and sequentially by the protocol.
246
+ */
247
+ function generateSampleId(): number {
248
+ return Math.floor(Math.random() * (MAX_RANDOM_ID - MIN_RANDOM_ID + 1)) + MIN_RANDOM_ID;
249
+ }
250
+
251
+ /**
252
+ * Validate that an ID is within the valid 7-digit range
253
+ */
254
+ function isValidId(id: number): boolean {
255
+ return id >= MIN_RANDOM_ID && id <= MAX_RANDOM_ID;
256
+ }
257
+
258
+ // ==================== MEMO HELPERS ====================
259
+
260
+ /** Memo prefix used by Rotate for on-chain descriptions */
261
+ export const MEMO_PREFIX = 'rotate:';
262
+
263
+ /**
264
+ * Create a memo instruction with a Rotate-prefixed description.
265
+ * Used internally by createLink* methods when `description` is provided.
266
+ */
267
+ export function createMemoInstruction(description: string, signer: PublicKey): TransactionInstruction {
268
+ return new TransactionInstruction({
269
+ keys: [{ pubkey: signer, isSigner: true, isWritable: false }],
270
+ programId: MEMO_PROGRAM_ID,
271
+ data: Buffer.from(MEMO_PREFIX + description, 'utf-8'),
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Recover a link description from its creation transaction memo.
277
+ * Returns null if no memo found or if the transaction doesn't have a Rotate memo.
278
+ *
279
+ * @param connection - Solana RPC connection
280
+ * @param linkId - The payment link ID
281
+ * @param programId - Optional custom program ID (defaults to PROGRAM_ID)
282
+ */
283
+ export async function getLinkDescription(
284
+ connection: Connection,
285
+ linkId: number,
286
+ programId: PublicKey = PROGRAM_ID,
287
+ ): Promise<string | null> {
288
+ try {
289
+ const [linkPda] = getLinkPda(linkId, programId);
290
+ const sigs = await connection.getSignaturesForAddress(linkPda, { limit: 5 });
291
+ if (sigs.length === 0) return null;
292
+
293
+ // Oldest signature is the creation tx
294
+ const creationSig = sigs[sigs.length - 1].signature;
295
+ const txData = await connection.getTransaction(creationSig, { maxSupportedTransactionVersion: 0 });
296
+ if (!txData?.transaction?.message) return null;
297
+
298
+ const msg = txData.transaction.message;
299
+ const accountKeys = (msg as any).staticAccountKeys || (msg as any).accountKeys || [];
300
+ const ixs = (msg as any).compiledInstructions || (msg as any).instructions || [];
301
+
302
+ for (const ix of ixs) {
303
+ const progKey = accountKeys[ix.programIdIndex];
304
+ if (progKey && progKey.toString() === MEMO_PROGRAM_ID.toString()) {
305
+ const memoBytes = ix.data instanceof Uint8Array
306
+ ? ix.data
307
+ : typeof ix.data === 'string'
308
+ ? Buffer.from(ix.data, 'base64')
309
+ : ix.data;
310
+ const memoText = new TextDecoder().decode(memoBytes);
311
+ if (memoText.startsWith(MEMO_PREFIX)) {
312
+ return memoText.slice(MEMO_PREFIX.length);
313
+ }
314
+ }
315
+ }
316
+ } catch {
317
+ // Memo recovery is best-effort
318
+ }
319
+ return null;
320
+ }
321
+
322
+ // ==================== FEE CALCULATION ====================
323
+
324
+ /**
325
+ * Calculate fees for a payment.
326
+ * Fees are split 50/50 between buyer and seller.
327
+ *
328
+ * **Rounding note:** When `totalFees` is odd, integer division means the
329
+ * seller share rounds down and the buyer pays one extra micro-unit.
330
+ * At worst this is a fraction of a cent and matches standard financial
331
+ * rounding conventions.
332
+ *
333
+ * @param amount - Payment amount in smallest unit (lamports or micro-USD/tokens).
334
+ * @param platformFeeBps - Platform fee in basis points (0-600).
335
+ */
336
+ export function calculateFees(amount: number, platformFeeBps: number) {
337
+ const protocolFee = Math.floor((amount * PROTOCOL_FEE_BPS) / BPS);
338
+ const platformFee = Math.floor((amount * platformFeeBps) / BPS);
339
+ const totalFees = protocolFee + platformFee;
340
+
341
+ // 50/50 split — seller share rounds down; buyer absorbs the extra micro-unit on odd totals
342
+ const sellerFeeShare = Math.floor(totalFees / 2);
343
+ const buyerFeeShare = totalFees - sellerFeeShare;
344
+
345
+ return {
346
+ amount,
347
+ protocolFee,
348
+ platformFee,
349
+ totalFees,
350
+ buyerFeeShare,
351
+ sellerFeeShare,
352
+ buyerPays: amount + buyerFeeShare,
353
+ merchantReceives: amount - sellerFeeShare,
354
+ };
355
+ }
356
+
357
+ // ==================== MAIN SDK CLASS ====================
358
+
359
+ export class RotateSDK {
360
+ private connection: Connection;
361
+ private programId: PublicKey;
362
+ private network: Network;
363
+ // Typed as `any` because the IDL is imported as JSON — Anchor can't infer
364
+ // account types without generated Program<RotateConnect> bindings.
365
+ // Generate types with `anchor idl type` for full type safety.
366
+ private program: any = null;
367
+ private paymentBaseUrl: string;
368
+ private priceApiUrl: string;
369
+ private qrApiUrl: string;
370
+ private linkCreationRetries: number;
371
+
372
+ constructor(config: RotateConfig) {
373
+ this.network = config.network;
374
+ this.programId = config.programId || PROGRAM_ID;
375
+ this.paymentBaseUrl = config.paymentBaseUrl || 'https://rotate.app';
376
+ this.priceApiUrl = config.priceApiUrl || 'https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd';
377
+ this.qrApiUrl = config.qrApiUrl || 'https://api.qrserver.com/v1/create-qr-code/?size={size}x{size}&data={url}';
378
+ this.linkCreationRetries = config.linkCreationRetries ?? 3;
379
+
380
+ const endpoint = config.rpcEndpoint ||
381
+ (config.network === 'mainnet-beta'
382
+ ? 'https://api.mainnet-beta.solana.com'
383
+ : 'https://api.devnet.solana.com');
384
+
385
+ this.connection = new Connection(endpoint, 'confirmed');
386
+ }
387
+
388
+ /**
389
+ * Initialize with a wallet (required for transactions)
390
+ */
391
+ initWithWallet(wallet: anchor.Wallet): void {
392
+ const provider = new anchor.AnchorProvider(
393
+ this.connection,
394
+ wallet,
395
+ { commitment: 'confirmed' }
396
+ );
397
+ this.program = new anchor.Program(IDL as any, provider);
398
+ }
399
+
400
+ /**
401
+ * Get the Anchor program instance.
402
+ * Throws if `initWithWallet()` has not been called.
403
+ *
404
+ * Returns `any` because the IDL is loaded from JSON at runtime.
405
+ * For full type safety, generate types with `anchor idl type` and
406
+ * cast the return value to `Program<RotateConnect>`.
407
+ */
408
+ getProgram(): any {
409
+ if (!this.program) {
410
+ throw new Error('SDK not initialized with wallet. Call initWithWallet() first.');
411
+ }
412
+ return this.program;
413
+ }
414
+
415
+ /**
416
+ * Get connection
417
+ */
418
+ getConnection(): Connection {
419
+ return this.connection;
420
+ }
421
+
422
+ // ==================== READ METHODS ====================
423
+
424
+ /**
425
+ * Get protocol data
426
+ */
427
+ async getProtocol(): Promise<Protocol | null> {
428
+ try {
429
+ const [pda] = getProtocolPda(this.programId);
430
+ const account = await this.connection.getAccountInfo(pda);
431
+ if (!account) return null;
432
+
433
+ return this.decodeProtocol(account.data);
434
+ } catch {
435
+ return null;
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Get platform data
441
+ */
442
+ async getPlatform(platformId: number): Promise<Platform | null> {
443
+ try {
444
+ const [pda] = getPlatformPda(platformId, this.programId);
445
+ const account = await this.connection.getAccountInfo(pda);
446
+ if (!account) return null;
447
+
448
+ return this.decodePlatform(account.data);
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Get merchant data
456
+ */
457
+ async getMerchant(merchantId: number): Promise<Merchant | null> {
458
+ try {
459
+ const [pda] = getMerchantPda(merchantId, this.programId);
460
+ const account = await this.connection.getAccountInfo(pda);
461
+ if (!account) return null;
462
+
463
+ return this.decodeMerchant(account.data);
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Get payment link data
471
+ */
472
+ async getPaymentLink(linkId: number): Promise<PaymentLink | null> {
473
+ try {
474
+ const [pda] = getLinkPda(linkId, this.programId);
475
+ const account = await this.connection.getAccountInfo(pda);
476
+ if (!account) return null;
477
+
478
+ return this.decodePaymentLink(account.data);
479
+ } catch {
480
+ return null;
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Check if a payment link is paid
486
+ */
487
+ async isLinkPaid(linkId: number): Promise<boolean> {
488
+ const link = await this.getPaymentLink(linkId);
489
+ return link?.status === 'Paid';
490
+ }
491
+
492
+ /**
493
+ * Poll for payment status
494
+ */
495
+ async waitForPayment(linkId: number, timeoutMs: number = 300000): Promise<PaymentLink | null> {
496
+ const startTime = Date.now();
497
+
498
+ while (Date.now() - startTime < timeoutMs) {
499
+ const link = await this.getPaymentLink(linkId);
500
+ if (link?.status === 'Paid') {
501
+ return link;
502
+ }
503
+
504
+ await new Promise(resolve => setTimeout(resolve, 2000));
505
+ }
506
+
507
+ return null;
508
+ }
509
+
510
+ // ==================== WRITE METHODS ====================
511
+
512
+ /**
513
+ * Internal helper: retry a link-creation callback when a PDA collision
514
+ * occurs (e.g. two concurrent `createLink*` calls compute the same
515
+ * `nextLinkId`). On each retry the protocol is re-fetched to pick up
516
+ * the incremented `link_count`.
517
+ */
518
+ private async _retryLinkCreation<T>(
519
+ fn: (nextLinkId: number) => Promise<T>,
520
+ ): Promise<T> {
521
+ let lastError: any;
522
+ for (let attempt = 0; attempt <= this.linkCreationRetries; attempt++) {
523
+ try {
524
+ const protocol = await this.getProtocol();
525
+ if (!protocol) throw new Error('Protocol not initialized');
526
+ const nextLinkId = protocol.linkCount + 1;
527
+ return await fn(nextLinkId);
528
+ } catch (err: any) {
529
+ lastError = err;
530
+ if (!RotateSDK._isPdaCollision(err) || attempt === this.linkCreationRetries) throw err;
531
+ // Exponential back-off before retry to let the previous tx confirm
532
+ await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
533
+ }
534
+ }
535
+ throw lastError;
536
+ }
537
+
538
+ /**
539
+ * Detect whether an error is a PDA-collision ("account already in use").
540
+ *
541
+ * Checks multiple signals so the heuristic survives Anchor / runtime
542
+ * message changes:
543
+ * 1. Error message substrings (Anchor & system program phrasing).
544
+ * 2. Anchor structured error codes (`err.error.errorCode`).
545
+ * 3. Transaction logs emitted by the runtime.
546
+ *
547
+ * @internal
548
+ */
549
+ private static _isPdaCollision(err: any): boolean {
550
+ // 1. Message-based detection (covers most Anchor versions)
551
+ const msg: string = (err?.message || '') + ' ' + (err?.error?.errorMessage || '');
552
+ if (
553
+ msg.includes('already in use') ||
554
+ msg.includes('AccountAlreadyExists') ||
555
+ // 0x0 is the system-program error code for "account already exists"
556
+ // when surfaced through Anchor's error wrapper
557
+ msg.includes('0x0')
558
+ ) {
559
+ return true;
560
+ }
561
+
562
+ // 2. Anchor structured error code (numeric)
563
+ // System program "AccountAlreadyExists" is code 0 in Anchor's mapping.
564
+ const code = err?.error?.errorCode?.number ?? err?.code;
565
+ if (code === 0) return true;
566
+
567
+ // 3. Transaction log inspection (runtime logs the system-program error)
568
+ const logs: string[] | undefined = err?.logs ?? err?.error?.logs;
569
+ if (Array.isArray(logs) && logs.some((l: string) => l.includes('already in use'))) {
570
+ return true;
571
+ }
572
+
573
+ return false;
574
+ }
575
+
576
+ /**
577
+ * Create a new platform with a random 7-digit ID
578
+ * @returns Transaction signature and the generated platform ID
579
+ */
580
+ async createPlatform(params: CreatePlatformParams): Promise<{ tx: string; platformId: number }> {
581
+ const program = this.getProgram();
582
+ const protocol = await this.getProtocol();
583
+ if (!protocol) throw new Error('Protocol not initialized');
584
+
585
+ // 7-digit sequential ID: 1000000 + platform_count
586
+ const platformId = MIN_RANDOM_ID + protocol.platformCount;
587
+
588
+ const [platformPda] = getPlatformPda(platformId, this.programId);
589
+ const [protocolPda] = getProtocolPda(this.programId);
590
+
591
+ const tx = await program.methods
592
+ .createPlatform(params.feeBps)
593
+ .accounts({
594
+ protocol: protocolPda,
595
+ platform: platformPda,
596
+ wallet: params.wallet,
597
+ admin: program.provider.publicKey,
598
+ systemProgram: SystemProgram.programId,
599
+ })
600
+ .rpc();
601
+
602
+ return { tx, platformId };
603
+ }
604
+
605
+ /**
606
+ * Create a new merchant with a random 7-digit ID
607
+ * @returns Transaction signature and the generated merchant ID
608
+ */
609
+ async createMerchant(params: CreateMerchantParams): Promise<{ tx: string; merchantId: number }> {
610
+ const program = this.getProgram();
611
+ const protocol = await this.getProtocol();
612
+ if (!protocol) throw new Error('Protocol not initialized');
613
+
614
+ // 7-digit sequential ID: 1000000 + merchant_count
615
+ const merchantId = MIN_RANDOM_ID + protocol.merchantCount;
616
+
617
+ const [merchantPda] = getMerchantPda(merchantId, this.programId);
618
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
619
+ const [protocolPda] = getProtocolPda(this.programId);
620
+
621
+ const tx = await program.methods
622
+ .createMerchant()
623
+ .accounts({
624
+ protocol: protocolPda,
625
+ platform: platformPda,
626
+ merchant: merchantPda,
627
+ wallet: params.wallet,
628
+ payer: program.provider.publicKey,
629
+ systemProgram: SystemProgram.programId,
630
+ })
631
+ .rpc();
632
+
633
+ return { tx, merchantId };
634
+ }
635
+
636
+ /**
637
+ * Create a USD-denominated payment link.
638
+ *
639
+ * Automatically retries on PDA collision (concurrent link creation).
640
+ */
641
+ async createLinkUsd(params: CreateLinkParams): Promise<{ tx: string; linkId: number }> {
642
+ return this._retryLinkCreation(async (nextLinkId) => {
643
+ const program = this.getProgram();
644
+ const [linkPda] = getLinkPda(nextLinkId, this.programId);
645
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
646
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
647
+ const [protocolPda] = getProtocolPda(this.programId);
648
+
649
+ const builder = program.methods
650
+ .createLinkUsd(
651
+ new BN(params.amount.toString()),
652
+ new BN(params.expiresAt || 0),
653
+ params.allowTips ?? false,
654
+ params.allowPartial ?? false,
655
+ new BN(params.orderRef || Date.now().toString())
656
+ )
657
+ .accounts({
658
+ protocol: protocolPda,
659
+ platform: platformPda,
660
+ merchant: merchantPda,
661
+ link: linkPda,
662
+ creator: program.provider.publicKey,
663
+ systemProgram: SystemProgram.programId,
664
+ });
665
+
666
+ // Attach description as on-chain memo (recoverable after cache clear)
667
+ if (params.description) {
668
+ builder.postInstructions([createMemoInstruction(params.description, program.provider.publicKey!)]);
669
+ }
670
+
671
+ const tx = await builder.rpc();
672
+ return { tx, linkId: nextLinkId };
673
+ });
674
+ }
675
+
676
+ /**
677
+ * Create a SOL payment link.
678
+ *
679
+ * Automatically retries on PDA collision (concurrent link creation).
680
+ */
681
+ async createLinkSol(params: CreateLinkParams): Promise<{ tx: string; linkId: number }> {
682
+ return this._retryLinkCreation(async (nextLinkId) => {
683
+ const program = this.getProgram();
684
+ const [linkPda] = getLinkPda(nextLinkId, this.programId);
685
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
686
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
687
+ const [protocolPda] = getProtocolPda(this.programId);
688
+
689
+ const builder = program.methods
690
+ .createLinkSol(
691
+ new BN(params.amount.toString()),
692
+ new BN(params.expiresAt || 0),
693
+ params.allowTips ?? false,
694
+ params.allowPartial ?? false,
695
+ new BN(params.orderRef || Date.now().toString())
696
+ )
697
+ .accounts({
698
+ protocol: protocolPda,
699
+ platform: platformPda,
700
+ merchant: merchantPda,
701
+ link: linkPda,
702
+ creator: program.provider.publicKey,
703
+ systemProgram: SystemProgram.programId,
704
+ });
705
+
706
+ if (params.description) {
707
+ builder.postInstructions([createMemoInstruction(params.description, program.provider.publicKey!)]);
708
+ }
709
+
710
+ const tx = await builder.rpc();
711
+ return { tx, linkId: nextLinkId };
712
+ });
713
+ }
714
+
715
+ /**
716
+ * Pay a SOL link
717
+ */
718
+ async payLinkSol(params: PayLinkParams): Promise<string> {
719
+ const program = this.getProgram();
720
+ const protocol = await this.getProtocol();
721
+ if (!protocol) throw new Error('Protocol not initialized');
722
+
723
+ const merchant = await this.getMerchant(params.merchantId);
724
+ if (!merchant) throw new Error('Merchant not found');
725
+
726
+ const platform = await this.getPlatform(params.platformId);
727
+ if (!platform) throw new Error('Platform not found');
728
+
729
+ const [linkPda] = getLinkPda(params.linkId, this.programId);
730
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
731
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
732
+ const [protocolPda] = getProtocolPda(this.programId);
733
+
734
+ const tx = await program.methods
735
+ .payLinkSol(
736
+ new BN(params.amount.toString()),
737
+ new BN((params.tip || 0n).toString())
738
+ )
739
+ .accounts({
740
+ protocol: protocolPda,
741
+ platform: platformPda,
742
+ merchant: merchantPda,
743
+ link: linkPda,
744
+ merchantWallet: merchant.wallet,
745
+ platformWallet: platform.wallet,
746
+ treasury: protocol.treasury,
747
+ payer: program.provider.publicKey,
748
+ systemProgram: SystemProgram.programId,
749
+ })
750
+ .rpc();
751
+
752
+ return tx;
753
+ }
754
+
755
+ /**
756
+ * Pay a USD link with SOL
757
+ *
758
+ * Includes on-chain price oracle slippage protection. If `expectedLamports` is provided,
759
+ * the contract validates that `lamportsAmount` is within 5% of the expected value.
760
+ * Pass 0n or omit to skip on-chain slippage validation (backwards-compatible).
761
+ */
762
+ async payLinkUsdSol(
763
+ params: PayLinkParams & { lamportsAmount: bigint; tipUsd?: bigint; tipLamports?: bigint; expectedLamports?: bigint }
764
+ ): Promise<string> {
765
+ const program = this.getProgram();
766
+ const protocol = await this.getProtocol();
767
+ if (!protocol) throw new Error('Protocol not initialized');
768
+
769
+ const merchant = await this.getMerchant(params.merchantId);
770
+ if (!merchant) throw new Error('Merchant not found');
771
+
772
+ const platform = await this.getPlatform(params.platformId);
773
+ if (!platform) throw new Error('Platform not found');
774
+
775
+ // If no expectedLamports provided, auto-fetch from oracle for slippage protection.
776
+ // If the price API is unavailable, we throw rather than silently skipping the
777
+ // on-chain slippage check — paying without price validation risks loss of funds.
778
+ let expectedLamports = params.expectedLamports || 0n;
779
+ if (expectedLamports === 0n) {
780
+ expectedLamports = await this.microUsdToLamports(params.amount);
781
+ }
782
+
783
+ const [linkPda] = getLinkPda(params.linkId, this.programId);
784
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
785
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
786
+ const [protocolPda] = getProtocolPda(this.programId);
787
+
788
+ const tx = await program.methods
789
+ .payLinkUsdSol(
790
+ new BN(params.amount.toString()), // usd_amount
791
+ new BN((params.tipUsd || 0n).toString()), // tip_usd
792
+ new BN(params.lamportsAmount.toString()), // lamports_amount
793
+ new BN((params.tipLamports || 0n).toString()), // tip_lamports
794
+ new BN(expectedLamports.toString()) // expected_lamports (oracle slippage check)
795
+ )
796
+ .accounts({
797
+ protocol: protocolPda,
798
+ platform: platformPda,
799
+ merchant: merchantPda,
800
+ link: linkPda,
801
+ merchantWallet: merchant.wallet,
802
+ platformWallet: platform.wallet,
803
+ treasury: protocol.treasury,
804
+ payer: program.provider.publicKey,
805
+ systemProgram: SystemProgram.programId,
806
+ })
807
+ .rpc();
808
+
809
+ return tx;
810
+ }
811
+
812
+ /**
813
+ * Pay a USD link with token (USDC/USDT)
814
+ */
815
+ async payLinkUsdToken(
816
+ params: PayLinkParams & { tipUsd?: bigint; currency: 'USDC' | 'USDT' }
817
+ ): Promise<string> {
818
+ const program = this.getProgram();
819
+ const protocol = await this.getProtocol();
820
+ if (!protocol) throw new Error('Protocol not initialized');
821
+
822
+ const merchant = await this.getMerchant(params.merchantId);
823
+ if (!merchant) throw new Error('Merchant not found');
824
+
825
+ const platform = await this.getPlatform(params.platformId);
826
+ if (!platform) throw new Error('Platform not found');
827
+
828
+ const mint = this.getTokenMint(params.currency);
829
+
830
+ const payerAta = await getAssociatedTokenAddress(mint, program.provider.publicKey);
831
+ const merchantAta = await getAssociatedTokenAddress(mint, merchant.wallet);
832
+ const platformAta = await getAssociatedTokenAddress(mint, platform.wallet);
833
+ const treasuryAta = await getAssociatedTokenAddress(mint, protocol.treasury);
834
+
835
+ const [linkPda] = getLinkPda(params.linkId, this.programId);
836
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
837
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
838
+ const [protocolPda] = getProtocolPda(this.programId);
839
+
840
+ const tx = await program.methods
841
+ .payLinkUsdToken(
842
+ new BN(params.amount.toString()), // usd_amount
843
+ new BN((params.tipUsd || 0n).toString()) // tip_usd
844
+ )
845
+ .accounts({
846
+ protocol: protocolPda,
847
+ platform: platformPda,
848
+ merchant: merchantPda,
849
+ link: linkPda,
850
+ payerTokenAccount: payerAta,
851
+ merchantTokenAccount: merchantAta,
852
+ platformTokenAccount: platformAta,
853
+ treasuryTokenAccount: treasuryAta,
854
+ payer: program.provider.publicKey,
855
+ tokenProgram: TOKEN_PROGRAM_ID,
856
+ })
857
+ .rpc();
858
+
859
+ return tx;
860
+ }
861
+
862
+ /**
863
+ * Cancel a payment link
864
+ */
865
+ async cancelLink(linkId: number, merchantId: number): Promise<string> {
866
+ const program = this.getProgram();
867
+
868
+ const merchant = await this.getMerchant(merchantId);
869
+ if (!merchant) throw new Error('Merchant not found');
870
+
871
+ const [linkPda] = getLinkPda(linkId, this.programId);
872
+ const [merchantPda] = getMerchantPda(merchantId, this.programId);
873
+ const [platformPda] = getPlatformPda(merchant.platformId, this.programId);
874
+
875
+ const tx = await program.methods
876
+ .cancelLink()
877
+ .accounts({
878
+ merchant: merchantPda,
879
+ platform: platformPda,
880
+ link: linkPda,
881
+ authority: program.provider.publicKey,
882
+ })
883
+ .rpc();
884
+
885
+ return tx;
886
+ }
887
+
888
+ // ==================== BATCH READ METHODS ====================
889
+
890
+ /**
891
+ * Fetch all merchant accounts belonging to a specific platform using
892
+ * `getProgramAccounts` with a `memcmp` filter.
893
+ *
894
+ * This is **significantly** faster than iterating through every merchant
895
+ * ID sequentially, especially for platforms with many merchants.
896
+ *
897
+ * The filter matches the `platform_id` field at byte offset 12 inside
898
+ * the Merchant account data (8-byte discriminator + 4-byte `id`).
899
+ *
900
+ * @param platformId - The platform ID to filter by.
901
+ * @param activeOnly - If true, only return active merchants (default: false).
902
+ */
903
+ async getMerchantsByPlatform(platformId: number, activeOnly: boolean = false): Promise<Merchant[]> {
904
+ const platformIdBuf = Buffer.alloc(4);
905
+ platformIdBuf.writeUInt32LE(platformId);
906
+
907
+ const accounts = await this.connection.getProgramAccounts(this.programId, {
908
+ filters: [
909
+ { memcmp: { offset: 0, bytes: RotateSDK.DISCRIMINATORS.Merchant.toString('base64') } },
910
+ { memcmp: { offset: 12, bytes: platformIdBuf.toString('base64') } },
911
+ ],
912
+ });
913
+
914
+ const merchants: Merchant[] = [];
915
+ for (const { account } of accounts) {
916
+ try {
917
+ const merchant = this.decodeMerchant(account.data as Buffer);
918
+ if (activeOnly && !merchant.active) continue;
919
+ merchants.push(merchant);
920
+ } catch {
921
+ // Skip accounts that fail to decode
922
+ }
923
+ }
924
+
925
+ return merchants;
926
+ }
927
+
928
+ // ==================== PROTOCOL ADMIN ====================
929
+
930
+ /**
931
+ * Update the protocol treasury address (authority only).
932
+ * Only the original protocol authority can call this.
933
+ */
934
+ async updateProtocol(newTreasury: PublicKey): Promise<string> {
935
+ const program = this.getProgram();
936
+ const [protocolPda] = getProtocolPda(this.programId);
937
+
938
+ const tx = await program.methods
939
+ .updateProtocol()
940
+ .accounts({
941
+ protocol: protocolPda,
942
+ newTreasury,
943
+ authority: program.provider.publicKey,
944
+ })
945
+ .rpc();
946
+
947
+ return tx;
948
+ }
949
+
950
+ // ==================== RENT RECLAMATION ====================
951
+
952
+ /**
953
+ * Close a completed/cancelled link and reclaim rent SOL back to the merchant wallet.
954
+ * Only works on links with status Paid or Cancelled.
955
+ */
956
+ async closeLink(linkId: number, merchantId: number): Promise<string> {
957
+ const program = this.getProgram();
958
+
959
+ const [linkPda] = getLinkPda(linkId, this.programId);
960
+ const [merchantPda] = getMerchantPda(merchantId, this.programId);
961
+
962
+ const tx = await program.methods
963
+ .closeLink()
964
+ .accounts({
965
+ merchant: merchantPda,
966
+ link: linkPda,
967
+ authority: program.provider.publicKey,
968
+ })
969
+ .rpc();
970
+
971
+ return tx;
972
+ }
973
+
974
+ // ==================== TOKEN LINK METHODS ====================
975
+
976
+ /**
977
+ * Create a token (USDC/USDT) payment link.
978
+ *
979
+ * Automatically retries on PDA collision (concurrent link creation).
980
+ */
981
+ async createLinkToken(params: CreateLinkTokenParams): Promise<{ tx: string; linkId: number }> {
982
+ return this._retryLinkCreation(async (nextLinkId) => {
983
+ const program = this.getProgram();
984
+ const mint = this.getTokenMint(params.currency);
985
+ const [linkPda] = getLinkPda(nextLinkId, this.programId);
986
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
987
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
988
+ const [protocolPda] = getProtocolPda(this.programId);
989
+
990
+ const builder = program.methods
991
+ .createLinkToken(
992
+ new BN(params.amount.toString()),
993
+ new BN(params.expiresAt || 0),
994
+ params.allowTips ?? false,
995
+ params.allowPartial ?? false,
996
+ new BN(params.orderRef || Date.now().toString())
997
+ )
998
+ .accounts({
999
+ protocol: protocolPda,
1000
+ platform: platformPda,
1001
+ merchant: merchantPda,
1002
+ link: linkPda,
1003
+ tokenMint: mint,
1004
+ creator: program.provider.publicKey,
1005
+ systemProgram: SystemProgram.programId,
1006
+ });
1007
+
1008
+ if (params.description) {
1009
+ builder.postInstructions([createMemoInstruction(params.description, program.provider.publicKey!)]);
1010
+ }
1011
+
1012
+ const tx = await builder.rpc();
1013
+ return { tx, linkId: nextLinkId };
1014
+ });
1015
+ }
1016
+
1017
+ /**
1018
+ * Pay a token (USDC/USDT) link
1019
+ */
1020
+ async payLinkToken(params: PayLinkParams & { currency: 'USDC' | 'USDT' }): Promise<string> {
1021
+ const program = this.getProgram();
1022
+ const protocol = await this.getProtocol();
1023
+ if (!protocol) throw new Error('Protocol not initialized');
1024
+
1025
+ const merchant = await this.getMerchant(params.merchantId);
1026
+ if (!merchant) throw new Error('Merchant not found');
1027
+
1028
+ const platform = await this.getPlatform(params.platformId);
1029
+ if (!platform) throw new Error('Platform not found');
1030
+
1031
+ const mint = this.getTokenMint(params.currency);
1032
+
1033
+ const payerAta = await getAssociatedTokenAddress(mint, program.provider.publicKey);
1034
+ const merchantAta = await getAssociatedTokenAddress(mint, merchant.wallet);
1035
+ const platformAta = await getAssociatedTokenAddress(mint, platform.wallet);
1036
+ const treasuryAta = await getAssociatedTokenAddress(mint, protocol.treasury);
1037
+
1038
+ const [linkPda] = getLinkPda(params.linkId, this.programId);
1039
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
1040
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
1041
+ const [protocolPda] = getProtocolPda(this.programId);
1042
+
1043
+ const tx = await program.methods
1044
+ .payLinkToken(
1045
+ new BN(params.amount.toString()),
1046
+ new BN((params.tip || 0n).toString())
1047
+ )
1048
+ .accounts({
1049
+ protocol: protocolPda,
1050
+ platform: platformPda,
1051
+ merchant: merchantPda,
1052
+ link: linkPda,
1053
+ payerTokenAccount: payerAta,
1054
+ merchantTokenAccount: merchantAta,
1055
+ platformTokenAccount: platformAta,
1056
+ treasuryTokenAccount: treasuryAta,
1057
+ payer: program.provider.publicKey,
1058
+ tokenProgram: TOKEN_PROGRAM_ID,
1059
+ })
1060
+ .rpc();
1061
+
1062
+ return tx;
1063
+ }
1064
+
1065
+ // ==================== DIRECT PAYMENT METHODS ====================
1066
+
1067
+ /**
1068
+ * Pay with SOL directly (no payment link)
1069
+ */
1070
+ async paySol(params: DirectPayParams): Promise<string> {
1071
+ const program = this.getProgram();
1072
+ const protocol = await this.getProtocol();
1073
+ if (!protocol) throw new Error('Protocol not initialized');
1074
+
1075
+ const merchant = await this.getMerchant(params.merchantId);
1076
+ if (!merchant) throw new Error('Merchant not found');
1077
+
1078
+ const platform = await this.getPlatform(params.platformId);
1079
+ if (!platform) throw new Error('Platform not found');
1080
+
1081
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
1082
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
1083
+ const [protocolPda] = getProtocolPda(this.programId);
1084
+
1085
+ const tx = await program.methods
1086
+ .paySol(
1087
+ new BN(params.amount.toString()),
1088
+ new BN(params.orderRef || Date.now().toString())
1089
+ )
1090
+ .accounts({
1091
+ protocol: protocolPda,
1092
+ platform: platformPda,
1093
+ merchant: merchantPda,
1094
+ merchantWallet: merchant.wallet,
1095
+ platformWallet: platform.wallet,
1096
+ treasury: protocol.treasury,
1097
+ payer: program.provider.publicKey,
1098
+ systemProgram: SystemProgram.programId,
1099
+ })
1100
+ .rpc();
1101
+
1102
+ return tx;
1103
+ }
1104
+
1105
+ /**
1106
+ * Pay with token (USDC/USDT) directly (no payment link)
1107
+ */
1108
+ async payToken(params: DirectPayParams & { currency: 'USDC' | 'USDT' }): Promise<string> {
1109
+ const program = this.getProgram();
1110
+ const protocol = await this.getProtocol();
1111
+ if (!protocol) throw new Error('Protocol not initialized');
1112
+
1113
+ const merchant = await this.getMerchant(params.merchantId);
1114
+ if (!merchant) throw new Error('Merchant not found');
1115
+
1116
+ const platform = await this.getPlatform(params.platformId);
1117
+ if (!platform) throw new Error('Platform not found');
1118
+
1119
+ const mint = this.getTokenMint(params.currency);
1120
+
1121
+ const payerAta = await getAssociatedTokenAddress(mint, program.provider.publicKey);
1122
+ const merchantAta = await getAssociatedTokenAddress(mint, merchant.wallet);
1123
+ const platformAta = await getAssociatedTokenAddress(mint, platform.wallet);
1124
+ const treasuryAta = await getAssociatedTokenAddress(mint, protocol.treasury);
1125
+
1126
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
1127
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
1128
+ const [protocolPda] = getProtocolPda(this.programId);
1129
+
1130
+ const tx = await program.methods
1131
+ .payToken(
1132
+ new BN(params.amount.toString()),
1133
+ new BN(params.orderRef || Date.now().toString())
1134
+ )
1135
+ .accounts({
1136
+ protocol: protocolPda,
1137
+ platform: platformPda,
1138
+ merchant: merchantPda,
1139
+ payerTokenAccount: payerAta,
1140
+ merchantTokenAccount: merchantAta,
1141
+ platformTokenAccount: platformAta,
1142
+ treasuryTokenAccount: treasuryAta,
1143
+ payer: program.provider.publicKey,
1144
+ tokenProgram: TOKEN_PROGRAM_ID,
1145
+ })
1146
+ .rpc();
1147
+
1148
+ return tx;
1149
+ }
1150
+
1151
+ // ==================== UPDATE METHODS ====================
1152
+
1153
+ /**
1154
+ * Update platform settings (admin only)
1155
+ */
1156
+ async updatePlatform(params: UpdatePlatformParams): Promise<string> {
1157
+ const program = this.getProgram();
1158
+
1159
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
1160
+
1161
+ const tx = await program.methods
1162
+ .updatePlatform(params.feeBps, params.active)
1163
+ .accounts({
1164
+ platform: platformPda,
1165
+ newWallet: params.newWallet,
1166
+ admin: program.provider.publicKey,
1167
+ })
1168
+ .rpc();
1169
+
1170
+ return tx;
1171
+ }
1172
+
1173
+ /**
1174
+ * Update merchant settings (wallet owner only)
1175
+ */
1176
+ async updateMerchant(params: UpdateMerchantParams): Promise<string> {
1177
+ const program = this.getProgram();
1178
+
1179
+ const [merchantPda] = getMerchantPda(params.merchantId, this.programId);
1180
+ const [platformPda] = getPlatformPda(params.platformId, this.programId);
1181
+
1182
+ const tx = await program.methods
1183
+ .updateMerchant(params.active)
1184
+ .accounts({
1185
+ platform: platformPda,
1186
+ merchant: merchantPda,
1187
+ newWallet: params.newWallet,
1188
+ authority: program.provider.publicKey,
1189
+ })
1190
+ .rpc();
1191
+
1192
+ return tx;
1193
+ }
1194
+
1195
+ // ==================== PAYMENT URL GENERATION ====================
1196
+
1197
+ /**
1198
+ * Generate payment URL for a link.
1199
+ *
1200
+ * URL format: `{base}/checkout/?link={id}&network={network}`
1201
+ *
1202
+ * The checkout page reads query parameters to load payment data from on-chain.
1203
+ * Merchant and platform IDs are resolved from the on-chain link automatically,
1204
+ * but can be provided explicitly for faster loading.
1205
+ *
1206
+ * @param linkId - The payment link ID.
1207
+ * @param options - Optional overrides for base URL, merchant/platform IDs, and brand.
1208
+ */
1209
+ getPaymentUrl(linkId: number, baseUrl?: string): string {
1210
+ const url = baseUrl || this.paymentBaseUrl;
1211
+ const params = new URLSearchParams({ link: String(linkId), network: this.network });
1212
+ return `${url}/checkout/?${params.toString()}`;
1213
+ }
1214
+
1215
+ /**
1216
+ * Generate QR code URL for a payment link.
1217
+ * Uses the configurable `qrApiUrl` template (default: api.qrserver.com).
1218
+ */
1219
+ getQRCodeUrl(linkId: number, baseUrl?: string, size: number = 300): string {
1220
+ const paymentUrl = this.getPaymentUrl(linkId, baseUrl);
1221
+ return this.qrApiUrl
1222
+ .replace('{url}', encodeURIComponent(paymentUrl))
1223
+ .replace(/\{size\}/g, String(size));
1224
+ }
1225
+
1226
+ // ==================== PRICE CONVERSION ====================
1227
+
1228
+ /**
1229
+ * Get current SOL price in USD.
1230
+ * Uses the configurable `priceApiUrl` (defaults to CoinGecko).
1231
+ *
1232
+ * **Throws** if the price API is unreachable or returns an unexpected
1233
+ * format, rather than silently falling back to a stale hardcoded value.
1234
+ * Callers that need a fallback should catch the error themselves.
1235
+ *
1236
+ * @throws {Error} If the price API request fails or returns no price.
1237
+ */
1238
+ async getSolPrice(): Promise<number> {
1239
+ const response = await fetch(this.priceApiUrl);
1240
+ if (!response.ok) {
1241
+ throw new Error(`SOL price API returned HTTP ${response.status}`);
1242
+ }
1243
+ const data = await response.json() as { solana?: { usd?: number } };
1244
+ const price = data.solana?.usd;
1245
+ if (typeof price !== 'number' || price <= 0) {
1246
+ throw new Error(
1247
+ 'SOL price API returned an invalid or missing price. ' +
1248
+ 'Ensure the priceApiUrl returns { solana: { usd: <number> } }.'
1249
+ );
1250
+ }
1251
+ return price;
1252
+ }
1253
+
1254
+ /**
1255
+ * Get current SOL price in USD, with a fallback value on failure.
1256
+ *
1257
+ * Use this when a best-effort price is acceptable (e.g. UI display).
1258
+ * For payment-critical paths, prefer `getSolPrice()` which throws on
1259
+ * failure so you can surface the error to the user.
1260
+ *
1261
+ * @param fallback - Price to return if the API call fails (default: 100).
1262
+ */
1263
+ async getSolPriceSafe(fallback: number = 100): Promise<number> {
1264
+ try {
1265
+ return await this.getSolPrice();
1266
+ } catch {
1267
+ return fallback;
1268
+ }
1269
+ }
1270
+
1271
+ /**
1272
+ * Convert USD to lamports.
1273
+ *
1274
+ * @throws {Error} If the SOL price cannot be fetched (propagated from `getSolPrice`).
1275
+ */
1276
+ async usdToLamports(usdAmount: number): Promise<bigint> {
1277
+ const solPrice = await this.getSolPrice();
1278
+ const solAmount = usdAmount / solPrice;
1279
+ return BigInt(Math.floor(solAmount * LAMPORTS_PER_SOL));
1280
+ }
1281
+
1282
+ /**
1283
+ * Convert micro-USD to lamports.
1284
+ *
1285
+ * @throws {Error} If the SOL price cannot be fetched (propagated from `getSolPrice`).
1286
+ */
1287
+ async microUsdToLamports(microUsd: bigint): Promise<bigint> {
1288
+ const usdAmount = Number(microUsd) / 1_000_000;
1289
+ return this.usdToLamports(usdAmount);
1290
+ }
1291
+
1292
+ // ==================== TOKEN HELPERS ====================
1293
+
1294
+ /**
1295
+ * Get token mint address for currency
1296
+ */
1297
+ getTokenMint(currency: 'USDC' | 'USDT'): PublicKey {
1298
+ return TOKEN_MINTS[this.network][currency];
1299
+ }
1300
+
1301
+ /**
1302
+ * Get token balance
1303
+ */
1304
+ async getTokenBalance(walletAddress: PublicKey, currency: 'USDC' | 'USDT'): Promise<number> {
1305
+ try {
1306
+ const mint = this.getTokenMint(currency);
1307
+ const ata = await getAssociatedTokenAddress(mint, walletAddress);
1308
+ const balance = await this.connection.getTokenAccountBalance(ata);
1309
+ return Number(balance.value.uiAmount);
1310
+ } catch {
1311
+ return 0;
1312
+ }
1313
+ }
1314
+
1315
+ // ==================== DECODE HELPERS ====================
1316
+
1317
+ /**
1318
+ * Anchor account discriminators: `sha256("account:<Name>")[0..8]`.
1319
+ * Used by `getProgramAccounts` filters and for decode-time validation.
1320
+ *
1321
+ * To regenerate:
1322
+ * ```ts
1323
+ * import { createHash } from 'crypto';
1324
+ * createHash('sha256').update('account:Merchant').digest().slice(0, 8);
1325
+ * ```
1326
+ */
1327
+ private static readonly DISCRIMINATORS = {
1328
+ // sha256("account:Protocol")[0..8]
1329
+ Protocol: Buffer.from([0x2d, 0x27, 0x65, 0x2b, 0x73, 0x48, 0x83, 0x28]),
1330
+ // sha256("account:Platform")[0..8]
1331
+ Platform: Buffer.from([0x4d, 0x5c, 0xcc, 0x3a, 0xbb, 0x62, 0x5b, 0x0c]),
1332
+ // sha256("account:Merchant")[0..8]
1333
+ Merchant: Buffer.from([0x47, 0xeb, 0x1e, 0x28, 0xe7, 0x15, 0x20, 0x40]),
1334
+ // sha256("account:PaymentLink")[0..8]
1335
+ PaymentLink: Buffer.from([0xa9, 0xf7, 0x93, 0xbd, 0x27, 0xef, 0x0e, 0x26]),
1336
+ } as const;
1337
+
1338
+ /** Expected on-chain account sizes (discriminator included). */
1339
+ private static readonly ACCOUNT_SIZES = {
1340
+ Protocol: 149, // 8 + 32 + 32 + 32 + 32 + 4 + 4 + 4 + 1
1341
+ Platform: 80, // 8 + 4 + 32 + 32 + 2 + 1 + 1
1342
+ Merchant: 50, // 8 + 4 + 4 + 32 + 1 + 1
1343
+ PaymentLink: 85, // 8 + 4 + 4 + 1 + 32 + 1 + 1 + 1 + 8 + 8 + 8 + 8 + 1
1344
+ } as const;
1345
+
1346
+ /** Validate buffer length and (optionally) discriminator before decoding. */
1347
+ private static assertAccountData(
1348
+ data: Buffer,
1349
+ accountName: keyof typeof RotateSDK.DISCRIMINATORS,
1350
+ ): void {
1351
+ const minSize = RotateSDK.ACCOUNT_SIZES[accountName];
1352
+ if (data.length < minSize) {
1353
+ throw new Error(
1354
+ `${accountName} account data too short: expected >= ${minSize} bytes, got ${data.length}`,
1355
+ );
1356
+ }
1357
+ const expected = RotateSDK.DISCRIMINATORS[accountName];
1358
+ const actual = data.slice(0, 8);
1359
+ if (!actual.equals(expected)) {
1360
+ throw new Error(
1361
+ `${accountName} discriminator mismatch: expected ${expected.toString('hex')}, got ${actual.toString('hex')}`,
1362
+ );
1363
+ }
1364
+ }
1365
+
1366
+ private decodeProtocol(data: Buffer): Protocol {
1367
+ RotateSDK.assertAccountData(data, 'Protocol');
1368
+ let offset = 8;
1369
+
1370
+ const authority = new PublicKey(data.slice(offset, offset + 32));
1371
+ offset += 32;
1372
+ const treasury = new PublicKey(data.slice(offset, offset + 32));
1373
+ offset += 32;
1374
+ const usdcMint = new PublicKey(data.slice(offset, offset + 32));
1375
+ offset += 32;
1376
+ const usdtMint = new PublicKey(data.slice(offset, offset + 32));
1377
+ offset += 32;
1378
+ const platformCount = data.readUInt32LE(offset);
1379
+ offset += 4;
1380
+ const merchantCount = data.readUInt32LE(offset);
1381
+ offset += 4;
1382
+ const linkCount = data.readUInt32LE(offset);
1383
+ offset += 4;
1384
+ const bump = data.readUInt8(offset);
1385
+
1386
+ return {
1387
+ authority,
1388
+ treasury,
1389
+ usdcMint,
1390
+ usdtMint,
1391
+ platformCount,
1392
+ merchantCount,
1393
+ linkCount,
1394
+ bump,
1395
+ };
1396
+ }
1397
+
1398
+ private decodePlatform(data: Buffer): Platform {
1399
+ RotateSDK.assertAccountData(data, 'Platform');
1400
+ let offset = 8;
1401
+
1402
+ const id = data.readUInt32LE(offset);
1403
+ offset += 4;
1404
+ const admin = new PublicKey(data.slice(offset, offset + 32));
1405
+ offset += 32;
1406
+ const wallet = new PublicKey(data.slice(offset, offset + 32));
1407
+ offset += 32;
1408
+ const feeBps = data.readUInt16LE(offset);
1409
+ offset += 2;
1410
+ const active = data.readUInt8(offset) === 1;
1411
+ offset += 1;
1412
+ const bump = data.readUInt8(offset);
1413
+
1414
+ return { id, admin, wallet, feeBps, active, bump };
1415
+ }
1416
+
1417
+ private decodeMerchant(data: Buffer): Merchant {
1418
+ RotateSDK.assertAccountData(data, 'Merchant');
1419
+ let offset = 8;
1420
+
1421
+ const id = data.readUInt32LE(offset);
1422
+ offset += 4;
1423
+ const platformId = data.readUInt32LE(offset);
1424
+ offset += 4;
1425
+ const wallet = new PublicKey(data.slice(offset, offset + 32));
1426
+ offset += 32;
1427
+ const active = data.readUInt8(offset) === 1;
1428
+ offset += 1;
1429
+ const bump = data.readUInt8(offset);
1430
+
1431
+ return { id, platformId, wallet, active, bump };
1432
+ }
1433
+
1434
+ private decodePaymentLink(data: Buffer): PaymentLink {
1435
+ RotateSDK.assertAccountData(data, 'PaymentLink');
1436
+ let offset = 8;
1437
+
1438
+ const id = data.readUInt32LE(offset);
1439
+ offset += 4;
1440
+ const merchantId = data.readUInt32LE(offset);
1441
+ offset += 4;
1442
+
1443
+ const tokenTypeValue = data.readUInt8(offset);
1444
+ offset += 1;
1445
+ const tokenType = ['Sol', 'Usdc', 'Usdt', 'Usd'][tokenTypeValue] as PaymentLink['tokenType'];
1446
+
1447
+ const tokenMint = new PublicKey(data.slice(offset, offset + 32));
1448
+ offset += 32;
1449
+
1450
+ const statusValue = data.readUInt8(offset);
1451
+ offset += 1;
1452
+ const status = ['Pending', 'PartiallyPaid', 'Paid', 'Cancelled'][statusValue] as PaymentLink['status'];
1453
+
1454
+ const allowTips = data.readUInt8(offset) === 1;
1455
+ offset += 1;
1456
+ const allowPartial = data.readUInt8(offset) === 1;
1457
+ offset += 1;
1458
+
1459
+ const amount = data.readBigUInt64LE(offset);
1460
+ offset += 8;
1461
+ const amountPaid = data.readBigUInt64LE(offset);
1462
+ offset += 8;
1463
+ const expiresAt = Number(data.readBigInt64LE(offset));
1464
+ offset += 8;
1465
+ const orderRef = data.readBigUInt64LE(offset).toString();
1466
+ offset += 8;
1467
+ const bump = data.readUInt8(offset);
1468
+
1469
+ return {
1470
+ id,
1471
+ merchantId,
1472
+ tokenType,
1473
+ tokenMint,
1474
+ status,
1475
+ allowTips,
1476
+ allowPartial,
1477
+ amount,
1478
+ amountPaid,
1479
+ expiresAt,
1480
+ orderRef,
1481
+ bump,
1482
+ };
1483
+ }
1484
+
1485
+ // ==================== MEMO / DESCRIPTION ====================
1486
+
1487
+ /**
1488
+ * Recover a payment link description from its on-chain memo.
1489
+ * Returns null if no description was embedded.
1490
+ */
1491
+ async getLinkDescription(linkId: number): Promise<string | null> {
1492
+ return getLinkDescription(this.connection, linkId, this.programId);
1493
+ }
1494
+ }
1495
+
1496
+ // ==================== EXPORTS ====================
1497
+
1498
+ // Re-export store & marketplace classes and types
1499
+ export { RotateStore, RotateCart } from './store';
1500
+ export type {
1501
+ Currency,
1502
+ Product,
1503
+ ProductInput,
1504
+ LineItem,
1505
+ Discount,
1506
+ DiscountInput,
1507
+ CartTotals,
1508
+ CheckoutResult,
1509
+ BatchLinkResult,
1510
+ StoreConfig,
1511
+ } from './store';
1512
+ export { RotateMarketplace, MarketplaceCart } from './marketplace';
1513
+ export { RotatePlatformManager } from './platform';
1514
+ export type {
1515
+ PlatformManagerConfig,
1516
+ OnboardMerchantInput,
1517
+ OnboardMerchantResult,
1518
+ BulkOnboardResult,
1519
+ MerchantInfo,
1520
+ PlatformStats,
1521
+ } from './platform';
1522
+ export type {
1523
+ Vendor,
1524
+ VendorInput,
1525
+ MarketplaceProduct,
1526
+ MarketplaceProductInput,
1527
+ MarketplaceLineItem,
1528
+ VendorSubtotal,
1529
+ MarketplaceCartTotals,
1530
+ MarketplaceCheckoutResult,
1531
+ MarketplaceConfig,
1532
+ } from './marketplace';
1533
+
1534
+ export default RotateSDK;
1535
+ export { IDL, generateSampleId, isValidId };
1536
+ // Note: RotateConfig, Protocol, Platform, Merchant, PaymentLink, MEMO_PROGRAM_ID, MEMO_PREFIX,
1537
+ // createMemoInstruction, getLinkDescription, and all param types are already exported
1538
+ // at their definition sites (export function / export interface / export type).