stellar-check 0.1.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/LICENSE +227 -0
- package/README.md +167 -0
- package/dist/amounts.d.ts +5 -0
- package/dist/amounts.js +18 -0
- package/dist/assess.d.ts +2 -0
- package/dist/assess.js +205 -0
- package/dist/check.d.ts +3 -0
- package/dist/check.js +88 -0
- package/dist/horizon.d.ts +17 -0
- package/dist/horizon.js +196 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/input.d.ts +10 -0
- package/dist/input.js +102 -0
- package/dist/state.d.ts +20 -0
- package/dist/state.js +88 -0
- package/dist/types.d.ts +108 -0
- package/dist/types.js +1 -0
- package/dist/xdr.d.ts +19 -0
- package/dist/xdr.js +72 -0
- package/package.json +51 -0
package/dist/check.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { assessPayment } from './assess.js';
|
|
2
|
+
import { isRecord, validateIntent } from './input.js';
|
|
3
|
+
import { validateNetwork } from './state.js';
|
|
4
|
+
/** Fresh observations on every call. An incomplete report may still contain known issues. */
|
|
5
|
+
export async function checkPayment(input, provider) {
|
|
6
|
+
let capturedInput = input;
|
|
7
|
+
const startedAt = new Date().toISOString();
|
|
8
|
+
const unavailable = {
|
|
9
|
+
ok: false,
|
|
10
|
+
code: 'PROVIDER_UNAVAILABLE',
|
|
11
|
+
message: 'This read was not attempted because required input or network metadata was unavailable.',
|
|
12
|
+
observedAt: startedAt,
|
|
13
|
+
};
|
|
14
|
+
const snapshot = {
|
|
15
|
+
network: unavailable,
|
|
16
|
+
source: unavailable,
|
|
17
|
+
destination: unavailable,
|
|
18
|
+
memo: unavailable,
|
|
19
|
+
};
|
|
20
|
+
function finish() {
|
|
21
|
+
const report = assessPayment(capturedInput, snapshot);
|
|
22
|
+
report.observation = {
|
|
23
|
+
...report.observation,
|
|
24
|
+
provider: provider.url,
|
|
25
|
+
startedAt,
|
|
26
|
+
finishedAt: new Date().toISOString(),
|
|
27
|
+
};
|
|
28
|
+
return report;
|
|
29
|
+
}
|
|
30
|
+
const validated = validateIntent(input);
|
|
31
|
+
if (!('payment' in validated))
|
|
32
|
+
return finish();
|
|
33
|
+
const payment = {
|
|
34
|
+
...validated.payment,
|
|
35
|
+
asset: { ...validated.payment.asset },
|
|
36
|
+
...(validated.payment.memo ? { memo: { ...validated.payment.memo } } : {}),
|
|
37
|
+
};
|
|
38
|
+
capturedInput = payment;
|
|
39
|
+
async function read(fn) {
|
|
40
|
+
try {
|
|
41
|
+
const result = await fn();
|
|
42
|
+
if (!isRecord(result) ||
|
|
43
|
+
typeof result.ok !== 'boolean' ||
|
|
44
|
+
typeof result.observedAt !== 'string' ||
|
|
45
|
+
(result.ok && !('value' in result)) ||
|
|
46
|
+
(!result.ok &&
|
|
47
|
+
(![
|
|
48
|
+
'PROVIDER_TIMEOUT',
|
|
49
|
+
'PROVIDER_RATE_LIMITED',
|
|
50
|
+
'PROVIDER_UNAVAILABLE',
|
|
51
|
+
'MALFORMED_DATA',
|
|
52
|
+
].includes(String(result.code)) ||
|
|
53
|
+
typeof result.message !== 'string')))
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
code: 'MALFORMED_DATA',
|
|
57
|
+
message: 'The provider returned a malformed observation.',
|
|
58
|
+
observedAt: new Date().toISOString(),
|
|
59
|
+
};
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
code: 'PROVIDER_UNAVAILABLE',
|
|
66
|
+
message: 'The provider failed to return an observation.',
|
|
67
|
+
observedAt: new Date().toISOString(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
snapshot.network = await read(() => provider.getNetwork());
|
|
72
|
+
if (!snapshot.network.ok)
|
|
73
|
+
return finish();
|
|
74
|
+
try {
|
|
75
|
+
validateNetwork(snapshot.network.value);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return finish();
|
|
79
|
+
}
|
|
80
|
+
if (snapshot.network.value.networkPassphrase !== payment.networkPassphrase)
|
|
81
|
+
return finish();
|
|
82
|
+
[snapshot.source, snapshot.destination, snapshot.memo] = await Promise.all([
|
|
83
|
+
read(() => provider.getAccount(payment.source)),
|
|
84
|
+
read(() => provider.getAccount(payment.destination)),
|
|
85
|
+
read(() => provider.checkMemo(payment)),
|
|
86
|
+
]);
|
|
87
|
+
return finish();
|
|
88
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MemoCheck, NetworkState, PaymentIntent, PaymentProvider, Read } from './types.js';
|
|
2
|
+
export interface HorizonOptions {
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
allowHttp?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare class HorizonProvider implements PaymentProvider {
|
|
8
|
+
readonly url: string;
|
|
9
|
+
private readonly transport;
|
|
10
|
+
private readonly timeoutMs;
|
|
11
|
+
constructor(url: string, options?: HorizonOptions);
|
|
12
|
+
private request;
|
|
13
|
+
private capture;
|
|
14
|
+
getNetwork(): Promise<Read<NetworkState>>;
|
|
15
|
+
getAccount(address: string): Promise<Read<unknown | null>>;
|
|
16
|
+
checkMemo(payment: PaymentIntent): Promise<Read<MemoCheck>>;
|
|
17
|
+
}
|
package/dist/horizon.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Account, AccountRequiresMemoError, Asset, Horizon, Operation, TransactionBuilder, } from '@stellar/stellar-sdk';
|
|
2
|
+
import { formatAmount } from './amounts.js';
|
|
3
|
+
import { isRecord, sdkMemo } from './input.js';
|
|
4
|
+
class ProviderFailure extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Restrict the SDK memo helper's account loading to our bounded read transport. */
|
|
12
|
+
class MemoServer extends Horizon.Server {
|
|
13
|
+
load;
|
|
14
|
+
constructor(url, load) {
|
|
15
|
+
super(url, { allowHttp: new URL(url).protocol === 'http:' });
|
|
16
|
+
this.load = load;
|
|
17
|
+
}
|
|
18
|
+
loadAccount(address) {
|
|
19
|
+
return this.load(address);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class HorizonProvider {
|
|
23
|
+
url;
|
|
24
|
+
transport;
|
|
25
|
+
timeoutMs;
|
|
26
|
+
constructor(url, options = {}) {
|
|
27
|
+
const parsed = new URL(url);
|
|
28
|
+
if (!['https:', 'http:'].includes(parsed.protocol) ||
|
|
29
|
+
parsed.username ||
|
|
30
|
+
parsed.password ||
|
|
31
|
+
parsed.search ||
|
|
32
|
+
parsed.hash)
|
|
33
|
+
throw new Error('Use a plain HTTP(S) Horizon URL without credentials, query or fragment.');
|
|
34
|
+
if (parsed.protocol === 'http:' && !options.allowHttp)
|
|
35
|
+
throw new Error('HTTP requires explicit allowHttp for local development.');
|
|
36
|
+
this.url = parsed.toString().replace(/\/$/, '');
|
|
37
|
+
this.transport = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
38
|
+
this.timeoutMs = options.timeoutMs ?? 10_000;
|
|
39
|
+
if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 60_000)
|
|
40
|
+
throw new Error('timeoutMs must be an integer between 1 and 60000.');
|
|
41
|
+
}
|
|
42
|
+
async request(path, allowMissing = false) {
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
let timer;
|
|
45
|
+
const timeout = new Promise((_, reject) => {
|
|
46
|
+
timer = setTimeout(() => {
|
|
47
|
+
reject(new ProviderFailure('PROVIDER_TIMEOUT', 'The Horizon read exceeded its time limit.'));
|
|
48
|
+
controller.abort();
|
|
49
|
+
}, this.timeoutMs);
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
return await Promise.race([
|
|
53
|
+
timeout,
|
|
54
|
+
(async () => {
|
|
55
|
+
const response = await this.transport(`${this.url}${path}`, {
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
headers: { Accept: 'application/json' },
|
|
58
|
+
cache: 'no-store',
|
|
59
|
+
});
|
|
60
|
+
if (response.status === 404 && allowMissing)
|
|
61
|
+
return { body: null };
|
|
62
|
+
if (response.status === 429)
|
|
63
|
+
throw new ProviderFailure('PROVIDER_RATE_LIMITED', 'Horizon rate-limited the read. Try again later.');
|
|
64
|
+
if (!response.ok)
|
|
65
|
+
throw new ProviderFailure('PROVIDER_UNAVAILABLE', `Horizon returned HTTP ${response.status}.`);
|
|
66
|
+
let body;
|
|
67
|
+
try {
|
|
68
|
+
body = await response.json();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new ProviderFailure('MALFORMED_DATA', 'Horizon returned invalid JSON.');
|
|
72
|
+
}
|
|
73
|
+
const header = response.headers.get('x-last-ledger');
|
|
74
|
+
if (header !== null &&
|
|
75
|
+
(!/^\d+$/.test(header) || !Number.isSafeInteger(Number(header)) || Number(header) <= 0))
|
|
76
|
+
throw new ProviderFailure('MALFORMED_DATA', 'Horizon returned an invalid ledger header.');
|
|
77
|
+
return header === null ? { body } : { body, ledger: Number(header) };
|
|
78
|
+
})(),
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async capture(fn) {
|
|
86
|
+
try {
|
|
87
|
+
return { ok: true, ...(await fn()), observedAt: new Date().toISOString() };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: error instanceof ProviderFailure ? error.code : 'PROVIDER_UNAVAILABLE',
|
|
93
|
+
message: error instanceof ProviderFailure
|
|
94
|
+
? error.message
|
|
95
|
+
: 'The Horizon read could not be completed.',
|
|
96
|
+
observedAt: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
getNetwork() {
|
|
101
|
+
return this.capture(async () => {
|
|
102
|
+
const root = await this.request('/');
|
|
103
|
+
if (!isRecord(root.body) ||
|
|
104
|
+
typeof root.body.network_passphrase !== 'string' ||
|
|
105
|
+
!root.body.network_passphrase.trim())
|
|
106
|
+
throw new ProviderFailure('MALFORMED_DATA', 'Horizon network passphrase is missing.');
|
|
107
|
+
const result = await this.request('/ledgers?order=desc&limit=1');
|
|
108
|
+
const embedded = isRecord(result.body) && result.body._embedded;
|
|
109
|
+
const records = isRecord(embedded) && embedded.records;
|
|
110
|
+
const ledger = Array.isArray(records) ? records[0] : undefined;
|
|
111
|
+
if (!isRecord(ledger) ||
|
|
112
|
+
typeof ledger.sequence !== 'number' ||
|
|
113
|
+
!Number.isSafeInteger(ledger.sequence) ||
|
|
114
|
+
ledger.sequence <= 0)
|
|
115
|
+
throw new ProviderFailure('MALFORMED_DATA', 'Horizon did not return a current ledger.');
|
|
116
|
+
function units(value) {
|
|
117
|
+
if ((typeof value !== 'number' && typeof value !== 'string') ||
|
|
118
|
+
!/^\d{1,10}$/.test(String(value)))
|
|
119
|
+
throw new ProviderFailure('MALFORMED_DATA', 'Ledger reserve or base fee is missing or malformed.');
|
|
120
|
+
const amount = BigInt(value);
|
|
121
|
+
if (amount <= 0n || amount > 4294967295n)
|
|
122
|
+
throw new ProviderFailure('MALFORMED_DATA', 'Ledger reserve or base fee is outside its range.');
|
|
123
|
+
return formatAmount(amount);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
value: {
|
|
127
|
+
networkPassphrase: root.body.network_passphrase,
|
|
128
|
+
baseReserve: units(ledger.base_reserve_in_stroops),
|
|
129
|
+
baseFee: units(ledger.base_fee_in_stroops),
|
|
130
|
+
ledger: ledger.sequence,
|
|
131
|
+
},
|
|
132
|
+
ledger: ledger.sequence,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
getAccount(address) {
|
|
137
|
+
return this.capture(async () => {
|
|
138
|
+
const { body, ...metadata } = await this.request(`/accounts/${encodeURIComponent(address)}`, true);
|
|
139
|
+
return { value: body, ...metadata };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
checkMemo(payment) {
|
|
143
|
+
return this.capture(async () => {
|
|
144
|
+
let observedLedger;
|
|
145
|
+
let missing = false;
|
|
146
|
+
const server = new MemoServer(this.url, async (address) => {
|
|
147
|
+
const result = await this.request(`/accounts/${encodeURIComponent(address)}`, true);
|
|
148
|
+
observedLedger = result.ledger;
|
|
149
|
+
if (result.body === null) {
|
|
150
|
+
missing = true;
|
|
151
|
+
throw new ProviderFailure('PROVIDER_UNAVAILABLE', 'Memo destination is missing.');
|
|
152
|
+
}
|
|
153
|
+
const record = result.body;
|
|
154
|
+
// Horizon REST calls this `data`; the SDK exposes it as `data_attr`.
|
|
155
|
+
if (!isRecord(record) ||
|
|
156
|
+
record.account_id !== address ||
|
|
157
|
+
typeof record.sequence !== 'string' ||
|
|
158
|
+
!/^\d+$/.test(record.sequence) ||
|
|
159
|
+
!isRecord(record.data) ||
|
|
160
|
+
Object.values(record.data).some((value) => typeof value !== 'string' ||
|
|
161
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)))
|
|
162
|
+
throw new ProviderFailure('MALFORMED_DATA', 'The account memo declaration data is missing or malformed.');
|
|
163
|
+
return new Horizon.AccountResponse({
|
|
164
|
+
...record,
|
|
165
|
+
data_attr: record.data,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
const asset = payment.asset.type === 'native'
|
|
169
|
+
? Asset.native()
|
|
170
|
+
: new Asset(payment.asset.code, payment.asset.issuer);
|
|
171
|
+
// Sequence 0 is only a container for SDK memo inspection; never signed or submitted.
|
|
172
|
+
const builder = new TransactionBuilder(new Account(payment.source, '0'), {
|
|
173
|
+
fee: '100',
|
|
174
|
+
networkPassphrase: payment.networkPassphrase,
|
|
175
|
+
})
|
|
176
|
+
.addOperation(Operation.payment({ destination: payment.destination, asset, amount: payment.amount }))
|
|
177
|
+
.setTimeout(0);
|
|
178
|
+
if (payment.memo)
|
|
179
|
+
builder.addMemo(sdkMemo(payment.memo));
|
|
180
|
+
let result = payment.memo ? 'present' : 'not_required';
|
|
181
|
+
try {
|
|
182
|
+
await server.checkMemoRequired(builder.build());
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (error instanceof AccountRequiresMemoError)
|
|
186
|
+
result = 'required';
|
|
187
|
+
else if (!missing)
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
value: { result },
|
|
192
|
+
...(observedLedger === undefined ? {} : { ledger: observedLedger }),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { checkPayment } from './check.js';
|
|
2
|
+
export { assessPayment } from './assess.js';
|
|
3
|
+
export { HorizonProvider } from './horizon.js';
|
|
4
|
+
export type { HorizonOptions } from './horizon.js';
|
|
5
|
+
export type * from './types.js';
|
|
6
|
+
export { importPaymentXdr } from './xdr.js';
|
|
7
|
+
export type { XdrImportResult } from './xdr.js';
|
package/dist/index.js
ADDED
package/dist/input.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Memo } from '@stellar/stellar-sdk';
|
|
2
|
+
import type { Diagnostic, PaymentIntent, PaymentMemo } from './types.js';
|
|
3
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
4
|
+
export declare function sdkMemo(memo: PaymentMemo): Memo;
|
|
5
|
+
export declare function validateIntent(value: unknown): {
|
|
6
|
+
payment: PaymentIntent;
|
|
7
|
+
issues: [];
|
|
8
|
+
} | {
|
|
9
|
+
issues: Diagnostic[];
|
|
10
|
+
};
|
package/dist/input.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Memo, StrKey } from '@stellar/stellar-sdk';
|
|
2
|
+
import { parseAmount } from './amounts.js';
|
|
3
|
+
export function isRecord(value) {
|
|
4
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
export function sdkMemo(memo) {
|
|
7
|
+
switch (memo.type) {
|
|
8
|
+
case 'text':
|
|
9
|
+
return Memo.text(memo.value);
|
|
10
|
+
case 'id':
|
|
11
|
+
return Memo.id(memo.value);
|
|
12
|
+
case 'hash':
|
|
13
|
+
return Memo.hash(memo.value);
|
|
14
|
+
case 'return':
|
|
15
|
+
return Memo.return(memo.value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function validateIntent(value) {
|
|
19
|
+
const fail = (unsupported, message) => ({
|
|
20
|
+
issues: [
|
|
21
|
+
{
|
|
22
|
+
code: unsupported ? 'UNSUPPORTED_PAYMENT' : 'INVALID_INPUT',
|
|
23
|
+
party: 'application',
|
|
24
|
+
message,
|
|
25
|
+
action: unsupported
|
|
26
|
+
? 'Use one classic payment between distinct G accounts, with one source and fee payer.'
|
|
27
|
+
: 'Correct the payment input and check again.',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
if (!isRecord(value))
|
|
32
|
+
return fail(false, 'Payment must be an object.');
|
|
33
|
+
const allowed = new Set([
|
|
34
|
+
'networkPassphrase',
|
|
35
|
+
'source',
|
|
36
|
+
'destination',
|
|
37
|
+
'asset',
|
|
38
|
+
'amount',
|
|
39
|
+
'feeBudget',
|
|
40
|
+
'memo',
|
|
41
|
+
'operationType',
|
|
42
|
+
'operationSource',
|
|
43
|
+
'feePayer',
|
|
44
|
+
]);
|
|
45
|
+
if (Object.keys(value).some((key) => !allowed.has(key)))
|
|
46
|
+
return fail(true, 'This payment includes unsupported fields or transaction features.');
|
|
47
|
+
if (value.operationType !== undefined && value.operationType !== 'payment')
|
|
48
|
+
return fail(true, 'Only a classic payment is supported.');
|
|
49
|
+
if (typeof value.source !== 'string' || typeof value.destination !== 'string')
|
|
50
|
+
return fail(false, 'Source and destination must be public G addresses.');
|
|
51
|
+
if (!value.source.startsWith('G') ||
|
|
52
|
+
!value.destination.startsWith('G') ||
|
|
53
|
+
value.source === value.destination)
|
|
54
|
+
return fail(true, 'Only distinct ordinary G-address accounts are supported.');
|
|
55
|
+
if (!StrKey.isValidEd25519PublicKey(value.source) ||
|
|
56
|
+
!StrKey.isValidEd25519PublicKey(value.destination))
|
|
57
|
+
return fail(false, 'Source or destination has an invalid address checksum.');
|
|
58
|
+
if ((value.feePayer !== undefined && value.feePayer !== value.source) ||
|
|
59
|
+
(value.operationSource !== undefined && value.operationSource !== value.source))
|
|
60
|
+
return fail(true, 'Separate operation sources and fee payers are unsupported.');
|
|
61
|
+
if (typeof value.networkPassphrase !== 'string' || !value.networkPassphrase.trim())
|
|
62
|
+
return fail(false, 'Choose an explicit network passphrase.');
|
|
63
|
+
if (!isRecord(value.asset) || !['native', 'credit'].includes(String(value.asset.type)))
|
|
64
|
+
return fail(true, 'Only native XLM or a classic issued asset is supported.');
|
|
65
|
+
const asset = value.asset;
|
|
66
|
+
if (asset.type === 'native' && Object.keys(asset).some((key) => key !== 'type'))
|
|
67
|
+
return fail(false, 'Native XLM must not specify a code or issuer.');
|
|
68
|
+
if (asset.type === 'credit') {
|
|
69
|
+
if (Object.keys(asset).some((key) => !['type', 'code', 'issuer'].includes(key)))
|
|
70
|
+
return fail(true, 'Unsupported asset fields.');
|
|
71
|
+
if (typeof asset.code !== 'string' ||
|
|
72
|
+
!/^[a-zA-Z0-9]{1,12}$/.test(asset.code) ||
|
|
73
|
+
typeof asset.issuer !== 'string' ||
|
|
74
|
+
!StrKey.isValidEd25519PublicKey(asset.issuer))
|
|
75
|
+
return fail(false, 'Issued assets require a 1–12 character alphanumeric code and valid G-address issuer.');
|
|
76
|
+
if (asset.issuer === value.source || asset.issuer === value.destination)
|
|
77
|
+
return fail(true, 'Payments involving the asset issuer as a party are unsupported.');
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
if (parseAmount(value.amount) === 0n)
|
|
81
|
+
return fail(false, 'Payment amount must be greater than zero.');
|
|
82
|
+
if (parseAmount(value.feeBudget, 4294967295n) === 0n)
|
|
83
|
+
return fail(false, 'Fee budget must be positive and within the transaction fee range.');
|
|
84
|
+
if (value.memo !== undefined) {
|
|
85
|
+
if (!isRecord(value.memo) ||
|
|
86
|
+
Object.keys(value.memo).some((k) => !['type', 'value'].includes(k)) ||
|
|
87
|
+
!['text', 'id', 'hash', 'return'].includes(String(value.memo.type)) ||
|
|
88
|
+
typeof value.memo.value !== 'string')
|
|
89
|
+
return fail(false, 'Use a text, ID, hash or return-hash memo.');
|
|
90
|
+
if (value.memo.type === 'id' && !/^\d{1,20}$/.test(value.memo.value))
|
|
91
|
+
return fail(false, 'Memo ID must be an unsigned integer string.');
|
|
92
|
+
if (['hash', 'return'].includes(String(value.memo.type)) &&
|
|
93
|
+
!/^[0-9a-fA-F]{64}$/.test(value.memo.value))
|
|
94
|
+
return fail(false, 'Hash memos must contain 64 hexadecimal characters.');
|
|
95
|
+
sdkMemo(value.memo);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return fail(false, error instanceof Error ? error.message : 'Invalid amount or memo.');
|
|
100
|
+
}
|
|
101
|
+
return { payment: value, issues: [] };
|
|
102
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Asset, NetworkState } from './types.js';
|
|
2
|
+
export interface Balance {
|
|
3
|
+
balance: bigint;
|
|
4
|
+
selling: bigint;
|
|
5
|
+
buying: bigint;
|
|
6
|
+
limit: bigint;
|
|
7
|
+
authorized: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface AccountState {
|
|
10
|
+
reserveUnits: bigint;
|
|
11
|
+
native: Balance;
|
|
12
|
+
asset?: Balance;
|
|
13
|
+
lastModifiedLedger: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function validateNetwork(value: NetworkState): {
|
|
16
|
+
reserve: bigint;
|
|
17
|
+
fee: bigint;
|
|
18
|
+
};
|
|
19
|
+
/** Validate relevant entries; unrelated pool-share entries never substitute for a trustline. */
|
|
20
|
+
export declare function parseAccount(value: unknown, address: string, asset: Asset): AccountState;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { StrKey } from '@stellar/stellar-sdk';
|
|
2
|
+
import { MAX_AMOUNT, parseAmount } from './amounts.js';
|
|
3
|
+
import { isRecord } from './input.js';
|
|
4
|
+
function count(value) {
|
|
5
|
+
if (typeof value !== 'number' ||
|
|
6
|
+
!Number.isSafeInteger(value) ||
|
|
7
|
+
value < 0 ||
|
|
8
|
+
value > 4_294_967_295)
|
|
9
|
+
throw new Error('Missing or invalid account counter/ledger.');
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function validateNetwork(value) {
|
|
13
|
+
if (!isRecord(value) ||
|
|
14
|
+
typeof value.networkPassphrase !== 'string' ||
|
|
15
|
+
!value.networkPassphrase.trim() ||
|
|
16
|
+
count(value.ledger) === 0)
|
|
17
|
+
throw new Error('Missing network identity or ledger.');
|
|
18
|
+
const reserve = parseAmount(value.baseReserve, 4294967295n), fee = parseAmount(value.baseFee, 4294967295n);
|
|
19
|
+
if (reserve === 0n || fee === 0n)
|
|
20
|
+
throw new Error('Base reserve and base fee must be positive.');
|
|
21
|
+
return { reserve, fee };
|
|
22
|
+
}
|
|
23
|
+
/** Validate relevant entries; unrelated pool-share entries never substitute for a trustline. */
|
|
24
|
+
export function parseAccount(value, address, asset) {
|
|
25
|
+
if (!isRecord(value) || value.account_id !== address || !Array.isArray(value.balances))
|
|
26
|
+
throw new Error('Account identity or balances are missing or inconsistent.');
|
|
27
|
+
const subentries = count(value.subentry_count), sponsoring = count(value.num_sponsoring), sponsored = count(value.num_sponsored);
|
|
28
|
+
if (sponsored > 2 + subentries)
|
|
29
|
+
throw new Error('Sponsored reserves exceed this account’s reserve entries.');
|
|
30
|
+
const reserveUnits = BigInt(2 + subentries + sponsoring - sponsored);
|
|
31
|
+
const entries = new Map();
|
|
32
|
+
for (const entry of value.balances) {
|
|
33
|
+
if (!isRecord(entry) || typeof entry.asset_type !== 'string')
|
|
34
|
+
throw new Error('Malformed balance entry.');
|
|
35
|
+
let key;
|
|
36
|
+
if (entry.asset_type === 'native')
|
|
37
|
+
key = 'native';
|
|
38
|
+
else if (entry.asset_type === 'liquidity_pool_shares') {
|
|
39
|
+
if (typeof entry.liquidity_pool_id !== 'string' ||
|
|
40
|
+
!/^[0-9a-f]{64}$/.test(entry.liquidity_pool_id))
|
|
41
|
+
throw new Error('Malformed pool-share identity.');
|
|
42
|
+
key = `pool:${entry.liquidity_pool_id}`;
|
|
43
|
+
}
|
|
44
|
+
else if (['credit_alphanum4', 'credit_alphanum12'].includes(entry.asset_type)) {
|
|
45
|
+
if (typeof entry.asset_code !== 'string' ||
|
|
46
|
+
!/^[a-zA-Z0-9]{1,12}$/.test(entry.asset_code) ||
|
|
47
|
+
typeof entry.asset_issuer !== 'string' ||
|
|
48
|
+
!StrKey.isValidEd25519PublicKey(entry.asset_issuer))
|
|
49
|
+
throw new Error('Malformed trustline asset identity.');
|
|
50
|
+
if (entry.asset_code.length <= 4 !== (entry.asset_type === 'credit_alphanum4'))
|
|
51
|
+
throw new Error('Asset code length does not match its type.');
|
|
52
|
+
key = `${entry.asset_code}:${entry.asset_issuer}`;
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
throw new Error('Unknown balance entry type.');
|
|
56
|
+
if (entries.has(key))
|
|
57
|
+
throw new Error('Duplicate balance entry.');
|
|
58
|
+
entries.set(key, entry);
|
|
59
|
+
}
|
|
60
|
+
if (entries.size - (entries.has('native') ? 1 : 0) > subentries)
|
|
61
|
+
throw new Error('Subentry count omits returned trustlines or pool shares.');
|
|
62
|
+
function balance(entry, native) {
|
|
63
|
+
if (!entry)
|
|
64
|
+
throw new Error('Native balance is missing.');
|
|
65
|
+
const amount = parseAmount(entry.balance), selling = parseAmount(entry.selling_liabilities), buying = parseAmount(entry.buying_liabilities);
|
|
66
|
+
const limit = native ? MAX_AMOUNT : parseAmount(entry.limit);
|
|
67
|
+
if (selling > amount || amount > limit || buying > limit - amount)
|
|
68
|
+
throw new Error('Balance, liabilities and limit are inconsistent.');
|
|
69
|
+
if (!native && typeof entry.is_authorized !== 'boolean')
|
|
70
|
+
throw new Error('Trustline authorization is missing.');
|
|
71
|
+
return {
|
|
72
|
+
balance: amount,
|
|
73
|
+
selling,
|
|
74
|
+
buying,
|
|
75
|
+
limit,
|
|
76
|
+
authorized: native || entry.is_authorized === true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const result = {
|
|
80
|
+
reserveUnits,
|
|
81
|
+
native: balance(entries.get('native'), true),
|
|
82
|
+
lastModifiedLedger: count(value.last_modified_ledger),
|
|
83
|
+
};
|
|
84
|
+
const relevant = asset.type === 'credit' ? entries.get(`${asset.code}:${asset.issuer}`) : undefined;
|
|
85
|
+
if (relevant)
|
|
86
|
+
result.asset = balance(relevant, false);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type Asset = {
|
|
2
|
+
type: 'native';
|
|
3
|
+
} | {
|
|
4
|
+
type: 'credit';
|
|
5
|
+
code: string;
|
|
6
|
+
issuer: string;
|
|
7
|
+
};
|
|
8
|
+
export type PaymentMemo = {
|
|
9
|
+
type: 'text' | 'id' | 'hash' | 'return';
|
|
10
|
+
value: string;
|
|
11
|
+
};
|
|
12
|
+
/** Decimal strings use XLM/asset units, including feeBudget (not stroops). */
|
|
13
|
+
export interface PaymentIntent {
|
|
14
|
+
networkPassphrase: string;
|
|
15
|
+
source: string;
|
|
16
|
+
destination: string;
|
|
17
|
+
asset: Asset;
|
|
18
|
+
amount: string;
|
|
19
|
+
feeBudget: string;
|
|
20
|
+
memo?: PaymentMemo;
|
|
21
|
+
operationType?: 'payment';
|
|
22
|
+
operationSource?: string;
|
|
23
|
+
feePayer?: string;
|
|
24
|
+
}
|
|
25
|
+
export type DiagnosticCode = 'INVALID_INPUT' | 'UNSUPPORTED_PAYMENT' | 'NETWORK_MISMATCH' | 'PROVIDER_TIMEOUT' | 'PROVIDER_RATE_LIMITED' | 'PROVIDER_UNAVAILABLE' | 'MALFORMED_DATA' | 'SOURCE_ACCOUNT_MISSING' | 'DESTINATION_ACCOUNT_MISSING' | 'SOURCE_TRUSTLINE_MISSING' | 'DESTINATION_TRUSTLINE_MISSING' | 'SOURCE_NOT_AUTHORIZED' | 'DESTINATION_NOT_AUTHORIZED' | 'INSUFFICIENT_XLM' | 'INSUFFICIENT_ASSET' | 'DESTINATION_CAPACITY_EXCEEDED' | 'FEE_BUDGET_TOO_LOW' | 'MEMO_REQUIRED';
|
|
26
|
+
export type Party = 'sender' | 'recipient' | 'issuer' | 'application' | 'provider';
|
|
27
|
+
export interface Diagnostic {
|
|
28
|
+
code: DiagnosticCode;
|
|
29
|
+
party: Party;
|
|
30
|
+
message: string;
|
|
31
|
+
action: string;
|
|
32
|
+
}
|
|
33
|
+
export type ProviderErrorCode = 'PROVIDER_TIMEOUT' | 'PROVIDER_RATE_LIMITED' | 'PROVIDER_UNAVAILABLE' | 'MALFORMED_DATA';
|
|
34
|
+
export type Read<T> = {
|
|
35
|
+
ok: true;
|
|
36
|
+
value: T;
|
|
37
|
+
observedAt: string;
|
|
38
|
+
ledger?: number;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
code: ProviderErrorCode;
|
|
42
|
+
message: string;
|
|
43
|
+
observedAt: string;
|
|
44
|
+
};
|
|
45
|
+
export interface NetworkState {
|
|
46
|
+
networkPassphrase: string;
|
|
47
|
+
baseReserve: string;
|
|
48
|
+
baseFee: string;
|
|
49
|
+
ledger: number;
|
|
50
|
+
}
|
|
51
|
+
export interface MemoCheck {
|
|
52
|
+
result: 'required' | 'present' | 'not_required';
|
|
53
|
+
}
|
|
54
|
+
export interface Snapshot {
|
|
55
|
+
network: Read<NetworkState>;
|
|
56
|
+
source: Read<unknown | null>;
|
|
57
|
+
destination: Read<unknown | null>;
|
|
58
|
+
memo: Read<MemoCheck>;
|
|
59
|
+
}
|
|
60
|
+
export interface PaymentProvider {
|
|
61
|
+
readonly url: string;
|
|
62
|
+
getNetwork(): Promise<Read<NetworkState>>;
|
|
63
|
+
getAccount(address: string): Promise<Read<unknown | null>>;
|
|
64
|
+
checkMemo(payment: PaymentIntent): Promise<Read<MemoCheck>>;
|
|
65
|
+
}
|
|
66
|
+
export type Check = 'input' | 'network' | 'source_account' | 'destination_account' | 'source_xlm' | 'source_asset' | 'destination_capacity' | 'memo';
|
|
67
|
+
export interface Coverage {
|
|
68
|
+
check: Check;
|
|
69
|
+
status: 'complete' | 'unresolved' | 'not_applicable';
|
|
70
|
+
reason?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface Facts {
|
|
73
|
+
baseReserve?: string;
|
|
74
|
+
baseFee?: string;
|
|
75
|
+
sourceReserve?: string;
|
|
76
|
+
sourceXlmBalance?: string;
|
|
77
|
+
nativeSellingLiabilities?: string;
|
|
78
|
+
feeBudget?: string;
|
|
79
|
+
availableXlm?: string;
|
|
80
|
+
requiredXlm?: string;
|
|
81
|
+
xlmShortfall?: string;
|
|
82
|
+
availableAsset?: string;
|
|
83
|
+
assetShortfall?: string;
|
|
84
|
+
receivingCapacity?: string;
|
|
85
|
+
capacityShortfall?: string;
|
|
86
|
+
memo?: 'required' | 'presence_only' | 'not_required';
|
|
87
|
+
}
|
|
88
|
+
export interface Report {
|
|
89
|
+
status: 'no_known_issues' | 'issues_found' | 'incomplete';
|
|
90
|
+
issues: Diagnostic[];
|
|
91
|
+
facts: Facts;
|
|
92
|
+
coverage: Coverage[];
|
|
93
|
+
observation: {
|
|
94
|
+
networkPassphrase: string | null;
|
|
95
|
+
provider?: string;
|
|
96
|
+
startedAt?: string;
|
|
97
|
+
finishedAt?: string;
|
|
98
|
+
reads: {
|
|
99
|
+
check: keyof Snapshot;
|
|
100
|
+
observedAt: string;
|
|
101
|
+
ledger?: number;
|
|
102
|
+
}[];
|
|
103
|
+
sourceLastModifiedLedger?: number;
|
|
104
|
+
destinationLastModifiedLedger?: number;
|
|
105
|
+
atomic: false;
|
|
106
|
+
};
|
|
107
|
+
limitations: string[];
|
|
108
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/xdr.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PaymentIntent } from './types.js';
|
|
2
|
+
export type XdrImportResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
payment: PaymentIntent;
|
|
5
|
+
context: {
|
|
6
|
+
sequence: string;
|
|
7
|
+
timeBounds?: {
|
|
8
|
+
minTime: string;
|
|
9
|
+
maxTime: string;
|
|
10
|
+
};
|
|
11
|
+
limitations: string[];
|
|
12
|
+
};
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
code: 'INVALID_XDR' | 'UNSUPPORTED_XDR';
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
/** Decode locally. This extracts a payment intent; it does not validate a transaction. */
|
|
19
|
+
export declare function importPaymentXdr(input: unknown, networkPassphrase: string): XdrImportResult;
|