@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.
- package/README.md +453 -0
- package/dist/catalog.d.ts +112 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +210 -0
- package/dist/catalog.js.map +1 -0
- package/dist/components/CheckoutForm.d.ts +86 -0
- package/dist/components/CheckoutForm.d.ts.map +1 -0
- package/dist/components/CheckoutForm.js +332 -0
- package/dist/components/CheckoutForm.js.map +1 -0
- package/dist/components/HostedCheckout.d.ts +57 -0
- package/dist/components/HostedCheckout.d.ts.map +1 -0
- package/dist/components/HostedCheckout.js +414 -0
- package/dist/components/HostedCheckout.js.map +1 -0
- package/dist/components/PaymentButton.d.ts +80 -0
- package/dist/components/PaymentButton.d.ts.map +1 -0
- package/dist/components/PaymentButton.js +210 -0
- package/dist/components/PaymentButton.js.map +1 -0
- package/dist/components/RotateProvider.d.ts +115 -0
- package/dist/components/RotateProvider.d.ts.map +1 -0
- package/dist/components/RotateProvider.js +264 -0
- package/dist/components/RotateProvider.js.map +1 -0
- package/dist/components/index.d.ts +17 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +27 -0
- package/dist/components/index.js.map +1 -0
- package/dist/embed.d.ts +85 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +313 -0
- package/dist/embed.js.map +1 -0
- package/dist/hooks.d.ts +156 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +280 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idl/rotate_connect.json +2572 -0
- package/dist/index.d.ts +505 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1197 -0
- package/dist/index.js.map +1 -0
- package/dist/marketplace.d.ts +257 -0
- package/dist/marketplace.d.ts.map +1 -0
- package/dist/marketplace.js +433 -0
- package/dist/marketplace.js.map +1 -0
- package/dist/platform.d.ts +234 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +268 -0
- package/dist/platform.js.map +1 -0
- package/dist/react.d.ts +140 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +429 -0
- package/dist/react.js.map +1 -0
- package/dist/store.d.ts +213 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +404 -0
- package/dist/store.js.map +1 -0
- package/dist/webhooks.d.ts +149 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +371 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +114 -0
- package/src/catalog.ts +299 -0
- package/src/components/CheckoutForm.tsx +608 -0
- package/src/components/HostedCheckout.tsx +675 -0
- package/src/components/PaymentButton.tsx +348 -0
- package/src/components/RotateProvider.tsx +370 -0
- package/src/components/index.ts +26 -0
- package/src/embed.ts +408 -0
- package/src/hooks.ts +518 -0
- package/src/idl/rotate_connect.json +2572 -0
- package/src/index.ts +1538 -0
- package/src/marketplace.ts +642 -0
- package/src/platform.ts +403 -0
- package/src/react.ts +459 -0
- package/src/store.ts +577 -0
- 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).
|