remesso-sdk 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +379 -2
- package/dist/abi.d.ts +63 -0
- package/dist/abi.js +14 -0
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +39 -0
- package/dist/attribution.d.ts +3 -0
- package/dist/attribution.js +29 -0
- package/dist/client.d.ts +75 -0
- package/dist/client.js +60 -0
- package/dist/context.d.ts +42 -0
- package/dist/context.js +157 -0
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.js +80 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +10 -0
- package/dist/events.d.ts +59 -0
- package/dist/events.js +53 -0
- package/dist/generated/abi.d.ts +1543 -0
- package/dist/generated/abi.js +1979 -0
- package/dist/generated/deployment.d.ts +114 -0
- package/dist/generated/deployment.js +127 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/links.d.ts +25 -0
- package/dist/links.js +60 -0
- package/dist/node.d.ts +5 -0
- package/dist/node.js +60 -0
- package/dist/payments.d.ts +39 -0
- package/dist/payments.js +232 -0
- package/dist/policies.d.ts +54 -0
- package/dist/policies.js +148 -0
- package/dist/requests.d.ts +103 -0
- package/dist/requests.js +145 -0
- package/dist/schedules.d.ts +111 -0
- package/dist/schedules.js +232 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +19 -0
- package/dist/vendor/erc8021.d.ts +9 -0
- package/dist/vendor/erc8021.js +82 -0
- package/dist/vendor/fee.d.ts +3 -0
- package/dist/vendor/fee.js +40 -0
- package/dist/vendor/payee-profile.d.ts +27 -0
- package/dist/vendor/payee-profile.js +73 -0
- package/dist/vendor/request-decline.d.ts +55 -0
- package/dist/vendor/request-decline.js +60 -0
- package/dist/vendor/safe-fetch.d.ts +2 -0
- package/dist/vendor/safe-fetch.js +154 -0
- package/dist/vendor/standard-webhooks.d.ts +8 -0
- package/dist/vendor/standard-webhooks.js +83 -0
- package/dist/vendor/webhook-actions.d.ts +84 -0
- package/dist/vendor/webhook-actions.js +123 -0
- package/dist/webhooks.d.ts +88 -0
- package/dist/webhooks.js +113 -0
- package/package.json +58 -4
package/dist/requests.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { checksum, findToken } from "./deployment.js";
|
|
2
|
+
import { RemessoError } from "./errors.js";
|
|
3
|
+
import { declineTypedData, MAX_DECLINE_TTL_SECS } from "./vendor/request-decline.js";
|
|
4
|
+
export function makeRequests(ctx, deps) {
|
|
5
|
+
const call = (body) => ctx.post(ctx.endpoints.requestPayment, body);
|
|
6
|
+
async function get(id) {
|
|
7
|
+
return await call({ action: "status", id: uuid(id) });
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
get,
|
|
11
|
+
/// Ask `payer` to pay you. Free. Returns the request and the link that
|
|
12
|
+
/// opens their form prefilled.
|
|
13
|
+
async create(input) {
|
|
14
|
+
const to = input.to ? checksum(input.to) : ctx.account?.address;
|
|
15
|
+
if (!to)
|
|
16
|
+
throw new RemessoError("invalid", "to is required without an account: where you want to be paid");
|
|
17
|
+
if (input.token && !findToken(input.token))
|
|
18
|
+
throw new RemessoError("invalid", `unknown token ${input.token}`);
|
|
19
|
+
const out = await call({
|
|
20
|
+
payer: checksum(input.payer),
|
|
21
|
+
to,
|
|
22
|
+
amount: input.amount,
|
|
23
|
+
token: input.token ? findToken(input.token).symbol : undefined,
|
|
24
|
+
every: input.every,
|
|
25
|
+
runs: input.runs,
|
|
26
|
+
from: input.from,
|
|
27
|
+
note: input.note,
|
|
28
|
+
});
|
|
29
|
+
return { ...out, reference: out.id.replace(/-/g, "") };
|
|
30
|
+
},
|
|
31
|
+
/// The live requests addressed to `payer` (default: this account):
|
|
32
|
+
/// pending and under 30 days old.
|
|
33
|
+
async list(payer) {
|
|
34
|
+
const who = payer ? checksum(payer) : ctx.requireAccount().address;
|
|
35
|
+
return (await call({ action: "list", payer: who })).requests;
|
|
36
|
+
},
|
|
37
|
+
/// Report the schedule that answers a request. It binds only if it is
|
|
38
|
+
/// from the wallet asked, pays the address that asked, was created after
|
|
39
|
+
/// the request, and answers no other request.
|
|
40
|
+
///
|
|
41
|
+
/// Seconds after a schedule lands the backend may not see it yet: its
|
|
42
|
+
/// indexer runs every minute, and a node behind forno's load balancer can
|
|
43
|
+
/// answer from an earlier block. It says so and asks to be tried again,
|
|
44
|
+
/// which this does for up to `waitMs` (default 90s), as it does after a
|
|
45
|
+
/// server error: answering twice with the same schedule is safe, the
|
|
46
|
+
/// backend gives the first answer again.
|
|
47
|
+
async answer(id, scheduleId, contract, opts = {}) {
|
|
48
|
+
const until = Date.now() + (opts.waitMs ?? 90_000);
|
|
49
|
+
for (let wait = 3_000;; wait = Math.min(wait * 2, 15_000)) {
|
|
50
|
+
try {
|
|
51
|
+
const out = await call({
|
|
52
|
+
action: "answer",
|
|
53
|
+
id: uuid(id),
|
|
54
|
+
schedule: String(scheduleId),
|
|
55
|
+
...(contract ? { contract: checksum(contract) } : {}),
|
|
56
|
+
});
|
|
57
|
+
return { status: out.status, signed: out.signed };
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
const unseen = e instanceof RemessoError && e.code === "conflict" && /hasn't been seen on-chain yet/.test(e.message);
|
|
61
|
+
const serverError = e instanceof RemessoError && e.code === "http" && Number(e.details?.status) >= 500;
|
|
62
|
+
if (!(unseen || serverError) || Date.now() + wait > until)
|
|
63
|
+
throw e;
|
|
64
|
+
await new Promise((res) => setTimeout(res, wait));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
/// Turn a request down, for everyone: signed by the wallet it was sent
|
|
69
|
+
/// to, or by an agent under a live policy from that wallet (`policyId`).
|
|
70
|
+
async decline(id, opts = {}) {
|
|
71
|
+
const account = ctx.requireAccount();
|
|
72
|
+
if (!ctx.wallet)
|
|
73
|
+
throw new RemessoError("no_account", "declining needs an account that can sign");
|
|
74
|
+
const rid = uuid(id);
|
|
75
|
+
const ttl = Math.min(Math.max(opts.ttlSeconds ?? 600, 60), MAX_DECLINE_TTL_SECS);
|
|
76
|
+
const expiry = Math.floor(Date.now() / 1000) + ttl;
|
|
77
|
+
const signature = await ctx.wallet.signTypedData({ account, ...declineTypedData(rid, expiry) });
|
|
78
|
+
return await call({
|
|
79
|
+
action: "decline",
|
|
80
|
+
id: rid,
|
|
81
|
+
expiry,
|
|
82
|
+
signature,
|
|
83
|
+
...(opts.policyId != null ? { agent: account.address, policy_id: String(opts.policyId) } : {}),
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
/// Pay a request through a policy, as an agent. The payment carries the
|
|
87
|
+
/// request's reference, so the requester sees it answered, and the
|
|
88
|
+
/// request id is the default idempotency key: one payment per request.
|
|
89
|
+
async pay(id, opts = {}) {
|
|
90
|
+
const r = await get(id);
|
|
91
|
+
if (!["pending", "accepted", "accepted_with_changes"].includes(r.status)) {
|
|
92
|
+
throw new RemessoError("refused", `request ${r.id} is ${r.status}, so it isn't paid`);
|
|
93
|
+
}
|
|
94
|
+
const amount = opts.amount ?? r.requested.amount;
|
|
95
|
+
if (!amount)
|
|
96
|
+
throw new RemessoError("invalid", `request ${r.id} names no amount, so pass one`);
|
|
97
|
+
const token = r.requested.token ?? "USDT";
|
|
98
|
+
// A request is addressed to one wallet. Paying someone else's from this
|
|
99
|
+
// person's wallet answers nothing.
|
|
100
|
+
const policyIds = opts.policyId != null ? [BigInt(opts.policyId)] : await deps.policies.find();
|
|
101
|
+
const mine = [];
|
|
102
|
+
for (const pid of policyIds) {
|
|
103
|
+
const p = await deps.policies.get(pid);
|
|
104
|
+
if (p.active && p.owner.toLowerCase() === r.payer.toLowerCase() && p.symbol === findToken(token)?.symbol)
|
|
105
|
+
mine.push(p);
|
|
106
|
+
}
|
|
107
|
+
if (!mine.length) {
|
|
108
|
+
throw new RemessoError("refused", `request ${r.id} was sent to ${r.payer}, and no active ${token} policy from that wallet names this agent`);
|
|
109
|
+
}
|
|
110
|
+
return await deps.pay({
|
|
111
|
+
...opts,
|
|
112
|
+
to: r.requested.to,
|
|
113
|
+
amount,
|
|
114
|
+
token,
|
|
115
|
+
memo: r.reference,
|
|
116
|
+
policyId: opts.policyId ?? mine[0].id,
|
|
117
|
+
idempotencyKey: opts.idempotencyKey ?? `request:${r.id}`,
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
/// Wait until a request is paid: a payment with a transaction, not an
|
|
121
|
+
/// answer. Polls `status`.
|
|
122
|
+
async waitUntilPaid(id, opts = {}) {
|
|
123
|
+
const until = Date.now() + (opts.timeoutMs ?? 20 * 60_000);
|
|
124
|
+
for (;;) {
|
|
125
|
+
const r = await get(id);
|
|
126
|
+
if (r.payment?.state === "paid")
|
|
127
|
+
return r;
|
|
128
|
+
if (r.status === "declined" || r.status === "expired")
|
|
129
|
+
throw new RemessoError("refused", `request ${r.id} is ${r.status}`);
|
|
130
|
+
if (Date.now() > until)
|
|
131
|
+
throw new RemessoError("timeout", `request ${r.id} isn't paid yet (${r.status})`);
|
|
132
|
+
if (opts.signal?.aborted)
|
|
133
|
+
throw new RemessoError("timeout", "stopped");
|
|
134
|
+
await new Promise((res) => setTimeout(res, opts.intervalMs ?? 15_000));
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
140
|
+
function uuid(id) {
|
|
141
|
+
const s = String(id).trim().toLowerCase();
|
|
142
|
+
if (!UUID.test(s))
|
|
143
|
+
throw new RemessoError("invalid", `not a payment request id: ${id}`);
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type Address, type Hex, type Log } from "viem";
|
|
2
|
+
import { type Ctx } from "./context.js";
|
|
3
|
+
import { type Token } from "./deployment.js";
|
|
4
|
+
import type { RequestStatus } from "./requests.js";
|
|
5
|
+
export declare const CADENCES: {
|
|
6
|
+
readonly week: number;
|
|
7
|
+
readonly fortnight: number;
|
|
8
|
+
readonly month: number;
|
|
9
|
+
readonly quarter: number;
|
|
10
|
+
};
|
|
11
|
+
export type Cadence = keyof typeof CADENCES;
|
|
12
|
+
export type CreateScheduleInput = {
|
|
13
|
+
/** Who gets paid. With `request`, the request's own address. */
|
|
14
|
+
to?: string;
|
|
15
|
+
/** Per payment, what leaves your wallet, as a decimal string: "1.50". Use this or `receive`. */
|
|
16
|
+
amount?: string;
|
|
17
|
+
/** Per payment, what the recipient receives after Remesso's fee. The SDK works out what to send. */
|
|
18
|
+
receive?: string;
|
|
19
|
+
/** Symbol or address. Default USDT, or the request's token. */
|
|
20
|
+
token?: string;
|
|
21
|
+
/** week | fortnight | month | quarter, or seconds (at least an hour). Default week, or the request's. */
|
|
22
|
+
every?: Cadence | number;
|
|
23
|
+
/** How many payments. With `request`, defaults to the request's. */
|
|
24
|
+
runs?: number;
|
|
25
|
+
/** When the first payment is due. Default now: it goes out within minutes. */
|
|
26
|
+
firstRunAt?: Date;
|
|
27
|
+
/**
|
|
28
|
+
* Who may bring a payment forward: "remesso" for Remesso's executor (whoever it is
|
|
29
|
+
* at the time), or an address. Same recipient, same amount, never an extra payment.
|
|
30
|
+
* Default nobody.
|
|
31
|
+
*/
|
|
32
|
+
trigger?: "remesso" | string | null;
|
|
33
|
+
/** How many early sends the trigger may make. Default: all of them. */
|
|
34
|
+
triggers?: number;
|
|
35
|
+
/** A payment request this schedule answers. Its terms fill whatever you leave out. */
|
|
36
|
+
request?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Approve the contract for this schedule's total on top of what is already
|
|
39
|
+
* approved (the default), or not at all when you manage approvals yourself.
|
|
40
|
+
*/
|
|
41
|
+
approve?: boolean;
|
|
42
|
+
};
|
|
43
|
+
export type CreatedSchedule = {
|
|
44
|
+
id: bigint;
|
|
45
|
+
hash: Hex;
|
|
46
|
+
approveHash: Hex | null;
|
|
47
|
+
to: Address;
|
|
48
|
+
token: Token["symbol"];
|
|
49
|
+
/** What leaves the payer's wallet each payment, in base units. */
|
|
50
|
+
amountIn: bigint;
|
|
51
|
+
/** What the recipient receives each payment, in base units, after the fee. */
|
|
52
|
+
recipientGets: bigint;
|
|
53
|
+
feeBps: number;
|
|
54
|
+
runs: number;
|
|
55
|
+
intervalSeconds: number;
|
|
56
|
+
firstRunAt: Date;
|
|
57
|
+
expiresAt: Date;
|
|
58
|
+
/** With `request`: how the request took the answer, or why it didn't. */
|
|
59
|
+
request?: {
|
|
60
|
+
status: string;
|
|
61
|
+
signed: unknown;
|
|
62
|
+
} | {
|
|
63
|
+
error: string;
|
|
64
|
+
};
|
|
65
|
+
warnings: string[];
|
|
66
|
+
};
|
|
67
|
+
export type Schedule = {
|
|
68
|
+
id: bigint;
|
|
69
|
+
sender: Address;
|
|
70
|
+
to: Address;
|
|
71
|
+
token: Address;
|
|
72
|
+
amountIn: bigint;
|
|
73
|
+
recipientGets: bigint;
|
|
74
|
+
feeBps: number;
|
|
75
|
+
intervalSeconds: number;
|
|
76
|
+
maxRuns: number;
|
|
77
|
+
runsExecuted: number;
|
|
78
|
+
nextRunAt: Date;
|
|
79
|
+
lastRunAt: Date | null;
|
|
80
|
+
expiresAt: Date;
|
|
81
|
+
active: boolean;
|
|
82
|
+
cancelled: boolean;
|
|
83
|
+
payoutType: number;
|
|
84
|
+
trigger: Address;
|
|
85
|
+
triggersLeft: number;
|
|
86
|
+
};
|
|
87
|
+
type Deps = {
|
|
88
|
+
getRequest(id: string): Promise<RequestStatus>;
|
|
89
|
+
answer(id: string, scheduleId: bigint): Promise<{
|
|
90
|
+
status: string;
|
|
91
|
+
signed: unknown;
|
|
92
|
+
}>;
|
|
93
|
+
};
|
|
94
|
+
export declare function makeSchedules(ctx: Ctx, deps: Deps): {
|
|
95
|
+
create: (input: CreateScheduleInput) => Promise<CreatedSchedule>;
|
|
96
|
+
get: (id: bigint | number | string) => Promise<Schedule>;
|
|
97
|
+
status(id: bigint | number | string): Promise<{
|
|
98
|
+
due: boolean;
|
|
99
|
+
funded: boolean;
|
|
100
|
+
approved: boolean;
|
|
101
|
+
nextRunAt: Date;
|
|
102
|
+
}>;
|
|
103
|
+
list(sender?: string): Promise<bigint[]>;
|
|
104
|
+
cancel(id: bigint | number | string): Promise<Hex>;
|
|
105
|
+
setActive(id: bigint | number | string, active: boolean): Promise<Hex>;
|
|
106
|
+
setTrigger(id: bigint | number | string, trigger: "remesso" | string | null, triggers?: number): Promise<Hex>;
|
|
107
|
+
};
|
|
108
|
+
export type Schedules = ReturnType<typeof makeSchedules>;
|
|
109
|
+
export declare function scheduleIdFrom(logs: readonly Log[]): bigint | null;
|
|
110
|
+
export declare function intervalOf(every: Cadence | number | string): number;
|
|
111
|
+
export {};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/// Schedules: a payer signs once, on-chain, a fixed amount to a fixed address
|
|
2
|
+
/// on a fixed cadence, and Remesso's executor sends each payment when it is
|
|
3
|
+
/// due. After signing, nothing can change the recipient, the amount or the
|
|
4
|
+
/// cadence: not Remesso, not the contract's owner, not the payer. Cancelling
|
|
5
|
+
/// is the only edit. Extracted from `examples/create-schedule.ts`.
|
|
6
|
+
import { parseEventLogs, zeroAddress } from "viem";
|
|
7
|
+
import { erc20Abi, executorAbi } from "./abi.js";
|
|
8
|
+
import { untilVisible, write } from "./context.js";
|
|
9
|
+
import { checksum, CURRENT_EXECUTOR, EXECUTOR, findToken, formatAmount, runLimits, token as tokenOf, toUnits, } from "./deployment.js";
|
|
10
|
+
import { RemessoError } from "./errors.js";
|
|
11
|
+
import { grossFor, netOf } from "./vendor/fee.js";
|
|
12
|
+
export const CADENCES = {
|
|
13
|
+
week: 7 * 86_400,
|
|
14
|
+
fortnight: 14 * 86_400,
|
|
15
|
+
month: 30 * 86_400,
|
|
16
|
+
quarter: 90 * 86_400,
|
|
17
|
+
};
|
|
18
|
+
/// Direct: the recipient receives the token the payer funds, with no swap.
|
|
19
|
+
const DIRECT = 2;
|
|
20
|
+
export function makeSchedules(ctx, deps) {
|
|
21
|
+
const read = (functionName, args = []) => ctx.pub.readContract({ address: EXECUTOR, abi: executorAbi, functionName, args });
|
|
22
|
+
/// The schedule's token, so a sender holding no CELO can pay for managing
|
|
23
|
+
/// it in that token, as they could for creating it.
|
|
24
|
+
const gasTokenOf = async (id) => findToken((await get(id)).token) ?? null;
|
|
25
|
+
async function get(id) {
|
|
26
|
+
const s = await untilVisible(() => read("getSchedule", [BigInt(id)]), (v) => v.sender !== zeroAddress);
|
|
27
|
+
if (s.sender === zeroAddress)
|
|
28
|
+
throw new RemessoError("not_found", `there is no schedule ${id} on ${EXECUTOR}`);
|
|
29
|
+
return {
|
|
30
|
+
id: BigInt(id),
|
|
31
|
+
sender: s.sender,
|
|
32
|
+
to: s.destination,
|
|
33
|
+
token: s.token,
|
|
34
|
+
amountIn: s.amountIn,
|
|
35
|
+
recipientGets: netOf(s.amountIn, s.feeBps),
|
|
36
|
+
feeBps: s.feeBps,
|
|
37
|
+
intervalSeconds: Number(s.interval),
|
|
38
|
+
maxRuns: s.maxRuns,
|
|
39
|
+
runsExecuted: s.runsExecuted,
|
|
40
|
+
nextRunAt: new Date(Number(s.nextRunAt) * 1000),
|
|
41
|
+
lastRunAt: s.lastRunAt ? new Date(Number(s.lastRunAt) * 1000) : null,
|
|
42
|
+
expiresAt: new Date(Number(s.expiresAt) * 1000),
|
|
43
|
+
active: s.active,
|
|
44
|
+
cancelled: s.cancelled,
|
|
45
|
+
payoutType: s.payoutType,
|
|
46
|
+
trigger: s.trigger,
|
|
47
|
+
triggersLeft: s.triggersLeft,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function create(input) {
|
|
51
|
+
const account = ctx.requireAccount();
|
|
52
|
+
await ctx.assertChain();
|
|
53
|
+
const me = account.address.toLowerCase();
|
|
54
|
+
const warnings = [];
|
|
55
|
+
// A request's terms fill whatever was left out. It must be addressed to
|
|
56
|
+
// this wallet: paying someone else's request answers nothing, and it is
|
|
57
|
+
// what an injected instruction would ask for.
|
|
58
|
+
let req = null;
|
|
59
|
+
let { to, amount, receive, token: tokenIn, every, runs } = input;
|
|
60
|
+
if (input.request) {
|
|
61
|
+
req = await deps.getRequest(input.request);
|
|
62
|
+
if (req.payer.toLowerCase() !== me) {
|
|
63
|
+
throw new RemessoError("refused", `request ${req.id} was sent to ${req.payer}, not to ${account.address}`);
|
|
64
|
+
}
|
|
65
|
+
if (req.status !== "pending")
|
|
66
|
+
throw new RemessoError("refused", `request ${req.id} is ${req.status}, so there is nothing to answer`);
|
|
67
|
+
if (to && to.toLowerCase() !== req.requested.to.toLowerCase()) {
|
|
68
|
+
throw new RemessoError("refused", `request ${req.id} asks to be paid at ${req.requested.to}, not ${to}`);
|
|
69
|
+
}
|
|
70
|
+
to = req.requested.to;
|
|
71
|
+
tokenIn ??= req.requested.token ?? undefined;
|
|
72
|
+
every ??= req.requested.intervalSeconds ?? undefined;
|
|
73
|
+
runs ??= req.requested.runs ?? undefined;
|
|
74
|
+
if (amount == null && receive == null && req.requested.amount)
|
|
75
|
+
receive = req.requested.amount;
|
|
76
|
+
}
|
|
77
|
+
if (!to)
|
|
78
|
+
throw new RemessoError("invalid", "to is required: who gets paid");
|
|
79
|
+
const dest = checksum(to);
|
|
80
|
+
if (dest.toLowerCase() === me)
|
|
81
|
+
throw new RemessoError("invalid", "to is this wallet: a schedule paying itself only loses the fee");
|
|
82
|
+
const t = tokenOf(tokenIn ?? "USDT");
|
|
83
|
+
const interval = intervalOf(every ?? "week");
|
|
84
|
+
if (runs == null)
|
|
85
|
+
throw new RemessoError("invalid", "runs is required: how many payments");
|
|
86
|
+
if (!Number.isInteger(runs) || runs < 1 || runs > 4_294_967_295)
|
|
87
|
+
throw new RemessoError("invalid", "runs must be a whole number of payments");
|
|
88
|
+
if ((amount == null) === (receive == null))
|
|
89
|
+
throw new RemessoError("invalid", "give amount (what you send each time) or receive (what arrives), not both");
|
|
90
|
+
const [allowed, feeBps, maxLifetime, approved, balance] = await ctx.pub.multicall({
|
|
91
|
+
allowFailure: false,
|
|
92
|
+
contracts: [
|
|
93
|
+
{ address: EXECUTOR, abi: executorAbi, functionName: "directTokenAllowed", args: [t.address] },
|
|
94
|
+
{ address: EXECUTOR, abi: executorAbi, functionName: "feeBps" },
|
|
95
|
+
{ address: EXECUTOR, abi: executorAbi, functionName: "MAX_LIFETIME" },
|
|
96
|
+
{ address: t.address, abi: erc20Abi, functionName: "allowance", args: [account.address, EXECUTOR] },
|
|
97
|
+
{ address: t.address, abi: erc20Abi, functionName: "balanceOf", args: [account.address] },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
if (!allowed)
|
|
101
|
+
throw new RemessoError("refused", `the contract doesn't take ${t.symbol} for new schedules`);
|
|
102
|
+
const amountIn = receive != null ? grossFor(toUnits(receive, t), Number(feeBps)) : toUnits(amount, t);
|
|
103
|
+
const { min, max } = runLimits(t);
|
|
104
|
+
if (amountIn < min || amountIn > max) {
|
|
105
|
+
throw new RemessoError("refused", `Remesso's executor sends ${formatAmount(min, t)} to ${formatAmount(max, t)} per payment, and this is ${formatAmount(amountIn, t)}. ` +
|
|
106
|
+
`A schedule outside that is valid on-chain and never runs.`);
|
|
107
|
+
}
|
|
108
|
+
const now = Math.floor(Date.now() / 1000);
|
|
109
|
+
const first = input.firstRunAt ? Math.floor(input.firstRunAt.getTime() / 1000) : now;
|
|
110
|
+
if (first < now - 60)
|
|
111
|
+
throw new RemessoError("invalid", "firstRunAt is in the past");
|
|
112
|
+
// An interval and an hour past the first payment's slot, as the example
|
|
113
|
+
// and the app do, so a run sent late still lands inside the schedule's
|
|
114
|
+
// life. `runs` still caps how many go out.
|
|
115
|
+
const expiresAt = first + runs * interval + 3600;
|
|
116
|
+
if (BigInt(expiresAt - now) > maxLifetime) {
|
|
117
|
+
throw new RemessoError("refused", `${runs} payments every ${interval / 86_400} days don't fit in a schedule's year`);
|
|
118
|
+
}
|
|
119
|
+
let trigger = zeroAddress;
|
|
120
|
+
if (input.trigger === "remesso")
|
|
121
|
+
trigger = CURRENT_EXECUTOR;
|
|
122
|
+
else if (input.trigger)
|
|
123
|
+
trigger = checksum(input.trigger);
|
|
124
|
+
const triggersLeft = trigger === zeroAddress ? 0 : Math.min(input.triggers ?? runs, 65_535);
|
|
125
|
+
const total = amountIn * BigInt(runs);
|
|
126
|
+
if (balance < amountIn)
|
|
127
|
+
warnings.push(`the balance (${formatAmount(balance, t)}) is below one payment: payments fail until it's funded`);
|
|
128
|
+
// The allowance is the real ceiling on what can ever be taken. Approve
|
|
129
|
+
// this schedule's total ON TOP of what is approved already: `approve`
|
|
130
|
+
// replaces rather than adds, and another schedule may rely on it.
|
|
131
|
+
let nonce = await ctx.pub.getTransactionCount({ address: account.address, blockTag: "pending" });
|
|
132
|
+
let approveHash = null;
|
|
133
|
+
if (input.approve !== false) {
|
|
134
|
+
({ hash: approveHash } = await write(ctx, {
|
|
135
|
+
address: t.address, abi: erc20Abi, functionName: "approve", args: [EXECUTOR, approved + total], gasToken: t, nonce,
|
|
136
|
+
}));
|
|
137
|
+
nonce++;
|
|
138
|
+
}
|
|
139
|
+
const { hash, receipt } = await write(ctx, {
|
|
140
|
+
address: EXECUTOR,
|
|
141
|
+
abi: executorAbi,
|
|
142
|
+
functionName: "createSchedule",
|
|
143
|
+
args: [dest, amountIn, BigInt(interval), 0n, runs, BigInt(expiresAt), BigInt(input.firstRunAt ? first : 0), DIRECT, t.address, zeroAddress, trigger, triggersLeft],
|
|
144
|
+
gasToken: t,
|
|
145
|
+
nonce,
|
|
146
|
+
});
|
|
147
|
+
const id = scheduleIdFrom(receipt.logs);
|
|
148
|
+
if (id == null)
|
|
149
|
+
throw new RemessoError("http", `created, but ${hash} has no ScheduleCreated log: read the receipt yourself`, { hash });
|
|
150
|
+
let answered;
|
|
151
|
+
if (req) {
|
|
152
|
+
try {
|
|
153
|
+
answered = await deps.answer(req.id, id);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
answered = { error: e.message };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
id,
|
|
161
|
+
hash,
|
|
162
|
+
approveHash,
|
|
163
|
+
to: dest,
|
|
164
|
+
token: t.symbol,
|
|
165
|
+
amountIn,
|
|
166
|
+
recipientGets: netOf(amountIn, Number(feeBps)),
|
|
167
|
+
feeBps: Number(feeBps),
|
|
168
|
+
runs,
|
|
169
|
+
intervalSeconds: interval,
|
|
170
|
+
firstRunAt: new Date(first * 1000),
|
|
171
|
+
expiresAt: new Date(expiresAt * 1000),
|
|
172
|
+
...(answered ? { request: answered } : {}),
|
|
173
|
+
warnings,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
create,
|
|
178
|
+
get,
|
|
179
|
+
/// Whether a payment is due now, and whether it can go: funded and approved.
|
|
180
|
+
async status(id) {
|
|
181
|
+
const [due, funded, approved, , nextRunAt] = await read("runnability", [BigInt(id)]);
|
|
182
|
+
return { due, funded, approved, nextRunAt: new Date(Number(nextRunAt) * 1000) };
|
|
183
|
+
},
|
|
184
|
+
/// The ids of every schedule `sender` (default: this account) has created.
|
|
185
|
+
async list(sender) {
|
|
186
|
+
const who = sender ? checksum(sender) : ctx.requireAccount().address;
|
|
187
|
+
return [...(await read("schedulesOf", [who]))];
|
|
188
|
+
},
|
|
189
|
+
/// Stops it for good. The approval stays: lower it if nothing else needs it.
|
|
190
|
+
async cancel(id) {
|
|
191
|
+
return (await write(ctx, {
|
|
192
|
+
address: EXECUTOR, abi: executorAbi, functionName: "cancelSchedule", args: [BigInt(id)], gasToken: await gasTokenOf(id),
|
|
193
|
+
})).hash;
|
|
194
|
+
},
|
|
195
|
+
/// Pauses (false) or resumes (true) the payments.
|
|
196
|
+
async setActive(id, active) {
|
|
197
|
+
return (await write(ctx, {
|
|
198
|
+
address: EXECUTOR, abi: executorAbi, functionName: "setScheduleActive", args: [BigInt(id), active], gasToken: await gasTokenOf(id),
|
|
199
|
+
})).hash;
|
|
200
|
+
},
|
|
201
|
+
/// Changes who may bring a payment forward; `null` revokes.
|
|
202
|
+
async setTrigger(id, trigger, triggers = 0) {
|
|
203
|
+
const who = trigger === "remesso" ? CURRENT_EXECUTOR : trigger ? checksum(trigger) : zeroAddress;
|
|
204
|
+
return (await write(ctx, {
|
|
205
|
+
address: EXECUTOR, abi: executorAbi, functionName: "setTrigger", args: [BigInt(id), who, who === zeroAddress ? 0 : triggers],
|
|
206
|
+
gasToken: await gasTokenOf(id),
|
|
207
|
+
})).hash;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/// The id of the schedule a transaction created, from its own ScheduleCreated
|
|
212
|
+
/// log, never from re-reading the counter: a node a block behind would answer
|
|
213
|
+
/// with somebody else's id. Null when there is none.
|
|
214
|
+
export function scheduleIdFrom(logs) {
|
|
215
|
+
const created = parseEventLogs({ abi: executorAbi, eventName: "ScheduleCreated", logs: logs })
|
|
216
|
+
.find((l) => l.address.toLowerCase() === EXECUTOR.toLowerCase());
|
|
217
|
+
return created ? created.args.id : null;
|
|
218
|
+
}
|
|
219
|
+
/// Seconds for a cadence name or a number of seconds.
|
|
220
|
+
export function intervalOf(every) {
|
|
221
|
+
if (typeof every === "number") {
|
|
222
|
+
if (!Number.isInteger(every) || every < 3600)
|
|
223
|
+
throw new RemessoError("invalid", "every, in seconds, must be a whole number of at least 3600");
|
|
224
|
+
return every;
|
|
225
|
+
}
|
|
226
|
+
const named = CADENCES[String(every).toLowerCase().replace(/ly$/, "")];
|
|
227
|
+
if (named)
|
|
228
|
+
return named;
|
|
229
|
+
if (/^\d+$/.test(String(every)))
|
|
230
|
+
return intervalOf(Number(every));
|
|
231
|
+
throw new RemessoError("invalid", `every must be ${Object.keys(CADENCES).join(", ")} or seconds`);
|
|
232
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Address, Hex } from "viem";
|
|
2
|
+
export type PaymentRecord = {
|
|
3
|
+
key: string | null;
|
|
4
|
+
deployment: string;
|
|
5
|
+
policyId: string;
|
|
6
|
+
to: Address;
|
|
7
|
+
units: string;
|
|
8
|
+
token: Address;
|
|
9
|
+
memo: Hex;
|
|
10
|
+
hash: Hex;
|
|
11
|
+
nonce: number;
|
|
12
|
+
raw: Hex;
|
|
13
|
+
status: "sent" | "confirmed" | "reverted" | "unsent" | "dropped";
|
|
14
|
+
at: string;
|
|
15
|
+
};
|
|
16
|
+
export interface PaymentStore {
|
|
17
|
+
byKey(key: string, deployment: string): Promise<PaymentRecord | undefined>;
|
|
18
|
+
since(since: number): Promise<PaymentRecord[]>;
|
|
19
|
+
save(rec: PaymentRecord): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export declare const KEEP_MS: number;
|
|
22
|
+
export declare const KEEP_COUNT = 1000;
|
|
23
|
+
export declare function upsert(list: PaymentRecord[], rec: PaymentRecord, now?: number): PaymentRecord[];
|
|
24
|
+
export declare function memoryStore(): PaymentStore;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/// How long records are kept, and how many.
|
|
2
|
+
export const KEEP_MS = 30 * 86_400_000;
|
|
3
|
+
export const KEEP_COUNT = 1000;
|
|
4
|
+
/// Applies `save`'s replacement rule and the retention to a list.
|
|
5
|
+
export function upsert(list, rec, now = Date.now()) {
|
|
6
|
+
const cutoff = now - KEEP_MS;
|
|
7
|
+
const rest = list.filter((p) => p.hash !== rec.hash && !(rec.key && p.key === rec.key && p.deployment === rec.deployment) && Date.parse(p.at) > cutoff);
|
|
8
|
+
return [...rest, rec].slice(-KEEP_COUNT);
|
|
9
|
+
}
|
|
10
|
+
export function memoryStore() {
|
|
11
|
+
let list = [];
|
|
12
|
+
return {
|
|
13
|
+
byKey: async (key, deployment) => [...list].reverse().find((p) => p.key === key && p.deployment === deployment),
|
|
14
|
+
since: async (since) => list.filter((p) => Date.parse(p.at) > since),
|
|
15
|
+
save: async (rec) => {
|
|
16
|
+
list = upsert(list, rec);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const MARKER = "80218021802180218021802180218021";
|
|
2
|
+
export declare const CODE: RegExp;
|
|
3
|
+
export declare const OWN_CODES: readonly string[];
|
|
4
|
+
export declare const MAX_INTEGRATOR_CODES = 3;
|
|
5
|
+
export declare function suffixFor(codes: string | readonly string[]): `0x${string}` | undefined;
|
|
6
|
+
export declare function parseSuffix(input: string): {
|
|
7
|
+
codes: string[];
|
|
8
|
+
} | null;
|
|
9
|
+
export declare function integratorCodes(codes: readonly string[], own?: readonly string[]): string[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Copied from supabase/functions/_shared/erc8021.ts by scripts/build-sdk.ts. Edit the original and re-run;
|
|
2
|
+
// `build-sdk.ts --check` fails while the two differ.
|
|
3
|
+
/// ERC-8021 Schema 0: writing a suffix, reading one back, and which of the
|
|
4
|
+
/// codes an integrator's transaction carries ride on the runs that follow it.
|
|
5
|
+
///
|
|
6
|
+
/// Pure, with no environment, so `remesso-sdk` carries an exact copy
|
|
7
|
+
/// (`scripts/build-sdk.ts`): a builder code the SDK accepts is one the backend
|
|
8
|
+
/// keeps. `attribution.ts` adds Remesso's own configured codes on top.
|
|
9
|
+
/// `attribution.test.ts` pins both directions to `@celo/attribution-tags`.
|
|
10
|
+
/// `[code:N][length:1][schema:1][marker:16]`
|
|
11
|
+
export const MARKER = "80218021802180218021802180218021";
|
|
12
|
+
const SCHEMA_0 = "00";
|
|
13
|
+
/// What Celo's SDK accepts in `toDataSuffix`.
|
|
14
|
+
export const CODE = /^[a-z0-9_]{1,32}$/;
|
|
15
|
+
/// Remesso's own codes, whether or not they're carried today. `remesso-sdk`
|
|
16
|
+
/// 0.1.0 writes both on every transaction, so a schedule created with it
|
|
17
|
+
/// after the event code leaves `ATTRIBUTION_CODE` must not hand that code
|
|
18
|
+
/// back to our runs as if it were an integrator's.
|
|
19
|
+
export const OWN_CODES = ["remesso", "celo_5cd35ca55baf"];
|
|
20
|
+
/// How many of an integrator's codes ride on each run. Each costs calldata
|
|
21
|
+
/// gas on every run, and three cover an app, a wallet and one partner.
|
|
22
|
+
export const MAX_INTEGRATOR_CODES = 3;
|
|
23
|
+
/// The suffix for one code or several, or `undefined` if none is usable.
|
|
24
|
+
///
|
|
25
|
+
/// Never throws: a malformed tag must not be the reason a remittance fails to
|
|
26
|
+
/// go out. A bad code is dropped and the rest still tag — a typo in one must
|
|
27
|
+
/// not cost the other its credit, since an untagged transaction stays
|
|
28
|
+
/// untagged forever. A comma inside a single code is refused rather than read
|
|
29
|
+
/// as a list; a list is passed as an array.
|
|
30
|
+
export function suffixFor(codes) {
|
|
31
|
+
const list = (typeof codes === "string" ? [codes] : codes).filter((code) => {
|
|
32
|
+
if (CODE.test(code))
|
|
33
|
+
return true;
|
|
34
|
+
console.error("attribution code must be 1-32 of [a-z0-9_]:", code);
|
|
35
|
+
return false;
|
|
36
|
+
});
|
|
37
|
+
if (list.length === 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
const field = list.join(",");
|
|
40
|
+
// The length is one byte.
|
|
41
|
+
if (field.length > 255)
|
|
42
|
+
return undefined;
|
|
43
|
+
const hex = [...new TextEncoder().encode(field)]
|
|
44
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
45
|
+
.join("");
|
|
46
|
+
const length = field.length.toString(16).padStart(2, "0");
|
|
47
|
+
return `0x${hex}${length}${SCHEMA_0}${MARKER}`;
|
|
48
|
+
}
|
|
49
|
+
/// The codes in a transaction's Schema 0 suffix, read from the end the way
|
|
50
|
+
/// Celo's `fromDataSuffix` reads them: the marker, the schema byte, the length
|
|
51
|
+
/// byte, then that many bytes of comma-joined codes, returned as written.
|
|
52
|
+
///
|
|
53
|
+
/// Null when there is no suffix, when it is another schema (Schema 2's roles
|
|
54
|
+
/// aren't read), or when the length runs past the start of the input. ox
|
|
55
|
+
/// reads whatever bytes are there in that last case; nothing honest writes it.
|
|
56
|
+
export function parseSuffix(input) {
|
|
57
|
+
const hex = input.toLowerCase().replace(/^0x/, "");
|
|
58
|
+
if (hex.length % 2 !== 0 || hex.length < 36 || !/^[0-9a-f]*$/.test(hex))
|
|
59
|
+
return null;
|
|
60
|
+
if (hex.slice(-32) !== MARKER || hex.slice(-34, -32) !== SCHEMA_0)
|
|
61
|
+
return null;
|
|
62
|
+
const length = parseInt(hex.slice(-36, -34), 16);
|
|
63
|
+
if (length === 0 || hex.length < 36 + length * 2)
|
|
64
|
+
return null;
|
|
65
|
+
const field = hex.slice(-36 - length * 2, -36);
|
|
66
|
+
const bytes = Uint8Array.from(field.match(/../g), (b) => parseInt(b, 16));
|
|
67
|
+
return { codes: new TextDecoder().decode(bytes).split(",") };
|
|
68
|
+
}
|
|
69
|
+
/// Which of a creating transaction's codes ride on a schedule's runs: Celo's
|
|
70
|
+
/// charset, none of Remesso's own (they're carried anyway), no repeats, and
|
|
71
|
+
/// the first three in the order written.
|
|
72
|
+
export function integratorCodes(codes, own = OWN_CODES) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const code of codes) {
|
|
75
|
+
if (!CODE.test(code) || own.includes(code) || out.includes(code))
|
|
76
|
+
continue;
|
|
77
|
+
out.push(code);
|
|
78
|
+
if (out.length === MAX_INTEGRATOR_CODES)
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|