cascrow-signer 0.1.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +35 -0
- package/README.md +130 -0
- package/SECURITY.md +71 -0
- package/dist/acceptance.d.ts +70 -0
- package/dist/acceptance.js +169 -0
- package/dist/actions.d.ts +60 -0
- package/dist/actions.js +319 -0
- package/dist/api.d.ts +83 -0
- package/dist/api.js +105 -0
- package/dist/book.d.ts +26 -0
- package/dist/book.js +62 -0
- package/dist/calldata.d.ts +62 -0
- package/dist/calldata.js +121 -0
- package/dist/chain.d.ts +59 -0
- package/dist/chain.js +233 -0
- package/dist/checks.d.ts +101 -0
- package/dist/checks.js +236 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +163 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.js +50 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +17 -0
- package/dist/files.d.ts +13 -0
- package/dist/files.js +78 -0
- package/dist/generated/milestone-escrow-v3.deployments.json +15 -0
- package/dist/generated/milestone-escrow-v3.json +1179 -0
- package/dist/generated/v3-actions.d.ts +49 -0
- package/dist/generated/v3-actions.js +102 -0
- package/dist/generated/v3-proof-manifest.d.ts +19 -0
- package/dist/generated/v3-proof-manifest.js +79 -0
- package/dist/journal.d.ts +41 -0
- package/dist/journal.js +186 -0
- package/dist/keystore.d.ts +30 -0
- package/dist/keystore.js +119 -0
- package/dist/link.d.ts +17 -0
- package/dist/link.js +65 -0
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +81 -0
- package/dist/network.d.ts +53 -0
- package/dist/network.js +122 -0
- package/dist/policy.d.ts +31 -0
- package/dist/policy.js +92 -0
- package/dist/remote.d.ts +31 -0
- package/dist/remote.js +104 -0
- package/dist/tools.d.ts +20 -0
- package/dist/tools.js +112 -0
- package/dist/watch.d.ts +35 -0
- package/dist/watch.js +129 -0
- package/package.json +64 -0
package/dist/calldata.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract as the signer sees it: ABI, acceptance domain and types, escrow
|
|
3
|
+
* IDs, and calldata built HERE from the ABI — never taken from a server.
|
|
4
|
+
*
|
|
5
|
+
* The ABI is a byte copy of the app's generated file (src/lib/abi/
|
|
6
|
+
* milestone-escrow-v3.json). `escrowIdFor`, `acceptanceDomain` and the
|
|
7
|
+
* acceptance types are ports of src/lib/escrow-v3.ts; signer-artifact-parity.test.ts
|
|
8
|
+
* runs both on the same inputs.
|
|
9
|
+
*
|
|
10
|
+
* Canonical arguments (the `call.args` form of a prepared transaction, design
|
|
11
|
+
* Rev. 10): ABI order, integers as decimal strings, bytes and addresses as
|
|
12
|
+
* lowercase 0x hex, tuples as objects keyed by component name.
|
|
13
|
+
*/
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { AbiCoder, Interface, ZeroAddress, getAddress, isAddress, keccak256 } from "ethers";
|
|
16
|
+
import { refuse } from "./errors.js";
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const artifact = require("./generated/milestone-escrow-v3.json");
|
|
19
|
+
export const ESCROW_V3_INTERFACE = new Interface(artifact.abi);
|
|
20
|
+
export const V3_PHASE_NAMES = Object.freeze([...artifact.phases]);
|
|
21
|
+
export const V3_LIMITS = Object.freeze({
|
|
22
|
+
MIN_REVIEW_SECONDS: artifact.constants.MIN_REVIEW,
|
|
23
|
+
MAX_REVIEW_SECONDS: artifact.constants.MAX_REVIEW,
|
|
24
|
+
MIN_ARBITRATION_SECONDS: artifact.constants.MIN_ARB,
|
|
25
|
+
MAX_ARBITRATION_SECONDS: artifact.constants.MAX_ARB,
|
|
26
|
+
MAX_DEADLINE_SECONDS: artifact.constants.MAX_DEADLINE_DURATION,
|
|
27
|
+
MAX_CLAIMS: artifact.constants.MAX_CLAIMS,
|
|
28
|
+
});
|
|
29
|
+
export const ACCEPTANCE_TYPES = Object.freeze(structuredClone(artifact.eip712.types));
|
|
30
|
+
export const ACCEPTANCE_PRIMARY_TYPE = artifact.eip712.primaryType;
|
|
31
|
+
const BYTES32 = /^0x[0-9a-fA-F]{64}$/;
|
|
32
|
+
/** keccak256(abi.encode(payer, ref, salt)) — the contract's `escrowIdFor`. */
|
|
33
|
+
export function escrowIdFor(payer, ref, salt) {
|
|
34
|
+
if (!isAddress(payer))
|
|
35
|
+
throw new Error("payer must be an address");
|
|
36
|
+
if (!BYTES32.test(ref))
|
|
37
|
+
throw new Error("ref must be a 0x-prefixed 32-byte hex string");
|
|
38
|
+
if (!BYTES32.test(salt))
|
|
39
|
+
throw new Error("salt must be a 0x-prefixed 32-byte hex string");
|
|
40
|
+
return keccak256(AbiCoder.defaultAbiCoder().encode(["address", "bytes32", "bytes32"], [payer, ref, salt]));
|
|
41
|
+
}
|
|
42
|
+
/** The domain a payee signs under — the PINNED chain and contract, never the server's. */
|
|
43
|
+
export function acceptanceDomain(chainId, verifyingContract) {
|
|
44
|
+
return { name: artifact.eip712.name, version: artifact.eip712.version, chainId, verifyingContract: getAddress(verifyingContract) };
|
|
45
|
+
}
|
|
46
|
+
/** The contract function each action calls. `resolve` is the arbiter's and is not offered. */
|
|
47
|
+
export const ACTION_FN = Object.freeze({
|
|
48
|
+
fund: "fund",
|
|
49
|
+
submitClaim: "submitClaim",
|
|
50
|
+
claim: "claim",
|
|
51
|
+
release: "release",
|
|
52
|
+
object: "object",
|
|
53
|
+
refund: "refund",
|
|
54
|
+
extendDeadline: "extendDeadline",
|
|
55
|
+
returnFunds: "returnFunds",
|
|
56
|
+
withdraw: "withdraw",
|
|
57
|
+
});
|
|
58
|
+
export const SIGNER_ACTIONS = Object.freeze(Object.keys(ACTION_FN));
|
|
59
|
+
/** Actions whose first argument is the escrow ID. */
|
|
60
|
+
export const PER_ESCROW_ACTIONS = new Set(["submitClaim", "claim", "release", "object", "refund", "extendDeadline", "returnFunds"]);
|
|
61
|
+
function canonicalValue(t, v) {
|
|
62
|
+
if (t.baseType === "tuple") {
|
|
63
|
+
const out = {};
|
|
64
|
+
const comps = t.components ?? [];
|
|
65
|
+
comps.forEach((c, i) => {
|
|
66
|
+
const src = v;
|
|
67
|
+
out[c.name] = canonicalValue(c, src[c.name] !== undefined ? src[c.name] : src[i]);
|
|
68
|
+
});
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
if (t.baseType === "array")
|
|
72
|
+
return v.map((x) => canonicalValue(t.arrayChildren, x));
|
|
73
|
+
if (t.baseType.startsWith("uint") || t.baseType.startsWith("int"))
|
|
74
|
+
return BigInt(v).toString(10);
|
|
75
|
+
if (t.baseType === "bool")
|
|
76
|
+
return Boolean(v);
|
|
77
|
+
return String(v).toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
/** Canonical form of `args` for `fn`, whatever shape (ethers Result, strings, bigints) they come in. */
|
|
80
|
+
export function canonicalArgs(fn, args) {
|
|
81
|
+
const f = ESCROW_V3_INTERFACE.getFunction(fn);
|
|
82
|
+
if (!f)
|
|
83
|
+
refuse("UNKNOWN_FUNCTION", `the escrow contract has no function ${fn}`);
|
|
84
|
+
if (!Array.isArray(args) || args.length !== f.inputs.length)
|
|
85
|
+
refuse("ARGS_SHAPE", `${fn} takes ${f.inputs.length} arguments`);
|
|
86
|
+
return f.inputs.map((p, i) => canonicalValue(p, args[i]));
|
|
87
|
+
}
|
|
88
|
+
/** Calldata for `fn(args)`, encoded from the pinned ABI. */
|
|
89
|
+
export function encodeCall(fn, args) {
|
|
90
|
+
return ESCROW_V3_INTERFACE.encodeFunctionData(fn, canonicalArgs(fn, args)).toLowerCase();
|
|
91
|
+
}
|
|
92
|
+
/** Decode calldata into its function name and canonical arguments. */
|
|
93
|
+
export function decodeCall(data) {
|
|
94
|
+
const parsed = ESCROW_V3_INTERFACE.parseTransaction({ data });
|
|
95
|
+
if (!parsed)
|
|
96
|
+
refuse("UNKNOWN_FUNCTION", "calldata does not call a MilestoneEscrowV3 function");
|
|
97
|
+
return { fn: parsed.name, args: canonicalArgs(parsed.name, parsed.args.toArray(true)) };
|
|
98
|
+
}
|
|
99
|
+
export function sameCanonical(a, b) {
|
|
100
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
101
|
+
}
|
|
102
|
+
export const ZERO_ADDRESS = ZeroAddress;
|
|
103
|
+
export function decodeGetEscrow(result) {
|
|
104
|
+
const [t] = ESCROW_V3_INTERFACE.decodeFunctionResult("getEscrow", result);
|
|
105
|
+
const n = (x) => Number(x);
|
|
106
|
+
return {
|
|
107
|
+
payer: getAddress(t.payer),
|
|
108
|
+
deadline: n(t.deadline),
|
|
109
|
+
reviewPeriod: n(t.reviewPeriod),
|
|
110
|
+
payee: getAddress(t.payee),
|
|
111
|
+
claimedAt: n(t.claimedAt),
|
|
112
|
+
claimCount: n(t.claimCount),
|
|
113
|
+
objected: Boolean(t.objected),
|
|
114
|
+
status: n(t.status),
|
|
115
|
+
arbiter: getAddress(t.arbiter),
|
|
116
|
+
objectedAt: n(t.objectedAt),
|
|
117
|
+
arbitrationPeriod: n(t.arbitrationPeriod),
|
|
118
|
+
token: getAddress(t.token),
|
|
119
|
+
amount: BigInt(t.amount),
|
|
120
|
+
};
|
|
121
|
+
}
|
package/dist/chain.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ChainEscrow } from "./calldata.js";
|
|
2
|
+
import { Rpc } from "./network.js";
|
|
3
|
+
import type { SignerNetwork } from "./network.js";
|
|
4
|
+
import { Journal } from "./journal.js";
|
|
5
|
+
import type { UnlockedKey } from "./keystore.js";
|
|
6
|
+
export type ChainCtx = {
|
|
7
|
+
rpc: Rpc;
|
|
8
|
+
net: SignerNetwork;
|
|
9
|
+
};
|
|
10
|
+
export declare function readEscrow(c: ChainCtx, escrowId: string): Promise<ChainEscrow>;
|
|
11
|
+
export declare function readCredit(c: ChainCtx, account: string): Promise<bigint>;
|
|
12
|
+
export declare function latestBlock(c: ChainCtx): Promise<{
|
|
13
|
+
number: number;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
baseFee: bigint | null;
|
|
16
|
+
}>;
|
|
17
|
+
/** Every `Funded(payer = me)` in the last 24 h of CHAIN time: escrowId → amount. Throws when the chain cannot be read. */
|
|
18
|
+
export declare function fundedByMeLastDay(c: ChainCtx, me: string, chainNow: {
|
|
19
|
+
number: number;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}): Promise<Map<string, bigint>>;
|
|
22
|
+
/** ClaimSubmitted events for one escrow, oldest first. */
|
|
23
|
+
export declare function claimsOf(c: ChainCtx, escrowId: string, fromBlock: number, toBlock: number): Promise<{
|
|
24
|
+
claimIndex: number;
|
|
25
|
+
proofHash: string;
|
|
26
|
+
claimedAt: number;
|
|
27
|
+
objectionDeadline: number;
|
|
28
|
+
block: number;
|
|
29
|
+
}[]>;
|
|
30
|
+
/** A contract revert, named by the contract's own error, or null. */
|
|
31
|
+
export declare function revertName(e: unknown): string | null;
|
|
32
|
+
export type SendResult = {
|
|
33
|
+
txHash: string;
|
|
34
|
+
nonce: number;
|
|
35
|
+
sent: boolean;
|
|
36
|
+
note?: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Estimate, sign locally and send to the pinned public RPC. Call inside `withLock`,
|
|
40
|
+
* after the reservation was written. The raw signed transaction exists only in
|
|
41
|
+
* this function's scope and in the RPC request body — it is never returned,
|
|
42
|
+
* logged or sent anywhere else.
|
|
43
|
+
*/
|
|
44
|
+
export declare function sendSigned(c: ChainCtx, key: UnlockedKey, journal: Journal, reservationId: string, tx: {
|
|
45
|
+
to: string;
|
|
46
|
+
data: string;
|
|
47
|
+
value: bigint;
|
|
48
|
+
}): Promise<SendResult>;
|
|
49
|
+
/**
|
|
50
|
+
* Replace a pending transaction of this key with the same nonce and fees raised by
|
|
51
|
+
* 25 % — the way to speed up an objection, instead of waiting. Refused once the
|
|
52
|
+
* nonce is used.
|
|
53
|
+
*/
|
|
54
|
+
export declare function speedUp(c: ChainCtx, key: UnlockedKey, journal: Journal, txHash: string): Promise<SendResult>;
|
|
55
|
+
/**
|
|
56
|
+
* Release reservations whose transaction provably spent nothing: signed, outcome
|
|
57
|
+
* unknown, its nonce now used, and no receipt for any of its hashes.
|
|
58
|
+
*/
|
|
59
|
+
export declare function settleUnknown(c: ChainCtx, me: string, journal: Journal): Promise<void>;
|
package/dist/chain.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the escrow from the chain and putting a signed transaction on the wire
|
|
3
|
+
* — both through the pinned network's own RPC (network.ts), never through cascrow.
|
|
4
|
+
*
|
|
5
|
+
* `sendSigned` must run INSIDE `withLock` (journal.ts): nonce, reservation,
|
|
6
|
+
* signature and send are one critical section, so two sends never take the same
|
|
7
|
+
* nonce and two fundings never both pass the daily limit.
|
|
8
|
+
*/
|
|
9
|
+
import { ZeroAddress, getAddress, keccak256, toBeHex, zeroPadValue } from "ethers";
|
|
10
|
+
import { refuse } from "./errors.js";
|
|
11
|
+
import { ESCROW_V3_INTERFACE, decodeGetEscrow } from "./calldata.js";
|
|
12
|
+
import { RpcError, verifyEndpoint } from "./network.js";
|
|
13
|
+
import { DAY_SECONDS } from "./journal.js";
|
|
14
|
+
async function ethCall(c, fn, args) {
|
|
15
|
+
return c.rpc.call("eth_call", [{ to: c.net.address, data: ESCROW_V3_INTERFACE.encodeFunctionData(fn, args) }, "latest"]);
|
|
16
|
+
}
|
|
17
|
+
export async function readEscrow(c, escrowId) {
|
|
18
|
+
return decodeGetEscrow(await ethCall(c, "getEscrow", [escrowId]));
|
|
19
|
+
}
|
|
20
|
+
export async function readCredit(c, account) {
|
|
21
|
+
const [v] = ESCROW_V3_INTERFACE.decodeFunctionResult("credit", await ethCall(c, "credit", [account, ZeroAddress]));
|
|
22
|
+
return BigInt(v);
|
|
23
|
+
}
|
|
24
|
+
export async function latestBlock(c) {
|
|
25
|
+
const b = await c.rpc.call("eth_getBlockByNumber", ["latest", false]);
|
|
26
|
+
if (!b || typeof b.timestamp !== "string")
|
|
27
|
+
refuse("CHAIN_UNREADABLE", "the RPC returned no latest block");
|
|
28
|
+
return { number: Number(BigInt(b.number)), timestamp: Number(BigInt(b.timestamp)), baseFee: b.baseFeePerGas ? BigInt(b.baseFeePerGas) : null };
|
|
29
|
+
}
|
|
30
|
+
async function blockTime(c, n) {
|
|
31
|
+
const b = await c.rpc.call("eth_getBlockByNumber", [toBeHex(n), false]);
|
|
32
|
+
if (!b)
|
|
33
|
+
refuse("CHAIN_UNREADABLE", `the RPC returned no block ${n}`);
|
|
34
|
+
return Number(BigInt(b.timestamp));
|
|
35
|
+
}
|
|
36
|
+
/** First block whose timestamp is after `cutoff`, searched between the deploy block and `latest`. */
|
|
37
|
+
async function firstBlockAfter(c, cutoff, latest) {
|
|
38
|
+
let lo = c.net.deployBlock;
|
|
39
|
+
let hi = latest;
|
|
40
|
+
while (lo < hi) {
|
|
41
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
42
|
+
if ((await blockTime(c, mid)) > cutoff)
|
|
43
|
+
hi = mid;
|
|
44
|
+
else
|
|
45
|
+
lo = mid + 1;
|
|
46
|
+
}
|
|
47
|
+
return lo;
|
|
48
|
+
}
|
|
49
|
+
const LOG_CHUNK = 10_000;
|
|
50
|
+
/** Every `Funded(payer = me)` in the last 24 h of CHAIN time: escrowId → amount. Throws when the chain cannot be read. */
|
|
51
|
+
export async function fundedByMeLastDay(c, me, chainNow) {
|
|
52
|
+
const from = await firstBlockAfter(c, chainNow.timestamp - DAY_SECONDS, chainNow.number);
|
|
53
|
+
const topic0 = ESCROW_V3_INTERFACE.getEvent("Funded").topicHash;
|
|
54
|
+
const payerTopic = zeroPadValue(getAddress(me), 32).toLowerCase();
|
|
55
|
+
const out = new Map();
|
|
56
|
+
for (let start = from; start <= chainNow.number; start += LOG_CHUNK) {
|
|
57
|
+
const end = Math.min(chainNow.number, start + LOG_CHUNK - 1);
|
|
58
|
+
const logs = await c.rpc.call("eth_getLogs", [
|
|
59
|
+
{ address: c.net.address, fromBlock: toBeHex(start), toBlock: toBeHex(end), topics: [topic0, null, payerTopic] },
|
|
60
|
+
]);
|
|
61
|
+
for (const l of logs ?? []) {
|
|
62
|
+
if (l.address.toLowerCase() !== c.net.address.toLowerCase())
|
|
63
|
+
continue;
|
|
64
|
+
const parsed = ESCROW_V3_INTERFACE.parseLog(l);
|
|
65
|
+
if (!parsed || parsed.name !== "Funded" || getAddress(parsed.args.payer) !== getAddress(me))
|
|
66
|
+
continue;
|
|
67
|
+
out.set(String(parsed.args.escrowId).toLowerCase(), BigInt(parsed.args.amount));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** ClaimSubmitted events for one escrow, oldest first. */
|
|
73
|
+
export async function claimsOf(c, escrowId, fromBlock, toBlock) {
|
|
74
|
+
const topic0 = ESCROW_V3_INTERFACE.getEvent("ClaimSubmitted").topicHash;
|
|
75
|
+
const out = [];
|
|
76
|
+
for (let start = Math.max(fromBlock, c.net.deployBlock); start <= toBlock; start += LOG_CHUNK) {
|
|
77
|
+
const end = Math.min(toBlock, start + LOG_CHUNK - 1);
|
|
78
|
+
const logs = await c.rpc.call("eth_getLogs", [
|
|
79
|
+
{ address: c.net.address, fromBlock: toBeHex(start), toBlock: toBeHex(end), topics: [topic0, escrowId.toLowerCase()] },
|
|
80
|
+
]);
|
|
81
|
+
for (const l of logs ?? []) {
|
|
82
|
+
const p = ESCROW_V3_INTERFACE.parseLog(l);
|
|
83
|
+
if (!p)
|
|
84
|
+
continue;
|
|
85
|
+
out.push({
|
|
86
|
+
claimIndex: Number(p.args.claimIndex),
|
|
87
|
+
proofHash: String(p.args.proofHash),
|
|
88
|
+
claimedAt: Number(p.args.claimedAt),
|
|
89
|
+
objectionDeadline: Number(p.args.objectionDeadline),
|
|
90
|
+
block: Number(BigInt(l.blockNumber)),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** A contract revert, named by the contract's own error, or null. */
|
|
97
|
+
export function revertName(e) {
|
|
98
|
+
const data = e instanceof RpcError ? e.data : null;
|
|
99
|
+
const hex = typeof data === "string" ? data : data && typeof data === "object" && typeof data.data === "string" ? data.data : null;
|
|
100
|
+
if (!hex)
|
|
101
|
+
return null;
|
|
102
|
+
try {
|
|
103
|
+
return ESCROW_V3_INTERFACE.parseError(hex)?.name ?? null;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const pct = (v, p) => (v * BigInt(p)) / BigInt(100);
|
|
110
|
+
async function feeFields(c, bumpFrom) {
|
|
111
|
+
const b = await latestBlock(c);
|
|
112
|
+
if (b.baseFee !== null) {
|
|
113
|
+
let prio;
|
|
114
|
+
try {
|
|
115
|
+
prio = BigInt(await c.rpc.call("eth_maxPriorityFeePerGas"));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
prio = BigInt(1_000_000_000);
|
|
119
|
+
}
|
|
120
|
+
let maxFee = b.baseFee * BigInt(2) + prio;
|
|
121
|
+
if (bumpFrom?.maxFeePerGas) {
|
|
122
|
+
prio = [prio, pct(BigInt(bumpFrom.maxPriorityFeePerGas), 125)].reduce((a, x) => (a > x ? a : x));
|
|
123
|
+
maxFee = [maxFee, pct(BigInt(bumpFrom.maxFeePerGas), 125)].reduce((a, x) => (a > x ? a : x));
|
|
124
|
+
}
|
|
125
|
+
return { type: 2, maxPriorityFeePerGas: prio, maxFeePerGas: maxFee };
|
|
126
|
+
}
|
|
127
|
+
let gasPrice = BigInt(await c.rpc.call("eth_gasPrice"));
|
|
128
|
+
if (bumpFrom?.gasPrice)
|
|
129
|
+
gasPrice = [gasPrice, pct(BigInt(bumpFrom.gasPrice), 125)].reduce((a, x) => (a > x ? a : x));
|
|
130
|
+
return { type: 0, gasPrice };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Estimate, sign locally and send to the pinned public RPC. Call inside `withLock`,
|
|
134
|
+
* after the reservation was written. The raw signed transaction exists only in
|
|
135
|
+
* this function's scope and in the RPC request body — it is never returned,
|
|
136
|
+
* logged or sent anywhere else.
|
|
137
|
+
*/
|
|
138
|
+
export async function sendSigned(c, key, journal, reservationId, tx) {
|
|
139
|
+
await verifyEndpoint(c.rpc, c.net);
|
|
140
|
+
if (getAddress(tx.to) !== getAddress(c.net.address))
|
|
141
|
+
refuse("WRONG_TARGET", "the signer only ever sends to the pinned escrow contract");
|
|
142
|
+
const from = key.address;
|
|
143
|
+
let gas;
|
|
144
|
+
try {
|
|
145
|
+
gas = BigInt(await c.rpc.call("eth_estimateGas", [{ from, to: tx.to, data: tx.data, value: toBeHex(tx.value) }]));
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
journal.release(reservationId, "gas estimation failed before signing");
|
|
149
|
+
const name = revertName(e);
|
|
150
|
+
refuse("WOULD_REVERT", `the contract would reject this transaction${name ? `: ${name}` : ""}; nothing was signed`);
|
|
151
|
+
}
|
|
152
|
+
const nonce = Number(BigInt(await c.rpc.call("eth_getTransactionCount", [from, "pending"])));
|
|
153
|
+
const fees = await feeFields(c);
|
|
154
|
+
const fields = { chainId: c.net.chainId, nonce, to: tx.to, data: tx.data, value: tx.value, gasLimit: pct(gas, 125), ...fees };
|
|
155
|
+
const signed = await key.signTransaction(fields);
|
|
156
|
+
const hash = keccak256(signed);
|
|
157
|
+
journal.signed(reservationId, hash, nonce, Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "bigint" ? v.toString() : v])));
|
|
158
|
+
try {
|
|
159
|
+
const answered = await c.rpc.call("eth_sendRawTransaction", [signed]);
|
|
160
|
+
if (typeof answered === "string" && answered.toLowerCase() !== hash.toLowerCase()) {
|
|
161
|
+
journal.unknown(reservationId, hash, "the RPC answered a different hash");
|
|
162
|
+
return { txHash: hash, nonce, sent: false, note: "the RPC answered a different transaction hash — check your account before sending again" };
|
|
163
|
+
}
|
|
164
|
+
journal.sent(reservationId, hash);
|
|
165
|
+
return { txHash: hash, nonce, sent: true };
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
// The answer was lost or refused. The transaction may still be in a mempool:
|
|
169
|
+
// the reservation keeps counting until its nonce is used. Never resend blindly.
|
|
170
|
+
journal.unknown(reservationId, hash, e instanceof Error ? e.message : "send failed");
|
|
171
|
+
return { txHash: hash, nonce, sent: false, note: "the send's outcome is unknown — do not send again; check the hash, or speed it up with the same nonce" };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Replace a pending transaction of this key with the same nonce and fees raised by
|
|
176
|
+
* 25 % — the way to speed up an objection, instead of waiting. Refused once the
|
|
177
|
+
* nonce is used.
|
|
178
|
+
*/
|
|
179
|
+
export async function speedUp(c, key, journal, txHash) {
|
|
180
|
+
await verifyEndpoint(c.rpc, c.net);
|
|
181
|
+
const r = journal.read().find((x) => x.signed && (x.signed.hash.toLowerCase() === txHash.toLowerCase() || x.sentHashes.some((h) => h.toLowerCase() === txHash.toLowerCase())));
|
|
182
|
+
if (!r || !r.signed)
|
|
183
|
+
refuse("NOT_IN_JOURNAL", "this transaction was not signed by this signer");
|
|
184
|
+
const used = Number(BigInt(await c.rpc.call("eth_getTransactionCount", [key.address, "latest"])));
|
|
185
|
+
if (used > r.signed.nonce)
|
|
186
|
+
refuse("NONCE_USED", "that transaction's nonce is already used — it was mined or replaced");
|
|
187
|
+
const prev = r.signed.tx;
|
|
188
|
+
const fees = await feeFields(c, prev);
|
|
189
|
+
const fields = {
|
|
190
|
+
chainId: c.net.chainId,
|
|
191
|
+
nonce: r.signed.nonce,
|
|
192
|
+
to: prev.to,
|
|
193
|
+
data: prev.data,
|
|
194
|
+
value: BigInt(prev.value),
|
|
195
|
+
gasLimit: BigInt(prev.gasLimit),
|
|
196
|
+
...fees,
|
|
197
|
+
};
|
|
198
|
+
if (getAddress(fields.to) !== getAddress(c.net.address))
|
|
199
|
+
refuse("WRONG_TARGET", "the journal entry does not target the pinned contract");
|
|
200
|
+
const signed = await key.signTransaction(fields);
|
|
201
|
+
const hash = keccak256(signed);
|
|
202
|
+
journal.signed(r.id, hash, r.signed.nonce, Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "bigint" ? v.toString() : v])));
|
|
203
|
+
try {
|
|
204
|
+
await c.rpc.call("eth_sendRawTransaction", [signed]);
|
|
205
|
+
journal.sent(r.id, hash);
|
|
206
|
+
return { txHash: hash, nonce: r.signed.nonce, sent: true };
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
journal.unknown(r.id, hash, e instanceof Error ? e.message : "send failed");
|
|
210
|
+
return { txHash: hash, nonce: r.signed.nonce, sent: false, note: "the replacement's outcome is unknown" };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Release reservations whose transaction provably spent nothing: signed, outcome
|
|
215
|
+
* unknown, its nonce now used, and no receipt for any of its hashes.
|
|
216
|
+
*/
|
|
217
|
+
export async function settleUnknown(c, me, journal) {
|
|
218
|
+
const open = journal.read().filter((r) => r.unknown && !r.released && r.signed);
|
|
219
|
+
if (!open.length)
|
|
220
|
+
return;
|
|
221
|
+
const used = Number(BigInt(await c.rpc.call("eth_getTransactionCount", [me, "latest"])));
|
|
222
|
+
for (const r of open) {
|
|
223
|
+
if (used <= r.signed.nonce)
|
|
224
|
+
continue;
|
|
225
|
+
const hashes = [r.signed.hash, ...r.sentHashes];
|
|
226
|
+
let mined = false;
|
|
227
|
+
for (const h of hashes)
|
|
228
|
+
if (await c.rpc.call("eth_getTransactionReceipt", [h]))
|
|
229
|
+
mined = true;
|
|
230
|
+
if (!mined)
|
|
231
|
+
journal.release(r.id, "nonce used by another transaction");
|
|
232
|
+
}
|
|
233
|
+
}
|
package/dist/checks.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ChainEscrow, SignerAction } from "./calldata.js";
|
|
2
|
+
import type { V3ChainFacts, V3EscrowAction } from "./generated/v3-actions.js";
|
|
3
|
+
import type { Policy } from "./policy.js";
|
|
4
|
+
import type { BookEntry } from "./book.js";
|
|
5
|
+
export type PreparedTx = {
|
|
6
|
+
chainId: number;
|
|
7
|
+
to: string;
|
|
8
|
+
value: string;
|
|
9
|
+
data: string;
|
|
10
|
+
call: {
|
|
11
|
+
fn: string;
|
|
12
|
+
args: unknown[];
|
|
13
|
+
};
|
|
14
|
+
escrowId: string;
|
|
15
|
+
action: string;
|
|
16
|
+
role: string;
|
|
17
|
+
expect: {
|
|
18
|
+
phase: string;
|
|
19
|
+
from: string;
|
|
20
|
+
};
|
|
21
|
+
deadline?: string;
|
|
22
|
+
};
|
|
23
|
+
export type Pinned = {
|
|
24
|
+
chainId: number;
|
|
25
|
+
address: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The envelope of a prepared transaction against what the signer knows: chain and
|
|
29
|
+
* contract from its own pinned config, the sender from its own key, the function
|
|
30
|
+
* from the action it was asked to perform, and `data` re-encoded from the ABI.
|
|
31
|
+
*/
|
|
32
|
+
export declare function checkPreparedEnvelope(p: unknown, action: SignerAction, pinned: Pinned, me: string): PreparedTx;
|
|
33
|
+
/** A locally built call and the server's prepared one must be the same transaction. */
|
|
34
|
+
export declare function checkMatchesLocal(local: {
|
|
35
|
+
to: string;
|
|
36
|
+
data: string;
|
|
37
|
+
value: bigint;
|
|
38
|
+
}, p: PreparedTx): void;
|
|
39
|
+
export type FundTerms = {
|
|
40
|
+
ref: string;
|
|
41
|
+
salt: string;
|
|
42
|
+
t: {
|
|
43
|
+
payee: string;
|
|
44
|
+
token: string;
|
|
45
|
+
amount: bigint;
|
|
46
|
+
deadline: bigint;
|
|
47
|
+
reviewPeriod: number;
|
|
48
|
+
arbiter: string;
|
|
49
|
+
arbitrationPeriod: number;
|
|
50
|
+
acceptBy: bigint;
|
|
51
|
+
};
|
|
52
|
+
payeeSig: string;
|
|
53
|
+
};
|
|
54
|
+
export declare function fundTermsFrom(p: PreparedTx): FundTerms;
|
|
55
|
+
/** Who signed this acceptance, under the PINNED domain (never the server's). Null when it does not recover. */
|
|
56
|
+
export declare function acceptanceSigner(pinned: Pinned, escrowId: string, t: FundTerms["t"], sig: string): string | null;
|
|
57
|
+
/** Seconds a fund needs between signing and `acceptBy` so it can still land. */
|
|
58
|
+
export declare const ACCEPT_BY_MARGIN_SECONDS = 120;
|
|
59
|
+
export type FundCheckInput = {
|
|
60
|
+
prepared: PreparedTx;
|
|
61
|
+
terms: FundTerms;
|
|
62
|
+
me: string;
|
|
63
|
+
pinned: Pinned;
|
|
64
|
+
policy: Policy;
|
|
65
|
+
/** Already committed in the rolling 24 h window (journal ∪ chain). */
|
|
66
|
+
daySumWei: bigint;
|
|
67
|
+
/** Latest block timestamp — CHAIN time, not the local clock. */
|
|
68
|
+
chainNowSec: number;
|
|
69
|
+
/** What the escrow slot holds on chain right now (0 = None). */
|
|
70
|
+
slotStatus: number;
|
|
71
|
+
/** Optional upper bound the caller named for this one funding. */
|
|
72
|
+
maxAmountWei?: bigint | null;
|
|
73
|
+
};
|
|
74
|
+
/** The local invariants for `fund` (design Rev. 10, "Lokale Invarianten für fund"). Returns whether the payee is a known counterparty. */
|
|
75
|
+
export declare function checkFund(i: FundCheckInput): {
|
|
76
|
+
payeeKnown: boolean;
|
|
77
|
+
limitWei: bigint;
|
|
78
|
+
};
|
|
79
|
+
export declare function chainFacts(e: ChainEscrow): V3ChainFacts;
|
|
80
|
+
/**
|
|
81
|
+
* Actions the payer may only sign for an escrow in the local book: the two that
|
|
82
|
+
* move the payer's money TOWARDS the payee or lengthen its lock. `object` is not
|
|
83
|
+
* one of them: it is the payer's veto with a clock, it moves nothing, and it is
|
|
84
|
+
* gated on the CHAIN alone (the on-chain payer must be this key) — a deleted or
|
|
85
|
+
* tampered book must never cost a payer the objection window (post-review H1).
|
|
86
|
+
* Only these actions read the book at all; every other action reads it, if at
|
|
87
|
+
* all, only to find an escrow ID, and a broken book is a loud note there.
|
|
88
|
+
*/
|
|
89
|
+
export declare const BOOK_BOUND_ACTIONS: ReadonlySet<SignerAction>;
|
|
90
|
+
/**
|
|
91
|
+
* A post-funding action against the CHAIN's record of the escrow and the chain's
|
|
92
|
+
* clock: the app's role × phase matrix, the local book for the payer's money
|
|
93
|
+
* moves, and the action's own argument.
|
|
94
|
+
*/
|
|
95
|
+
export declare function checkEscrowAction(action: V3EscrowAction, me: string, e: ChainEscrow, chainNowSec: number, opts: {
|
|
96
|
+
book: BookEntry | null;
|
|
97
|
+
newDeadline?: bigint | null;
|
|
98
|
+
proofHash?: string | null;
|
|
99
|
+
reasonHash?: string | null;
|
|
100
|
+
}): void;
|
|
101
|
+
export declare function checkWithdraw(me: string, credit: bigint): void;
|