Skip to content

Commit a553650

Browse files
authored
Merge branch 'main' into feature/549-escrow-vault-manager
2 parents 4d31802 + 797a8fb commit a553650

86 files changed

Lines changed: 11011 additions & 211 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@ All notable changes to this project will be documented in this file.
1111
### Features
1212

1313

14+
- **Add invoice due-date reminder scheduler (closes #542)**
15+
- `InvoiceReminderScheduler.schedule(invoiceId, offsets: number[])` registers reminders at each offset (ms) before an invoice's due date
16+
- Schedules persist via `saveReminderSchedules`/`loadReminderSchedules` (`src/snapshot.ts`), keyed by invoice, so reminders survive process restarts
17+
- On construction, past-due reminders within `gracePeriodMs` (default 60 000 ms) fire automatically; older ones are marked `expired`
18+
- Emits `invoiceReminderDue` with `{ invoiceId, offsetMs, dueAt }` via the existing `TypedEventEmitter`
19+
- `InvoiceReminderScheduler.cancel(invoiceId)` removes all pending reminders for an invoice
20+
- New types: `ReminderSchedule`, `ReminderEvent`, `ReminderStatus`; `InvoiceRecord.dueAt` added
21+
- **Add auth-required trustline request handler (closes #541)**
22+
- `TrustlineAuthHandler.checkAndRequest(recipientId, asset)` detects `AUTH_REQUIRED` issuers and emits `trustlineAuthRequired` with the issuer's public key
23+
- `TrustlineAuthHandler.grantAuth(recipientId, asset, issuerKeypair)` builds, signs, and submits the approval operation
24+
- Prefers `SetTrustLineFlags` (protocol >= 18) and falls back to legacy `AllowTrust` on older networks, detected via new `src/sorobanFeatureDetector.ts`
25+
- Issuer account flags read via new `src/accountFlagsInspector.ts`; integrated into `src/preflightChecker.ts` as `checkTrustlineAuthRequirement`
26+
- Emits `trustlineAuthGranted` after successful submission
27+
- **Add SEP-31 cross-border payment initiator (closes #540)**
28+
- `Sep31Initiator.initiate(...)` completes the anchor `/send` call and stores the returned transaction record
29+
- `Sep31Initiator.getRequiredFields(anchorDomain, asset)` reads the anchor `/info` endpoint and returns a typed field schema
30+
- `Sep31Initiator.pollStatus(transactionId, anchorDomain)` is an async generator yielding status updates until a terminal state (`completed`/`error`)
31+
- Resolves the receiving anchor's `DIRECT_PAYMENT_SERVER` from its stellar.toml via `StellarToml.Resolver`
32+
- SEP-10 JWT passed to `initiate()` is reused automatically for subsequent `pollStatus` calls
33+
- New types: `Sep31PaymentRecord`, `Sep31Status`, `Sep31StatusChangedEvent`, `Sep31RequiredFields`, `Sep31FieldSpec`
1434
- **Build invoice diff utility — compare two invoice states (closes #363)**
1535
- `diffInvoices(a: Invoice, b: Invoice)` returns structured diff of two invoice objects
1636
- Returns `InvoiceDiff` as `{ field: string, before: unknown, after: unknown }[]` — only changed fields listed

package-lock.json

Lines changed: 40 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@
3737
"scripts": {
3838
"build": "tsup",
3939
"dev": "tsup --watch",
40-
"test": "vitest run test/client.test.ts test/retryPolicy.test.ts test/property-roundtrip.test.ts test/property-address.test.ts test/property-deadline.test.ts test/property-invoice-params.test.ts test/property-client-invariants.test.ts",
41-
"test": "vitest run test/client.test.ts test/retryPolicy.test.ts test/resilience.test.ts",
42-
"test": "vitest run test/client.test.ts test/multiTenant.test.ts",
43-
"test": "vitest run test/client.test.ts test/profiler.test.ts",
4440
"test": "vitest run test/client.test.ts test/retryPolicy.test.ts",
4541
"test:ui": "vitest run test/ui/",
4642
"test:all": "vitest run",
@@ -57,7 +53,8 @@
5753
"@noble/curves": "^2.2.0",
5854
"@stellar/freighter-api": "^3.1.0",
5955
"@stellar/stellar-sdk": "^13.3.0",
60-
"@walletconnect/sign-client": "^2.23.9"
56+
"@walletconnect/sign-client": "^2.23.9",
57+
"ajv": "^8.20.0"
6158
},
6259
"devDependencies": {
6360
"@opentelemetry/api": "^1.9.0",

src/accountDataManager.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Typed CRUD manager for Stellar account data entries.
3+
*
4+
* Wraps `Operation.manageData()` with validation for the protocol's 64-byte
5+
* key/value limits and 64-entry-per-account cap, so callers can store custom
6+
* metadata alongside SDK state without hand-rolling raw manageData calls.
7+
*/
8+
9+
import {
10+
Account,
11+
Horizon,
12+
Keypair,
13+
Operation,
14+
TransactionBuilder,
15+
BASE_FEE,
16+
} from "@stellar/stellar-sdk";
17+
import type { AccountDataMap } from "./types.js";
18+
import { DataEntryValidationError } from "./errors.js";
19+
20+
/** Stellar protocol limit for both data entry keys and values, in bytes. */
21+
const MAX_DATA_ENTRY_BYTES = 64;
22+
23+
/** Stellar protocol limit on the number of data entries per account. */
24+
const MAX_DATA_ENTRIES = 64;
25+
26+
/** Result of submitting a manageData transaction. */
27+
export interface TransactionResult {
28+
txHash: string;
29+
}
30+
31+
/** Configuration for {@link AccountDataManager}. */
32+
export interface AccountDataManagerConfig {
33+
/** Horizon server URL. */
34+
horizonUrl: string;
35+
/** Stellar network passphrase. */
36+
networkPassphrase: string;
37+
}
38+
39+
function byteLength(value: string): number {
40+
return Buffer.byteLength(value, "utf8");
41+
}
42+
43+
/**
44+
* Typed CRUD manager for account data entries, built on top of
45+
* `Operation.manageData()` and `Server.loadAccount().data_attr`.
46+
*/
47+
export class AccountDataManager {
48+
private readonly server: Horizon.Server;
49+
private readonly networkPassphrase: string;
50+
51+
constructor(config: AccountDataManagerConfig) {
52+
this.server = new Horizon.Server(config.horizonUrl);
53+
this.networkPassphrase = config.networkPassphrase;
54+
}
55+
56+
/**
57+
* Set (create or update) a data entry on `accountId`.
58+
*
59+
* @throws DataEntryValidationError if the key/value exceed 64 bytes, or if
60+
* the account already has 64 entries and `key` is new.
61+
*/
62+
async set(
63+
accountId: string,
64+
key: string,
65+
value: string,
66+
signerSecret: string,
67+
): Promise<TransactionResult> {
68+
await this.validateEntry(accountId, key, value);
69+
return this.submitManageData(accountId, key, value, signerSecret);
70+
}
71+
72+
/**
73+
* Fetch the current value of `key` on `accountId`, or `null` if absent.
74+
*/
75+
async get(accountId: string, key: string): Promise<string | null> {
76+
const entries = await this.list(accountId);
77+
return Object.prototype.hasOwnProperty.call(entries, key) ? entries[key]! : null;
78+
}
79+
80+
/**
81+
* Delete a data entry by submitting `manageData` with a `null` value.
82+
*/
83+
async delete(
84+
accountId: string,
85+
key: string,
86+
signerSecret: string,
87+
): Promise<TransactionResult> {
88+
return this.submitManageData(accountId, key, null, signerSecret);
89+
}
90+
91+
/**
92+
* Return all data entries currently stored on `accountId`, decoded from
93+
* base64 to UTF-8 strings.
94+
*/
95+
async list(accountId: string): Promise<AccountDataMap> {
96+
const account = await this.server.loadAccount(accountId);
97+
const raw = account.data_attr as Record<string, string> | undefined;
98+
const result: AccountDataMap = {};
99+
for (const [key, base64Value] of Object.entries(raw ?? {})) {
100+
result[key] = Buffer.from(base64Value, "base64").toString("utf8");
101+
}
102+
return result;
103+
}
104+
105+
private async validateEntry(accountId: string, key: string, value: string): Promise<void> {
106+
if (byteLength(key) > MAX_DATA_ENTRY_BYTES) {
107+
throw new DataEntryValidationError(
108+
`key "${key}" exceeds ${MAX_DATA_ENTRY_BYTES} bytes`,
109+
{ key },
110+
);
111+
}
112+
if (byteLength(value) > MAX_DATA_ENTRY_BYTES) {
113+
throw new DataEntryValidationError(
114+
`value for key "${key}" exceeds ${MAX_DATA_ENTRY_BYTES} bytes`,
115+
{ key },
116+
);
117+
}
118+
119+
const existing = await this.list(accountId);
120+
const isNewKey = !Object.prototype.hasOwnProperty.call(existing, key);
121+
if (isNewKey && Object.keys(existing).length >= MAX_DATA_ENTRIES) {
122+
throw new DataEntryValidationError(
123+
`account ${accountId} already has ${MAX_DATA_ENTRIES} data entries`,
124+
{ accountId },
125+
);
126+
}
127+
}
128+
129+
private async submitManageData(
130+
accountId: string,
131+
key: string,
132+
value: string | null,
133+
signerSecret: string,
134+
): Promise<TransactionResult> {
135+
const keypair = Keypair.fromSecret(signerSecret);
136+
const loaded = await this.server.loadAccount(accountId);
137+
const sourceAccount = new Account(loaded.accountId(), loaded.sequenceNumber());
138+
139+
const tx = new TransactionBuilder(sourceAccount, {
140+
fee: BASE_FEE,
141+
networkPassphrase: this.networkPassphrase,
142+
})
143+
.addOperation(Operation.manageData({ name: key, value: value ?? null }))
144+
.setTimeout(30)
145+
.build();
146+
147+
tx.sign(keypair);
148+
const result = await this.server.submitTransaction(tx);
149+
return { txHash: result.hash };
150+
}
151+
}

src/accountFlagsInspector.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Account Flags State Inspector — decodes the AUTH_* flags on a Stellar
3+
* account's Horizon `AccountRecord` into a typed `AccountFlagSet`, so callers
4+
* don't need to inspect Horizon's raw `flags` object themselves.
5+
*/
6+
7+
import { Horizon } from "@stellar/stellar-sdk";
8+
import type { AccountFlagSet } from "./types.js";
9+
10+
/** Operations that AUTH_REQUIRED blocks until the holder has been explicitly authorized. */
11+
const AUTH_REQUIRED_BLOCKS = new Set(["trustline", "create_trustline", "payment"]);
12+
13+
function buildFlagSet(flags: Horizon.HorizonApi.Flags): AccountFlagSet {
14+
const flagSet: AccountFlagSet = {
15+
authRequired: flags.auth_required,
16+
authRevocable: flags.auth_revocable,
17+
authImmutable: flags.auth_immutable,
18+
authClawbackEnabled: flags.auth_clawback_enabled,
19+
isCompatibleWith(operation: string): boolean {
20+
if (flagSet.authRequired && AUTH_REQUIRED_BLOCKS.has(operation.toLowerCase())) {
21+
return false;
22+
}
23+
return true;
24+
},
25+
};
26+
return flagSet;
27+
}
28+
29+
/**
30+
* Fetch and decode the AUTH_* flags for a Stellar account.
31+
*
32+
* @param accountId - Stellar address to inspect.
33+
* @param horizonUrl - Horizon API base URL used to load the account.
34+
*/
35+
export async function inspectFlags(
36+
accountId: string,
37+
horizonUrl: string,
38+
): Promise<AccountFlagSet> {
39+
const server = new Horizon.Server(horizonUrl);
40+
const account = await server.loadAccount(accountId);
41+
return buildFlagSet(account.flags);
42+
}
43+
44+
/**
45+
* Convenience quick-fail check: `true` when any flag that can restrict a
46+
* counterparty's ability to hold or transact in this account's asset is set
47+
* (`authRequired`, `authRevocable`, or `authClawbackEnabled`).
48+
*
49+
* `authImmutable` is excluded — it only affects whether the issuer's own
50+
* flags can change in the future, not a counterparty's current operations.
51+
*/
52+
export function hasAnyRestrictiveFlag(flags: AccountFlagSet): boolean {
53+
return flags.authRequired || flags.authRevocable || flags.authClawbackEnabled;
54+
}

0 commit comments

Comments
 (0)