@recta/client 0.1.2 → 0.1.4
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 +45 -3
- package/dist/client.d.ts +8 -0
- package/dist/client.js +68 -10
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +4 -0
- package/dist/funds.d.ts +88 -0
- package/dist/funds.js +77 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/signer.d.ts +9 -2
- package/dist/signer.js +20 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ It never receives a key. Before it signs or sends anything it checks it locally,
|
|
|
18
18
|
npm install @recta/client viem
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
`viem` (>= 2.
|
|
21
|
+
`viem` (>= 2.38.3, < 3) is the only dependency, and you choose its version.
|
|
22
22
|
|
|
23
23
|
## Use
|
|
24
24
|
|
|
@@ -36,7 +36,9 @@ const recta = createRectaClient({
|
|
|
36
36
|
referrer: "0x…", // optional: the integrator that ships this agent (see "Referral for integrators")
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
// Dry run: evaluates and checks, signs no permit, sends no transaction.
|
|
39
|
+
// Dry run: evaluates and checks, signs no permit, sends no transaction. With Recta's collection on it stops at the
|
|
40
|
+
// fee permit, before the transactions exist (Recta hands them out only after the permit is signed): `preview.fee`
|
|
41
|
+
// carries the permit it would sign and `funds`, whether this wallet can pay it (see "Paying the fee").
|
|
40
42
|
const preview = await recta.run(request, { dryRun: true });
|
|
41
43
|
|
|
42
44
|
// The whole flow.
|
|
@@ -46,7 +48,7 @@ const result = await recta.run(request);
|
|
|
46
48
|
`request` is Recta's evaluation request (schema in the
|
|
47
49
|
[service descriptor](https://api.recta.build/v1/golden-corridor/commercial-evaluations/schema)); its `sender` must be
|
|
48
50
|
your signer's address. Each step is also available alone: `evaluate`, `authorizeFee`, `check`, `execute`, `report`,
|
|
49
|
-
`ledger`, `payDebt`.
|
|
51
|
+
`ledger`, `payDebt`, `feeFunds`, `feeApproval`.
|
|
50
52
|
|
|
51
53
|
Your account must be an EOA: Recta's signed requests are EIP-191 signatures recovered to the sender's address.
|
|
52
54
|
|
|
@@ -95,6 +97,42 @@ really uses. If your agent bridges through LI.FI (its public API or an aggregato
|
|
|
95
97
|
wherever the fit check (`GET /v1/golden-corridor/fit`) lists it in `declarable_baselines`; it is refused with
|
|
96
98
|
`latency_class` SECONDS or MINUTES. Recta reads LI.FI's own transaction, its fee included. If that route is already the cheapest, Recta answers with no saving and charges nothing.
|
|
97
99
|
|
|
100
|
+
## Paying the fee: USDC and the Permit2 allowance on Base
|
|
101
|
+
|
|
102
|
+
Recta's fee is collected in USDC on Base through Permit2. Signing the permit is not enough: when Recta settles it,
|
|
103
|
+
Permit2 pulls the fee with USDC's own `transferFrom`, so the wallet also needs, on Base, (1) at least the permit's amount
|
|
104
|
+
in USDC and (2) an ERC-20 allowance from USDC to Permit2 (`0x000000000022D473030F116dDEE9F6B43aC78BA3`) of at least that
|
|
105
|
+
amount. A new wallet has neither.
|
|
106
|
+
|
|
107
|
+
Before it signs a fee or debt permit, the client reads both through `chain.readFeeFunds` (on Base: `viemChainReader`
|
|
108
|
+
uses `publicClients[8453]`) and refuses, with `RectaCheckError` `fee_balance` or `fee_allowance`, when either is short.
|
|
109
|
+
Nothing is signed and no route transaction is sent, so a route never executes with a fee Recta cannot collect.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const funds = await recta.feeFunds(); // { ready, balanceUsdcAtomic, permit2AllowanceUsdcAtomic, missing }
|
|
113
|
+
if (funds.missing.includes("allowance")) {
|
|
114
|
+
const approval = recta.feeApproval(); // USDC.approve(Permit2, maxPermitUsdcAtomic) on Base, for you to send
|
|
115
|
+
// send `approval` (chainId, to, data, value) with your own wallet
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The approval is bounded: `feeApproval` defaults to, and may not exceed, `maxPermitUsdcAtomic`, and
|
|
120
|
+
`permit2ApprovalTransaction` refuses 0 and any "unlimited" amount. The client never approves by itself. Each collected
|
|
121
|
+
fee lowers the allowance; approve again when `feeFunds` reports it short. The check is a snapshot at signing: a wallet
|
|
122
|
+
that later spends its USDC or revokes the allowance leaves the fee unpaid, which Recta records as a debt to settle
|
|
123
|
+
before the next evaluation (`payDebt`).
|
|
124
|
+
|
|
125
|
+
When the route itself starts from USDC on Base, the transfer spends the same balance before the fee is pulled, so the
|
|
126
|
+
balance must cover the transfer plus the fee: `run` passes the request to `authorizeFee` for that (pass it yourself as
|
|
127
|
+
`authorizeFee(id, { request })` when calling the steps alone), and `execute` checks the funds again, for every fee
|
|
128
|
+
this client authorized or found authorized, right before the first transaction. A reader error never carries the RPC
|
|
129
|
+
URL (it may hold an API key).
|
|
130
|
+
|
|
131
|
+
Since 0.1.4 `RectaChainReader` requires `readFeeFunds`; `createRectaClient` refuses a reader without it.
|
|
132
|
+
|
|
133
|
+
A custom `RectaChainReader` must implement `readFeeFunds(owner)`; when it cannot read Base it must throw, and the permit
|
|
134
|
+
is then not signed (a dry run reports `funds: null` and the reason).
|
|
135
|
+
|
|
98
136
|
## What is checked before signing
|
|
99
137
|
|
|
100
138
|
**Pins.** The package carries Recta's fee addresses in its own code: Permit2, x402's `upto` proxy, USDC on Base,
|
|
@@ -132,6 +170,10 @@ the declared messaging fee, within `maxNativeValueWei`.
|
|
|
132
170
|
`RectaDebtError` with the offer. The package never signs a debt by itself: call `payDebt(offer)` if you agree (same
|
|
133
171
|
checks, exact amount).
|
|
134
172
|
|
|
173
|
+
## What each request identifies
|
|
174
|
+
|
|
175
|
+
Every request this package sends carries the User-Agent `recta-client/<version>`, so Recta can count real use of the package apart from other clients. It names the package and its version only: never an address, a key or a request body.
|
|
176
|
+
|
|
135
177
|
## Limits of this version
|
|
136
178
|
|
|
137
179
|
- EVM sources and EVM recipients only. Solana, Tron and Bitcoin routes, Relay and deBridge are refused.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Hex } from "viem";
|
|
2
2
|
import { type CheckedArtifact, type EvaluationRequestView } from "./artifact.js";
|
|
3
|
+
import { type FeeReadiness, type Permit2Approval } from "./funds.js";
|
|
3
4
|
import type { RectaChainReader, RectaSigner, RectaTypedData } from "./signer.js";
|
|
4
5
|
export interface RectaClientOptions {
|
|
5
6
|
readonly signer: RectaSigner;
|
|
@@ -47,6 +48,8 @@ export type FeeOutcome = {
|
|
|
47
48
|
readonly kind: "DRY_RUN";
|
|
48
49
|
readonly amountUsdcAtomic: bigint;
|
|
49
50
|
readonly typedData: RectaTypedData;
|
|
51
|
+
readonly funds: FeeReadiness | null;
|
|
52
|
+
readonly fundsError?: string;
|
|
50
53
|
};
|
|
51
54
|
/**
|
|
52
55
|
* Recta's flow for an agent that holds its own EOA: evaluate → authorizeFee → execute → report. Every step can be
|
|
@@ -59,6 +62,7 @@ export declare function createRectaClient(options: RectaClientOptions): {
|
|
|
59
62
|
}) => Promise<Record<string, unknown>>;
|
|
60
63
|
authorizeFee: (resultId: string, opts?: {
|
|
61
64
|
readonly dryRun?: boolean;
|
|
65
|
+
readonly request?: EvaluationRequestView;
|
|
62
66
|
}) => Promise<{
|
|
63
67
|
readonly fee: FeeOutcome;
|
|
64
68
|
readonly artifact: unknown;
|
|
@@ -89,6 +93,8 @@ export declare function createRectaClient(options: RectaClientOptions): {
|
|
|
89
93
|
readonly kind: "DRY_RUN";
|
|
90
94
|
readonly amountUsdcAtomic: bigint;
|
|
91
95
|
readonly typedData: RectaTypedData;
|
|
96
|
+
readonly funds: FeeReadiness | null;
|
|
97
|
+
readonly fundsError?: string;
|
|
92
98
|
};
|
|
93
99
|
checked?: never;
|
|
94
100
|
} | {
|
|
@@ -125,5 +131,7 @@ export declare function createRectaClient(options: RectaClientOptions): {
|
|
|
125
131
|
checked?: never;
|
|
126
132
|
}>;
|
|
127
133
|
walletPolicy: () => Promise<void>;
|
|
134
|
+
feeFunds: (requiredUsdcAtomic?: bigint) => Promise<FeeReadiness>;
|
|
135
|
+
feeApproval: (amountUsdcAtomic?: bigint) => Permit2Approval;
|
|
128
136
|
};
|
|
129
137
|
export type RectaClient = ReturnType<typeof createRectaClient>;
|
package/dist/client.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { keccak256 } from "viem";
|
|
2
2
|
import { checkExecutionArtifact } from "./artifact.js";
|
|
3
|
-
import { ISSUED_AT_HEADER, PATHS, RECTA_ORIGIN, SIGNATURE_HEADER, SIGNED_REQUEST_PREFIX, SIGNER_HEADER } from "./constants.js";
|
|
3
|
+
import { ISSUED_AT_HEADER, PATHS, RECTA_ORIGIN, SIGNATURE_HEADER, SIGNED_REQUEST_PREFIX, SIGNER_HEADER, USER_AGENT } from "./constants.js";
|
|
4
4
|
import { RectaCheckError, RectaDebtError, RectaHttpError } from "./errors.js";
|
|
5
5
|
import { checkFeePermit } from "./fee.js";
|
|
6
|
+
import { assertFeeReady, feeReadiness, permit2ApprovalTransaction, routeReserveOf, safeErrorText } from "./funds.js";
|
|
6
7
|
import { parseWalletPolicy } from "./policy.js";
|
|
7
8
|
/** The text a sender signs for one request (EIP-191), exactly as Recta's server rebuilds it. */
|
|
8
9
|
export function signedRequestMessage(f) {
|
|
@@ -44,10 +45,18 @@ export function createRectaClient(options) {
|
|
|
44
45
|
const nowUnix = () => BigInt(Math.floor(now() / 1000));
|
|
45
46
|
const payer = signer.address.toLowerCase();
|
|
46
47
|
const referral = referrerQuery(options.referrer, payer);
|
|
48
|
+
if (typeof chain.readFeeFunds !== "function") {
|
|
49
|
+
// Since 0.1.4 (ASTRA T2) a reader must read the fee funds on Base; an older custom reader fails here, clearly.
|
|
50
|
+
throw new RectaCheckError("chain_reader", "the chain reader has no readFeeFunds(owner): since @recta/client 0.1.4 it must read the payer's USDC balance and Permit2 allowance on Base (viemChainReader does)");
|
|
51
|
+
}
|
|
52
|
+
/** The fee permit amount of each decision this client saw authorized: execute() checks the funds against it again
|
|
53
|
+
* right before the first transaction (Fable B-1/M-1 on recta #12). */
|
|
54
|
+
const feeAmounts = new Map();
|
|
55
|
+
const parseAtomic = (v) => (typeof v === "string" && /^(0|[1-9][0-9]{0,30})$/.test(v) ? BigInt(v) : undefined);
|
|
47
56
|
let policyChecked = false;
|
|
48
57
|
async function call(method, path, body, idempotencyKey, signed = true) {
|
|
49
58
|
const bytes = body === undefined ? new Uint8Array() : new TextEncoder().encode(JSON.stringify(body));
|
|
50
|
-
const headers = { accept: "application/json" };
|
|
59
|
+
const headers = { accept: "application/json", "user-agent": USER_AGENT };
|
|
51
60
|
if (body !== undefined)
|
|
52
61
|
headers["content-type"] = "application/json";
|
|
53
62
|
if (idempotencyKey !== undefined)
|
|
@@ -92,17 +101,32 @@ export function createRectaClient(options) {
|
|
|
92
101
|
}
|
|
93
102
|
/**
|
|
94
103
|
* Step 2: the decision's committed transactions. With Recta's collection on, the artifact first asks for the fee
|
|
95
|
-
* permit: it is checked, rebuilt and signed only if it passes
|
|
96
|
-
*
|
|
104
|
+
* permit: it is checked, rebuilt, and signed only if it passes and the payer holds the permit's amount in USDC on
|
|
105
|
+
* Base with at least that allowance to Permit2 (otherwise RectaCheckError `fee_balance` / `fee_allowance`, nothing
|
|
106
|
+
* signed; see `feeApproval`); then it is stored and the answer carries the artifact. With `dryRun` and collection on,
|
|
107
|
+
* it stops at the permit, before the artifact exists, and returns the typed data it would sign and the payer's funds:
|
|
108
|
+
* the transactions can only be inspected after the permit is signed. Pass the evaluation's `request` so a route that
|
|
109
|
+
* starts from USDC on Base reserves its own amount from the balance; `execute` checks the funds again either way.
|
|
97
110
|
*/
|
|
98
111
|
async function authorizeFee(resultId, opts = {}) {
|
|
99
112
|
const id = resultIdOf(resultId);
|
|
100
113
|
const artifactPath = `${PATHS.evaluations}/${id}/execution-artifact`;
|
|
114
|
+
const reserve = opts.request === undefined ? 0n : routeReserveOf(opts.request);
|
|
101
115
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
102
116
|
const a = await call("GET", artifactPath);
|
|
103
117
|
if (a.status === 200) {
|
|
104
|
-
const
|
|
105
|
-
|
|
118
|
+
const fa = field(a.body, "fee_authorization");
|
|
119
|
+
if (field(fa, "status") === "AUTHORIZED") {
|
|
120
|
+
// A permit stored earlier: its amount is the stored cap plus what it carries; the funds are checked again
|
|
121
|
+
// before execute() sends anything.
|
|
122
|
+
const cap = parseAtomic(field(fa, "max_fee_authorization_usdc_atomic"));
|
|
123
|
+
const carried = parseAtomic(field(fa, "carried_usdc_atomic"));
|
|
124
|
+
if (cap === undefined || carried === undefined)
|
|
125
|
+
throw new RectaCheckError("fee_amount", "the stored fee authorization has no readable amount");
|
|
126
|
+
feeAmounts.set(id, cap + carried);
|
|
127
|
+
return { fee: { kind: "ALREADY_AUTHORIZED" }, artifact: field(a.body, "artifact") };
|
|
128
|
+
}
|
|
129
|
+
return { fee: { kind: "NOT_REQUIRED" }, artifact: field(a.body, "artifact") };
|
|
106
130
|
}
|
|
107
131
|
if (a.status !== 402 || field(a.body, "status") !== "SIGNATURE_REQUIRED")
|
|
108
132
|
throw new RectaHttpError(a.status, a.body);
|
|
@@ -113,10 +137,24 @@ export function createRectaClient(options) {
|
|
|
113
137
|
nowUnix: nowUnix(),
|
|
114
138
|
...(typeof expiry === "string" && /^[0-9]+$/.test(expiry) ? { commercialExpiryUnix: BigInt(expiry) } : {}),
|
|
115
139
|
});
|
|
116
|
-
if (opts.dryRun === true)
|
|
117
|
-
|
|
140
|
+
if (opts.dryRun === true) {
|
|
141
|
+
// A preview reports the funds; a reader that cannot read Base yields `funds: null` and the reason, never a signature.
|
|
142
|
+
let funds = null;
|
|
143
|
+
let fundsError;
|
|
144
|
+
try {
|
|
145
|
+
funds = feeReadiness(await chain.readFeeFunds(signer.address), checked.amount, reserve);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
fundsError = safeErrorText(e);
|
|
149
|
+
}
|
|
150
|
+
return { fee: { kind: "DRY_RUN", amountUsdcAtomic: checked.amount, typedData: checked.typedData, funds, ...(fundsError === undefined ? {} : { fundsError }) }, artifact: undefined };
|
|
151
|
+
}
|
|
152
|
+
// ASTRA T2: never sign a fee Recta could not collect (no USDC or no Permit2 allowance on Base).
|
|
153
|
+
assertFeeReady(feeReadiness(await chain.readFeeFunds(signer.address), checked.amount, reserve));
|
|
118
154
|
const signature = await signer.signTypedData(checked.typedData);
|
|
119
155
|
const s = await call("POST", `${PATHS.evaluations}/${id}/fee-authorization${referral}`, { signature, amount_usdc_atomic: checked.amount.toString() });
|
|
156
|
+
if (s.status === 200 || s.status === 201)
|
|
157
|
+
feeAmounts.set(id, checked.amount);
|
|
120
158
|
if (s.status === 200 || s.status === 201)
|
|
121
159
|
return { fee: { kind: "AUTHORIZED", amountUsdcAtomic: checked.amount, signature }, artifact: field(s.body, "artifact") };
|
|
122
160
|
if (!(s.status === 409 && field(s.body, "reason_code") === "OFFER_STALE"))
|
|
@@ -131,6 +169,11 @@ export function createRectaClient(options) {
|
|
|
131
169
|
}
|
|
132
170
|
/** Step 3: checks, then sends the transactions in order, each mined successfully before the next. */
|
|
133
171
|
async function execute(resultId, artifact, request) {
|
|
172
|
+
// The last moment before anything is sent: with a fee authorized for this decision, the payer must still hold it
|
|
173
|
+
// in USDC on Base (plus the transfer itself when it starts from Base USDC) with the Permit2 allowance for it.
|
|
174
|
+
const fee = feeAmounts.get(resultIdOf(resultId));
|
|
175
|
+
if (fee !== undefined)
|
|
176
|
+
assertFeeReady(feeReadiness(await chain.readFeeFunds(signer.address), fee, routeReserveOf(request)));
|
|
134
177
|
const checked = await check(resultId, artifact, request);
|
|
135
178
|
let approvalTxHash;
|
|
136
179
|
for (const tx of checked.transactions) {
|
|
@@ -174,6 +217,7 @@ export function createRectaClient(options) {
|
|
|
174
217
|
const debtId = field(offer, "debt_id");
|
|
175
218
|
if (typeof debtId !== "string")
|
|
176
219
|
throw new RectaCheckError("debt", "the offer has no debt_id");
|
|
220
|
+
assertFeeReady(feeReadiness(await chain.readFeeFunds(signer.address), checked.amount));
|
|
177
221
|
const signature = await signer.signTypedData(checked.typedData);
|
|
178
222
|
const a = await call("POST", PATHS.feeDebts, { debt_id: debtId, deadline_unix: checked.deadline.toString(), signature });
|
|
179
223
|
if (a.status !== 200 && a.status !== 201)
|
|
@@ -189,7 +233,7 @@ export function createRectaClient(options) {
|
|
|
189
233
|
const resultId = field(decision, "result_id");
|
|
190
234
|
if (field(field(decision, "decision"), "state") !== "VERIFIED_POSITIVE" || typeof resultId !== "string")
|
|
191
235
|
return { stage: "NO_ROUTE", decision };
|
|
192
|
-
const { fee, artifact } = await authorizeFee(resultId, opts.dryRun === true ? { dryRun: true } : {});
|
|
236
|
+
const { fee, artifact } = await authorizeFee(resultId, opts.dryRun === true ? { dryRun: true, request } : { request });
|
|
193
237
|
if (fee.kind === "DRY_RUN")
|
|
194
238
|
return { stage: "DRY_RUN", decision, fee };
|
|
195
239
|
if (opts.dryRun === true)
|
|
@@ -197,5 +241,19 @@ export function createRectaClient(options) {
|
|
|
197
241
|
const sent = await execute(resultId, artifact, request);
|
|
198
242
|
return { stage: "REPORTED", decision, fee, ...sent, report: await report(resultId, sent.originTxHash, sent.approvalTxHash) };
|
|
199
243
|
}
|
|
200
|
-
|
|
244
|
+
/** The payer's USDC balance and Permit2 allowance on Base against an amount (default: this client's permit limit). */
|
|
245
|
+
async function feeFunds(requiredUsdcAtomic = limits.maxPermitUsdcAtomic) {
|
|
246
|
+
return feeReadiness(await chain.readFeeFunds(signer.address), requiredUsdcAtomic);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The bounded approval `USDC.approve(Permit2, amount)` on Base, for the operator to send (`sendTransaction` is not
|
|
250
|
+
* called here: this client never approves by itself). The amount defaults to, and may not exceed, this client's
|
|
251
|
+
* permit limit, so the allowance never reaches past what one permit may authorize.
|
|
252
|
+
*/
|
|
253
|
+
function feeApproval(amountUsdcAtomic = limits.maxPermitUsdcAtomic) {
|
|
254
|
+
if (amountUsdcAtomic > limits.maxPermitUsdcAtomic)
|
|
255
|
+
throw new RectaCheckError("fee_approval", `the approval ${amountUsdcAtomic} exceeds your permit limit ${limits.maxPermitUsdcAtomic}`);
|
|
256
|
+
return permit2ApprovalTransaction(amountUsdcAtomic);
|
|
257
|
+
}
|
|
258
|
+
return { evaluate, authorizeFee, check, execute, report, ledger, payDebt, run, walletPolicy, feeFunds, feeApproval };
|
|
201
259
|
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* Every value is checked equal to the Recta server's own configuration by a test in the Recta repository.
|
|
7
7
|
*/
|
|
8
8
|
export declare const RECTA_ORIGIN = "https://api.recta.build";
|
|
9
|
+
/** This package's version (equal to package.json, checked in CI). */
|
|
10
|
+
export declare const CLIENT_VERSION = "0.1.4";
|
|
11
|
+
/** Sent on every request so Recta can count real use of this package apart from other clients (no address, no key). */
|
|
12
|
+
export declare const USER_AGENT = "recta-client/0.1.4";
|
|
9
13
|
/** The fee: x402 `upto` on Base, a Permit2 `permitWitnessTransferFrom`. All lowercase. */
|
|
10
14
|
export declare const FEE_CHAIN_ID = 8453;
|
|
11
15
|
export declare const PERMIT2 = "0x000000000022d473030f116ddee9f6b43ac78ba3";
|
package/dist/constants.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* Every value is checked equal to the Recta server's own configuration by a test in the Recta repository.
|
|
7
7
|
*/
|
|
8
8
|
export const RECTA_ORIGIN = "https://api.recta.build";
|
|
9
|
+
/** This package's version (equal to package.json, checked in CI). */
|
|
10
|
+
export const CLIENT_VERSION = "0.1.4";
|
|
11
|
+
/** Sent on every request so Recta can count real use of this package apart from other clients (no address, no key). */
|
|
12
|
+
export const USER_AGENT = `recta-client/${CLIENT_VERSION}`;
|
|
9
13
|
/** The fee: x402 `upto` on Base, a Permit2 `permitWitnessTransferFrom`. All lowercase. */
|
|
10
14
|
export const FEE_CHAIN_ID = 8453;
|
|
11
15
|
export const PERMIT2 = "0x000000000022d473030f116ddee9f6b43ac78ba3";
|
package/dist/funds.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type Hex } from "viem";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a fee permit can actually be collected (ASTRA T2). Signing a Permit2 permit is not enough: when Recta settles
|
|
4
|
+
* it, Permit2 pulls the fee with USDC's own `transferFrom`, which needs (1) the payer's USDC balance on Base and (2) the
|
|
5
|
+
* payer's ERC-20 allowance from USDC to Permit2. A permit signed without them lets the route execute and then the fee
|
|
6
|
+
* fail, which leaves a debt that blocks the next evaluation. This package therefore reads both before it signs a fee or
|
|
7
|
+
* debt permit and refuses when either is short. It never approves anything by itself.
|
|
8
|
+
*/
|
|
9
|
+
/** What the payer holds on Base, read at signing time (both in USDC atomic units, 6 decimals). */
|
|
10
|
+
export interface FeeFunds {
|
|
11
|
+
readonly balanceUsdcAtomic: bigint;
|
|
12
|
+
readonly permit2AllowanceUsdcAtomic: bigint;
|
|
13
|
+
}
|
|
14
|
+
export interface FeeReadiness {
|
|
15
|
+
readonly ready: boolean;
|
|
16
|
+
/** The permit's amount: the most this fee can pull. */
|
|
17
|
+
readonly requiredUsdcAtomic: bigint;
|
|
18
|
+
/** USDC on Base the route itself will spend before the fee is pulled (a route that starts from Base USDC); the
|
|
19
|
+
* balance must cover it plus the fee (Fable B-1 on recta #12). */
|
|
20
|
+
readonly reservedForRouteUsdcAtomic: bigint;
|
|
21
|
+
readonly balanceUsdcAtomic: bigint;
|
|
22
|
+
readonly permit2AllowanceUsdcAtomic: bigint;
|
|
23
|
+
/** What is short, if anything: `balance` (hold more USDC on Base), `allowance` (approve Permit2 for at least the amount). */
|
|
24
|
+
readonly missing: readonly ("balance" | "allowance")[];
|
|
25
|
+
}
|
|
26
|
+
export declare const ERC20_READ_ABI: readonly [{
|
|
27
|
+
readonly type: "function";
|
|
28
|
+
readonly name: "balanceOf";
|
|
29
|
+
readonly stateMutability: "view";
|
|
30
|
+
readonly inputs: readonly [{
|
|
31
|
+
readonly name: "owner";
|
|
32
|
+
readonly type: "address";
|
|
33
|
+
}];
|
|
34
|
+
readonly outputs: readonly [{
|
|
35
|
+
readonly name: "";
|
|
36
|
+
readonly type: "uint256";
|
|
37
|
+
}];
|
|
38
|
+
}, {
|
|
39
|
+
readonly type: "function";
|
|
40
|
+
readonly name: "allowance";
|
|
41
|
+
readonly stateMutability: "view";
|
|
42
|
+
readonly inputs: readonly [{
|
|
43
|
+
readonly name: "owner";
|
|
44
|
+
readonly type: "address";
|
|
45
|
+
}, {
|
|
46
|
+
readonly name: "spender";
|
|
47
|
+
readonly type: "address";
|
|
48
|
+
}];
|
|
49
|
+
readonly outputs: readonly [{
|
|
50
|
+
readonly name: "";
|
|
51
|
+
readonly type: "uint256";
|
|
52
|
+
}];
|
|
53
|
+
}];
|
|
54
|
+
/**
|
|
55
|
+
* The fee's amount against the payer's funds. `reservedForRouteUsdcAtomic`: USDC on Base the route will spend first
|
|
56
|
+
* (see `routeReserveOf`); the balance must cover it plus the fee. The allowance only needs to cover the fee: a route's
|
|
57
|
+
* own approval goes to the bridge, not to Permit2.
|
|
58
|
+
*/
|
|
59
|
+
export declare function feeReadiness(funds: FeeFunds, requiredUsdcAtomic: bigint, reservedForRouteUsdcAtomic?: bigint): FeeReadiness;
|
|
60
|
+
/**
|
|
61
|
+
* USDC on Base that a route spends before Recta pulls its fee: the request's amount when the route starts from USDC on
|
|
62
|
+
* Base (the same balance pays both), otherwise 0. Throws when such an amount is not a plain decimal: fail closed.
|
|
63
|
+
*/
|
|
64
|
+
export declare function routeReserveOf(request: {
|
|
65
|
+
readonly source_network: string;
|
|
66
|
+
readonly source_asset: string;
|
|
67
|
+
readonly amount: string;
|
|
68
|
+
}): bigint;
|
|
69
|
+
/** A read error's text without what an RPC URL may carry (an API key in the path or query): viem's short message,
|
|
70
|
+
* and any URL replaced (Fable M-2 on recta #12). */
|
|
71
|
+
export declare function safeErrorText(e: unknown): string;
|
|
72
|
+
/** Throws a RectaCheckError naming what is short and how to fix it; nothing has been signed when it throws. */
|
|
73
|
+
export declare function assertFeeReady(r: FeeReadiness): void;
|
|
74
|
+
/** The one transaction that lets Permit2 pull up to `amountUsdcAtomic` of USDC on Base: `USDC.approve(Permit2, amount)`. */
|
|
75
|
+
export interface Permit2Approval {
|
|
76
|
+
readonly chainId: number;
|
|
77
|
+
readonly to: Hex;
|
|
78
|
+
readonly data: Hex;
|
|
79
|
+
readonly value: bigint;
|
|
80
|
+
readonly amountUsdcAtomic: bigint;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Builds the exact approval for the agent's operator to send with their own wallet. Bounded on purpose: it refuses 0
|
|
84
|
+
* and anything of 2^128 or more, so an "unlimited" approval (2^256 − 1, 2^160 − 1) cannot be built here. Approving
|
|
85
|
+
* replaces, not adds to, the current allowance; each collected fee lowers it, so approve again when a check reports
|
|
86
|
+
* `allowance` short.
|
|
87
|
+
*/
|
|
88
|
+
export declare function permit2ApprovalTransaction(amountUsdcAtomic: bigint): Permit2Approval;
|
package/dist/funds.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { encodeFunctionData } from "viem";
|
|
2
|
+
import { FEE_CHAIN_ID, PERMIT2, USDC_BASE } from "./constants.js";
|
|
3
|
+
import { RectaCheckError } from "./errors.js";
|
|
4
|
+
export const ERC20_READ_ABI = [
|
|
5
|
+
{ type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ name: "owner", type: "address" }], outputs: [{ name: "", type: "uint256" }] },
|
|
6
|
+
{ type: "function", name: "allowance", stateMutability: "view", inputs: [{ name: "owner", type: "address" }, { name: "spender", type: "address" }], outputs: [{ name: "", type: "uint256" }] },
|
|
7
|
+
];
|
|
8
|
+
const APPROVE_ABI = [
|
|
9
|
+
{ type: "function", name: "approve", stateMutability: "nonpayable", inputs: [{ name: "spender", type: "address" }, { name: "amount", type: "uint256" }], outputs: [{ name: "", type: "bool" }] },
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* The fee's amount against the payer's funds. `reservedForRouteUsdcAtomic`: USDC on Base the route will spend first
|
|
13
|
+
* (see `routeReserveOf`); the balance must cover it plus the fee. The allowance only needs to cover the fee: a route's
|
|
14
|
+
* own approval goes to the bridge, not to Permit2.
|
|
15
|
+
*/
|
|
16
|
+
export function feeReadiness(funds, requiredUsdcAtomic, reservedForRouteUsdcAtomic = 0n) {
|
|
17
|
+
const missing = [];
|
|
18
|
+
if (funds.balanceUsdcAtomic < requiredUsdcAtomic + reservedForRouteUsdcAtomic)
|
|
19
|
+
missing.push("balance");
|
|
20
|
+
if (funds.permit2AllowanceUsdcAtomic < requiredUsdcAtomic)
|
|
21
|
+
missing.push("allowance");
|
|
22
|
+
return { ready: missing.length === 0, requiredUsdcAtomic, reservedForRouteUsdcAtomic, balanceUsdcAtomic: funds.balanceUsdcAtomic, permit2AllowanceUsdcAtomic: funds.permit2AllowanceUsdcAtomic, missing };
|
|
23
|
+
}
|
|
24
|
+
const DECIMAL_USDC = /^(0|[1-9][0-9]{0,17})(\.[0-9]{1,6})?$/;
|
|
25
|
+
/**
|
|
26
|
+
* USDC on Base that a route spends before Recta pulls its fee: the request's amount when the route starts from USDC on
|
|
27
|
+
* Base (the same balance pays both), otherwise 0. Throws when such an amount is not a plain decimal: fail closed.
|
|
28
|
+
*/
|
|
29
|
+
export function routeReserveOf(request) {
|
|
30
|
+
if (request.source_network !== "base" || request.source_asset.toUpperCase() !== "USDC")
|
|
31
|
+
return 0n;
|
|
32
|
+
if (!DECIMAL_USDC.test(request.amount))
|
|
33
|
+
throw new RectaCheckError("amount", "the request's amount is not a decimal USDC amount");
|
|
34
|
+
const [whole, frac = ""] = request.amount.split(".");
|
|
35
|
+
return BigInt(whole) * 1000000n + BigInt(frac.padEnd(6, "0"));
|
|
36
|
+
}
|
|
37
|
+
/** A read error's text without what an RPC URL may carry (an API key in the path or query): viem's short message,
|
|
38
|
+
* and any URL replaced (Fable M-2 on recta #12). */
|
|
39
|
+
export function safeErrorText(e) {
|
|
40
|
+
const short = typeof e === "object" && e !== null && typeof e.shortMessage === "string" ? e.shortMessage : e instanceof Error ? e.message : String(e);
|
|
41
|
+
return short.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi, "<url>").split("\n")[0].slice(0, 300);
|
|
42
|
+
}
|
|
43
|
+
const usdc = (atomic) => {
|
|
44
|
+
const whole = atomic / 1000000n;
|
|
45
|
+
const frac = (atomic % 1000000n).toString().padStart(6, "0").replace(/0+$/, "");
|
|
46
|
+
return frac === "" ? whole.toString() : `${whole}.${frac}`;
|
|
47
|
+
};
|
|
48
|
+
/** Throws a RectaCheckError naming what is short and how to fix it; nothing has been signed when it throws. */
|
|
49
|
+
export function assertFeeReady(r) {
|
|
50
|
+
if (r.ready)
|
|
51
|
+
return;
|
|
52
|
+
const parts = [];
|
|
53
|
+
if (r.missing.includes("balance"))
|
|
54
|
+
parts.push(`hold at least ${usdc(r.requiredUsdcAtomic + r.reservedForRouteUsdcAtomic)} USDC on Base (now ${usdc(r.balanceUsdcAtomic)}${r.reservedForRouteUsdcAtomic > 0n ? `; ${usdc(r.reservedForRouteUsdcAtomic)} of it is the transfer itself, which leaves Base first` : ""})`);
|
|
55
|
+
if (r.missing.includes("allowance"))
|
|
56
|
+
parts.push(`approve Permit2 (${PERMIT2}) to spend at least ${usdc(r.requiredUsdcAtomic)} USDC on Base (now ${usdc(r.permit2AllowanceUsdcAtomic)}); permit2ApprovalTransaction(amount) builds that exact, bounded approval for you to send`);
|
|
57
|
+
throw new RectaCheckError(r.missing.includes("allowance") ? "fee_allowance" : "fee_balance", `Recta could not collect this fee: ${parts.join("; ")}. Nothing was signed.`);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Builds the exact approval for the agent's operator to send with their own wallet. Bounded on purpose: it refuses 0
|
|
61
|
+
* and anything of 2^128 or more, so an "unlimited" approval (2^256 − 1, 2^160 − 1) cannot be built here. Approving
|
|
62
|
+
* replaces, not adds to, the current allowance; each collected fee lowers it, so approve again when a check reports
|
|
63
|
+
* `allowance` short.
|
|
64
|
+
*/
|
|
65
|
+
export function permit2ApprovalTransaction(amountUsdcAtomic) {
|
|
66
|
+
if (amountUsdcAtomic <= 0n)
|
|
67
|
+
throw new RectaCheckError("fee_approval", "the approval must be more than 0");
|
|
68
|
+
if (amountUsdcAtomic >= 2n ** 128n)
|
|
69
|
+
throw new RectaCheckError("fee_approval", "refusing an unlimited approval: approve a bounded amount");
|
|
70
|
+
return {
|
|
71
|
+
chainId: FEE_CHAIN_ID,
|
|
72
|
+
to: USDC_BASE,
|
|
73
|
+
data: encodeFunctionData({ abi: APPROVE_ABI, functionName: "approve", args: [PERMIT2, amountUsdcAtomic] }),
|
|
74
|
+
value: 0n,
|
|
75
|
+
amountUsdcAtomic,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createRectaClient, signedRequestMessage, referrerQuery, type RectaClient, type RectaClientOptions, type FeeOutcome } from "./client.js";
|
|
2
2
|
export { checkFeePermit, type FeePermitLimits } from "./fee.js";
|
|
3
|
+
export { feeReadiness, assertFeeReady, permit2ApprovalTransaction, routeReserveOf, safeErrorText, ERC20_READ_ABI, type FeeFunds, type FeeReadiness, type Permit2Approval } from "./funds.js";
|
|
3
4
|
export { checkExecutionArtifact, pinnedRouteOf, type CheckedArtifact, type EvaluationRequestView, type ArtifactLimits } from "./artifact.js";
|
|
4
5
|
export { PINNED_ROUTES, type PinnedRoute } from "./pinned-routes.js";
|
|
5
6
|
export { parseWalletPolicy, type WalletPolicy, type WalletPolicyNetwork } from "./policy.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createRectaClient, signedRequestMessage, referrerQuery } from "./client.js";
|
|
2
2
|
export { checkFeePermit } from "./fee.js";
|
|
3
|
+
export { feeReadiness, assertFeeReady, permit2ApprovalTransaction, routeReserveOf, safeErrorText, ERC20_READ_ABI } from "./funds.js";
|
|
3
4
|
export { checkExecutionArtifact, pinnedRouteOf } from "./artifact.js";
|
|
4
5
|
export { PINNED_ROUTES } from "./pinned-routes.js";
|
|
5
6
|
export { parseWalletPolicy } from "./policy.js";
|
package/dist/signer.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Account, Chain, Hex, PublicClient, Transport, TransactionSerializable, WalletClient } from "viem";
|
|
2
|
+
import { type FeeFunds } from "./funds.js";
|
|
2
3
|
/** One EIP-1559 transaction of an execution artifact, as this package asks a signer to send it. */
|
|
3
4
|
export interface RectaTransaction {
|
|
4
5
|
readonly chainId: number;
|
|
@@ -34,10 +35,16 @@ export interface RectaSigner {
|
|
|
34
35
|
/** Sends one transaction on `tx.chainId` and returns its hash. */
|
|
35
36
|
sendTransaction(tx: RectaTransaction): Promise<Hex>;
|
|
36
37
|
}
|
|
37
|
-
/** Read access to the
|
|
38
|
+
/** Read access to the chains (the agent's own RPC): receipts, and the payer's USDC on Base before a fee is signed. */
|
|
38
39
|
export interface RectaChainReader {
|
|
39
40
|
/** Waits until the transaction is mined and returns whether it succeeded. */
|
|
40
41
|
waitForSuccess(chainId: number, hash: Hex): Promise<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* The payer's USDC balance on Base and its ERC-20 allowance to Permit2 (ASTRA T2): read before every fee or debt
|
|
44
|
+
* permit is signed, which is refused when either is below the permit's amount. A reader that cannot answer (no Base
|
|
45
|
+
* RPC) must throw: the permit is then not signed.
|
|
46
|
+
*/
|
|
47
|
+
readFeeFunds(owner: Hex): Promise<FeeFunds>;
|
|
41
48
|
}
|
|
42
49
|
/** A RectaSigner from a viem account and one wallet client per chain. */
|
|
43
50
|
export declare function viemSigner(args: {
|
|
@@ -100,5 +107,5 @@ export declare function cdpSigner(args: {
|
|
|
100
107
|
readonly account: CdpEvmAccountLike;
|
|
101
108
|
readonly walletClientFor?: (chainId: number) => WalletClient<Transport, Chain | undefined, Account | undefined>;
|
|
102
109
|
}): RectaSigner;
|
|
103
|
-
/** A RectaChainReader from one viem public client per chain. */
|
|
110
|
+
/** A RectaChainReader from one viem public client per chain (`publicClientFor(8453)` reads the fee funds on Base). */
|
|
104
111
|
export declare function viemChainReader(publicClientFor: (chainId: number) => PublicClient): RectaChainReader;
|
package/dist/signer.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { toAccount } from "viem/accounts";
|
|
2
|
+
import { FEE_CHAIN_ID, PERMIT2, USDC_BASE } from "./constants.js";
|
|
3
|
+
import { ERC20_READ_ABI, safeErrorText } from "./funds.js";
|
|
2
4
|
/** A RectaSigner from a viem account and one wallet client per chain. */
|
|
3
5
|
export function viemSigner(args) {
|
|
4
6
|
const { account } = args;
|
|
@@ -85,9 +87,26 @@ export function cdpSigner(args) {
|
|
|
85
87
|
},
|
|
86
88
|
};
|
|
87
89
|
}
|
|
88
|
-
/** A RectaChainReader from one viem public client per chain. */
|
|
90
|
+
/** A RectaChainReader from one viem public client per chain (`publicClientFor(8453)` reads the fee funds on Base). */
|
|
89
91
|
export function viemChainReader(publicClientFor) {
|
|
90
92
|
return {
|
|
91
93
|
waitForSuccess: async (chainId, hash) => (await publicClientFor(chainId).waitForTransactionReceipt({ hash })).status === "success",
|
|
94
|
+
readFeeFunds: async (owner) => {
|
|
95
|
+
const base = publicClientFor(FEE_CHAIN_ID);
|
|
96
|
+
const chainId = await base.getChainId().catch((e) => { throw new Error(`reading Base's chain id failed: ${safeErrorText(e)}`); });
|
|
97
|
+
if (chainId !== FEE_CHAIN_ID)
|
|
98
|
+
throw new Error(`publicClientFor(${FEE_CHAIN_ID}) returned a client on chain ${chainId}`);
|
|
99
|
+
try {
|
|
100
|
+
const [balanceUsdcAtomic, permit2AllowanceUsdcAtomic] = await Promise.all([
|
|
101
|
+
base.readContract({ address: USDC_BASE, abi: ERC20_READ_ABI, functionName: "balanceOf", args: [owner] }),
|
|
102
|
+
base.readContract({ address: USDC_BASE, abi: ERC20_READ_ABI, functionName: "allowance", args: [owner, PERMIT2] }),
|
|
103
|
+
]);
|
|
104
|
+
return { balanceUsdcAtomic, permit2AllowanceUsdcAtomic };
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
// viem's full message names the RPC URL, which may carry an API key: never pass it on (Fable M-2).
|
|
108
|
+
throw new Error(`reading USDC on Base failed: ${safeErrorText(e)}`);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
92
111
|
};
|
|
93
112
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@recta/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Client for Recta (api.recta.build): evaluate a cross-chain stablecoin transfer, authorize Recta's 5 %-of-verified-saving fee, execute the committed route and report it, with local checks before every signature.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Recta (https://recta.build)",
|