forked from ZyntariHQ/Invoisio
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsoroban-invoice-client.ts
More file actions
486 lines (428 loc) · 16.5 KB
/
Copy pathsoroban-invoice-client.ts
File metadata and controls
486 lines (428 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
import {
Account,
BASE_FEE,
Contract,
Keypair,
rpc,
Transaction,
TransactionBuilder,
xdr,
scValToNative,
} from '@stellar/stellar-sdk';
import {
ContractConfig,
PaymentHistoryPage,
PaymentRecord,
RecordPaymentParams,
SorobanInvoiceClientConfig,
TransactionResult,
} from './types';
import {
decodeContractConfig,
decodePaymentRecord,
decodePaymentHistoryPage,
encodeAddress,
encodeBool,
encodeI128,
encodeString,
encodeU32,
parseContractError,
} from './codec';
/**
* Upper bound on ledger polls when awaiting transaction confirmation.
* 10 × 2 s = 20 s covers ~4 Stellar ledger closes at ~5 s each.
*/
const MAX_POLL_ATTEMPTS = 10;
/** Sleep duration between each poll. */
const POLL_INTERVAL_MS = 2_000;
/** Transaction validity window submitted to the network. */
const TX_TIMEOUT_SECONDS = 30;
/**
* Minimal client helper for the Invoisio `invoice-payment` Soroban contract.
*
* ## Instantiation
* Create one instance per process lifetime; the `rpc.Server` and
* `Keypair` are initialised once in the constructor and reused across calls.
*
* ## Complexity
* | Method | Time | Space |
* |------------------|----------------------------|-------|
* | `recordPayment` | O(k), k ≤ MAX_POLL_ATTEMPTS | O(1) |
* | `getPayment` | O(1) | O(1) |
* | `hasPayment` | O(1) | O(1) |
* | `getPaymentCount`| O(1) | O(1) |
* | `allowAsset` | O(k), k ≤ MAX_POLL_ATTEMPTS | O(1) |
* | `revokeAsset` | O(k), k ≤ MAX_POLL_ATTEMPTS | O(1) |
* | `setAllowNative` | O(k), k ≤ MAX_POLL_ATTEMPTS | O(1) |
* | `setPaused` | O(k), k ≤ MAX_POLL_ATTEMPTS | O(1) |
* | `getAdmin` | O(1) | O(1) |
* | `isPaused` | O(1) | O(1) |
*
* Read methods use `new Account(pk, '0')` instead of `server.getAccount()`.
* Simulation does not validate the sequence number, so this saves one
* full network round-trip per read call.
*/
export class SorobanInvoiceClient {
private readonly server: rpc.Server;
private readonly contract: Contract;
private readonly config: SorobanInvoiceClientConfig;
/** Cached keypair — derived once at construction, not re-derived per call. */
private readonly keypair: Keypair | undefined;
constructor(config: SorobanInvoiceClientConfig) {
if (!config.signerSecretKey && !config.sourcePublicKey) {
throw new Error(
'SorobanInvoiceClient requires either signerSecretKey or sourcePublicKey',
);
}
this.config = config;
// Created once; underlying HTTP connection is reused across all calls.
this.server = new rpc.Server(config.rpcUrl, { allowHttp: false });
this.contract = new Contract(config.contractId);
// Parse the keypair once — elliptic curve derivation is not free.
this.keypair = config.signerSecretKey
? Keypair.fromSecret(config.signerSecretKey)
: undefined;
}
// ─── Write operations ───────────────────────────────────────────────────────
/**
* Record a verified invoice payment on-chain.
*
* The caller is responsible for confirming the companion Stellar Payment on
* Horizon **before** calling this method. The contract admin keypair must be
* provided via `signerSecretKey` in the config.
*
* `params.settlementRef` is the normalised settlement reference (e.g. a
* SHA-256 hash or reconciliation ID) used for backend deduplication and
* idempotent reconciliation. It must be non-empty and at most 128 chars —
* the contract rejects longer values with `InvalidSettlementRef`.
*
* @throws {SorobanContractError} on contract-level rejection
* (e.g. `PaymentAlreadyRecorded`, `InvalidAmount`, `InvalidSettlementRef`)
* @throws {Error} on network errors or confirmation timeout
*/
async recordPayment(params: RecordPaymentParams): Promise<TransactionResult> {
this.requireSigner();
// server.getAccount() is needed here: submitted transactions must carry
// the correct on-chain sequence number to prevent replay attacks.
const account = await this.server.getAccount(this.keypair!.publicKey());
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(
this.contract.call(
'record_payment',
encodeString(params.invoiceId),
encodeAddress(params.payer),
encodeString(params.assetCode),
encodeString(params.assetIssuer),
encodeI128(params.amount),
encodeString(params.settlementRef),
),
)
.setTimeout(TX_TIMEOUT_SECONDS)
.build();
return this.submitWrite(tx);
}
/**
* Step 1 of the two-step admin handoff: propose `newAdmin` as the next
* contract admin.
*
* The **current admin** keypair must be provided via `signerSecretKey` in
* the config. The role does NOT change until the proposed address calls
* `acceptAdmin`.
*
* @throws {SorobanContractError} on contract-level rejection
* (e.g. `PendingAdminExists`, `InvalidProposedAdmin`)
*/
async proposeAdmin(newAdmin: string): Promise<TransactionResult> {
this.requireSigner();
const account = await this.server.getAccount(this.keypair!.publicKey());
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(this.contract.call('propose_admin', encodeAddress(newAdmin)))
.setTimeout(TX_TIMEOUT_SECONDS)
.build();
return this.submitWrite(tx);
}
/**
* Step 2 of the two-step admin handoff: accept a pending proposal and become
* the contract admin.
*
* The **proposed admin** keypair must be provided via `signerSecretKey` in
* the config — the caller is derived from that keypair and must match the
* address proposed by `proposeAdmin`.
*
* @throws {SorobanContractError} on contract-level rejection
* (e.g. `NoPendingAdmin`, `Unauthorized`)
*/
async acceptAdmin(): Promise<TransactionResult> {
this.requireSigner();
const account = await this.server.getAccount(this.keypair!.publicKey());
const caller = this.keypair!.publicKey();
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(this.contract.call('accept_admin', encodeAddress(caller)))
.setTimeout(TX_TIMEOUT_SECONDS)
.build();
return this.submitWrite(tx);
}
/**
* Add a `(code, issuer)` token pair to the admin-controlled allowlist.
*
* Only assets that have been allowlisted are accepted by `recordPayment`.
* The **contract admin** keypair must be provided via `signerSecretKey`.
*
* @throws {SorobanContractError} on contract-level rejection
* (e.g. `NotInitialized`, `InvalidAsset`, `Unauthorized`)
*/
async allowAsset(code: string, issuer: string): Promise<TransactionResult> {
this.requireSigner();
const account = await this.server.getAccount(this.keypair!.publicKey());
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(
this.contract.call('allow_asset', encodeString(code), encodeString(issuer)),
)
.setTimeout(TX_TIMEOUT_SECONDS)
.build();
return this.submitWrite(tx);
}
/**
* Remove a `(code, issuer)` token pair from the allowlist.
*
* The **contract admin** keypair must be provided via `signerSecretKey`.
* Revoking an asset that was never allowlisted is a no-op on-chain.
*
* @throws {SorobanContractError} on contract-level rejection
* (e.g. `NotInitialized`, `InvalidAsset`, `Unauthorized`)
*/
async revokeAsset(code: string, issuer: string): Promise<TransactionResult> {
this.requireSigner();
const account = await this.server.getAccount(this.keypair!.publicKey());
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(
this.contract.call('revoke_asset', encodeString(code), encodeString(issuer)),
)
.setTimeout(TX_TIMEOUT_SECONDS)
.build();
return this.submitWrite(tx);
}
/**
* Toggle whether native XLM payments are accepted by `recordPayment`.
*
* The **contract admin** keypair must be provided via `signerSecretKey`.
*
* @throws {SorobanContractError} on contract-level rejection
* (e.g. `NotInitialized`, `Unauthorized`)
*/
async setAllowNative(allowed: boolean): Promise<TransactionResult> {
this.requireSigner();
const account = await this.server.getAccount(this.keypair!.publicKey());
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(this.contract.call('set_allow_native', encodeBool(allowed)))
.setTimeout(TX_TIMEOUT_SECONDS)
.build();
return this.submitWrite(tx);
}
/**
* Pause or unpause the contract.
*
* While paused, write operations (e.g. `recordPayment`) are rejected with
* `ContractPaused`; read operations remain available. The caller is derived
* from `signerSecretKey` and must match the contract admin.
*
* @throws {SorobanContractError} on contract-level rejection
* (e.g. `NotInitialized`, `Unauthorized`)
*/
async setPaused(paused: boolean): Promise<TransactionResult> {
this.requireSigner();
const account = await this.server.getAccount(this.keypair!.publicKey());
const caller = this.keypair!.publicKey();
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(
this.contract.call('set_paused', encodeAddress(caller), encodeBool(paused)),
)
.setTimeout(TX_TIMEOUT_SECONDS)
.build();
return this.submitWrite(tx);
}
// ─── Read operations (permissionless) ──────────────────────────────────────
/**
* Return the stable high-level contract configuration snapshot.
*
* This is the preferred single-call read for deployment checks, backend
* health probes, and UI bootstrapping because it includes admin ownership,
* initialization status, version metadata, and allowlist policy together.
*/
async getConfig(): Promise<ContractConfig> {
const retval = await this.simulateView('config');
return decodeContractConfig(retval);
}
/**
* Return the current contract admin address. Permissionless read.
*
* @throws {SorobanContractError} with code `NotInitialized` if the contract
* has not been initialised yet.
*/
async getAdmin(): Promise<string> {
const retval = await this.simulateView('admin');
return String(scValToNative(retval));
}
/**
* Return the address currently proposed as the next admin, or `null` when no
* admin transfer is in flight. Permissionless read.
*/
async getPendingAdmin(): Promise<string | null> {
const retval = await this.simulateView('pending_admin');
const native = scValToNative(retval);
return native === null || native === undefined ? null : String(native);
}
/**
* Fetch the full `PaymentRecord` for an invoice.
*
* @throws {SorobanContractError} with code `PaymentNotFound` if not recorded
*/
async getPayment(invoiceId: string): Promise<PaymentRecord> {
const retval = await this.simulateView('get_payment', encodeString(invoiceId));
return decodePaymentRecord(retval);
}
/**
* Return `true` if a payment has been recorded for the given invoice ID.
* Use this as an idempotency check before calling `recordPayment`.
*/
async hasPayment(invoiceId: string): Promise<boolean> {
const retval = await this.simulateView('has_payment', encodeString(invoiceId));
return Boolean(scValToNative(retval));
}
/**
* Return the total number of payments recorded in this contract instance.
*/
async getPaymentCount(): Promise<number> {
const retval = await this.simulateView('payment_count');
return Number(scValToNative(retval));
}
/**
* Fetch a bounded page of payment history using a cursor-based read.
*
* `cursor` is the next history index to read, and `limit` is capped by the
* contract so responses remain bounded and predictable.
*/
async getPaymentHistory(cursor = 0, limit = 25): Promise<PaymentHistoryPage> {
const retval = await this.simulateView(
'payment_history',
encodeU32(cursor),
encodeU32(limit),
);
return decodePaymentHistoryPage(retval);
}
/**
* Return `true` if the contract is currently paused (writes disabled).
* Permissionless read.
*/
async isPaused(): Promise<boolean> {
const retval = await this.simulateView('is_paused');
return Boolean(scValToNative(retval));
}
// ─── Private helpers ────────────────────────────────────────────────────────
/**
* Simulate, sign, submit, and await a write transaction with the configured
* signer keypair. Shared by all admin-gated write operations.
*
* Time: O(k), k ≤ MAX_POLL_ATTEMPTS.
*/
private async submitWrite(tx: Transaction): Promise<TransactionResult> {
// prepareTransaction simulates and assembles the fee + storage footprint.
// It throws if the simulation fails (e.g. contract returns Err(...)).
let prepared: Transaction;
try {
prepared = await this.server.prepareTransaction(tx);
} catch (err) {
throw parseContractError(err instanceof Error ? err.message : String(err));
}
prepared.sign(this.keypair!);
const sendResult = await this.server.sendTransaction(prepared);
if (sendResult.status === 'ERROR') {
const detail = sendResult.errorResult?.toXDR('base64') ?? 'unknown';
throw new Error(`Transaction rejected by network: ${detail}`);
}
return this.awaitTransaction(sendResult.hash);
}
/**
* Build and simulate a read-only contract call without submitting a transaction.
*
* Uses `new Account(pk, '0')` instead of `server.getAccount()` because
* Soroban simulation does not validate the sequence number — this saves one
* network round-trip per read call.
*
* Time: O(1) — single RPC round-trip.
*/
private async simulateView(method: string, ...args: xdr.ScVal[]): Promise<xdr.ScVal> {
// Sequence '0' is intentional: simulation ignores it.
const account = new Account(this.resolveSourcePublicKey(), '0');
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(this.contract.call(method, ...args))
.setTimeout(TX_TIMEOUT_SECONDS)
.build();
const result = await this.server.simulateTransaction(tx);
if (rpc.Api.isSimulationError(result)) {
throw parseContractError(result.error);
}
if (!result.result?.retval) {
throw new Error(`Contract method '${method}' returned no value`);
}
return result.result.retval;
}
/**
* Poll for transaction confirmation until SUCCESS, FAILED, or the attempt
* limit is reached.
*
* Time: O(k) where k ≤ MAX_POLL_ATTEMPTS.
*/
private async awaitTransaction(hash: string): Promise<TransactionResult> {
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
const result = await this.server.getTransaction(hash);
if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) {
return { hash, ledger: result.ledger };
}
if (result.status === rpc.Api.GetTransactionStatus.FAILED) {
throw new Error(`Transaction failed on-chain: ${hash}`);
}
// NOT_FOUND → not yet included in a ledger; sleep and retry.
await sleep(POLL_INTERVAL_MS);
}
throw new Error(
`Transaction ${hash} not confirmed after ${MAX_POLL_ATTEMPTS} polls ` +
`(${(MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS) / 1_000} s)`,
);
}
private resolveSourcePublicKey(): string {
return this.keypair?.publicKey() ?? this.config.sourcePublicKey!;
}
private requireSigner(): void {
if (!this.keypair) {
throw new Error('signerSecretKey is required for write operations');
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}