import { BigNumber } from 'ethers'; import { GenerateContractResponse } from '../domain/loan-contract'; import { DepositParams, KycParams } from '../facade/types'; import { Flow } from './flow'; import { WaitableTransaction } from './observable'; /** * The KYC-gated deposit pipeline, headless. * * This is the state machine kasu-ui and kasu-mobile each hand-wrote and then * had to keep in step by hand: sign an auth message, generate the loan * agreement, park while the lender reads it, sign it, approve the EXACT amount, * fetch the KYC signature, submit, wait. Two implementations of one money path * is one too many, so it lives here once and each application drives it from * its own UI. * * **No React, no copy, no I/O of its own.** Every side effect is an injected * port and every observable state is a code — `{ step: 'approve', reason: * 'cancelled' }`, never "USDC approval was cancelled in your wallet". The * applications keep their own `DEPOSIT_STEP_ERRORS` tables and map the codes to * their words, in their design system and their language. Nothing in this file * may be shown to a lender. * * ## Order, and why each step is where it is * * 1. **Allowance pre-check** — before anything is signed, because it decides * `approvalRequired`, which decides `stepTotal`. A badge that said "3 of 4" * and then silently became "3 of 3" would be describing a pipeline the * lender is not in. A failed read assumes an approve IS needed: the safe * default is a redundant approval, never a reverted deposit. * 2. **`generating-sign`** — the lender signs the auth message * (`buildLoanAgreementSignMessage`, or the legacy builder). kasu-backend * reconstructs that string byte-for-byte to verify the signature. * 3. **`generating-fetch`** — the agreements service returns the contract. * 4. **`awaiting-accept`** — the run PARKS on a promise the consumer settles * with `acceptContract()` or `declineContract()`. This is the only point at * which a lender is committing to anything. * 5. **`accepting-sign`** — the acceptance signature, which becomes the * on-chain `depositData` via `encodeDepositData`. * 6. **The 5-minute TTL guard** — checked AFTER the accept, because that is * where the idling happens. An expired agreement is refused here rather than * broadcast as a transaction that cannot succeed. * 7. **`approve`** — the EXACT amount, never `MaxUint256`. House rule, and it * is why the allowance drops to zero after every deposit and why step 1 * reads it fresh rather than trusting a cache. * 8. **`request-sign` / `request-confirm`** — KYC signature, then the deposit, * then the receipt. * * ## Failure codes * * A wallet rejection (`isUserRejected`) is `'cancelled'` — the lender changed * their mind, and telling them something broke would be a lie. Only a WALLET * call is ever classified that way: the HTTP ports are always `'failed'`, * because a backend that words a refusal "declined" did not involve the * lender's wallet. A revert on the request step is read for WHAT reverted: a * protocol error the ABI declares is `'reverted'` with the name, and only a * token balance or allowance failure — or a revert nothing can decode — stays * `'insufficient-balance'`. Everything else is `'failed'` and carries the * original error for the consumer's crash reporter. * * ## Both signatures are checked before they are used * * A wallet is not obliged to hand back 65 bytes, and `encodeDepositData` will * not catch one that does not: `defaultAbiCoder` encodes any even-length hex * as `bytes`, `0x` included, so a malformed acceptance signature becomes a * `depositData` blob that is broadcast, mined, and then can never be verified * by the agreements service. Both personal signs are therefore length-checked * where they are taken, and a bad one fails the step it was taken on rather * than the one it would eventually have broken. * * ```ts * const flow = new DepositFlow(ports); * const stop = flow.subscribe((s) => render(s)); * await flow.start({ poolId, trancheId, amount, userAddress, ... }); * // …the consumer shows `flow.state.contract` and calls: * await flow.acceptContract(); * ``` */ /** * Where the run is. `success`, `declined` and `error` are terminal; everything * else is in flight. */ export type DepositPhase = 'idle' | 'generating-sign' | 'generating-fetch' | 'awaiting-accept' | 'accepting-sign' | 'approve' | 'request-sign' | 'request-confirm' | 'success' | 'declined' | 'error'; /** * The four steps a lender sees as a badge. `approve` drops out of the sequence * when the allowance already covers the deposit, which is why `stepIndex` and * `stepTotal` are published rather than derived by each consumer. */ export type DepositStep = 'generate' | 'confirm' | 'approve' | 'request'; /** * Why a run did not reach `success`, as a code plus the step it happened on. * * The consumer maps this to its own words. `error` carries the underlying * throw for a crash reporter — a `cancelled` and a `contract-expired` do not, * because neither is a fault worth reporting. */ export type DepositFailure = { step: DepositStep; reason: 'cancelled'; } | { step: DepositStep; reason: 'failed'; error: unknown; } | { step: 'request'; reason: 'insufficient-balance'; error: unknown; } | { step: 'request'; reason: 'reverted'; /** * The custom error the contract reverted with, exactly as the ABI * declares it — `'ClearingIsPending'`, `'LendingPoolIsStopped'`, * `'UserNotKycd'`, and the rest of * `ILendingPoolManagerAbi` / `IKasuAllowListAbi`. * * Render `reverted` with generic copy and special-case only the * names you have words for: a contract upgrade can add an error, and * a consumer that assumed the set was closed would have nothing to * show for the new one. */ revertError: string; error: unknown; } | { step: 'request'; reason: 'contract-expired'; }; export type { WaitableTransaction }; /** What the KYC signing service hands back. */ export interface KycSignature { signature: string; blockExpiration: number | string; } /** * The `/contract/generate` body, assembled by the flow and posted by the * consumer's own port — through its server-side proxy (kasu-ui) or straight to * the agreements service (kasu-mobile). The SDK never makes the call itself and * never learns the URL or the key. */ export interface GenerateContractRequest { /** * The lender's address, LOWERCASED. The legacy message embeds this casing * and kasu-backend rebuilds the string from the body, so the two must * agree. */ address: string; /** Signature over `signedMessage`. */ signature: string; /** ms-epoch. The same value `signedMessage` states — do not re-clock it. */ timestamp: number; /** * The exact text that was signed. Sent so a consumer can log or assert on * it; the backend rebuilds it from the other fields rather than trusting * this one. */ signedMessage: string; poolId: string; trancheId: string; /** `'0'` for a variable-rate deposit. */ fixedTermConfigId: string; /** * The deposit in DISPLAY units, forwarded verbatim from * `DepositFlowInput.depositAmount`. kasu-backend cross-checks it against * the leading number of `amountLabel`. */ depositAmount?: number; /** The four human-readable fields, present only on the new format. */ strategyName?: string; region?: string; optionName?: string; amountLabel?: string; } /** * The human-readable `/contract/generate` format (BD deck slide 19): the lender * signs a statement naming the strategy, region, option and amount. * * `amountLabel` must be derived from the same value as `depositAmount` — the * backend refuses a message that states an amount other than the one being * executed. The SDK does not format it, because formatting is the * application's (and its locale's) business. */ export interface LoanAgreementRequest { format: 'loan-agreement'; strategyName: string; region: string; optionName: string; amountLabel: string; } /** * The legacy `I request contract content for {address} at {timestamp}.` * format, which kasu-backend still accepts and `/contract/resolve` has no * alternative to. */ export interface LegacyContractRequest { format: 'legacy'; } export type ContractMessageRequest = LoanAgreementRequest | LegacyContractRequest; /** * Every side effect the pipeline needs, injected. * * `kasu.flows.deposit()` fills `readAllowance`, `approve`, `deposit` and * `buildKycParams` from the SDK's own signer-bound implementations; the three * that reach the consumer's own backend or wallet have no sensible default and * are always supplied by the application. */ export interface DepositPorts { /** EIP-191 personal sign. Rejects when the lender refuses. */ signMessage(message: string): Promise; /** POST the generate request; resolve with the agreements service's reply. */ generateContract(req: GenerateContractRequest): Promise; /** Build the Nexera KYC params. Defaults to `kasu.deposits.buildKycParams`. */ buildKycParams(userAddress: `0x${string}`): KycParams | Promise; /** Exchange those params for a signature at the consumer's own backend. */ getKycSignature(params: KycParams): Promise; /** ERC-20 `allowance(owner, spender)`, in base units. */ readAllowance(owner: string, spender: string): Promise; /** ERC-20 `approve(spender, amount)`. The flow only ever passes the EXACT amount. */ approve(spender: string, amount: BigNumber): Promise; /** `requestDepositWithKyc`. Defaults to `kasu.deposits.deposit`. */ deposit(params: DepositParams): Promise; /** ms-epoch clock. Defaults to `Date.now`; injected so the TTL is testable. */ now?(): number; } /** Construction options. `kasu.flows.deposit()` fills `spender` in. */ export interface DepositFlowOptions { /** Agreement validity window; defaults to `CONTRACT_TTL_MS`. */ contractTtlMs?: number; /** * The ERC-20 spender every run approves and deposits through, when the * input does not name one. `kasu.flows.deposit()` passes this chain's * `contracts.LendingPoolManager`, which is the only contract the default * deposit port calls. */ spender?: string; } export interface DepositFlowInput { poolId: string; trancheId: string; /** The deposit in BASE units (6dp for USDC and AUDD). */ amount: BigNumber; /** `'0'` for a variable-rate deposit. */ fixedTermConfigId: string; userAddress: `0x${string}`; /** * The ERC-20 spender, when it is NOT this chain's `LendingPoolManager`. * * Leave it out: `kasu.flows.deposit()` defaults it from the chain config, * and the default deposit port calls no other contract. It exists for a * consumer that replaced the `deposit` port with one that spends * somewhere else — a wrong spender is an approval granted to the wrong * contract and then a revert diagnosed as `insufficient-balance`. */ spender?: string; /** Which signed-message format to use, and its fields. */ contractMessage: ContractMessageRequest; /** * The deposit in DISPLAY units, for the generate request only. * * NOT derived from `amount`: turning base units back into a display number * is formatting, and formatting is the application's job — it is also the * application that produced `amountLabel`, and kasu-backend refuses the two * if they disagree. Pass the same value both were built from. */ depositAmount?: number; } export interface DepositState { phase: DepositPhase; /** The step `phase` belongs to; `null` only while idle. */ step: DepositStep | null; /** 1-based badge position of `step`; `0` while idle. */ stepIndex: number; /** `4`, or `3` when the allowance already covers the deposit. */ stepTotal: number; /** Whether the approve step is in scope for this run. */ approvalRequired: boolean; /** The generated agreement, from `generating-fetch` onwards. */ contract: GenerateContractResponse | null; /** Set with `phase: 'error'`, cleared by `reset()`. */ failure: DepositFailure | null; } /** Generated agreements are valid for five minutes upstream. */ export declare const CONTRACT_TTL_MS: number; /** What a run without a spender, from either source, fails with. */ export declare const NO_SPENDER_MESSAGE = "DepositFlow: no ERC-20 spender; build the flow with kasu.flows.deposit() or pass `spender` on the input"; /** What a malformed auth signature fails the generate step with. */ export declare const INVALID_AUTH_SIGNATURE_MESSAGE = "DepositFlow: the wallet returned a malformed authentication signature; expected 65 bytes of 0x-prefixed hex"; /** What a malformed acceptance signature fails the confirm step with. */ export declare const INVALID_ACCEPTANCE_SIGNATURE_MESSAGE = "DepositFlow: the wallet returned a malformed acceptance signature; expected 65 bytes of 0x-prefixed hex"; export declare class DepositFlow extends Flow { private readonly _ports; private readonly _ttlMs; private readonly _defaultSpender; private readonly _now; /** * The accept handshake. The run parks on this promise; `acceptContract`, * `declineContract` and `reset` each settle it with an `AcceptOutcome`. * Cleared the moment it settles so a stale resolver from an abandoned run * can never leak into the next one. */ private _accept; /** * The run token that is between `acceptContract()` and the wallet * settling, or `null`. * * A token rather than a boolean, because the flag has to belong to the RUN * that set it: after `reset()` out of a wallet prompt that never answers, * the abandoned run's `finally` may not arrive for minutes, and a boolean * left standing refuses both Accept and Decline on every run after it. * A stale token simply is not the current generation. */ private _acceptingFor; constructor(_ports: DepositPorts, opts?: DepositFlowOptions); /** * Sign the agreement and resume the parked run. A no-op when nothing is * parked, so a double tap cannot sign twice. */ acceptContract(): Promise; /** * Back out of the agreement. The run ends on `declined` — a legitimate * choice, not a failure, and `state.failure` stays null. * * Ignored once `acceptContract()` has opened the wallet: an agreement in * the middle of being signed cannot also be refused. `reset()` is the way * out of a prompt that never answers. */ declineContract(): void; /** True only while THIS generation is waiting on the acceptance signature. */ private _isAccepting; /** `reset()`: unpark the abandoned run and drop its handshake. */ protected _onAbandon(): void; protected _run(input: DepositFlowInput, token: number): Promise; private _fail; }