@defuse-protocol/nearintents-mpp-sdk 0.0.0 → 0.1.1
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 +21 -0
- package/README.md +235 -0
- package/dist/Errors.d.ts +57 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Errors.js +54 -0
- package/dist/Errors.js.map +1 -0
- package/dist/Methods.d.ts +44 -0
- package/dist/Methods.d.ts.map +1 -0
- package/dist/Methods.js +23 -0
- package/dist/Methods.js.map +1 -0
- package/dist/Types.d.ts +125 -0
- package/dist/Types.d.ts.map +1 -0
- package/dist/Types.js +195 -0
- package/dist/Types.js.map +1 -0
- package/dist/client/Charge.d.ts +141 -0
- package/dist/client/Charge.d.ts.map +1 -0
- package/dist/client/Charge.js +167 -0
- package/dist/client/Charge.js.map +1 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/OneClick.d.ts +243 -0
- package/dist/internal/OneClick.d.ts.map +1 -0
- package/dist/internal/OneClick.js +422 -0
- package/dist/internal/OneClick.js.map +1 -0
- package/dist/server/Charge.d.ts +241 -0
- package/dist/server/Charge.d.ts.map +1 -0
- package/dist/server/Charge.js +404 -0
- package/dist/server/Charge.js.map +1 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +87 -9
- package/src/Errors.ts +86 -0
- package/src/Methods.ts +24 -0
- package/src/Types.ts +256 -0
- package/src/client/Charge.ts +268 -0
- package/src/client/index.ts +4 -0
- package/src/index.ts +4 -0
- package/src/internal/OneClick.ts +634 -0
- package/src/server/Charge.ts +621 -0
- package/src/server/index.ts +4 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { Errors, Method, Store } from 'mppx'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SettlementFailedError,
|
|
5
|
+
SettlementTimeoutError,
|
|
6
|
+
SettlementUnavailableError,
|
|
7
|
+
} from '../Errors.js'
|
|
8
|
+
import * as OneClick from '../internal/OneClick.js'
|
|
9
|
+
import * as Methods from '../Methods.js'
|
|
10
|
+
import * as Types from '../Types.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a `nearintents` charge method for usage on the server.
|
|
14
|
+
*
|
|
15
|
+
* Each 402 challenge carries a unique single-use 1Click deposit address as
|
|
16
|
+
* `recipient` (minted via a wet `EXACT_OUTPUT` quote and cached with early
|
|
17
|
+
* refresh so the challenge `expires` always precedes the quote deadline).
|
|
18
|
+
* Verification confirms the client's deposit by 1Click status observation,
|
|
19
|
+
* drives the swap to a terminal state, and issues the extended receipt on
|
|
20
|
+
* `SUCCESS`.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { Mppx } from 'mppx/server'
|
|
25
|
+
* import { nearintents } from '@defuse-protocol/nearintents-mpp-sdk/server'
|
|
26
|
+
*
|
|
27
|
+
* const mppx = Mppx.create({
|
|
28
|
+
* secretKey: process.env.MPP_SECRET_KEY!,
|
|
29
|
+
* methods: [
|
|
30
|
+
* nearintents.charge({
|
|
31
|
+
* originAsset: 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
|
|
32
|
+
* destinationAsset: 'near:mainnet/nep141:1720…33a1',
|
|
33
|
+
* destinationRecipient: 'merchant.near',
|
|
34
|
+
* refundTo: '0x2527D02599Ba641c19FEa793cD0F9a6e8457C317',
|
|
35
|
+
* amountOut: '1000000',
|
|
36
|
+
* oneClick: { jwt: process.env.ONE_CLICK_JWT },
|
|
37
|
+
* }),
|
|
38
|
+
* ],
|
|
39
|
+
* })
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function charge(parameters: charge.Parameters) {
|
|
43
|
+
const {
|
|
44
|
+
amountOut: defaultAmountOut,
|
|
45
|
+
description,
|
|
46
|
+
destinationAsset,
|
|
47
|
+
destinationRecipient,
|
|
48
|
+
externalId,
|
|
49
|
+
oneClick = {},
|
|
50
|
+
originAsset,
|
|
51
|
+
// Distribution-channel attribution: every 1Click swap minted by this
|
|
52
|
+
// payment method carries the "mpp" referral (mirrors the x402 gateway).
|
|
53
|
+
referral = 'mpp',
|
|
54
|
+
refundTo,
|
|
55
|
+
slippageTolerance = 100,
|
|
56
|
+
} = parameters
|
|
57
|
+
|
|
58
|
+
// Merchant observability: settlement progress is reported as structured
|
|
59
|
+
// events (the library never logs). A throwing handler must not affect
|
|
60
|
+
// payment processing.
|
|
61
|
+
const emit = (event: charge.Event): void => {
|
|
62
|
+
try {
|
|
63
|
+
parameters.onEvent?.(event)
|
|
64
|
+
} catch {
|
|
65
|
+
// Observer errors are the observer's problem.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fail fast on malformed merchant config (throws at construction time).
|
|
70
|
+
const originNetwork = Types.chainOf(originAsset)
|
|
71
|
+
const destinationNetwork = Types.chainOf(destinationAsset)
|
|
72
|
+
|
|
73
|
+
const expiresWindowMs = (parameters.expiresWindow ?? 300) * 1000
|
|
74
|
+
const quoteDeadlineBufferMs = (parameters.quoteDeadlineBuffer ?? 900) * 1000
|
|
75
|
+
const pollIntervalMs = parameters.pollInterval ?? 2000
|
|
76
|
+
|
|
77
|
+
const store = Store.from(
|
|
78
|
+
(parameters.store ?? Store.memory()) as Store.AtomicStore<charge.StoreItemMap>,
|
|
79
|
+
{ keyPrefix: parameters.storeKeyPrefix ?? '' },
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Token list is fetched lazily and re-fetched once on an unresolved asset
|
|
83
|
+
// (the list evolves); a persistent miss is a merchant-config error.
|
|
84
|
+
let assetMapPromise: Promise<OneClick.AssetMap> | undefined
|
|
85
|
+
function getAssetMap(refresh = false): Promise<OneClick.AssetMap> {
|
|
86
|
+
if (!assetMapPromise || refresh) {
|
|
87
|
+
assetMapPromise = OneClick.getTokens(oneClick)
|
|
88
|
+
.then((tokens) => OneClick.createAssetMap(tokens, oneClick))
|
|
89
|
+
.catch((error) => {
|
|
90
|
+
assetMapPromise = undefined
|
|
91
|
+
throw error
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
return assetMapPromise
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function resolveAssetId(caip19Id: string): Promise<string> {
|
|
98
|
+
let assetId = (await getAssetMap()).toAssetId(caip19Id)
|
|
99
|
+
if (!assetId) assetId = (await getAssetMap(true)).toAssetId(caip19Id)
|
|
100
|
+
if (!assetId)
|
|
101
|
+
throw new Error(
|
|
102
|
+
`nearintents-mpp-sdk: asset "${caip19Id}" is not on the 1Click token list — check the merchant configuration (or extend oneClick.networks).`,
|
|
103
|
+
)
|
|
104
|
+
return assetId
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const depositKey = (address: string) => `nearintents-mpp-sdk:deposit:${address}` as const
|
|
108
|
+
const quoteKey = (identity: string) => `nearintents-mpp-sdk:quote:${identity}` as const
|
|
109
|
+
const hashKey = (hash: string) => `nearintents-mpp-sdk:hash:${hash.toLowerCase()}` as const
|
|
110
|
+
|
|
111
|
+
function identityOf(amountOut: string): string {
|
|
112
|
+
return [
|
|
113
|
+
originAsset,
|
|
114
|
+
destinationAsset,
|
|
115
|
+
amountOut,
|
|
116
|
+
destinationRecipient,
|
|
117
|
+
refundTo,
|
|
118
|
+
slippageTolerance,
|
|
119
|
+
referral,
|
|
120
|
+
].join('|')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Returns the route's active canonical request, minting a fresh 1Click
|
|
125
|
+
* quote when there is none — or when the cached one is stale (early
|
|
126
|
+
* refresh: a quote is stale once `now + expiresWindow > quote deadline`,
|
|
127
|
+
* so the challenge `expires` always precedes the active quote's deadline).
|
|
128
|
+
*/
|
|
129
|
+
async function resolveActiveRequest(amountOut: string): Promise<Types.ChargeRequest> {
|
|
130
|
+
const identity = identityOf(amountOut)
|
|
131
|
+
|
|
132
|
+
const pointer = await store.get(quoteKey(identity))
|
|
133
|
+
if (pointer && pointer.identity === identity) {
|
|
134
|
+
const deposit = await store.get(depositKey(pointer.depositAddress))
|
|
135
|
+
if (
|
|
136
|
+
deposit &&
|
|
137
|
+
deposit.state === 'active' &&
|
|
138
|
+
deposit.identity === identity &&
|
|
139
|
+
Date.parse(deposit.deadline) > Date.now() + expiresWindowMs
|
|
140
|
+
) {
|
|
141
|
+
emit({ type: 'quote.reused', depositAddress: deposit.request.recipient })
|
|
142
|
+
return deposit.request
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const [originAssetId, destinationAssetId] = await Promise.all([
|
|
147
|
+
resolveAssetId(originAsset),
|
|
148
|
+
resolveAssetId(destinationAsset),
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
const requestedDeadline = new Date(
|
|
152
|
+
Date.now() + expiresWindowMs + quoteDeadlineBufferMs,
|
|
153
|
+
).toISOString()
|
|
154
|
+
const { quote } = await OneClick.quote(oneClick, {
|
|
155
|
+
originAsset: originAssetId,
|
|
156
|
+
destinationAsset: destinationAssetId,
|
|
157
|
+
amountOut,
|
|
158
|
+
recipient: destinationRecipient,
|
|
159
|
+
refundTo,
|
|
160
|
+
slippageTolerance,
|
|
161
|
+
deadline: requestedDeadline,
|
|
162
|
+
referral,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// Spec §Expiry: `expires` MUST be at or before the quote deadline. The
|
|
166
|
+
// backend's deadline is authoritative; if it cannot cover the route's
|
|
167
|
+
// static expires window, the route is misconfigured — fail loud.
|
|
168
|
+
if (Date.parse(quote.deadline) <= Date.now() + expiresWindowMs)
|
|
169
|
+
throw new Error(
|
|
170
|
+
`nearintents-mpp-sdk: 1Click quote deadline (${quote.deadline}) does not cover the route's expires window (${expiresWindowMs / 1000}s) — lower expiresWindow or raise quoteDeadlineBuffer.`,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const request: Types.ChargeRequest = {
|
|
174
|
+
amount: quote.amountIn,
|
|
175
|
+
currency: originAsset,
|
|
176
|
+
recipient: quote.depositAddress,
|
|
177
|
+
...(description !== undefined && { description }),
|
|
178
|
+
...(externalId !== undefined && { externalId }),
|
|
179
|
+
methodDetails: {
|
|
180
|
+
originNetwork,
|
|
181
|
+
destinationNetwork,
|
|
182
|
+
destinationAsset,
|
|
183
|
+
destinationRecipient,
|
|
184
|
+
amountOut,
|
|
185
|
+
minAmountIn: quote.minAmountIn,
|
|
186
|
+
depositMemo: quote.depositMemo ?? null,
|
|
187
|
+
slippageTolerance,
|
|
188
|
+
timeEstimate: quote.timeEstimate,
|
|
189
|
+
refundTo,
|
|
190
|
+
settlementBackend: Types.settlementBackend,
|
|
191
|
+
credentialTypes: [...Types.credentialTypes],
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await store.put(depositKey(quote.depositAddress), {
|
|
196
|
+
identity,
|
|
197
|
+
request,
|
|
198
|
+
deadline: quote.deadline,
|
|
199
|
+
timeEstimate: quote.timeEstimate,
|
|
200
|
+
depositMemo: quote.depositMemo ?? null,
|
|
201
|
+
state: 'active',
|
|
202
|
+
})
|
|
203
|
+
await store.put(quoteKey(identity), { identity, depositAddress: quote.depositAddress })
|
|
204
|
+
|
|
205
|
+
emit({
|
|
206
|
+
type: 'quote.minted',
|
|
207
|
+
depositAddress: quote.depositAddress,
|
|
208
|
+
amountIn: quote.amountIn,
|
|
209
|
+
minAmountIn: quote.minAmountIn,
|
|
210
|
+
amountOut,
|
|
211
|
+
deadline: quote.deadline,
|
|
212
|
+
timeEstimate: quote.timeEstimate,
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
return request
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Marks a deposit spent and drops the cache pointer so the next 402 mints fresh. */
|
|
219
|
+
async function retireDeposit(depositAddress: string, identity: string): Promise<void> {
|
|
220
|
+
await store.update(depositKey(depositAddress), (current) => {
|
|
221
|
+
if (!current) return { op: 'noop', result: undefined }
|
|
222
|
+
return { op: 'set', value: { ...current, state: 'settled' }, result: undefined }
|
|
223
|
+
})
|
|
224
|
+
await store.update(quoteKey(identity), (current) => {
|
|
225
|
+
if (current?.depositAddress !== depositAddress) return { op: 'noop', result: undefined }
|
|
226
|
+
return { op: 'delete', result: undefined }
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Atomically claims `hash` as in-flight. Expired leases are reclaimable. */
|
|
231
|
+
async function claimHash(
|
|
232
|
+
hash: string,
|
|
233
|
+
leaseUntil: number,
|
|
234
|
+
): Promise<'claimed' | 'inflight' | 'consumed'> {
|
|
235
|
+
return store.update(hashKey(hash), (current) => {
|
|
236
|
+
if (current?.state === 'consumed') return { op: 'noop', result: 'consumed' }
|
|
237
|
+
if (current?.state === 'inflight' && current.leaseUntil > Date.now())
|
|
238
|
+
return { op: 'noop', result: 'inflight' }
|
|
239
|
+
return { op: 'set', value: { state: 'inflight', leaseUntil }, result: 'claimed' }
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return Method.toServer<typeof Methods.charge, charge.Defaults>(Methods.charge, {
|
|
244
|
+
// Valid canonical placeholder — replaced by the request hook on every
|
|
245
|
+
// real path; only the fallback challenge (request hook threw a
|
|
246
|
+
// PaymentError) ever serializes it.
|
|
247
|
+
defaults: {
|
|
248
|
+
amount: '0',
|
|
249
|
+
currency: originAsset,
|
|
250
|
+
recipient: 'unavailable',
|
|
251
|
+
...(description !== undefined && { description }),
|
|
252
|
+
...(externalId !== undefined && { externalId }),
|
|
253
|
+
methodDetails: {
|
|
254
|
+
originNetwork,
|
|
255
|
+
destinationNetwork,
|
|
256
|
+
destinationAsset,
|
|
257
|
+
destinationRecipient,
|
|
258
|
+
amountOut: defaultAmountOut ?? '0',
|
|
259
|
+
minAmountIn: '0',
|
|
260
|
+
depositMemo: null,
|
|
261
|
+
slippageTolerance,
|
|
262
|
+
refundTo,
|
|
263
|
+
settlementBackend: Types.settlementBackend,
|
|
264
|
+
credentialTypes: [...Types.credentialTypes],
|
|
265
|
+
},
|
|
266
|
+
} as charge.Defaults,
|
|
267
|
+
|
|
268
|
+
// Spec §Challenge Binding: the deposit address, amount, and source asset
|
|
269
|
+
// are challenge-specific. Binding them means a rotated quote yields a
|
|
270
|
+
// binding mismatch → 402 with a fresh challenge (the client-recovery flow).
|
|
271
|
+
stableBinding(request) {
|
|
272
|
+
const { amount, currency, methodDetails, recipient } = request
|
|
273
|
+
return { amount, currency, originNetwork: methodDetails.originNetwork, recipient }
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
async request({ credential, request }) {
|
|
277
|
+
const amountOut =
|
|
278
|
+
(request.methodDetails as Partial<Types.MethodDetails> | undefined)?.amountOut ??
|
|
279
|
+
defaultAmountOut
|
|
280
|
+
if (!amountOut || amountOut === '0')
|
|
281
|
+
throw new Error(
|
|
282
|
+
'nearintents-mpp-sdk: amountOut is required — set it in charge({ amountOut }) or per route via methodDetails.amountOut.',
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
// Credential-bearing request: resolve the quote the challenge was
|
|
286
|
+
// minted from (store lookup keyed by deposit address) — never mint a
|
|
287
|
+
// quote for a credential. Note the echoed challenge is NOT yet
|
|
288
|
+
// HMAC-verified here; the lookup returns OUR stored request, so a
|
|
289
|
+
// forged recipient can at most cause a cache-limited quote refresh.
|
|
290
|
+
if (credential) {
|
|
291
|
+
const recipient = (credential.challenge.request as Partial<Types.ChargeRequest>)?.recipient
|
|
292
|
+
if (typeof recipient === 'string' && recipient) {
|
|
293
|
+
const deposit = await store.get(depositKey(recipient))
|
|
294
|
+
if (
|
|
295
|
+
deposit &&
|
|
296
|
+
deposit.state === 'active' &&
|
|
297
|
+
deposit.identity === identityOf(amountOut) &&
|
|
298
|
+
Date.parse(deposit.deadline) > Date.now()
|
|
299
|
+
)
|
|
300
|
+
return deposit.request
|
|
301
|
+
}
|
|
302
|
+
// Unknown, spent, or expired deposit address: fall through to the
|
|
303
|
+
// current active quote. The stableBinding mismatch then yields a 402
|
|
304
|
+
// carrying this fresh challenge — the spec's client-recovery flow.
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return resolveActiveRequest(amountOut)
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
async verify({ credential, request }) {
|
|
311
|
+
const { challenge } = credential
|
|
312
|
+
const resolved = (() => {
|
|
313
|
+
const parsed = Methods.charge.schema.request.safeParse(request)
|
|
314
|
+
if (parsed.success) return parsed.data
|
|
315
|
+
return request as unknown as Types.ChargeRequest
|
|
316
|
+
})()
|
|
317
|
+
|
|
318
|
+
const payload = credential.payload
|
|
319
|
+
if (payload.type !== 'hash')
|
|
320
|
+
throw new Errors.InvalidPayloadError({ reason: 'only "hash" credentials are accepted' })
|
|
321
|
+
const hash = payload.hash
|
|
322
|
+
|
|
323
|
+
// Spec §Verification step 4: the deposit address must correspond to an
|
|
324
|
+
// active, non-expired, non-settled quote in the server's state.
|
|
325
|
+
const recipient = resolved.recipient
|
|
326
|
+
const deposit = await store.get(depositKey(recipient))
|
|
327
|
+
if (!deposit)
|
|
328
|
+
throw new Errors.InvalidChallengeError({
|
|
329
|
+
id: challenge.id,
|
|
330
|
+
reason: 'no active quote for this deposit address',
|
|
331
|
+
})
|
|
332
|
+
if (deposit.state === 'settled')
|
|
333
|
+
throw new Errors.InvalidChallengeError({
|
|
334
|
+
id: challenge.id,
|
|
335
|
+
reason: 'the quote for this deposit address is already settled',
|
|
336
|
+
})
|
|
337
|
+
if (Date.parse(deposit.deadline) <= Date.now())
|
|
338
|
+
throw new Errors.PaymentExpiredError({ expires: deposit.deadline })
|
|
339
|
+
|
|
340
|
+
const settlementTimeoutMs =
|
|
341
|
+
(parameters.settlementTimeout ?? deposit.timeEstimate + 120) * 1000
|
|
342
|
+
|
|
343
|
+
// Spec §Verification step 5 / §Replay: claim the hash in-flight
|
|
344
|
+
// atomically; consume permanently only on a terminal settlement state.
|
|
345
|
+
// The lease covers the settlement budget so a crashed settlement never
|
|
346
|
+
// strands a legitimate retry.
|
|
347
|
+
const claim = await claimHash(hash, Date.now() + settlementTimeoutMs + 60_000)
|
|
348
|
+
if (claim === 'consumed')
|
|
349
|
+
throw new Errors.VerificationFailedError({
|
|
350
|
+
reason: 'transaction hash has already been used',
|
|
351
|
+
})
|
|
352
|
+
if (claim === 'inflight')
|
|
353
|
+
throw new Errors.VerificationFailedError({
|
|
354
|
+
reason: 'settlement for this transaction hash is already in progress',
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
let hashOutcome: 'release' | 'consume' = 'release'
|
|
358
|
+
try {
|
|
359
|
+
// Spec §Settlement step 1: notify the backend (optional accelerator;
|
|
360
|
+
// status observation below is authoritative, so failures are benign).
|
|
361
|
+
const accepted = await OneClick.submitDeposit(oneClick, {
|
|
362
|
+
txHash: hash,
|
|
363
|
+
depositAddress: recipient,
|
|
364
|
+
...(deposit.depositMemo !== null && { memo: deposit.depositMemo }),
|
|
365
|
+
}).then(
|
|
366
|
+
() => true,
|
|
367
|
+
() => false,
|
|
368
|
+
)
|
|
369
|
+
emit({ type: 'deposit.submitted', depositAddress: recipient, originTxHash: hash, accepted })
|
|
370
|
+
|
|
371
|
+
// Spec §Settlement step 2 + §Verification step 3: poll to a terminal
|
|
372
|
+
// status; the backend detecting a qualifying deposit at `recipient`
|
|
373
|
+
// IS the deposit confirmation (status-observation mode).
|
|
374
|
+
const status = await OneClick.pollToTerminal(oneClick, {
|
|
375
|
+
depositAddress: recipient,
|
|
376
|
+
...(deposit.depositMemo !== null && { depositMemo: deposit.depositMemo }),
|
|
377
|
+
timeoutMs: settlementTimeoutMs,
|
|
378
|
+
intervalMs: pollIntervalMs,
|
|
379
|
+
onStatus: (observed) =>
|
|
380
|
+
emit({ type: 'settlement.status', depositAddress: recipient, status: observed }),
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// Any terminal state spends the quote and its deposit address —
|
|
384
|
+
// including SUCCESS whose observed deposits don't match the presented
|
|
385
|
+
// hash (below). Retiring here, before that match check, is deliberate:
|
|
386
|
+
// once the backend reaches SUCCESS the deposit is delivered, so the
|
|
387
|
+
// quote MUST leave rotation regardless of which credential presented
|
|
388
|
+
// it. Deferring the retire until after a match check would strand a
|
|
389
|
+
// backend-settled deposit as "active" in the store, and a later
|
|
390
|
+
// cache hit could hand a fresh payer an already-spent deposit address.
|
|
391
|
+
await retireDeposit(recipient, deposit.identity)
|
|
392
|
+
emit({
|
|
393
|
+
type: 'settlement.terminal',
|
|
394
|
+
depositAddress: recipient,
|
|
395
|
+
originTxHash: hash,
|
|
396
|
+
status: status.status,
|
|
397
|
+
...(OneClick.destinationTxHash(status) !== undefined && {
|
|
398
|
+
destinationTxHash: OneClick.destinationTxHash(status),
|
|
399
|
+
}),
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
if (status.status === 'SUCCESS') {
|
|
403
|
+
// Deposit confirmation: the presented hash must be among the
|
|
404
|
+
// origin-chain transactions the backend observed for this address.
|
|
405
|
+
if (!OneClick.matchesOriginTx(status, hash))
|
|
406
|
+
throw new Errors.VerificationFailedError({
|
|
407
|
+
reason:
|
|
408
|
+
'the presented transaction hash is not among the deposits observed for this address',
|
|
409
|
+
})
|
|
410
|
+
hashOutcome = 'consume'
|
|
411
|
+
const reference =
|
|
412
|
+
OneClick.destinationTxHash(status) ?? status.swapDetails?.nearTxHashes?.[0]
|
|
413
|
+
// The merchant has been paid at this point; a missing reference is
|
|
414
|
+
// a backend anomaly worth failing loudly on (500), not a 402.
|
|
415
|
+
if (!reference)
|
|
416
|
+
throw new Error(
|
|
417
|
+
`nearintents-mpp-sdk: swap for ${recipient} reached SUCCESS but reported no settlement transaction hash.`,
|
|
418
|
+
)
|
|
419
|
+
const receipt = Types.toReceipt({
|
|
420
|
+
challengeId: challenge.id,
|
|
421
|
+
reference,
|
|
422
|
+
originTxHash: hash,
|
|
423
|
+
destinationNetwork: resolved.methodDetails.destinationNetwork,
|
|
424
|
+
...(resolved.externalId !== undefined && { externalId: resolved.externalId }),
|
|
425
|
+
})
|
|
426
|
+
emit({
|
|
427
|
+
type: 'receipt.issued',
|
|
428
|
+
challengeId: challenge.id,
|
|
429
|
+
depositAddress: recipient,
|
|
430
|
+
originTxHash: hash,
|
|
431
|
+
reference,
|
|
432
|
+
})
|
|
433
|
+
return receipt
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Non-success terminal (FAILED/REFUNDED/INCOMPLETE_DEPOSIT): the
|
|
437
|
+
// deposit is refunded to refundTo and the hash can never deliver —
|
|
438
|
+
// consume it. The client recovers with a fresh challenge.
|
|
439
|
+
hashOutcome = 'consume'
|
|
440
|
+
throw (
|
|
441
|
+
OneClick.terminalError(status) ??
|
|
442
|
+
new SettlementFailedError({ reason: `unexpected terminal status ${status.status}` })
|
|
443
|
+
)
|
|
444
|
+
} catch (error) {
|
|
445
|
+
// Backend unavailability / settlement timeout: 5xx, never
|
|
446
|
+
// verification-failed; do not settle, release the claim so the same
|
|
447
|
+
// credential can be re-presented.
|
|
448
|
+
if (error instanceof OneClick.OneClickUnavailableError) {
|
|
449
|
+
emit({
|
|
450
|
+
type: 'settlement.suspended',
|
|
451
|
+
depositAddress: recipient,
|
|
452
|
+
originTxHash: hash,
|
|
453
|
+
reason: 'unavailable',
|
|
454
|
+
})
|
|
455
|
+
throw new SettlementUnavailableError({ reason: error.message })
|
|
456
|
+
}
|
|
457
|
+
if (error instanceof OneClick.PollTimeoutError) {
|
|
458
|
+
emit({
|
|
459
|
+
type: 'settlement.suspended',
|
|
460
|
+
depositAddress: recipient,
|
|
461
|
+
originTxHash: hash,
|
|
462
|
+
reason: 'timeout',
|
|
463
|
+
})
|
|
464
|
+
throw new SettlementTimeoutError({ timeoutMs: settlementTimeoutMs })
|
|
465
|
+
}
|
|
466
|
+
throw error
|
|
467
|
+
} finally {
|
|
468
|
+
if (hashOutcome === 'consume') await store.put(hashKey(hash), { state: 'consumed' })
|
|
469
|
+
else await store.delete(hashKey(hash))
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export declare namespace charge {
|
|
476
|
+
/**
|
|
477
|
+
* Structured settlement-progress events for merchant observability (wire
|
|
478
|
+
* them to your logger via `charge({ onEvent })`). All referenced values —
|
|
479
|
+
* deposit addresses and tx hashes — are public on-chain data. Outcome-level
|
|
480
|
+
* events (`payment.success` / `payment.failed` / `challenge.created`) come
|
|
481
|
+
* from mppx itself via `mppx.on(...)`.
|
|
482
|
+
*/
|
|
483
|
+
type Event =
|
|
484
|
+
| {
|
|
485
|
+
type: 'quote.minted'
|
|
486
|
+
depositAddress: string
|
|
487
|
+
amountIn: string
|
|
488
|
+
minAmountIn: string
|
|
489
|
+
amountOut: string
|
|
490
|
+
deadline: string
|
|
491
|
+
timeEstimate: number
|
|
492
|
+
}
|
|
493
|
+
| { type: 'quote.reused'; depositAddress: string }
|
|
494
|
+
| { type: 'deposit.submitted'; depositAddress: string; originTxHash: string; accepted: boolean }
|
|
495
|
+
| { type: 'settlement.status'; depositAddress: string; status: OneClick.SwapStatus }
|
|
496
|
+
| {
|
|
497
|
+
type: 'settlement.terminal'
|
|
498
|
+
depositAddress: string
|
|
499
|
+
originTxHash: string
|
|
500
|
+
status: OneClick.SwapStatus
|
|
501
|
+
destinationTxHash?: string | undefined
|
|
502
|
+
}
|
|
503
|
+
| {
|
|
504
|
+
type: 'settlement.suspended'
|
|
505
|
+
depositAddress: string
|
|
506
|
+
originTxHash: string
|
|
507
|
+
/** The credential was NOT consumed; the client re-presents it later. */
|
|
508
|
+
reason: 'unavailable' | 'timeout'
|
|
509
|
+
}
|
|
510
|
+
| {
|
|
511
|
+
type: 'receipt.issued'
|
|
512
|
+
challengeId: string
|
|
513
|
+
depositAddress: string
|
|
514
|
+
originTxHash: string
|
|
515
|
+
reference: string
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
type DepositState = {
|
|
519
|
+
identity: string
|
|
520
|
+
request: Types.ChargeRequest
|
|
521
|
+
/** Quote deadline (ISO 8601) — the deposit address validity window. */
|
|
522
|
+
deadline: string
|
|
523
|
+
timeEstimate: number
|
|
524
|
+
depositMemo: string | null
|
|
525
|
+
state: 'active' | 'settled'
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
type HashState = { state: 'inflight'; leaseUntil: number } | { state: 'consumed' }
|
|
529
|
+
|
|
530
|
+
type QuotePointer = { identity: string; depositAddress: string }
|
|
531
|
+
|
|
532
|
+
type StoreItemMap = {
|
|
533
|
+
[key: `nearintents-mpp-sdk:deposit:${string}`]: DepositState
|
|
534
|
+
[key: `nearintents-mpp-sdk:quote:${string}`]: QuotePointer
|
|
535
|
+
[key: `nearintents-mpp-sdk:hash:${string}`]: HashState
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
type Defaults = Types.ChargeRequest
|
|
539
|
+
|
|
540
|
+
type Parameters = {
|
|
541
|
+
/** Source asset the client pays with, as CAIP-19. Its chain is the origin network. */
|
|
542
|
+
originAsset: string
|
|
543
|
+
/** Destination asset the merchant receives, as CAIP-19. Its chain is the destination network. */
|
|
544
|
+
destinationAsset: string
|
|
545
|
+
/** Merchant address on the destination chain. */
|
|
546
|
+
destinationRecipient: string
|
|
547
|
+
/**
|
|
548
|
+
* Merchant-configured refund address on the origin chain. The server
|
|
549
|
+
* cannot know the payer pre-payment; clients recover refunds off-band
|
|
550
|
+
* (document this in the merchant's terms).
|
|
551
|
+
*/
|
|
552
|
+
refundTo: string
|
|
553
|
+
/**
|
|
554
|
+
* Default price: exact amount the merchant receives, in base units of
|
|
555
|
+
* `destinationAsset` (EXACT_OUTPUT). Overridable per route via
|
|
556
|
+
* `methodDetails.amountOut`.
|
|
557
|
+
*/
|
|
558
|
+
amountOut?: string | undefined
|
|
559
|
+
/** Slippage tolerance in basis points, applied to the input side. @default 100 */
|
|
560
|
+
slippageTolerance?: number | undefined
|
|
561
|
+
/**
|
|
562
|
+
* 1Click referral identifier (distribution-channel attribution / fee
|
|
563
|
+
* tracking) attached to every quote this method mints. @default "mpp"
|
|
564
|
+
*/
|
|
565
|
+
referral?: string | undefined
|
|
566
|
+
/**
|
|
567
|
+
* Settlement-progress observer ({@link charge.Event}) — the library never
|
|
568
|
+
* logs on its own; wire this to your logger. Handler errors are swallowed
|
|
569
|
+
* and never affect payment processing.
|
|
570
|
+
*/
|
|
571
|
+
onEvent?: ((event: Event) => void) | undefined
|
|
572
|
+
/** Human-readable payment description for challenges. */
|
|
573
|
+
description?: string | undefined
|
|
574
|
+
/** Merchant reference echoed into challenges and receipts. */
|
|
575
|
+
externalId?: string | undefined
|
|
576
|
+
/** 1Click API configuration (base URL, JWT, fetch, network tables). */
|
|
577
|
+
oneClick?: OneClick.Config | undefined
|
|
578
|
+
/**
|
|
579
|
+
* Atomic store for the quote cache and replay protection (in-flight /
|
|
580
|
+
* consumed hashes). Defaults to in-memory; use a shared store (e.g.
|
|
581
|
+
* redis) in production and multi-instance deployments — atomicity is a
|
|
582
|
+
* hard requirement for the replay guarantees.
|
|
583
|
+
*/
|
|
584
|
+
store?: Store.AtomicStore | undefined
|
|
585
|
+
/** Prefix prepended to every store key. */
|
|
586
|
+
storeKeyPrefix?: string | undefined
|
|
587
|
+
/**
|
|
588
|
+
* The route's challenge-expiry window in seconds.
|
|
589
|
+
*
|
|
590
|
+
* **MUST equal the `expires` you pass to the mppx route** (`mppx.charge({
|
|
591
|
+
* expires })`). mppx computes the challenge `expires` per route, before
|
|
592
|
+
* this method's `request` hook runs and unreadable by it, so the window
|
|
593
|
+
* cannot be auto-derived — it is a manual coupling the caller owns.
|
|
594
|
+
*
|
|
595
|
+
* It sizes the quote-cache early refresh so the invariant "advertised
|
|
596
|
+
* `expires` ≤ quote deadline" holds. If the route `expires` is set
|
|
597
|
+
* **larger** than `expiresWindow`, that invariant can break near the
|
|
598
|
+
* cache-staleness boundary: a challenge may advertise an `expires` after
|
|
599
|
+
* the quote deadline, and a client depositing just before `expires` lands
|
|
600
|
+
* after the deadline and is refunded rather than swapped (no fund loss —
|
|
601
|
+
* the client recovers with a fresh challenge — but wasted gas and a silent
|
|
602
|
+
* UX failure). Keep the two values identical. Size to the origin chain
|
|
603
|
+
* (minutes for fast chains, 45–60 min for BTC).
|
|
604
|
+
* @default 300
|
|
605
|
+
*/
|
|
606
|
+
expiresWindow?: number | undefined
|
|
607
|
+
/**
|
|
608
|
+
* Extra seconds of quote (deposit-address) validity requested beyond the
|
|
609
|
+
* expires window, so deposits made just before `expires` still settle
|
|
610
|
+
* within the quote's validity. @default 900
|
|
611
|
+
*/
|
|
612
|
+
quoteDeadlineBuffer?: number | undefined
|
|
613
|
+
/**
|
|
614
|
+
* Settlement budget in seconds verify() will hold the request while
|
|
615
|
+
* polling for a terminal status. @default quote timeEstimate + 120
|
|
616
|
+
*/
|
|
617
|
+
settlementTimeout?: number | undefined
|
|
618
|
+
/** Status poll interval in milliseconds. @default 2000 */
|
|
619
|
+
pollInterval?: number | undefined
|
|
620
|
+
}
|
|
621
|
+
}
|