diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f17a6e..885f97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ All notable changes to this project will be documented in this file. ### Features +- **Add invoice due-date reminder scheduler (closes #542)** + - `InvoiceReminderScheduler.schedule(invoiceId, offsets: number[])` registers reminders at each offset (ms) before an invoice's due date + - Schedules persist via `saveReminderSchedules`/`loadReminderSchedules` (`src/snapshot.ts`), keyed by invoice, so reminders survive process restarts + - On construction, past-due reminders within `gracePeriodMs` (default 60 000 ms) fire automatically; older ones are marked `expired` + - Emits `invoiceReminderDue` with `{ invoiceId, offsetMs, dueAt }` via the existing `TypedEventEmitter` + - `InvoiceReminderScheduler.cancel(invoiceId)` removes all pending reminders for an invoice + - New types: `ReminderSchedule`, `ReminderEvent`, `ReminderStatus`; `InvoiceRecord.dueAt` added +- **Add auth-required trustline request handler (closes #541)** + - `TrustlineAuthHandler.checkAndRequest(recipientId, asset)` detects `AUTH_REQUIRED` issuers and emits `trustlineAuthRequired` with the issuer's public key + - `TrustlineAuthHandler.grantAuth(recipientId, asset, issuerKeypair)` builds, signs, and submits the approval operation + - Prefers `SetTrustLineFlags` (protocol >= 18) and falls back to legacy `AllowTrust` on older networks, detected via new `src/sorobanFeatureDetector.ts` + - Issuer account flags read via new `src/accountFlagsInspector.ts`; integrated into `src/preflightChecker.ts` as `checkTrustlineAuthRequirement` + - Emits `trustlineAuthGranted` after successful submission +- **Add SEP-31 cross-border payment initiator (closes #540)** + - `Sep31Initiator.initiate(...)` completes the anchor `/send` call and stores the returned transaction record + - `Sep31Initiator.getRequiredFields(anchorDomain, asset)` reads the anchor `/info` endpoint and returns a typed field schema + - `Sep31Initiator.pollStatus(transactionId, anchorDomain)` is an async generator yielding status updates until a terminal state (`completed`/`error`) + - Resolves the receiving anchor's `DIRECT_PAYMENT_SERVER` from its stellar.toml via `StellarToml.Resolver` + - SEP-10 JWT passed to `initiate()` is reused automatically for subsequent `pollStatus` calls + - New types: `Sep31PaymentRecord`, `Sep31Status`, `Sep31StatusChangedEvent`, `Sep31RequiredFields`, `Sep31FieldSpec` - **Build invoice diff utility — compare two invoice states (closes #363)** - `diffInvoices(a: Invoice, b: Invoice)` returns structured diff of two invoice objects - Returns `InvoiceDiff` as `{ field: string, before: unknown, after: unknown }[]` — only changed fields listed diff --git a/src/index.ts b/src/index.ts index fd17073..309a85c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -739,6 +739,15 @@ export type { export { ScheduledPaymentManager } from "./scheduler.js"; export type { ScheduledPayment } from "./scheduler.js"; +export { InvoiceReminderScheduler, DEFAULT_GRACE_PERIOD_MS } from "./invoiceReminderScheduler.js"; +export type { + InvoiceReminderSchedulerEventMap, + InvoiceDueAtResolver, + InvoiceReminderSchedulerOptions, +} from "./invoiceReminderScheduler.js"; +export { loadReminderSchedules, saveReminderSchedules } from "./snapshot.js"; +export type { ReminderSchedule, ReminderEvent, ReminderStatus } from "./types.js"; + export { compileFilter, applyFilter, FilterIndex } from "./invoiceFilter.js"; export type { FilterCriteria, CompiledFilter } from "./invoiceFilter.js"; @@ -772,6 +781,21 @@ export type { export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js"; export type { Sep41TokenCapabilities } from "./sep41Adapter.js"; +export { Sep31Initiator, resolveDirectPaymentServer } from "./sep/sep31Initiator.js"; +export type { + Sep31InitiatorEventMap, + Sep31Asset, + Sep31PartyInfo, + Sep31InitiateParams, +} from "./sep/sep31Initiator.js"; +export type { + Sep31PaymentRecord, + Sep31Status, + Sep31StatusChangedEvent, + Sep31FieldSpec, + Sep31RequiredFields, +} from "./types.js"; + export { HorizonFallbackReader } from "./horizonFallback.js"; export type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js"; diff --git a/src/invoiceReminderScheduler.ts b/src/invoiceReminderScheduler.ts new file mode 100644 index 0000000..c70ac7e --- /dev/null +++ b/src/invoiceReminderScheduler.ts @@ -0,0 +1,175 @@ +/** + * Invoice due-date reminder scheduler for StellarSplit. + * + * Registers reminders at configurable offsets before an invoice's due date + * and fires a typed event when each one comes due. Schedules are persisted + * (see {@link ../snapshot.js}) so reminders survive process restarts — + * on construction, any pending reminder whose fire time has already passed + * is either fired immediately (when still within the configurable grace + * period) or marked `expired` (when the process was down too long for the + * reminder to still be meaningful). + * + * Follows the same persist-then-arm-timer approach as {@link ../scheduler.js} + * (`ScheduledPaymentManager`), and the typed-event-emitter pattern used by + * {@link ../sep/sep24Handler.js}. + */ + +import { randomUUID } from "crypto"; +import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; +import { loadReminderSchedules, saveReminderSchedules } from "./snapshot.js"; +import type { ReminderSchedule, ReminderEvent } from "./types.js"; + +/** Events emitted by {@link InvoiceReminderScheduler}. */ +export interface InvoiceReminderSchedulerEventMap { + invoiceReminderDue: ReminderEvent; + [key: string]: unknown; +} + +/** + * Resolves the Unix timestamp (milliseconds) an invoice is due. + * May be async since the due date typically comes from a contract read or DB lookup. + */ +export type InvoiceDueAtResolver = (invoiceId: string) => Promise | number; + +export interface InvoiceReminderSchedulerOptions { + /** + * How long (ms) after a reminder's scheduled fire time it is still + * considered current on startup recovery. Reminders discovered further in + * the past than this are marked `expired` instead of fired. + * Defaults to 60 000 ms (60s). + */ + gracePeriodMs?: number; +} + +/** Default grace period for firing missed reminders after a restart. */ +export const DEFAULT_GRACE_PERIOD_MS = 60_000; + +/** + * Schedules and fires due-date reminders for invoices. + * + * @example + * ```typescript + * const scheduler = new InvoiceReminderScheduler((invoiceId) => invoice.dueAt); + * scheduler.on("invoiceReminderDue", ({ invoiceId, offsetMs }) => { + * notifyRecipient(invoiceId, offsetMs); + * }); + * await scheduler.schedule("inv_123", [7 * 24 * 60 * 60 * 1000, 24 * 60 * 60 * 1000, 60 * 60 * 1000]); + * ``` + */ +export class InvoiceReminderScheduler extends TypedEventEmitter { + private schedules: ReminderSchedule[]; + private readonly timers = new Map>(); + private readonly getDueAt: InvoiceDueAtResolver; + private readonly gracePeriodMs: number; + + constructor(getDueAt: InvoiceDueAtResolver, options: InvoiceReminderSchedulerOptions = {}) { + super(); + this.getDueAt = getDueAt; + this.gracePeriodMs = options.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS; + this.schedules = loadReminderSchedules(); + + for (const entry of this.schedules) { + if (entry.status === "pending") this._arm(entry); + } + } + + /** + * Register reminders at each offset (ms before the invoice's due date). + * Persists the schedule immediately so it survives a restart. + */ + async schedule(invoiceId: string, offsets: number[]): Promise { + const dueAt = await this.getDueAt(invoiceId); + const created: ReminderSchedule[] = []; + + for (const offsetMs of offsets) { + const entry: ReminderSchedule = { + id: randomUUID(), + invoiceId, + offsetMs, + dueAt, + fireAt: dueAt - offsetMs, + status: "pending", + }; + this.schedules.push(entry); + created.push(entry); + this._arm(entry); + } + + this._persist(); + return created; + } + + /** Remove all pending reminders for an invoice from the store. */ + cancel(invoiceId: string): void { + const cancelled = this.schedules.filter( + (s) => s.invoiceId === invoiceId && s.status === "pending", + ); + for (const entry of cancelled) { + const timer = this.timers.get(entry.id); + if (timer !== undefined) clearTimeout(timer); + this.timers.delete(entry.id); + } + if (cancelled.length === 0) return; + + const cancelledIds = new Set(cancelled.map((c) => c.id)); + this.schedules = this.schedules.map((s) => + cancelledIds.has(s.id) ? { ...s, status: "cancelled" as const } : s, + ); + this._persist(); + } + + /** Return the current set of reminder schedules (all invoices, all statuses). */ + list(): ReminderSchedule[] { + return [...this.schedules]; + } + + /** Stop all pending timers and detach listeners. Does not clear persisted state. */ + destroy(): void { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + this.removeAllListeners(); + } + + /** + * Arm a timer for `entry`. Reminders already due (delay <= 0, e.g. loaded + * from storage after a restart) are deferred via `setTimeout(fn, 0)` so + * that callers get a chance to attach `on("invoiceReminderDue", ...)` + * listeners before the event can fire. + */ + private _arm(entry: ReminderSchedule): void { + const delayMs = Math.max(0, entry.fireAt - Date.now()); + const timer = setTimeout(() => { + this.timers.delete(entry.id); + const overdueBy = Date.now() - entry.fireAt; + if (overdueBy > this.gracePeriodMs) { + this._expire(entry.id); + } else { + this._fire(entry.id); + } + }, delayMs); + this.timers.set(entry.id, timer); + } + + private _fire(id: string): void { + const live = this.schedules.find((s) => s.id === id); + if (!live || live.status !== "pending") return; + live.status = "fired"; + this._persist(); + this.emit("invoiceReminderDue", { + invoiceId: live.invoiceId, + offsetMs: live.offsetMs, + dueAt: live.dueAt, + }); + } + + private _expire(id: string): void { + const live = this.schedules.find((s) => s.id === id); + if (!live || live.status !== "pending") return; + live.status = "expired"; + this._persist(); + } + + private _persist(): void { + saveReminderSchedules(this.schedules); + } +} diff --git a/src/sep/sep31Initiator.ts b/src/sep/sep31Initiator.ts new file mode 100644 index 0000000..851d5de --- /dev/null +++ b/src/sep/sep31Initiator.ts @@ -0,0 +1,348 @@ +/** + * SEP-31 cross-border direct payment initiator for StellarSplit. + * + * Abstracts the full initiator-side SEP-31 protocol flow: resolving the + * receiving anchor's `DIRECT_PAYMENT_SERVER` from its stellar.toml, reading + * required fields from `/info`, submitting the payment via `/send`, and + * polling `/transaction/:id` for status until a terminal state is reached. + * + * Follows the same typed-event-emitter and anchor-polling patterns used by + * {@link ./sep24Handler.js} (`Sep24Handler`). + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0031.md + */ + +import { StellarToml } from "@stellar/stellar-sdk"; +import { TypedEventEmitter } from "../events/TypedEventEmitter.js"; +import type { + Sep31FieldSpec, + Sep31PaymentRecord, + Sep31RequiredFields, + Sep31Status, + Sep31StatusChangedEvent, +} from "../types.js"; + +// --------------------------------------------------------------------------- +// Event map +// --------------------------------------------------------------------------- + +/** Events emitted by {@link Sep31Initiator}. */ +export interface Sep31InitiatorEventMap { + sep31StatusChanged: Sep31StatusChangedEvent; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Asset descriptor for the payment. */ +export interface Sep31Asset { + /** Asset code (e.g. "USDC"). */ + code: string; + /** Asset issuer's Stellar address. */ + issuer: string; +} + +/** + * Party information for the sender or receiver of a SEP-31 payment. + * + * `id`, when present, is a SEP-12 customer ID for this party already known + * to the anchor. Any other keys are sent as anchor-required transaction + * fields (as discovered via {@link Sep31Initiator.getRequiredFields}). + */ +export type Sep31PartyInfo = Record; + +/** Parameters for {@link Sep31Initiator.initiate}. */ +export interface Sep31InitiateParams { + /** Asset to send. */ + asset: Sep31Asset; + /** Payment amount as a decimal string (e.g. "100.00"). */ + amount: string; + /** Information about the receiving party. */ + receiverInfo: Sep31PartyInfo; + /** Information about the sending party. */ + senderInfo: Sep31PartyInfo; + /** Home domain of the receiving anchor (e.g. "anchor.example.com"). */ + receiverAnchorDomain: string; + /** SEP-10 JWT for the receiving anchor, attached to every request. */ + jwt: string; +} + +interface Sep31InfoResponse { + receive?: Record< + string, + { + min_amount?: number; + max_amount?: number; + fields?: { transaction?: Record }; + } + >; +} + +interface Sep31SendResponse { + id: string; + stellar_account_id?: string; + stellar_memo?: string; + stellar_memo_type?: string; +} + +interface Sep31TransactionResponse { + transaction: { + id: string; + status: string; + amount_in?: string; + stellar_transaction_id?: string | null; + started_at?: string; + updated_at?: string; + required_info_message?: string | null; + message?: string | null; + }; +} + +const TERMINAL_STATUSES: Sep31Status[] = ["completed", "error"]; + +const VALID_STATUSES: Sep31Status[] = [ + "pending_sender", + "pending_receiver", + "pending_transaction_info_update", + "pending_stellar", + "pending_external", + "completed", + "error", +]; + +// --------------------------------------------------------------------------- +// Initiator +// --------------------------------------------------------------------------- + +/** + * Drives the initiator side of a SEP-31 cross-border direct payment. + * + * @example + * ```typescript + * const initiator = new Sep31Initiator(); + * initiator.on("sep31StatusChanged", (e) => console.log(e.payment.status)); + * + * const fields = await initiator.getRequiredFields("anchor.example.com", { code: "USDC", issuer: "G..." }); + * const payment = await initiator.initiate({ + * asset: { code: "USDC", issuer: "G..." }, + * amount: "100.00", + * receiverInfo: { routing_number: "121122676" }, + * senderInfo: {}, + * receiverAnchorDomain: "anchor.example.com", + * jwt: sep10Token, + * }); + * + * for await (const update of initiator.pollStatus(payment.id, "anchor.example.com")) { + * if (update.status === "completed") break; + * } + * ``` + */ +export class Sep31Initiator extends TypedEventEmitter { + private payment: Sep31PaymentRecord | null = null; + private jwt = ""; + + /** + * Fetch the receiving anchor's `/info` endpoint and return the typed field + * schema required to send `asset`. + */ + async getRequiredFields(anchorDomain: string, asset: Sep31Asset): Promise { + const serverUrl = await resolveDirectPaymentServer(anchorDomain); + const response = await fetch(`${serverUrl.replace(/\/$/, "")}/info`); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error(`SEP-31 /info request failed (${response.status}): ${errorText}`); + } + + const data = (await response.json()) as Sep31InfoResponse; + const assetInfo = data.receive?.[asset.code]; + if (!assetInfo) { + throw new Error(`Asset ${asset.code} is not supported for receive by anchor ${anchorDomain}`); + } + + const transactionFields: Record = {}; + for (const [key, spec] of Object.entries(assetInfo.fields?.transaction ?? {})) { + transactionFields[key] = { + description: spec.description, + choices: spec.choices, + optional: spec.optional, + }; + } + + return { + minAmount: assetInfo.min_amount, + maxAmount: assetInfo.max_amount, + transactionFields, + }; + } + + /** + * Complete the `/send` call to initiate a SEP-31 payment and store the + * returned transaction record. The SEP-10 JWT passed here is reused + * automatically by subsequent {@link pollStatus} calls. + */ + async initiate(params: Sep31InitiateParams): Promise { + const serverUrl = await resolveDirectPaymentServer(params.receiverAnchorDomain); + this.jwt = params.jwt; + + const { id: senderId, ...senderFields } = params.senderInfo; + const { id: receiverId, ...receiverFields } = params.receiverInfo; + + const body: Record = { + amount: params.amount, + asset_code: params.asset.code, + asset_issuer: params.asset.issuer, + }; + if (senderId) body.sender_id = senderId; + if (receiverId) body.receiver_id = receiverId; + if (Object.keys(senderFields).length || Object.keys(receiverFields).length) { + body.fields = { transaction: { ...senderFields, ...receiverFields } }; + } + + const response = await fetch(`${serverUrl.replace(/\/$/, "")}/send`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.jwt}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error(`SEP-31 /send request failed (${response.status}): ${errorText}`); + } + + const data = (await response.json()) as Sep31SendResponse; + const now = Date.now(); + + const record: Sep31PaymentRecord = { + id: data.id, + status: "pending_receiver", + assetCode: params.asset.code, + assetIssuer: params.asset.issuer, + amount: params.amount, + anchorDomain: params.receiverAnchorDomain, + stellarTxId: null, + startedAt: now, + updatedAt: now, + requiredInfoMessage: null, + errorMessage: null, + }; + + this.payment = record; + this.emit("sep31StatusChanged", { payment: record, previousStatus: null }); + + return record; + } + + /** + * Poll `/transaction/:id` until the payment reaches a terminal state + * (`completed` or `error`), yielding a status update each time it changes. + */ + async *pollStatus( + transactionId: string, + anchorDomain: string, + intervalMs = 3000, + ): AsyncIterableIterator { + const serverUrl = await resolveDirectPaymentServer(anchorDomain); + const url = `${serverUrl.replace(/\/$/, "")}/transaction/${encodeURIComponent(transactionId)}`; + + let previousStatus: Sep31Status | null = this.payment?.status ?? null; + + while (true) { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${this.jwt}` }, + }); + + if (response.ok) { + const data = (await response.json()) as Sep31TransactionResponse; + const record = this._applyTransaction(data.transaction, anchorDomain); + + if (record.status !== previousStatus) { + this.emit("sep31StatusChanged", { payment: record, previousStatus }); + previousStatus = record.status; + yield record; + } + + if (TERMINAL_STATUSES.includes(record.status)) { + return; + } + } + + await sleep(intervalMs); + } + } + + /** Get the current payment record, or null if {@link initiate} has not been called. */ + getPayment(): Sep31PaymentRecord | null { + return this.payment; + } + + private _applyTransaction( + txn: Sep31TransactionResponse["transaction"], + anchorDomain: string, + ): Sep31PaymentRecord { + const status = normalizeStatus(txn.status); + const base = this.payment ?? { + id: txn.id, + status, + assetCode: "", + assetIssuer: "", + amount: txn.amount_in ?? "0", + anchorDomain, + stellarTxId: null, + startedAt: Date.now(), + updatedAt: Date.now(), + requiredInfoMessage: null, + errorMessage: null, + }; + + const record: Sep31PaymentRecord = { + ...base, + id: txn.id, + status, + stellarTxId: txn.stellar_transaction_id ?? base.stellarTxId, + updatedAt: Date.now(), + requiredInfoMessage: txn.required_info_message ?? null, + errorMessage: status === "error" ? (txn.message ?? null) : null, + }; + + this.payment = record; + return record; + } +} + +// --------------------------------------------------------------------------- +// Helper: resolve the receiving anchor's DIRECT_PAYMENT_SERVER +// --------------------------------------------------------------------------- + +/** + * Fetch the `DIRECT_PAYMENT_SERVER` URL from an anchor's stellar.toml. + * + * @param homeDomain - The anchor's home domain (e.g. "anchor.example.com"). + * @throws When the domain does not publish a `DIRECT_PAYMENT_SERVER`. + */ +export async function resolveDirectPaymentServer(homeDomain: string): Promise { + const toml = await StellarToml.Resolver.resolve(homeDomain); + const server = (toml as Record).DIRECT_PAYMENT_SERVER as string | undefined; + if (!server) { + throw new Error(`Anchor ${homeDomain} does not publish a DIRECT_PAYMENT_SERVER in stellar.toml`); + } + return server; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function normalizeStatus(raw: string): Sep31Status { + const status = raw.toLowerCase().trim(); + return (VALID_STATUSES as string[]).includes(status) ? (status as Sep31Status) : "pending_receiver"; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/snapshot.ts b/src/snapshot.ts index e4067a7..b75b74d 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -1,5 +1,5 @@ import { createHash } from "crypto"; -import type { Invoice, Payment } from "./types.js"; +import type { Invoice, Payment, ReminderSchedule } from "./types.js"; export interface InvoiceSnapshot { snapshotId: string; diff --git a/src/trustlineAuthHandler.ts b/src/trustlineAuthHandler.ts new file mode 100644 index 0000000..a72d386 --- /dev/null +++ b/src/trustlineAuthHandler.ts @@ -0,0 +1,144 @@ +/** + * Auth-required trustline request handler for StellarSplit. + * + * Assets issued by an account with the `AUTH_REQUIRED` flag set require the + * issuer to explicitly approve each trustline before a recipient can hold + * the asset. This module detects the condition via + * {@link ./preflightChecker.js} (`checkTrustlineAuthRequirement`, backed by + * {@link ./accountFlagsInspector.js}), notifies the issuer through a typed + * event, and builds/submits the approval transaction on the issuer's behalf — + * preferring `SetTrustLineFlags` (protocol >= 18) and falling back to the + * legacy `AllowTrust` operation on older networks, as detected by + * {@link ./sorobanFeatureDetector.js}. + */ + +import { Account, Asset, Horizon, Keypair, Operation, TransactionBuilder, BASE_FEE } from "@stellar/stellar-sdk"; +import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; +import { checkTrustlineAuthRequirement } from "./preflightChecker.js"; +import { detectProtocolVersion, supportsSetTrustLineFlags } from "./sorobanFeatureDetector.js"; +import type { TrustlineAuthRequest } from "./types.js"; + +/** Events emitted by {@link TrustlineAuthHandler}. */ +export interface TrustlineAuthHandlerEventMap { + trustlineAuthRequired: TrustlineAuthRequest; + trustlineAuthGranted: TrustlineAuthRequest; + [key: string]: unknown; +} + +/** Asset descriptor identifying a non-native Stellar asset. */ +export interface TrustlineAuthAsset { + /** Asset code (e.g. "USDC"). */ + code: string; + /** Asset issuer's Stellar address. */ + issuer: string; +} + +/** + * Detects when a recipient's trustline needs issuer approval, and + * facilitates the approval transaction on the issuer's behalf. + * + * @example + * ```typescript + * const handler = new TrustlineAuthHandler(horizonServer, networkPassphrase); + * handler.on("trustlineAuthRequired", ({ recipientId, assetIssuer }) => { + * notifyIssuer(assetIssuer, recipientId); + * }); + * + * const check = await handler.checkAndRequest(recipientId, { code: "USDC", issuer }); + * if (check.authRequired) { + * await handler.grantAuth(recipientId, { code: "USDC", issuer }, issuerKeypair); + * } + * ``` + */ +export class TrustlineAuthHandler extends TypedEventEmitter { + private readonly server: Horizon.Server; + private readonly networkPassphrase: string; + + constructor(server: Horizon.Server, networkPassphrase: string) { + super(); + this.server = server; + this.networkPassphrase = networkPassphrase; + } + + /** + * Detect whether `asset` requires issuer authorization for `recipientId`'s + * trustline. Emits `trustlineAuthRequired` (with the issuer's public key) + * when it does. + */ + async checkAndRequest(recipientId: string, asset: TrustlineAuthAsset): Promise { + const { authRequired } = await checkTrustlineAuthRequirement(this.server, asset.issuer); + + const request: TrustlineAuthRequest = { + recipientId, + assetCode: asset.code, + assetIssuer: asset.issuer, + authRequired, + status: authRequired ? "required" : "not_required", + requestedAt: Date.now(), + }; + + if (authRequired) { + this.emit("trustlineAuthRequired", request); + } + + return request; + } + + /** + * Build and submit the authorization operation for `recipientId`'s + * trustline, signed by `issuerKeypair`. + * + * Uses `SetTrustLineFlags` (authorized) on protocol >= 18, and falls back + * to the legacy `AllowTrust` operation on older networks. Emits + * `trustlineAuthGranted` after successful submission. + */ + async grantAuth( + recipientId: string, + asset: TrustlineAuthAsset, + issuerKeypair: Keypair, + ): Promise { + const protocolVersion = await detectProtocolVersion(this.server); + const account = await this.server.loadAccount(issuerKeypair.publicKey()); + const sourceAccount = new Account(account.accountId(), account.sequenceNumber()); + + const operationType = supportsSetTrustLineFlags(protocolVersion) ? "setTrustLineFlags" : "allowTrust"; + + const operation = + operationType === "setTrustLineFlags" + ? Operation.setTrustLineFlags({ + trustor: recipientId, + asset: new Asset(asset.code, asset.issuer), + flags: { authorized: true }, + }) + : Operation.allowTrust({ + trustor: recipientId, + assetCode: asset.code, + authorize: true, + }); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + tx.sign(issuerKeypair); + const result = await this.server.submitTransaction(tx); + + const granted: TrustlineAuthRequest = { + recipientId, + assetCode: asset.code, + assetIssuer: asset.issuer, + authRequired: true, + status: "granted", + requestedAt: Date.now(), + operationType, + txHash: result.hash, + }; + + this.emit("trustlineAuthGranted", granted); + return granted; + } +} diff --git a/src/types.ts b/src/types.ts index 029d671..b439457 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1261,6 +1261,41 @@ export interface InvoiceRecord { status: InvoiceStatus; /** Total amount required. */ totalOwed: bigint; + /** Unix timestamp (milliseconds) when payment is due. Used by {@link InvoiceReminderScheduler}. */ + dueAt?: number; +} + +// --------------------------------------------------------------------------- +// Invoice Reminder Scheduler Types +// --------------------------------------------------------------------------- + +/** Lifecycle status of a single scheduled reminder. */ +export type ReminderStatus = "pending" | "fired" | "cancelled" | "expired"; + +/** A single reminder scheduled to fire before an invoice's due date. */ +export interface ReminderSchedule { + /** Unique ID for this reminder entry. */ + id: string; + /** Invoice this reminder is associated with. */ + invoiceId: string; + /** Milliseconds before `dueAt` that this reminder should fire. */ + offsetMs: number; + /** Unix timestamp (milliseconds) the invoice is due. */ + dueAt: number; + /** Unix timestamp (milliseconds) this reminder is scheduled to fire (`dueAt - offsetMs`). */ + fireAt: number; + /** Current lifecycle status of this reminder. */ + status: ReminderStatus; +} + +/** Payload emitted when a reminder fires. */ +export interface ReminderEvent { + /** Invoice the reminder is for. */ + invoiceId: string; + /** Offset (ms before due date) that triggered this reminder. */ + offsetMs: number; + /** Unix timestamp (milliseconds) the invoice is due. */ + dueAt: number; } // --------------------------------------------------------------------------- @@ -1718,6 +1753,104 @@ export interface Sep24StatusChangedEvent { previousStatus: Sep24Status; } +// --------------------------------------------------------------------------- +// Auth-Required Trustline Handler Types +// --------------------------------------------------------------------------- + +/** Lifecycle status of an auth-required trustline approval. */ +export type TrustlineAuthStatus = "required" | "not_required" | "granted"; + +/** Stellar operation used to grant trustline authorization. */ +export type TrustlineAuthOperationType = "setTrustLineFlags" | "allowTrust"; + +/** A request to authorize a recipient's trustline for an AUTH_REQUIRED asset. */ +export interface TrustlineAuthRequest { + /** Stellar address of the recipient whose trustline needs authorization. */ + recipientId: string; + /** Asset code (e.g. "USDC"). */ + assetCode: string; + /** Asset issuer's Stellar address. */ + assetIssuer: string; + /** Whether the issuer account has the AUTH_REQUIRED flag set. */ + authRequired: boolean; + /** Current status of this authorization request. */ + status: TrustlineAuthStatus; + /** Unix timestamp (milliseconds) this request/grant was recorded. */ + requestedAt: number; + /** Operation type used to grant authorization, set once `status` is "granted". */ + operationType?: TrustlineAuthOperationType; + /** Submission transaction hash, set once `status` is "granted". */ + txHash?: string; +} + +// --------------------------------------------------------------------------- +// SEP-31 Cross-Border Direct Payment Types +// --------------------------------------------------------------------------- + +/** Lifecycle status of a SEP-31 direct payment, per the SEP-31 spec. */ +export type Sep31Status = + | "pending_sender" + | "pending_receiver" + | "pending_transaction_info_update" + | "pending_stellar" + | "pending_external" + | "completed" + | "error"; + +/** Description of a single field required by the receiving anchor's /send endpoint. */ +export interface Sep31FieldSpec { + /** Human-readable description of the field. */ + description: string; + /** Allowed values, when the field is an enum. */ + choices?: string[]; + /** Whether the field may be omitted. */ + optional?: boolean; +} + +/** Typed field schema returned by the receiving anchor's /info endpoint for one asset. */ +export interface Sep31RequiredFields { + /** Minimum payment amount the anchor will accept, if published. */ + minAmount?: number; + /** Maximum payment amount the anchor will accept, if published. */ + maxAmount?: number; + /** Additional transaction-level fields the anchor requires (e.g. routing_number). */ + transactionFields: Record; +} + +/** A record tracking a single SEP-31 cross-border direct payment. */ +export interface Sep31PaymentRecord { + /** Transaction ID returned by the receiving anchor. */ + id: string; + /** Current lifecycle status. */ + status: Sep31Status; + /** Asset code (e.g. "USDC"). */ + assetCode: string; + /** Asset issuer's Stellar address. */ + assetIssuer: string; + /** Payment amount as a decimal string. */ + amount: string; + /** Home domain of the receiving anchor. */ + anchorDomain: string; + /** Stellar transaction ID once the payment settles on-chain. */ + stellarTxId: string | null; + /** Unix timestamp (milliseconds) the payment was initiated. */ + startedAt: number; + /** Unix timestamp (milliseconds) of the last status update. */ + updatedAt: number; + /** Anchor-supplied message describing what additional info is needed, if any. */ + requiredInfoMessage: string | null; + /** Human-readable error message when status is "error". */ + errorMessage: string | null; +} + +/** Event emitted when a SEP-31 payment's status changes. */ +export interface Sep31StatusChangedEvent { + /** The payment record with updated status. */ + payment: Sep31PaymentRecord; + /** The previous status before this change, or null for the initial creation. */ + previousStatus: Sep31Status | null; +} + // --------------------------------------------------------------------------- // Horizon Paginator Types // --------------------------------------------------------------------------- diff --git a/test/invoiceReminderScheduler.test.ts b/test/invoiceReminderScheduler.test.ts new file mode 100644 index 0000000..8c53fcf --- /dev/null +++ b/test/invoiceReminderScheduler.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + InvoiceReminderScheduler, + DEFAULT_GRACE_PERIOD_MS, +} from "../src/invoiceReminderScheduler.js"; +import { loadReminderSchedules } from "../src/snapshot.js"; +import type { ReminderEvent } from "../src/types.js"; + +const INVOICE_ID = "inv_123"; +const NOW = 1_700_000_000_000; +const DUE_AT = NOW + 24 * 60 * 60 * 1000; // due in 24h + +describe("InvoiceReminderScheduler", () => { + let scheduler: InvoiceReminderScheduler | null = null; + + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + scheduler?.destroy(); + scheduler = null; + vi.useRealTimers(); + }); + + it("registers a reminder per offset and persists the schedule", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + + const offsets = [60 * 60 * 1000, 10 * 60 * 1000]; + const created = await scheduler.schedule(INVOICE_ID, offsets); + + expect(created).toHaveLength(2); + expect(created.map((r) => r.offsetMs).sort()).toEqual(offsets.slice().sort()); + for (const entry of created) { + expect(entry.invoiceId).toBe(INVOICE_ID); + expect(entry.dueAt).toBe(DUE_AT); + expect(entry.fireAt).toBe(DUE_AT - entry.offsetMs); + expect(entry.status).toBe("pending"); + } + + const persisted = loadReminderSchedules(); + expect(persisted).toHaveLength(2); + }); + + it("emits invoiceReminderDue with { invoiceId, offsetMs, dueAt } when a reminder fires", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + const offsetMs = 60 * 60 * 1000; // 1h before due + await scheduler.schedule(INVOICE_ID, [offsetMs]); + + // Not due yet. + vi.advanceTimersByTime(DUE_AT - offsetMs - NOW - 1); + expect(events).toHaveLength(0); + + // Reaches fire time. + vi.advanceTimersByTime(1); + expect(events).toEqual([{ invoiceId: INVOICE_ID, offsetMs, dueAt: DUE_AT }]); + + const persisted = loadReminderSchedules(); + expect(persisted[0]!.status).toBe("fired"); + }); + + it("cancel() removes all pending reminders for an invoice and stops their timers", async () => { + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + await scheduler.schedule(INVOICE_ID, [60 * 60 * 1000, 30 * 60 * 1000]); + scheduler.cancel(INVOICE_ID); + + expect(scheduler.list().every((s) => s.invoiceId !== INVOICE_ID || s.status === "cancelled")).toBe(true); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1); + expect(events).toHaveLength(0); + + const persisted = loadReminderSchedules(); + expect(persisted.every((s) => s.status === "cancelled")).toBe(true); + }); + + it("on startup, fires reminders that are past due but within the grace period", async () => { + const fireAt = NOW - 5_000; // 5s ago, well within the 60s default grace period + localStorage.setItem( + "stellar_split_reminder_schedules", + JSON.stringify([ + { + id: "r1", + invoiceId: INVOICE_ID, + offsetMs: 60_000, + dueAt: fireAt + 60_000, + fireAt, + status: "pending", + }, + ]), + ); + + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + // Recovery fire is deferred via setTimeout(0) so listeners can attach first. + expect(events).toHaveLength(0); + vi.advanceTimersByTime(0); + + expect(events).toEqual([{ invoiceId: INVOICE_ID, offsetMs: 60_000, dueAt: fireAt + 60_000 }]); + }); + + it("on startup, marks reminders past the grace period as expired without firing them", async () => { + const fireAt = NOW - (DEFAULT_GRACE_PERIOD_MS + 5_000); // well outside the grace window + localStorage.setItem( + "stellar_split_reminder_schedules", + JSON.stringify([ + { + id: "r1", + invoiceId: INVOICE_ID, + offsetMs: 60_000, + dueAt: fireAt + 60_000, + fireAt, + status: "pending", + }, + ]), + ); + + scheduler = new InvoiceReminderScheduler(() => DUE_AT); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + vi.advanceTimersByTime(0); + + expect(events).toHaveLength(0); + expect(scheduler.list()[0]!.status).toBe("expired"); + }); + + it("respects a custom gracePeriodMs", async () => { + const fireAt = NOW - 10_000; + localStorage.setItem( + "stellar_split_reminder_schedules", + JSON.stringify([ + { id: "r1", invoiceId: INVOICE_ID, offsetMs: 1000, dueAt: fireAt + 1000, fireAt, status: "pending" }, + ]), + ); + + scheduler = new InvoiceReminderScheduler(() => DUE_AT, { gracePeriodMs: 5_000 }); + const events: ReminderEvent[] = []; + scheduler.on("invoiceReminderDue", (e) => events.push(e)); + + vi.advanceTimersByTime(0); + + expect(events).toHaveLength(0); + expect(scheduler.list()[0]!.status).toBe("expired"); + }); +}); diff --git a/test/sep31Initiator.test.ts b/test/sep31Initiator.test.ts new file mode 100644 index 0000000..b6fee89 --- /dev/null +++ b/test/sep31Initiator.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + StellarToml: { + Resolver: { + resolve: vi.fn(), + }, + }, + }; +}); + +import { Sep31Initiator, resolveDirectPaymentServer } from "../src/sep/sep31Initiator.js"; +import { StellarToml } from "@stellar/stellar-sdk"; +import type { Sep31PaymentRecord } from "../src/types.js"; + +const ANCHOR_DOMAIN = "anchor.example.com"; +const DIRECT_PAYMENT_SERVER = "https://anchor.example.com/sep31"; +const ASSET = { code: "USDC", issuer: "GISSUER00000000000000000000000000000000000000000000000000000" }; + +function mockToml() { + (StellarToml.Resolver.resolve as ReturnType).mockResolvedValue({ + DIRECT_PAYMENT_SERVER: DIRECT_PAYMENT_SERVER, + }); +} + +function mockFetchSequence(responses: Array<{ status: number; body: unknown }>) { + let callIndex = 0; + const calls: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + const resp = responses[Math.min(callIndex, responses.length - 1)]!; + callIndex++; + return { + ok: resp.status < 400, + status: resp.status, + json: async () => resp.body, + text: async () => JSON.stringify(resp.body), + }; + }); + return calls; +} + +describe("Sep31Initiator", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("getRequiredFields calls the anchor /info endpoint and returns the typed field schema", async () => { + mockToml(); + mockFetchSequence([ + { + status: 200, + body: { + receive: { + USDC: { + min_amount: 1, + max_amount: 10000, + fields: { + transaction: { + routing_number: { description: "Routing number", optional: false }, + }, + }, + }, + }, + }, + }, + ]); + + const initiator = new Sep31Initiator(); + const fields = await initiator.getRequiredFields(ANCHOR_DOMAIN, ASSET); + + expect(fields.minAmount).toBe(1); + expect(fields.maxAmount).toBe(10000); + expect(fields.transactionFields.routing_number).toEqual({ + description: "Routing number", + choices: undefined, + optional: false, + }); + }); + + it("resolveDirectPaymentServer throws when the anchor does not publish DIRECT_PAYMENT_SERVER", async () => { + (StellarToml.Resolver.resolve as ReturnType).mockResolvedValue({}); + await expect(resolveDirectPaymentServer(ANCHOR_DOMAIN)).rejects.toThrow("DIRECT_PAYMENT_SERVER"); + }); + + it("initiate completes the /send call, stores the transaction ID, and attaches the SEP-10 JWT", async () => { + mockToml(); + const calls = mockFetchSequence([{ status: 200, body: { id: "txn-abc123" } }]); + + const initiator = new Sep31Initiator(); + const payment = await initiator.initiate({ + asset: ASSET, + amount: "100.00", + receiverInfo: { routing_number: "121122676" }, + senderInfo: {}, + receiverAnchorDomain: ANCHOR_DOMAIN, + jwt: "test-jwt", + }); + + expect(payment.id).toBe("txn-abc123"); + expect(payment.status).toBe("pending_receiver"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(`${DIRECT_PAYMENT_SERVER}/send`); + expect((calls[0]!.init!.headers as Record).Authorization).toBe("Bearer test-jwt"); + const body = JSON.parse(calls[0]!.init!.body as string); + expect(body.amount).toBe("100.00"); + expect(body.asset_code).toBe("USDC"); + expect(body.fields.transaction.routing_number).toBe("121122676"); + }); + + it("pollStatus yields status updates until a terminal state, reusing the stored JWT", async () => { + mockToml(); + const calls = mockFetchSequence([ + { status: 200, body: { id: "txn-abc123" } }, // /send + { status: 200, body: { transaction: { id: "txn-abc123", status: "pending_receiver" } } }, + { status: 200, body: { transaction: { id: "txn-abc123", status: "pending_stellar" } } }, + { + status: 200, + body: { + transaction: { id: "txn-abc123", status: "completed", stellar_transaction_id: "stellar-tx-1" }, + }, + }, + ]); + + const initiator = new Sep31Initiator(); + const events: Sep31PaymentRecord[] = []; + initiator.on("sep31StatusChanged", (e) => events.push(e.payment)); + + const payment = await initiator.initiate({ + asset: ASSET, + amount: "50", + receiverInfo: {}, + senderInfo: {}, + receiverAnchorDomain: ANCHOR_DOMAIN, + jwt: "test-jwt", + }); + + const updates: Sep31PaymentRecord[] = []; + for await (const update of initiator.pollStatus(payment.id, ANCHOR_DOMAIN, 1)) { + updates.push(update); + } + + expect(updates.map((u) => u.status)).toEqual(["pending_stellar", "completed"]); + expect(updates.at(-1)!.stellarTxId).toBe("stellar-tx-1"); + + // initiate's creation event + 2 status-change events during polling + expect(events).toHaveLength(3); + + const pollCall = calls.find((c) => c.url.includes("/transaction/txn-abc123")); + expect(pollCall).toBeDefined(); + expect((pollCall!.init!.headers as Record).Authorization).toBe("Bearer test-jwt"); + }); +}); diff --git a/test/trustlineAuthHandler.test.ts b/test/trustlineAuthHandler.test.ts new file mode 100644 index 0000000..4146ffa --- /dev/null +++ b/test/trustlineAuthHandler.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// --------------------------------------------------------------------------- +// Stellar SDK mock (hoisting-safe — no top-level vi.fn() refs in factory) +// --------------------------------------------------------------------------- + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + Account: vi.fn().mockImplementation((id: string, seq: string) => ({ + accountId: () => id, + sequenceNumber: () => seq, + incrementSequenceNumber: vi.fn(), + })), + TransactionBuilder: vi.fn().mockImplementation(() => ({ + addOperation: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({ sign: vi.fn() }), + })), + BASE_FEE: "100", + Asset: vi.fn().mockImplementation((code: string, issuer: string) => ({ code, issuer })), + Keypair: { + fromSecret: vi.fn().mockImplementation((secret: string) => ({ + publicKey: () => `PUB_${secret}`, + secret: () => secret, + })), + }, + Operation: { + setTrustLineFlags: vi.fn().mockReturnValue({ type: "setTrustLineFlags" }), + allowTrust: vi.fn().mockReturnValue({ type: "allowTrust" }), + }, + Horizon: { Server: vi.fn() }, + }; +}); + +import { TrustlineAuthHandler } from "../src/trustlineAuthHandler.js"; +import { Operation, Keypair } from "@stellar/stellar-sdk"; +import type { TrustlineAuthRequest } from "../src/types.js"; + +const RECIPIENT = "GBRECIPIENT000000000000000000000000000000000000000000000000"; +const ISSUER = "GBISSUER0000000000000000000000000000000000000000000000000000"; +const ASSET = { code: "USDC", issuer: ISSUER }; + +function buildMockHorizonServer({ + authRequired = true, + currentProtocolVersion = 21, + submitHash = "abc123hash", +} = {}) { + return { + loadAccount: vi.fn().mockResolvedValue({ + accountId: () => ISSUER, + sequenceNumber: () => "100", + flags: { auth_required: authRequired, auth_revocable: true, auth_immutable: false }, + }), + root: vi.fn().mockResolvedValue({ current_protocol_version: currentProtocolVersion }), + submitTransaction: vi.fn().mockResolvedValue({ hash: submitHash }), + }; +} + +describe("TrustlineAuthHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("checkAndRequest detects AUTH_REQUIRED and emits trustlineAuthRequired with the issuer's public key", async () => { + const server = buildMockHorizonServer({ authRequired: true }); + const handler = new TrustlineAuthHandler(server as never, "Test SDF Network ; September 2015"); + + const events: TrustlineAuthRequest[] = []; + handler.on("trustlineAuthRequired", (e) => events.push(e)); + + const result = await handler.checkAndRequest(RECIPIENT, ASSET); + + expect(result.authRequired).toBe(true); + expect(result.status).toBe("required"); + expect(events).toHaveLength(1); + expect(events[0]!.assetIssuer).toBe(ISSUER); + expect(events[0]!.recipientId).toBe(RECIPIENT); + }); + + it("checkAndRequest does not emit when the issuer does not require authorization", async () => { + const server = buildMockHorizonServer({ authRequired: false }); + const handler = new TrustlineAuthHandler(server as never, "Test SDF Network ; September 2015"); + + const events: TrustlineAuthRequest[] = []; + handler.on("trustlineAuthRequired", (e) => events.push(e)); + + const result = await handler.checkAndRequest(RECIPIENT, ASSET); + + expect(result.authRequired).toBe(false); + expect(result.status).toBe("not_required"); + expect(events).toHaveLength(0); + }); + + it("grantAuth submits SetTrustLineFlags on protocol >= 18 and emits trustlineAuthGranted", async () => { + const server = buildMockHorizonServer({ currentProtocolVersion: 21, submitHash: "hash-settrustlineflags" }); + const handler = new TrustlineAuthHandler(server as never, "Test SDF Network ; September 2015"); + + const events: TrustlineAuthRequest[] = []; + handler.on("trustlineAuthGranted", (e) => events.push(e)); + + const issuerKeypair = Keypair.fromSecret("SISSUERSECRET"); + const result = await handler.grantAuth(RECIPIENT, ASSET, issuerKeypair as never); + + expect(Operation.setTrustLineFlags).toHaveBeenCalledWith( + expect.objectContaining({ trustor: RECIPIENT, flags: { authorized: true } }), + ); + expect(Operation.allowTrust).not.toHaveBeenCalled(); + expect(result.status).toBe("granted"); + expect(result.operationType).toBe("setTrustLineFlags"); + expect(result.txHash).toBe("hash-settrustlineflags"); + expect(events).toHaveLength(1); + }); + + it("grantAuth falls back to AllowTrust on protocol < 18", async () => { + const server = buildMockHorizonServer({ currentProtocolVersion: 17, submitHash: "hash-allowtrust" }); + const handler = new TrustlineAuthHandler(server as never, "Test SDF Network ; September 2015"); + + const issuerKeypair = Keypair.fromSecret("SISSUERSECRET"); + const result = await handler.grantAuth(RECIPIENT, ASSET, issuerKeypair as never); + + expect(Operation.allowTrust).toHaveBeenCalledWith( + expect.objectContaining({ trustor: RECIPIENT, assetCode: ASSET.code, authorize: true }), + ); + expect(Operation.setTrustLineFlags).not.toHaveBeenCalled(); + expect(result.status).toBe("granted"); + expect(result.operationType).toBe("allowTrust"); + expect(result.txHash).toBe("hash-allowtrust"); + }); +});