@kasufinance/kasu-sdk 2.8.0 → 2.10.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/README.md +120 -9
- package/dist/bundle.cjs.js +2077 -476
- package/dist/bundle.esm.js +2039 -478
- package/dist/domain/batch-proof.d.ts +210 -0
- package/dist/domain/batch-proof.js +384 -0
- package/dist/domain/batch-proof.js.map +1 -0
- package/dist/domain/explorer-urls.d.ts +33 -0
- package/dist/domain/explorer-urls.js +38 -0
- package/dist/domain/explorer-urls.js.map +1 -0
- package/dist/domain/fixed-term.d.ts +74 -0
- package/dist/domain/fixed-term.js +86 -0
- package/dist/domain/fixed-term.js.map +1 -0
- package/dist/domain/index.d.ts +12 -2
- package/dist/domain/index.js +7 -2
- package/dist/domain/index.js.map +1 -1
- package/dist/domain/partners.d.ts +29 -0
- package/dist/domain/partners.js +20 -0
- package/dist/domain/partners.js.map +1 -1
- package/dist/domain/requests.d.ts +24 -0
- package/dist/domain/requests.js +33 -0
- package/dist/domain/requests.js.map +1 -1
- package/dist/domain/sponsored-calls.d.ts +74 -0
- package/dist/domain/sponsored-calls.js +156 -0
- package/dist/domain/sponsored-calls.js.map +1 -0
- package/dist/domain/yields.d.ts +121 -0
- package/dist/domain/yields.js +151 -0
- package/dist/domain/yields.js.map +1 -0
- package/dist/facade/calls.d.ts +59 -0
- package/dist/facade/calls.js +89 -0
- package/dist/facade/calls.js.map +1 -0
- package/dist/facade/chain-configs.js +5 -0
- package/dist/facade/chain-configs.js.map +1 -1
- package/dist/facade/flows.d.ts +24 -3
- package/dist/facade/flows.js +22 -2
- package/dist/facade/flows.js.map +1 -1
- package/dist/facade/index.d.ts +3 -1
- package/dist/facade/index.js +1 -0
- package/dist/facade/index.js.map +1 -1
- package/dist/facade/kasu.d.ts +8 -2
- package/dist/facade/kasu.js +17 -5
- package/dist/facade/kasu.js.map +1 -1
- package/dist/facade/strategies.d.ts +43 -2
- package/dist/facade/strategies.js +80 -4
- package/dist/facade/strategies.js.map +1 -1
- package/dist/facade/types.d.ts +18 -0
- package/dist/flows/batch-errors.d.ts +74 -0
- package/dist/flows/batch-errors.js +83 -0
- package/dist/flows/batch-errors.js.map +1 -0
- package/dist/flows/batch-submission.d.ts +223 -0
- package/dist/flows/batch-submission.js +321 -0
- package/dist/flows/batch-submission.js.map +1 -0
- package/dist/flows/deposit-flow.d.ts +13 -20
- package/dist/flows/deposit-flow.js +14 -25
- package/dist/flows/deposit-flow.js.map +1 -1
- package/dist/flows/index.d.ts +6 -2
- package/dist/flows/index.js +4 -2
- package/dist/flows/index.js.map +1 -1
- package/dist/flows/withdraw-flow.d.ts +95 -4
- package/dist/flows/withdraw-flow.js +107 -2
- package/dist/flows/withdraw-flow.js.map +1 -1
- package/dist/services/DataService/queries.js +1 -0
- package/dist/services/DataService/queries.js.map +1 -1
- package/dist/services/Portfolio/portfolio.js +11 -0
- package/dist/services/Portfolio/portfolio.js.map +1 -1
- package/dist/services/UserLending/user-lending.d.ts +13 -0
- package/dist/services/UserLending/user-lending.js +25 -4
- package/dist/services/UserLending/user-lending.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/batch-proof.ts +576 -0
- package/src/domain/explorer-urls.ts +74 -0
- package/src/domain/fixed-term.ts +115 -0
- package/src/domain/index.ts +56 -0
- package/src/domain/partners.ts +35 -0
- package/src/domain/requests.ts +37 -0
- package/src/domain/sponsored-calls.ts +213 -0
- package/src/domain/yields.ts +189 -0
- package/src/facade/calls.ts +135 -0
- package/src/facade/chain-configs.ts +5 -0
- package/src/facade/flows.ts +23 -2
- package/src/facade/index.ts +3 -0
- package/src/facade/kasu.ts +31 -2
- package/src/facade/strategies.ts +98 -7
- package/src/facade/types.ts +19 -0
- package/src/flows/batch-errors.ts +118 -0
- package/src/flows/batch-submission.ts +511 -0
- package/src/flows/deposit-flow.ts +27 -33
- package/src/flows/index.ts +33 -2
- package/src/flows/withdraw-flow.ts +219 -10
- package/src/services/DataService/queries.ts +1 -0
- package/src/services/Portfolio/portfolio.ts +14 -0
- package/src/services/UserLending/user-lending.ts +38 -10
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The errors an atomic-batch submitter throws, in one module both
|
|
3
|
+
* `DepositFlow` and `submitBatch` import — so `instanceof` is one class
|
|
4
|
+
* whichever of them a consumer imports it through.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a `depositBatch` port throws when it KNOWS the batch reverted — from a
|
|
9
|
+
* simulation before sending, from the submitter refusing to broadcast a batch
|
|
10
|
+
* that would revert, or from a mined receipt whose user operation reverted.
|
|
11
|
+
* The run ends `{ step: 'request', reason: 'reverted', revertError }`.
|
|
12
|
+
*
|
|
13
|
+
* `revertError` is the custom error the contract named (`'ClearingIsPending'`,
|
|
14
|
+
* `'UserNotKycd'`, …), exactly as the ABI declares it. Pass `''` when the
|
|
15
|
+
* batch is known to revert but not why: the consumer then renders its generic
|
|
16
|
+
* `reverted` copy, which is what an unknown name gets anyway.
|
|
17
|
+
*
|
|
18
|
+
* A revert is DEFINITIVE — nothing was deposited. Throw it only when that is
|
|
19
|
+
* known; anything less certain is a `BatchPendingError`.
|
|
20
|
+
*/
|
|
21
|
+
export class BatchRevertedError extends Error {
|
|
22
|
+
readonly revertError: string;
|
|
23
|
+
|
|
24
|
+
constructor(revertError: string, message?: string) {
|
|
25
|
+
super(
|
|
26
|
+
message ??
|
|
27
|
+
`DepositFlow: the batch reverted${
|
|
28
|
+
revertError ? ` with ${revertError}` : ''
|
|
29
|
+
}`,
|
|
30
|
+
);
|
|
31
|
+
this.name = 'BatchRevertedError';
|
|
32
|
+
this.revertError = revertError;
|
|
33
|
+
// Keeps `instanceof` honest under a down-levelled build.
|
|
34
|
+
Object.setPrototypeOf(this, BatchRevertedError.prototype);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What became of a batch submission that did NOT end in a proven result and is
|
|
40
|
+
* not a known revert:
|
|
41
|
+
*
|
|
42
|
+
* - `failed` — nothing was sent and nothing can land: refused before the
|
|
43
|
+
* batch left (no session, a validation mismatch, a definitive refusal).
|
|
44
|
+
* "Please try again" is true.
|
|
45
|
+
* - `pending` — the batch was, or may have been, accepted, and nothing says
|
|
46
|
+
* whether it deposited. Calling that "failed" invites a second deposit; the
|
|
47
|
+
* lender is told it may still be processing.
|
|
48
|
+
* - `already-submitted` — an earlier submission has since been proven, so this
|
|
49
|
+
* one is refused rather than lend twice.
|
|
50
|
+
*/
|
|
51
|
+
export type BatchSubmissionOutcome = 'failed' | 'pending' | 'already-submitted';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every non-revert failure of a batch submission, carrying its outcome and a
|
|
55
|
+
* short code (`'status_timeout'`, `'receipt_unavailable'`, …).
|
|
56
|
+
*
|
|
57
|
+
* The message is fixed and names no party: the wallet-error classifier reads a
|
|
58
|
+
* thrown error's text for a USER rejection, and a relay outage worded "…wallet
|
|
59
|
+
* refused…" must never reach the lender as something they did.
|
|
60
|
+
*
|
|
61
|
+
* `outcome` has NO default. `failed` is the one outcome that invites a second
|
|
62
|
+
* deposit, so a port author has to spell it out — a `throw new
|
|
63
|
+
* BatchSubmissionError(code)` written in a catch block after the POST left
|
|
64
|
+
* must not quietly become "try again".
|
|
65
|
+
*/
|
|
66
|
+
export class BatchSubmissionError extends Error {
|
|
67
|
+
readonly code: string;
|
|
68
|
+
readonly outcome: BatchSubmissionOutcome;
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
code: string,
|
|
72
|
+
outcome: BatchSubmissionOutcome,
|
|
73
|
+
options?: { cause?: unknown },
|
|
74
|
+
) {
|
|
75
|
+
super(`Batch submission ${outcome} (${code})`);
|
|
76
|
+
this.name = 'BatchSubmissionError';
|
|
77
|
+
this.code = code;
|
|
78
|
+
this.outcome = outcome;
|
|
79
|
+
// Set by hand: `Error`'s own `cause` option is ES2022.
|
|
80
|
+
if (options && 'cause' in options) {
|
|
81
|
+
Object.defineProperty(this, 'cause', {
|
|
82
|
+
value: options.cause,
|
|
83
|
+
enumerable: false,
|
|
84
|
+
writable: true,
|
|
85
|
+
configurable: true,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
Object.setPrototypeOf(this, BatchSubmissionError.prototype);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The ambiguous outcome: the batch may have landed. An instance of
|
|
94
|
+
* `BatchSubmissionError` with `outcome: 'pending'`.
|
|
95
|
+
*/
|
|
96
|
+
export class BatchPendingError extends BatchSubmissionError {
|
|
97
|
+
constructor(code: string, options?: { cause?: unknown }) {
|
|
98
|
+
super(code, 'pending', options);
|
|
99
|
+
this.name = 'BatchPendingError';
|
|
100
|
+
Object.setPrototypeOf(this, BatchPendingError.prototype);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The outcome a thrown error stands for, or `null` if it is not a submission error. */
|
|
105
|
+
export function batchOutcomeOf(err: unknown): BatchSubmissionOutcome | null {
|
|
106
|
+
return err instanceof BatchSubmissionError ? err.outcome : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Nothing was deposited and nothing can be: a known revert, or a definitive
|
|
111
|
+
* `failed` refusal. Everything else — including an error this SDK did not
|
|
112
|
+
* produce — is NOT proof of that, and a consumer must not invite a retry on it.
|
|
113
|
+
*/
|
|
114
|
+
export function isDefinitivelyNotSubmitted(err: unknown): boolean {
|
|
115
|
+
return (
|
|
116
|
+
err instanceof BatchRevertedError || batchOutcomeOf(err) === 'failed'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DepositProof,
|
|
3
|
+
DepositProofExpectation,
|
|
4
|
+
proveDepositInReceipt,
|
|
5
|
+
proveUserOperationInReceipt,
|
|
6
|
+
ReceiptLike,
|
|
7
|
+
UserOperationProof,
|
|
8
|
+
UserOperationProofExpectation,
|
|
9
|
+
} from '../domain/batch-proof';
|
|
10
|
+
import {
|
|
11
|
+
SPONSORED_CALL_KINDS,
|
|
12
|
+
SPONSORED_CALL_SELECTORS,
|
|
13
|
+
SponsoredCall,
|
|
14
|
+
} from '../domain/sponsored-calls';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
BatchPendingError,
|
|
18
|
+
BatchRevertedError,
|
|
19
|
+
BatchSubmissionError,
|
|
20
|
+
} from './batch-errors';
|
|
21
|
+
import { WaitableTransaction } from './observable';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Submitting a sponsored atomic deposit batch, from the accepted calls to a
|
|
25
|
+
* PROVEN deposit — the polling policy and the outcome taxonomy, and nothing
|
|
26
|
+
* else. No relay vendor, no URL, no wallet: the app's ports do the I/O, and
|
|
27
|
+
* this decides what each answer MEANS.
|
|
28
|
+
*
|
|
29
|
+
* The one rule everything here serves: **an ambiguous result is never
|
|
30
|
+
* reported as failed.** "Failed" tells a lender to try again, and a batch that
|
|
31
|
+
* may already have moved the money must never invite a second deposit.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** One call of the batch. Structurally `BatchCall`. */
|
|
35
|
+
export interface SubmitBatchCall {
|
|
36
|
+
to: string;
|
|
37
|
+
data: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One read of the submitted batch's status, already NORMALISED by the app's
|
|
42
|
+
* port — the vendor's status vocabulary stays in the app.
|
|
43
|
+
*/
|
|
44
|
+
export type BatchStatusRead =
|
|
45
|
+
/** Mined: a transaction hash exists. */
|
|
46
|
+
| { kind: 'mined'; txHash: string; userOperationHash?: string | null }
|
|
47
|
+
/** The vendor says, definitively, that it reverted: nothing deposited. */
|
|
48
|
+
| { kind: 'reverted' }
|
|
49
|
+
/**
|
|
50
|
+
* Final for the vendor but silent about the deposit, or the status read
|
|
51
|
+
* was refused: stop polling, outcome UNKNOWN (→ pending).
|
|
52
|
+
*/
|
|
53
|
+
| { kind: 'unknown'; code: string }
|
|
54
|
+
/** Not there yet, replaced, or a transient failure: ask again. */
|
|
55
|
+
| { kind: 'again'; userOperationHash?: string | null };
|
|
56
|
+
|
|
57
|
+
export interface BatchSubmissionPorts<R extends ReceiptLike = ReceiptLike> {
|
|
58
|
+
/**
|
|
59
|
+
* Hand the batch to the submitter; resolve with its transaction id once it
|
|
60
|
+
* is ACCEPTED.
|
|
61
|
+
*
|
|
62
|
+
* Throw `BatchRevertedError` when it is known to revert, and
|
|
63
|
+
* `BatchSubmissionError(code, 'failed')` ONLY when nothing was sent — no
|
|
64
|
+
* session, a refusal before broadcast. Any other throw is read as
|
|
65
|
+
* PENDING: this cannot know whether an unrecognised error left before or
|
|
66
|
+
* after the batch was accepted. Resending the same signed request after an
|
|
67
|
+
* ambiguous answer is the port's business (it owns the wire protocol).
|
|
68
|
+
*/
|
|
69
|
+
post(calls: readonly SubmitBatchCall[]): Promise<{ transactionId: string }>;
|
|
70
|
+
/** One status read. A throw is treated as `{ kind: 'again' }`. */
|
|
71
|
+
getStatus(transactionId: string): Promise<BatchStatusRead>;
|
|
72
|
+
/**
|
|
73
|
+
* The mined receipt. Bound it with a timeout of your own; a throw is
|
|
74
|
+
* PENDING (`receipt_unavailable`) — the batch was mined, its receipt was
|
|
75
|
+
* just not readable.
|
|
76
|
+
*/
|
|
77
|
+
getReceipt(txHash: string): Promise<R>;
|
|
78
|
+
/** ms epoch. */
|
|
79
|
+
now(): number;
|
|
80
|
+
sleep(ms: number): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface BatchPollingPolicy {
|
|
84
|
+
/** Delay before the second status read (the first is immediate). */
|
|
85
|
+
initialMs: number;
|
|
86
|
+
/** Ceiling on the delay between reads. */
|
|
87
|
+
maxMs: number;
|
|
88
|
+
/** Multiplier applied to the delay after each read, ≥ 1. */
|
|
89
|
+
backoff: number;
|
|
90
|
+
/** Stop polling — PENDING, never failed — after this long. */
|
|
91
|
+
timeoutMs: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* First read at once, then 3 s backing off ×1.5 to a 10 s ceiling, for up to
|
|
96
|
+
* 10 minutes. Running out of time is PENDING: the batch was accepted and may
|
|
97
|
+
* still land.
|
|
98
|
+
*/
|
|
99
|
+
export const DEFAULT_BATCH_POLLING_POLICY: Readonly<BatchPollingPolicy> =
|
|
100
|
+
Object.freeze({
|
|
101
|
+
initialMs: 3_000,
|
|
102
|
+
maxMs: 10_000,
|
|
103
|
+
backoff: 1.5,
|
|
104
|
+
timeoutMs: 10 * 60 * 1000,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** A proven deposit: the `deposited` proof and the receipt it was read from. */
|
|
108
|
+
export interface ProvenBatch<R extends ReceiptLike = ReceiptLike> {
|
|
109
|
+
proof: Extract<DepositProof, { kind: 'deposited' }>;
|
|
110
|
+
receipt: R;
|
|
111
|
+
txHash: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** What `submitBatch` resolves with once the batch is accepted. */
|
|
115
|
+
export interface BatchSubmission<R extends ReceiptLike = ReceiptLike>
|
|
116
|
+
extends WaitableTransaction {
|
|
117
|
+
transactionId: string;
|
|
118
|
+
/**
|
|
119
|
+
* Polls to a mined receipt and proves the deposit in it. Resolves ONLY on
|
|
120
|
+
* a proven deposit. Rejects with `BatchRevertedError` when it definitively
|
|
121
|
+
* did not deposit, and with `BatchPendingError` for everything else.
|
|
122
|
+
*/
|
|
123
|
+
wait(): Promise<ProvenBatch<R>>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolvePolicy(
|
|
127
|
+
policy: Partial<BatchPollingPolicy> | undefined,
|
|
128
|
+
): BatchPollingPolicy {
|
|
129
|
+
const resolved: BatchPollingPolicy = {
|
|
130
|
+
initialMs: policy?.initialMs ?? DEFAULT_BATCH_POLLING_POLICY.initialMs,
|
|
131
|
+
maxMs: policy?.maxMs ?? DEFAULT_BATCH_POLLING_POLICY.maxMs,
|
|
132
|
+
backoff: policy?.backoff ?? DEFAULT_BATCH_POLLING_POLICY.backoff,
|
|
133
|
+
timeoutMs: policy?.timeoutMs ?? DEFAULT_BATCH_POLLING_POLICY.timeoutMs,
|
|
134
|
+
};
|
|
135
|
+
const { initialMs, maxMs, backoff, timeoutMs } = resolved;
|
|
136
|
+
if (
|
|
137
|
+
!(Number.isFinite(initialMs) && initialMs > 0) ||
|
|
138
|
+
!(Number.isFinite(maxMs) && maxMs >= initialMs) ||
|
|
139
|
+
!(Number.isFinite(backoff) && backoff >= 1) ||
|
|
140
|
+
!(Number.isFinite(timeoutMs) && timeoutMs > 0)
|
|
141
|
+
) {
|
|
142
|
+
throw new TypeError('submitBatch: invalid polling policy');
|
|
143
|
+
}
|
|
144
|
+
return resolved;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Submit `calls` and return once the submitter ACCEPTED them; `wait()` then
|
|
149
|
+
* polls and proves. Use it as the body of a `DepositFlow` `depositBatch`
|
|
150
|
+
* port, after `validateDepositBatchCalls` and the app's own signing:
|
|
151
|
+
*
|
|
152
|
+
* ```ts
|
|
153
|
+
* depositBatch: (calls) => {
|
|
154
|
+
* const { tranche, amount } = validateDepositBatchCalls(calls, expected);
|
|
155
|
+
* return submitBatch(
|
|
156
|
+
* // `pendingPool`: the strategy's pending pool, so only ITS
|
|
157
|
+
* // `DepositRequested` can prove the deposit.
|
|
158
|
+
* { calls, expect: { lender, tranche, amount, pendingPool } },
|
|
159
|
+
* { post, getStatus, getReceipt, now: Date.now, sleep },
|
|
160
|
+
* );
|
|
161
|
+
* },
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* | what happened | outcome |
|
|
165
|
+
* | ---------------------------------------------------- | ---------------------------------------- |
|
|
166
|
+
* | `post` throws `BatchRevertedError` | rethrown — reverted, nothing deposited |
|
|
167
|
+
* | `post` throws `BatchSubmissionError` | rethrown with its own outcome |
|
|
168
|
+
* | `post` throws anything else | `BatchPendingError('post_unknown')` |
|
|
169
|
+
* | `post` resolves without a transaction id | `BatchPendingError('malformed_answer')` |
|
|
170
|
+
* | status `reverted` | `BatchRevertedError('')` |
|
|
171
|
+
* | status `unknown` | `BatchPendingError(code)` |
|
|
172
|
+
* | polling runs out of time | `BatchPendingError('status_timeout')` |
|
|
173
|
+
* | receipt unreadable | `BatchPendingError('receipt_unavailable')` |
|
|
174
|
+
* | receipt: user operation reverted | `BatchRevertedError(revertError)` |
|
|
175
|
+
* | receipt: this deposit not shown | `BatchPendingError('deposit_unproven:<reason>')` |
|
|
176
|
+
* | receipt: this lender's deposit, tranche and amount | resolves `ProvenBatch` |
|
|
177
|
+
*
|
|
178
|
+
* Nothing on this path ever produces a `failed` outcome: by the time a batch
|
|
179
|
+
* has been posted, only a revert proves that nothing was deposited.
|
|
180
|
+
*/
|
|
181
|
+
export async function submitBatch<R extends ReceiptLike = ReceiptLike>(
|
|
182
|
+
input: {
|
|
183
|
+
calls: readonly SubmitBatchCall[];
|
|
184
|
+
/** What the receipt must show — see `proveDepositInReceipt`. */
|
|
185
|
+
expect: Omit<DepositProofExpectation, 'userOperationHash'>;
|
|
186
|
+
},
|
|
187
|
+
ports: BatchSubmissionPorts<R>,
|
|
188
|
+
policy?: Partial<BatchPollingPolicy>,
|
|
189
|
+
): Promise<BatchSubmission<R>> {
|
|
190
|
+
const resolved = resolvePolicy(policy);
|
|
191
|
+
const transactionId = await postOnce(() => ports.post(input.calls));
|
|
192
|
+
|
|
193
|
+
let settled: Promise<ProvenBatch<R>> | null = null;
|
|
194
|
+
return {
|
|
195
|
+
transactionId,
|
|
196
|
+
// Memoised: a second `wait()` reads the same outcome instead of
|
|
197
|
+
// polling the submitter twice.
|
|
198
|
+
wait: (): Promise<ProvenBatch<R>> =>
|
|
199
|
+
(settled ??= settle(transactionId, input.expect, ports, resolved)),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The post, and what its answer means: the transaction id, or the error the
|
|
205
|
+
* outcome table names. Shared by both submitters, so the two cannot come to
|
|
206
|
+
* disagree about which throws are ambiguous.
|
|
207
|
+
*/
|
|
208
|
+
async function postOnce(
|
|
209
|
+
post: () => Promise<{ transactionId: string }>,
|
|
210
|
+
): Promise<string> {
|
|
211
|
+
let accepted: { transactionId: string };
|
|
212
|
+
try {
|
|
213
|
+
accepted = await post();
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err instanceof BatchRevertedError) throw err;
|
|
216
|
+
if (err instanceof BatchSubmissionError) throw err;
|
|
217
|
+
throw new BatchPendingError('post_unknown', { cause: err });
|
|
218
|
+
}
|
|
219
|
+
const transactionId = (accepted as { transactionId?: unknown } | null)
|
|
220
|
+
?.transactionId;
|
|
221
|
+
if (typeof transactionId !== 'string' || transactionId === '') {
|
|
222
|
+
throw new BatchPendingError('malformed_answer');
|
|
223
|
+
}
|
|
224
|
+
return transactionId;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function readStatus<R extends ReceiptLike>(
|
|
228
|
+
ports: Pick<BatchSubmissionPorts<R>, 'getStatus'>,
|
|
229
|
+
transactionId: string,
|
|
230
|
+
): Promise<BatchStatusRead> {
|
|
231
|
+
try {
|
|
232
|
+
return await ports.getStatus(transactionId);
|
|
233
|
+
} catch {
|
|
234
|
+
return { kind: 'again' };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Poll until a mined hash, or a typed reason there will not be one. */
|
|
239
|
+
async function waitForMined<R extends ReceiptLike>(
|
|
240
|
+
transactionId: string,
|
|
241
|
+
ports: Pick<BatchSubmissionPorts<R>, 'getStatus' | 'now' | 'sleep'>,
|
|
242
|
+
policy: BatchPollingPolicy,
|
|
243
|
+
): Promise<{ txHash: string; userOperationHash: string | null }> {
|
|
244
|
+
const deadline = ports.now() + policy.timeoutMs;
|
|
245
|
+
let userOperationHash: string | null = null;
|
|
246
|
+
let delay = policy.initialMs;
|
|
247
|
+
|
|
248
|
+
for (;;) {
|
|
249
|
+
const read = await readStatus(ports, transactionId);
|
|
250
|
+
switch (read.kind) {
|
|
251
|
+
case 'mined':
|
|
252
|
+
if (typeof read.txHash !== 'string' || read.txHash === '') {
|
|
253
|
+
break; // a malformed "mined" is not an answer; ask again
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
txHash: read.txHash,
|
|
257
|
+
userOperationHash:
|
|
258
|
+
read.userOperationHash ?? userOperationHash,
|
|
259
|
+
};
|
|
260
|
+
case 'reverted':
|
|
261
|
+
throw new BatchRevertedError('');
|
|
262
|
+
case 'unknown':
|
|
263
|
+
throw new BatchPendingError(read.code);
|
|
264
|
+
case 'again':
|
|
265
|
+
if (read.userOperationHash) {
|
|
266
|
+
userOperationHash = read.userOperationHash;
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
default:
|
|
270
|
+
// A read the port did not normalise: not an answer.
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
if (ports.now() + delay > deadline) {
|
|
274
|
+
throw new BatchPendingError('status_timeout');
|
|
275
|
+
}
|
|
276
|
+
await ports.sleep(delay);
|
|
277
|
+
delay = Math.min(policy.maxMs, Math.round(delay * policy.backoff));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Poll to a mined hash and read its receipt; a receipt read that throws is PENDING. */
|
|
282
|
+
async function minedReceipt<R extends ReceiptLike>(
|
|
283
|
+
transactionId: string,
|
|
284
|
+
ports: BatchSubmissionPorts<R> | SponsoredCallSubmissionPorts<R>,
|
|
285
|
+
policy: BatchPollingPolicy,
|
|
286
|
+
): Promise<{ receipt: R; txHash: string; userOperationHash: string | null }> {
|
|
287
|
+
const mined = await waitForMined(transactionId, ports, policy);
|
|
288
|
+
try {
|
|
289
|
+
return {
|
|
290
|
+
receipt: await ports.getReceipt(mined.txHash),
|
|
291
|
+
txHash: mined.txHash,
|
|
292
|
+
userOperationHash: mined.userOperationHash,
|
|
293
|
+
};
|
|
294
|
+
} catch (err) {
|
|
295
|
+
throw new BatchPendingError('receipt_unavailable', { cause: err });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function settle<R extends ReceiptLike>(
|
|
300
|
+
transactionId: string,
|
|
301
|
+
expect: Omit<DepositProofExpectation, 'userOperationHash'>,
|
|
302
|
+
ports: BatchSubmissionPorts<R>,
|
|
303
|
+
policy: BatchPollingPolicy,
|
|
304
|
+
): Promise<ProvenBatch<R>> {
|
|
305
|
+
const mined = await minedReceipt(transactionId, ports, policy);
|
|
306
|
+
let proof: DepositProof;
|
|
307
|
+
try {
|
|
308
|
+
proof = proveDepositInReceipt(mined.receipt, {
|
|
309
|
+
...expect,
|
|
310
|
+
userOperationHash: mined.userOperationHash,
|
|
311
|
+
});
|
|
312
|
+
} catch (err) {
|
|
313
|
+
// A receipt the proof cannot even read proves nothing either way.
|
|
314
|
+
throw new BatchPendingError('deposit_unproven', { cause: err });
|
|
315
|
+
}
|
|
316
|
+
if (proof.kind === 'reverted')
|
|
317
|
+
throw new BatchRevertedError(proof.revertError);
|
|
318
|
+
if (proof.kind === 'unproven') {
|
|
319
|
+
// The proof's reason rides in the code: "no-user-operation" (the hash
|
|
320
|
+
// is not this lender's operation at all) and "no-deposit-requested"
|
|
321
|
+
// (the operation ran and shows no deposit) are the two things an
|
|
322
|
+
// operator reading the alert needs to tell apart first.
|
|
323
|
+
throw new BatchPendingError(`deposit_unproven:${proof.reason}`);
|
|
324
|
+
}
|
|
325
|
+
return { proof, receipt: mined.receipt, txHash: mined.txHash };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* What became of an earlier submission that ended PENDING — ONE status read,
|
|
330
|
+
* no polling — for an app's guard against lending twice:
|
|
331
|
+
*
|
|
332
|
+
* - `deposited` — mined, and the receipt proves the deposit;
|
|
333
|
+
* - `not-deposited` — definitively reverted (vendor status or the user
|
|
334
|
+
* operation's own flag);
|
|
335
|
+
* - `unknown` — anything else, including a read or receipt that failed.
|
|
336
|
+
*
|
|
337
|
+
* Only `not-deposited` may let a new lend proceed unprompted; `deposited`
|
|
338
|
+
* means the lender is told the earlier one landed (`already-submitted`).
|
|
339
|
+
*/
|
|
340
|
+
export async function checkBatchOutcome<R extends ReceiptLike = ReceiptLike>(
|
|
341
|
+
transactionId: string,
|
|
342
|
+
expect: Omit<DepositProofExpectation, 'userOperationHash'>,
|
|
343
|
+
ports: Pick<BatchSubmissionPorts<R>, 'getStatus' | 'getReceipt'>,
|
|
344
|
+
): Promise<'deposited' | 'not-deposited' | 'unknown'> {
|
|
345
|
+
let read: BatchStatusRead;
|
|
346
|
+
try {
|
|
347
|
+
read = await ports.getStatus(transactionId);
|
|
348
|
+
} catch {
|
|
349
|
+
return 'unknown';
|
|
350
|
+
}
|
|
351
|
+
if (read.kind === 'reverted') return 'not-deposited';
|
|
352
|
+
if (read.kind !== 'mined' || typeof read.txHash !== 'string') {
|
|
353
|
+
return 'unknown';
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const receipt = await ports.getReceipt(read.txHash);
|
|
357
|
+
const proof = proveDepositInReceipt(receipt, {
|
|
358
|
+
...expect,
|
|
359
|
+
userOperationHash: read.userOperationHash ?? null,
|
|
360
|
+
});
|
|
361
|
+
if (proof.kind === 'deposited') return 'deposited';
|
|
362
|
+
if (proof.kind === 'reverted') return 'not-deposited';
|
|
363
|
+
return 'unknown';
|
|
364
|
+
} catch {
|
|
365
|
+
return 'unknown';
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// One sponsored call — withdraw, cancel, withdraw at expiry
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* `BatchSubmissionPorts` for ONE sponsored call: the same status, receipt and
|
|
375
|
+
* clock ports — a relay client written for the deposit batch serves both —
|
|
376
|
+
* with a `post` that receives the call together with its relay `kind`, so the
|
|
377
|
+
* app never maps one from the other.
|
|
378
|
+
*/
|
|
379
|
+
export interface SponsoredCallSubmissionPorts<
|
|
380
|
+
R extends ReceiptLike = ReceiptLike,
|
|
381
|
+
> extends Omit<BatchSubmissionPorts<R>, 'post'> {
|
|
382
|
+
/**
|
|
383
|
+
* Hand the call to the submitter (`kind` and `[call]` in the relay's
|
|
384
|
+
* body); resolve with its transaction id once it is ACCEPTED. The same
|
|
385
|
+
* throw rules as `BatchSubmissionPorts.post`: `BatchRevertedError` when
|
|
386
|
+
* it is known to revert, `BatchSubmissionError(code, 'failed')` ONLY when
|
|
387
|
+
* nothing was sent, anything else is read as PENDING.
|
|
388
|
+
*/
|
|
389
|
+
post(call: SponsoredCall): Promise<{ transactionId: string }>;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** An executed call: the `executed` proof and the receipt it was read from. */
|
|
393
|
+
export interface ExecutedCall<R extends ReceiptLike = ReceiptLike> {
|
|
394
|
+
proof: Extract<UserOperationProof, { kind: 'executed' }>;
|
|
395
|
+
receipt: R;
|
|
396
|
+
txHash: string;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** What `submitSponsoredCall` resolves with once the call is accepted. */
|
|
400
|
+
export interface SponsoredCallSubmission<R extends ReceiptLike = ReceiptLike>
|
|
401
|
+
extends WaitableTransaction {
|
|
402
|
+
transactionId: string;
|
|
403
|
+
/**
|
|
404
|
+
* Polls to a mined receipt and proves the sender's operation EXECUTED.
|
|
405
|
+
* Rejects with `BatchRevertedError` when it definitively did not, and
|
|
406
|
+
* with `BatchPendingError` for everything else.
|
|
407
|
+
*/
|
|
408
|
+
wait(): Promise<ExecutedCall<R>>;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* `submitBatch` for ONE sponsored `LendingPoolManager` call — the body of a
|
|
413
|
+
* `WithdrawFlow` `submitCall` port, and of an app's cancel or
|
|
414
|
+
* withdraw-at-expiry action. Same post, same polling policy, same outcome
|
|
415
|
+
* table; the proof is `proveUserOperationInReceipt` (the sender's operation
|
|
416
|
+
* succeeded) instead of the deposit proof, and an unproven receipt is
|
|
417
|
+
* `call_unproven:<reason>`.
|
|
418
|
+
*
|
|
419
|
+
* ```ts
|
|
420
|
+
* submitCall: (call) =>
|
|
421
|
+
* submitSponsoredCall(
|
|
422
|
+
* { call, expect: { sender: lender } },
|
|
423
|
+
* { post: (c) => postToMyRelay(c.kind, [c.call]), getStatus, getReceipt, now, sleep },
|
|
424
|
+
* ),
|
|
425
|
+
* ```
|
|
426
|
+
*
|
|
427
|
+
* The call is checked against its kind before anything is posted — a known
|
|
428
|
+
* kind, exactly `{ to, data }`, and `data` opening with that kind's selector —
|
|
429
|
+
* and a mismatch is `BatchSubmissionError('call_mismatch', 'failed')`: nothing
|
|
430
|
+
* was sent. Build calls with `kasu.calls`, and this never fires.
|
|
431
|
+
*
|
|
432
|
+
* As on the deposit path, nothing after the post ever produces `failed`.
|
|
433
|
+
*/
|
|
434
|
+
export async function submitSponsoredCall<R extends ReceiptLike = ReceiptLike>(
|
|
435
|
+
input: {
|
|
436
|
+
call: SponsoredCall;
|
|
437
|
+
/** Whose operation must have executed — see `proveUserOperationInReceipt`. */
|
|
438
|
+
expect: Omit<UserOperationProofExpectation, 'userOperationHash'>;
|
|
439
|
+
},
|
|
440
|
+
ports: SponsoredCallSubmissionPorts<R>,
|
|
441
|
+
policy?: Partial<BatchPollingPolicy>,
|
|
442
|
+
): Promise<SponsoredCallSubmission<R>> {
|
|
443
|
+
const resolved = resolvePolicy(policy);
|
|
444
|
+
if (!isWellFormedSponsoredCall(input.call)) {
|
|
445
|
+
throw new BatchSubmissionError('call_mismatch', 'failed');
|
|
446
|
+
}
|
|
447
|
+
const transactionId = await postOnce(() => ports.post(input.call));
|
|
448
|
+
|
|
449
|
+
let settled: Promise<ExecutedCall<R>> | null = null;
|
|
450
|
+
return {
|
|
451
|
+
transactionId,
|
|
452
|
+
wait: (): Promise<ExecutedCall<R>> =>
|
|
453
|
+
(settled ??= settleCall(
|
|
454
|
+
transactionId,
|
|
455
|
+
input.expect,
|
|
456
|
+
ports,
|
|
457
|
+
resolved,
|
|
458
|
+
)),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isWellFormedSponsoredCall(value: unknown): value is SponsoredCall {
|
|
463
|
+
if (value === null || typeof value !== 'object') return false;
|
|
464
|
+
const { kind, call } = value as { kind?: unknown; call?: unknown };
|
|
465
|
+
if (
|
|
466
|
+
typeof kind !== 'string' ||
|
|
467
|
+
!(SPONSORED_CALL_KINDS as readonly string[]).includes(kind)
|
|
468
|
+
) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
if (call === null || typeof call !== 'object') return false;
|
|
472
|
+
const keys = Object.keys(call);
|
|
473
|
+
if (keys.length !== 2 || !keys.includes('to') || !keys.includes('data')) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
const { to, data } = call as { to: unknown; data: unknown };
|
|
477
|
+
return (
|
|
478
|
+
typeof to === 'string' &&
|
|
479
|
+
/^0x[0-9a-fA-F]{40}$/.test(to) &&
|
|
480
|
+
typeof data === 'string' &&
|
|
481
|
+
/^0x(?:[0-9a-fA-F]{2})*$/.test(data) &&
|
|
482
|
+
data
|
|
483
|
+
.toLowerCase()
|
|
484
|
+
.startsWith(SPONSORED_CALL_SELECTORS[kind as SponsoredCall['kind']])
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function settleCall<R extends ReceiptLike>(
|
|
489
|
+
transactionId: string,
|
|
490
|
+
expect: Omit<UserOperationProofExpectation, 'userOperationHash'>,
|
|
491
|
+
ports: SponsoredCallSubmissionPorts<R>,
|
|
492
|
+
policy: BatchPollingPolicy,
|
|
493
|
+
): Promise<ExecutedCall<R>> {
|
|
494
|
+
const mined = await minedReceipt(transactionId, ports, policy);
|
|
495
|
+
let proof: UserOperationProof;
|
|
496
|
+
try {
|
|
497
|
+
proof = proveUserOperationInReceipt(mined.receipt, {
|
|
498
|
+
...expect,
|
|
499
|
+
userOperationHash: mined.userOperationHash,
|
|
500
|
+
});
|
|
501
|
+
} catch (err) {
|
|
502
|
+
throw new BatchPendingError('call_unproven', { cause: err });
|
|
503
|
+
}
|
|
504
|
+
if (proof.kind === 'reverted') {
|
|
505
|
+
throw new BatchRevertedError(proof.revertError);
|
|
506
|
+
}
|
|
507
|
+
if (proof.kind === 'unproven') {
|
|
508
|
+
throw new BatchPendingError(`call_unproven:${proof.reason}`);
|
|
509
|
+
}
|
|
510
|
+
return { proof, receipt: mined.receipt, txHash: mined.txHash };
|
|
511
|
+
}
|