@ixo/oracle-runtime-workers 0.24.0 → 0.26.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/package.json +4 -2
- package/src/action-proposals/store.ts +0 -40
- package/src/action-proposals/topic-store.ts +0 -42
- package/src/attachments/extract.ts +108 -14
- package/src/attachments/index.ts +15 -0
- package/src/attachments/pdf-text.ts +99 -0
- package/src/attachments/pipeline.ts +47 -4
- package/src/attachments/sanitize.ts +14 -0
- package/src/attachments/view-cache.ts +95 -0
- package/src/attachments/view.ts +308 -3
- package/src/delivery/plan.ts +4 -1
- package/src/do/contracts.ts +0 -1
- package/src/do/turn-messages.ts +54 -0
- package/src/do/user-oracle-do.ts +309 -143
- package/src/hub-actions/client.ts +58 -5
- package/src/hub-actions/outcome-signature.ts +227 -0
- package/src/hub-actions/request.ts +16 -7
- package/src/hub-actions/store.ts +61 -37
- package/src/hub-actions/transfer.ts +182 -0
- package/src/memory/user-context-invalidation.ts +6 -0
- package/src/plugin-api/types.ts +25 -2
- package/src/plugins/hub-actions/hub-actions.plugin.ts +154 -25
- package/src/plugins/memory/document-chunker.ts +313 -0
- package/src/plugins/memory/document-source.ts +229 -0
- package/src/plugins/memory/memory-engine-client.ts +352 -0
- package/src/plugins/memory/memory.plugin.ts +25 -2
- package/src/plugins/memory/oracle-document-tools.ts +677 -0
- package/src/plugins/vfs/index.ts +11 -2
- package/src/plugins/vfs/vfs-content.ts +86 -22
- package/src/plugins/vfs/vfs.plugin.ts +28 -19
- package/src/shell/app.ts +36 -20
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
type ActionOutcomeV1,
|
|
10
10
|
type ActionReferenceV1,
|
|
11
11
|
type HubActionOptionsV2,
|
|
12
|
+
type HubActionOptionsV3,
|
|
12
13
|
} from '@ixo/action-protocol';
|
|
14
|
+
import { hubPrepareRefusal } from './transfer';
|
|
13
15
|
|
|
14
16
|
export interface HubActionStatus {
|
|
15
17
|
reference: ActionReferenceV1;
|
|
@@ -27,8 +29,17 @@ export interface HubActionTransport {
|
|
|
27
29
|
fetcher?: { fetch(request: Request): Promise<Response> };
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The protocol version this runtime declares on every operation call
|
|
34
|
+
* (capabilities, POST operations, GET status, options). Version 3 is the
|
|
35
|
+
* 0.5.0 contract: capabilities `transfer.limits`, any-denom bank sends, v2
|
|
36
|
+
* receipts with `denom` and `amountBaseUnits`, options with the owner's
|
|
37
|
+
* tokens. Version 2 callers (runtime 0.23.0) keep the 0.4.1 shapes; the
|
|
38
|
+
* envelopes are still tagged `ixo.auth-outcome/2` and
|
|
39
|
+
* `ixo.auth-capabilities/2`, which `@ixo/action-protocol` parses.
|
|
40
|
+
*/
|
|
41
|
+
const HUB_PROTOCOL_VERSION = '3';
|
|
42
|
+
const HUB_PROTOCOL_QUERY = `version=${HUB_PROTOCOL_VERSION}`;
|
|
32
43
|
|
|
33
44
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
45
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -73,6 +84,22 @@ export class HubActionClient {
|
|
|
73
84
|
const response = await (this.transport.fetcher
|
|
74
85
|
? this.transport.fetcher.fetch(request)
|
|
75
86
|
: fetch(request));
|
|
87
|
+
if (
|
|
88
|
+
body !== undefined &&
|
|
89
|
+
(response.status === 400 ||
|
|
90
|
+
response.status === 409 ||
|
|
91
|
+
response.status === 503)
|
|
92
|
+
) {
|
|
93
|
+
// A prepare the Hub refused with a typed code (a transfer that would
|
|
94
|
+
// fail, a fee above the ceiling, not enough funds, a busy or
|
|
95
|
+
// unavailable transfer path, an operation the account may not use):
|
|
96
|
+
// the model relays it in words.
|
|
97
|
+
const refusal = hubPrepareRefusal(
|
|
98
|
+
response.status,
|
|
99
|
+
await response.json().catch(() => null),
|
|
100
|
+
);
|
|
101
|
+
if (refusal) throw refusal;
|
|
102
|
+
}
|
|
76
103
|
if (!response.ok)
|
|
77
104
|
throw new Error(`Hub action request returned ${response.status}`);
|
|
78
105
|
return response.json();
|
|
@@ -85,9 +112,9 @@ export class HubActionClient {
|
|
|
85
112
|
expiresAt: number;
|
|
86
113
|
operation: ActionOperationV1;
|
|
87
114
|
}): Promise<ActionReferenceV1> {
|
|
88
|
-
// `version=
|
|
89
|
-
//
|
|
90
|
-
//
|
|
115
|
+
// `version=3` on every operation call: the Hub answers older shapes
|
|
116
|
+
// (no any-denom sends, no receipt amounts) without it, and stores the
|
|
117
|
+
// version per action for the outcomes it pushes.
|
|
91
118
|
const response = await this.send(
|
|
92
119
|
`/api/internal/actions/operations?${HUB_PROTOCOL_QUERY}`,
|
|
93
120
|
{
|
|
@@ -111,6 +138,32 @@ export class HubActionClient {
|
|
|
111
138
|
async options(input: {
|
|
112
139
|
bindingId: string;
|
|
113
140
|
bindingRevision: number;
|
|
141
|
+
}): Promise<HubActionOptionsV3> {
|
|
142
|
+
// Options version 3: version 2 plus the owner's bank tokens
|
|
143
|
+
// (`transfer.tokens`), which any-token transfers are chosen from.
|
|
144
|
+
const query = new URLSearchParams({
|
|
145
|
+
bindingId: input.bindingId,
|
|
146
|
+
bindingRevision: String(input.bindingRevision),
|
|
147
|
+
version: HUB_PROTOCOL_VERSION,
|
|
148
|
+
});
|
|
149
|
+
const options = parseActionOptions(
|
|
150
|
+
await this.send(
|
|
151
|
+
`/api/internal/actions/operations/options?${query.toString()}`,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
if (options.version !== 'ixo.auth-options/3')
|
|
155
|
+
throw new Error('Hub action options require version 3');
|
|
156
|
+
return options;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Options version 2 (connections, targets, resources; no token list),
|
|
161
|
+
* for the Gmail connection check: it does not make the Hub read the
|
|
162
|
+
* owner's balances.
|
|
163
|
+
*/
|
|
164
|
+
async connectionOptions(input: {
|
|
165
|
+
bindingId: string;
|
|
166
|
+
bindingRevision: number;
|
|
114
167
|
}): Promise<HubActionOptionsV2> {
|
|
115
168
|
const query = new URLSearchParams({
|
|
116
169
|
bindingId: input.bindingId,
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OUTCOME_SIGNATURE_HEADERS,
|
|
3
|
+
OUTCOME_SIGNATURE_MAX_SKEW_SECONDS,
|
|
4
|
+
outcomeAudience,
|
|
5
|
+
publicKeyFromDidDocument,
|
|
6
|
+
verifyOutcomeRequest,
|
|
7
|
+
} from '@ixo/action-protocol';
|
|
8
|
+
import { boundedBytes } from '../core/domain-context/transport';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Signed Auth Hub outcome callbacks. The Hub signs every callback with the
|
|
12
|
+
* `#key-0` Ed25519 key of its own did:web document; the key is resolved
|
|
13
|
+
* from the configured `AUTH_HUB_URL` (never from the request), and the
|
|
14
|
+
* signature covers this oracle's origin (the audience) so a callback
|
|
15
|
+
* signed for another oracle never verifies here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** How long a verified Hub key is trusted per isolate. */
|
|
19
|
+
export const HUB_DID_CACHE_MS = 60 * 60_000;
|
|
20
|
+
/**
|
|
21
|
+
* The soonest the Hub's DID document is read again after any read, whether
|
|
22
|
+
* it succeeded or failed (a Hub outage or a forged stream must not become a
|
|
23
|
+
* stream of Hub reads).
|
|
24
|
+
*/
|
|
25
|
+
export const HUB_DID_REFRESH_MIN_MS = 60_000;
|
|
26
|
+
/** The largest DID document read. */
|
|
27
|
+
const HUB_DID_MAX_BYTES = 64 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Keys from documents that passed the id and key checks, per document URL. */
|
|
30
|
+
const hubKeys = new Map<string, { publicKey: Uint8Array; at: number }>();
|
|
31
|
+
/** When each document URL was last read (success or failure): the negative cache. */
|
|
32
|
+
const lastAttemptAt = new Map<string, number>();
|
|
33
|
+
/** One read per document URL at a time. */
|
|
34
|
+
const inFlight = new Map<string, Promise<Uint8Array | null>>();
|
|
35
|
+
|
|
36
|
+
/** Tests only: forget every cached Hub key, attempt time and pending read. */
|
|
37
|
+
export function resetHubDidCache(): void {
|
|
38
|
+
hubKeys.clear();
|
|
39
|
+
lastAttemptAt.clear();
|
|
40
|
+
inFlight.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The did:web DID of the Hub at `hubUrl` (https only): its key id is `#key-0`. */
|
|
44
|
+
export function hubDidOf(hubUrl: string): string {
|
|
45
|
+
const url = new URL(hubUrl);
|
|
46
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
47
|
+
throw new TypeError('Auth Hub URL must be https');
|
|
48
|
+
return `did:web:${url.port ? `${url.hostname}%3A${url.port}` : url.hostname}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The checks that need no key: the Hub's key id, a well-formed timestamp
|
|
53
|
+
* within the allowed skew either way, and a signature that decodes to 64
|
|
54
|
+
* bytes. A callback failing any of them never causes a Hub read.
|
|
55
|
+
*/
|
|
56
|
+
function plausibleSignatureHeaders(
|
|
57
|
+
headers: Headers,
|
|
58
|
+
keyId: string,
|
|
59
|
+
nowSeconds: number,
|
|
60
|
+
): boolean {
|
|
61
|
+
const timestamp = headers.get(OUTCOME_SIGNATURE_HEADERS.timestamp);
|
|
62
|
+
const signature = headers.get(OUTCOME_SIGNATURE_HEADERS.signature);
|
|
63
|
+
if (headers.get(OUTCOME_SIGNATURE_HEADERS.keyId) !== keyId) return false;
|
|
64
|
+
if (!timestamp || !/^[1-9][0-9]{0,15}$/.test(timestamp)) return false;
|
|
65
|
+
if (
|
|
66
|
+
Math.abs(nowSeconds - Number(timestamp)) >
|
|
67
|
+
OUTCOME_SIGNATURE_MAX_SKEW_SECONDS
|
|
68
|
+
)
|
|
69
|
+
return false;
|
|
70
|
+
// 64 bytes in unpadded base64url is exactly 86 characters.
|
|
71
|
+
if (!signature || !/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
|
|
72
|
+
try {
|
|
73
|
+
const padded = signature.replace(/-/g, '+').replace(/_/g, '/');
|
|
74
|
+
return atob(`${padded}==`).length === 64;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read the Hub's DID document and return its `#key-0` key, or null. Every
|
|
82
|
+
* attempt is recorded first, so a read happens at most once per
|
|
83
|
+
* `HUB_DID_REFRESH_MIN_MS` per URL whatever the outcome; concurrent callers
|
|
84
|
+
* share one read; only a document that names exactly the Hub's DID and holds
|
|
85
|
+
* the key is cached.
|
|
86
|
+
*/
|
|
87
|
+
function readHubKey(
|
|
88
|
+
hubUrl: string,
|
|
89
|
+
hubDid: string,
|
|
90
|
+
keyId: string,
|
|
91
|
+
fetcher: { fetch(request: Request): Promise<Response> } | undefined,
|
|
92
|
+
nowMs: number,
|
|
93
|
+
): Promise<Uint8Array | null> {
|
|
94
|
+
const url = new URL('/.well-known/did.json', hubUrl);
|
|
95
|
+
const pending = inFlight.get(url.href);
|
|
96
|
+
if (pending) return pending;
|
|
97
|
+
const last = lastAttemptAt.get(url.href);
|
|
98
|
+
if (last !== undefined && nowMs - last < HUB_DID_REFRESH_MIN_MS)
|
|
99
|
+
return Promise.resolve(null);
|
|
100
|
+
lastAttemptAt.set(url.href, nowMs);
|
|
101
|
+
const read = (async (): Promise<Uint8Array | null> => {
|
|
102
|
+
try {
|
|
103
|
+
const request = new Request(url, {
|
|
104
|
+
headers: { accept: 'application/did+json, application/json' },
|
|
105
|
+
// workerd has no `redirect: 'error'`; a redirect is not ok below.
|
|
106
|
+
redirect: 'manual',
|
|
107
|
+
signal: AbortSignal.timeout(5_000),
|
|
108
|
+
});
|
|
109
|
+
const response = await (fetcher
|
|
110
|
+
? fetcher.fetch(request)
|
|
111
|
+
: fetch(request));
|
|
112
|
+
if (!response.ok)
|
|
113
|
+
throw new Error(`Hub DID document returned ${response.status}`);
|
|
114
|
+
const document: unknown = JSON.parse(
|
|
115
|
+
new TextDecoder().decode(
|
|
116
|
+
await boundedBytes(response, HUB_DID_MAX_BYTES),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
if (
|
|
120
|
+
!document ||
|
|
121
|
+
typeof document !== 'object' ||
|
|
122
|
+
!('id' in document) ||
|
|
123
|
+
document.id !== hubDid
|
|
124
|
+
)
|
|
125
|
+
throw new TypeError('Hub DID document is for another DID');
|
|
126
|
+
const publicKey = publicKeyFromDidDocument(document, keyId);
|
|
127
|
+
hubKeys.set(url.href, { publicKey, at: nowMs });
|
|
128
|
+
return publicKey;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.warn(
|
|
131
|
+
`[hub-actions] Hub DID key unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
132
|
+
);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
})();
|
|
136
|
+
inFlight.set(url.href, read);
|
|
137
|
+
void read.finally(() => {
|
|
138
|
+
if (inFlight.get(url.href) === read) inFlight.delete(url.href);
|
|
139
|
+
});
|
|
140
|
+
return read;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function verifiedWith(
|
|
144
|
+
publicKey: Uint8Array,
|
|
145
|
+
input: { rawBody: string; headers: Headers },
|
|
146
|
+
keyId: string,
|
|
147
|
+
audience: string,
|
|
148
|
+
nowSeconds: number,
|
|
149
|
+
): Promise<boolean> {
|
|
150
|
+
try {
|
|
151
|
+
return await verifyOutcomeRequest({
|
|
152
|
+
rawBody: input.rawBody,
|
|
153
|
+
headers: input.headers,
|
|
154
|
+
publicKey,
|
|
155
|
+
expectedKeyId: keyId,
|
|
156
|
+
expectedAudience: audience,
|
|
157
|
+
now: nowSeconds,
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
// A key WebCrypto refuses to import is a failed check, not a 500.
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether `rawBody` with `headers` is a callback the configured Hub signed
|
|
167
|
+
* for `audience` (this oracle's https origin) within the allowed clock skew.
|
|
168
|
+
* The cheap header checks run first and need no key. The key comes from the
|
|
169
|
+
* Hub's DID document, trusted for `HUB_DID_CACHE_MS`. A Hub key rotation
|
|
170
|
+
* (same `#key-0` id, new key) is picked up by reading the document again
|
|
171
|
+
* when the cached key does not verify, subject to the same once-a-minute
|
|
172
|
+
* read limit. Anything that fails is false.
|
|
173
|
+
*/
|
|
174
|
+
export async function verifyHubOutcomeCallback(input: {
|
|
175
|
+
hubUrl: string;
|
|
176
|
+
rawBody: string;
|
|
177
|
+
headers: Headers;
|
|
178
|
+
audience: string;
|
|
179
|
+
fetcher?: { fetch(request: Request): Promise<Response> };
|
|
180
|
+
now?: number;
|
|
181
|
+
}): Promise<boolean> {
|
|
182
|
+
const nowMs = input.now ?? Date.now();
|
|
183
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
184
|
+
let hubDid: string;
|
|
185
|
+
let audience: string;
|
|
186
|
+
try {
|
|
187
|
+
hubDid = hubDidOf(input.hubUrl);
|
|
188
|
+
audience = outcomeAudience(input.audience);
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
const keyId = `${hubDid}#key-0`;
|
|
193
|
+
if (!plausibleSignatureHeaders(input.headers, keyId, nowSeconds))
|
|
194
|
+
return false;
|
|
195
|
+
const href = new URL('/.well-known/did.json', input.hubUrl).href;
|
|
196
|
+
const cached = hubKeys.get(href);
|
|
197
|
+
if (cached && nowMs - cached.at < HUB_DID_CACHE_MS) {
|
|
198
|
+
if (
|
|
199
|
+
await verifiedWith(cached.publicKey, input, keyId, audience, nowSeconds)
|
|
200
|
+
)
|
|
201
|
+
return true;
|
|
202
|
+
const fresh = await readHubKey(
|
|
203
|
+
input.hubUrl,
|
|
204
|
+
hubDid,
|
|
205
|
+
keyId,
|
|
206
|
+
input.fetcher,
|
|
207
|
+
nowMs,
|
|
208
|
+
);
|
|
209
|
+
return (
|
|
210
|
+
!!fresh &&
|
|
211
|
+
!sameBytes(fresh, cached.publicKey) &&
|
|
212
|
+
verifiedWith(fresh, input, keyId, audience, nowSeconds)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const key = await readHubKey(
|
|
216
|
+
input.hubUrl,
|
|
217
|
+
hubDid,
|
|
218
|
+
keyId,
|
|
219
|
+
input.fetcher,
|
|
220
|
+
nowMs,
|
|
221
|
+
);
|
|
222
|
+
return !!key && verifiedWith(key, input, keyId, audience, nowSeconds);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
226
|
+
return a.length === b.length && a.every((byte, i) => byte === b[i]);
|
|
227
|
+
}
|
|
@@ -36,14 +36,23 @@ export function assertOperationEnabled(
|
|
|
36
36
|
)
|
|
37
37
|
throw new Error('Hub form does not match the registered target');
|
|
38
38
|
} else if (operation.kind === 'ixo.bank.send.v1') {
|
|
39
|
-
if (
|
|
40
|
-
capabilities.network !== operation.network ||
|
|
41
|
-
!capabilities.transfer ||
|
|
42
|
-
BigInt(operation.amountBaseUnits) >
|
|
43
|
-
BigInt(capabilities.transfer.maxAmountUixo) ||
|
|
44
|
-
BigInt(operation.feeBaseUnits) > BigInt(capabilities.transfer.maxFeeUixo)
|
|
45
|
-
)
|
|
39
|
+
if (capabilities.network !== operation.network || !capabilities.transfer)
|
|
46
40
|
throw new Error("Hub transfer is outside this oracle's capability");
|
|
41
|
+
// A pre-check of the per-denom cap only (the Hub enforces it, and the
|
|
42
|
+
// fee is the Hub's: it simulates gas and sets it).
|
|
43
|
+
const transfer = capabilities.transfer;
|
|
44
|
+
const cap =
|
|
45
|
+
'limits' in transfer
|
|
46
|
+
? transfer.limits.find((limit) => limit.denom === operation.denom)
|
|
47
|
+
?.maxAmountBaseUnits
|
|
48
|
+
: operation.denom === 'uixo'
|
|
49
|
+
? transfer.maxAmountUixo
|
|
50
|
+
: undefined;
|
|
51
|
+
if (cap !== undefined && BigInt(operation.amountBaseUnits) > BigInt(cap))
|
|
52
|
+
throw new HubActionRefused(
|
|
53
|
+
'refused',
|
|
54
|
+
`This oracle may move at most ${cap} base units of ${operation.denom} per transfer.`,
|
|
55
|
+
);
|
|
47
56
|
} else if (operation.kind === 'resource.read.v1') {
|
|
48
57
|
if (
|
|
49
58
|
operation.ownerDid !== ownerDid ||
|
package/src/hub-actions/store.ts
CHANGED
|
@@ -7,6 +7,23 @@ import {
|
|
|
7
7
|
type ActionReferenceV1,
|
|
8
8
|
} from '@ixo/action-protocol';
|
|
9
9
|
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
10
|
+
import type { TransferToken } from './transfer';
|
|
11
|
+
|
|
12
|
+
function isTransferToken(value: unknown): value is TransferToken {
|
|
13
|
+
return (
|
|
14
|
+
!!value &&
|
|
15
|
+
typeof value === 'object' &&
|
|
16
|
+
'denom' in value &&
|
|
17
|
+
typeof value.denom === 'string' &&
|
|
18
|
+
'symbol' in value &&
|
|
19
|
+
typeof value.symbol === 'string' &&
|
|
20
|
+
'decimals' in value &&
|
|
21
|
+
typeof value.decimals === 'number' &&
|
|
22
|
+
Number.isSafeInteger(value.decimals) &&
|
|
23
|
+
'verified' in value &&
|
|
24
|
+
typeof value.verified === 'boolean'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
10
27
|
|
|
11
28
|
/**
|
|
12
29
|
* Outcomes after which the Hub never reports another for the action
|
|
@@ -19,10 +36,6 @@ export const HUB_CHECK_BASE_MS = 15_000;
|
|
|
19
36
|
export const HUB_CHECK_MAX_MS = 10 * 60_000;
|
|
20
37
|
/** A non-terminal action is no longer refreshed this long after its review expired. */
|
|
21
38
|
export const HUB_CHECK_AFTER_EXPIRY_MS = 24 * 60 * 60_000;
|
|
22
|
-
/** Prepares (Hub POSTs) per agent run, per user per hour and per user per day. */
|
|
23
|
-
export const HUB_ACTIONS_PER_RUN = 2;
|
|
24
|
-
export const HUB_ACTIONS_PER_HOUR = 5;
|
|
25
|
-
export const HUB_ACTIONS_PER_DAY = 10;
|
|
26
39
|
/** Rows are dropped this long after their review expired. */
|
|
27
40
|
const HUB_RETENTION_SECONDS = 30 * 24 * 60 * 60;
|
|
28
41
|
|
|
@@ -108,6 +121,10 @@ export class HubActionStore {
|
|
|
108
121
|
await this.db.run(
|
|
109
122
|
`ALTER TABLE hub_actions ADD COLUMN check_attempts INTEGER NOT NULL DEFAULT 0`,
|
|
110
123
|
);
|
|
124
|
+
// The token a transfer was prepared with (symbol, decimals, verified),
|
|
125
|
+
// for the owner-facing outcome line.
|
|
126
|
+
if (!actionColumns.some((c) => c.name === 'token'))
|
|
127
|
+
await this.db.run(`ALTER TABLE hub_actions ADD COLUMN token TEXT`);
|
|
111
128
|
if (!actionColumns.some((c) => c.name === 'expires_at'))
|
|
112
129
|
await this.db.run(
|
|
113
130
|
`ALTER TABLE hub_actions ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0`,
|
|
@@ -185,6 +202,31 @@ export class HubActionStore {
|
|
|
185
202
|
return row?.action_id ?? null;
|
|
186
203
|
}
|
|
187
204
|
|
|
205
|
+
/** Keep the token a transfer was prepared with, for its outcome line. */
|
|
206
|
+
async setToken(actionId: string, token: TransferToken): Promise<void> {
|
|
207
|
+
await this.db.run('UPDATE hub_actions SET token = ? WHERE action_id = ?', [
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
denom: token.denom,
|
|
210
|
+
symbol: token.symbol,
|
|
211
|
+
decimals: token.decimals,
|
|
212
|
+
verified: token.verified,
|
|
213
|
+
}),
|
|
214
|
+
actionId,
|
|
215
|
+
]);
|
|
216
|
+
}
|
|
217
|
+
async token(
|
|
218
|
+
ownerDid: string,
|
|
219
|
+
actionId: string,
|
|
220
|
+
): Promise<TransferToken | null> {
|
|
221
|
+
const row = await this.db.get<{ token: string | null }>(
|
|
222
|
+
'SELECT token FROM hub_actions WHERE owner_did = ? AND action_id = ?',
|
|
223
|
+
[ownerDid, actionId],
|
|
224
|
+
);
|
|
225
|
+
if (!row?.token) return null;
|
|
226
|
+
const value: unknown = JSON.parse(row.token);
|
|
227
|
+
return isTransferToken(value) ? value : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
188
230
|
/** The reference already recorded for a request key, if any. */
|
|
189
231
|
async byRequestKey(requestKey: string): Promise<ActionReferenceV1 | null> {
|
|
190
232
|
const row = await this.db.get<{ reference: string }>(
|
|
@@ -194,38 +236,6 @@ export class HubActionStore {
|
|
|
194
236
|
return row ? parseReference(JSON.parse(row.reference)) : null;
|
|
195
237
|
}
|
|
196
238
|
|
|
197
|
-
/**
|
|
198
|
-
* A new request (not a retry of a reserved key) within the caps:
|
|
199
|
-
* `HUB_ACTIONS_PER_RUN` per run, `HUB_ACTIONS_PER_HOUR` and
|
|
200
|
-
* `HUB_ACTIONS_PER_DAY` per owner.
|
|
201
|
-
*/
|
|
202
|
-
private async assertCapacity(origin: HubActionOrigin): Promise<void> {
|
|
203
|
-
const run = await this.db.get<{ n: number }>(
|
|
204
|
-
'SELECT COUNT(*) AS n FROM hub_action_requests WHERE owner_did = ? AND session_id = ? AND run_id = ?',
|
|
205
|
-
[origin.ownerDid, origin.sessionId, origin.runId],
|
|
206
|
-
);
|
|
207
|
-
if ((run?.n ?? 0) >= HUB_ACTIONS_PER_RUN)
|
|
208
|
-
throw new HubActionRefused(
|
|
209
|
-
'limit',
|
|
210
|
-
'This request already prepared the most reviews one turn may prepare.',
|
|
211
|
-
);
|
|
212
|
-
const now = this.now();
|
|
213
|
-
const counts = await this.db.get<{ hour: number; day: number }>(
|
|
214
|
-
'SELECT SUM(created_at > ?) AS hour, COUNT(*) AS day FROM hub_action_requests WHERE owner_did = ? AND created_at > ?',
|
|
215
|
-
[now - 60 * 60_000, origin.ownerDid, now - 24 * 60 * 60_000],
|
|
216
|
-
);
|
|
217
|
-
if ((counts?.hour ?? 0) >= HUB_ACTIONS_PER_HOUR)
|
|
218
|
-
throw new HubActionRefused(
|
|
219
|
-
'limit',
|
|
220
|
-
'Too many reviews were prepared in the last hour; try again later.',
|
|
221
|
-
);
|
|
222
|
-
if ((counts?.day ?? 0) >= HUB_ACTIONS_PER_DAY)
|
|
223
|
-
throw new HubActionRefused(
|
|
224
|
-
'limit',
|
|
225
|
-
'Too many reviews were prepared today; try again tomorrow.',
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
239
|
async reserve(
|
|
230
240
|
origin: HubActionOrigin,
|
|
231
241
|
operation: ActionOperationV1,
|
|
@@ -253,7 +263,6 @@ export class HubActionStore {
|
|
|
253
263
|
);
|
|
254
264
|
return previous.expires_at;
|
|
255
265
|
}
|
|
256
|
-
await this.assertCapacity(origin);
|
|
257
266
|
await this.db.run(
|
|
258
267
|
`INSERT INTO hub_action_requests(request_key,owner_did,session_id,run_id,request_id,binding_id,binding_revision,operation,expires_at,created_at)
|
|
259
268
|
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
@@ -467,6 +476,21 @@ export class HubActionStore {
|
|
|
467
476
|
});
|
|
468
477
|
}
|
|
469
478
|
|
|
479
|
+
/** The stored outcome of the owner's action at `sequence`, if any. */
|
|
480
|
+
async outcomeAt(
|
|
481
|
+
ownerDid: string,
|
|
482
|
+
actionId: string,
|
|
483
|
+
sequence: number,
|
|
484
|
+
): Promise<ActionOutcomeV1 | null> {
|
|
485
|
+
const row = await this.db.get<{ outcome: string }>(
|
|
486
|
+
`SELECT o.outcome FROM hub_action_outcomes o
|
|
487
|
+
JOIN hub_actions a ON a.action_id = o.action_id
|
|
488
|
+
WHERE a.owner_did = ? AND o.action_id = ? AND o.sequence = ?`,
|
|
489
|
+
[ownerDid, actionId, sequence],
|
|
490
|
+
);
|
|
491
|
+
return row ? parseOutcome(JSON.parse(row.outcome)) : null;
|
|
492
|
+
}
|
|
493
|
+
|
|
470
494
|
async outcomesForRun(
|
|
471
495
|
ownerDid: string,
|
|
472
496
|
sessionId: string,
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { ActionReceiptV1, OptionsToken } from '@ixo/action-protocol';
|
|
2
|
+
import { HubActionRefused } from './store';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Any-token bank sends: the model names a token from the owner's
|
|
6
|
+
* `hub_action_options` list and a decimal amount in that token's display
|
|
7
|
+
* units; the runtime does the base-unit arithmetic itself, from the decimals
|
|
8
|
+
* the Hub listed. It never guesses a denom (`ibc/…` hashes included) or a
|
|
9
|
+
* fee: the Hub simulates gas and sets the fee.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The token as the owner object stores it next to a prepared transfer. */
|
|
13
|
+
export type TransferToken = Pick<
|
|
14
|
+
OptionsToken,
|
|
15
|
+
'denom' | 'symbol' | 'decimals' | 'verified'
|
|
16
|
+
>;
|
|
17
|
+
|
|
18
|
+
const DECIMAL_AMOUNT = /^(0|[1-9][0-9]*)(\.[0-9]+)?$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `amount` (a decimal string in display units) as base units for a token
|
|
22
|
+
* with `decimals`. Refused: not a plain decimal, more fraction digits than
|
|
23
|
+
* the token has (an unverified token has 0: base units), and zero.
|
|
24
|
+
*/
|
|
25
|
+
export function toBaseUnits(amount: string, decimals: number): bigint {
|
|
26
|
+
const match = DECIMAL_AMOUNT.exec(amount);
|
|
27
|
+
if (!match || amount.length > 80)
|
|
28
|
+
throw new HubActionRefused(
|
|
29
|
+
'refused',
|
|
30
|
+
'The amount must be a plain decimal number such as 1.5.',
|
|
31
|
+
);
|
|
32
|
+
const whole = match[1]!;
|
|
33
|
+
// Trailing zeros carry no value: "1.50" of a 1-decimal token is 1.5.
|
|
34
|
+
const fraction = (match[2] ?? '').slice(1).replace(/0+$/, '');
|
|
35
|
+
if (fraction.length > decimals)
|
|
36
|
+
throw new HubActionRefused(
|
|
37
|
+
'refused',
|
|
38
|
+
decimals === 0
|
|
39
|
+
? 'This token is counted in whole base units: use an amount without decimals.'
|
|
40
|
+
: `This token has ${decimals} decimals: use at most ${decimals} digits after the point.`,
|
|
41
|
+
);
|
|
42
|
+
const base =
|
|
43
|
+
BigInt(whole) * 10n ** BigInt(decimals) +
|
|
44
|
+
BigInt(fraction.padEnd(decimals, '0') || '0');
|
|
45
|
+
if (base <= 0n)
|
|
46
|
+
throw new HubActionRefused('refused', 'The amount must be above zero.');
|
|
47
|
+
return base;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Base units in display units for `decimals` (no rounding, no trailing zeros). */
|
|
51
|
+
export function fromBaseUnits(base: string | bigint, decimals: number): string {
|
|
52
|
+
const value = BigInt(base);
|
|
53
|
+
if (decimals === 0) return value.toString();
|
|
54
|
+
const scale = 10n ** BigInt(decimals);
|
|
55
|
+
const whole = value / scale;
|
|
56
|
+
const fraction = (value % scale)
|
|
57
|
+
.toString()
|
|
58
|
+
.padStart(decimals, '0')
|
|
59
|
+
.replace(/0+$/, '');
|
|
60
|
+
return fraction ? `${whole}.${fraction}` : whole.toString();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How an amount of `token` reads to the owner: the listed symbol for a
|
|
65
|
+
* verified token (with the full denom for anything but `uixo`), base units
|
|
66
|
+
* and the full denom with an unverified marker otherwise.
|
|
67
|
+
*/
|
|
68
|
+
export function describeAmount(
|
|
69
|
+
amountBaseUnits: string,
|
|
70
|
+
token: TransferToken,
|
|
71
|
+
): string {
|
|
72
|
+
if (!token.verified)
|
|
73
|
+
return `${amountBaseUnits} base units of ${token.denom} (unverified token)`;
|
|
74
|
+
const display = `${fromBaseUnits(amountBaseUnits, token.decimals)} ${token.symbol}`;
|
|
75
|
+
return token.denom === 'uixo' ? display : `${display} (${token.denom})`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** IXO itself: the chain's staking token, always verified. */
|
|
79
|
+
const IXO_TOKEN: TransferToken = {
|
|
80
|
+
denom: 'uixo',
|
|
81
|
+
symbol: 'IXO',
|
|
82
|
+
decimals: 6,
|
|
83
|
+
verified: true,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The owner-facing line for a bank receipt (v2 receipts carry `denom` and
|
|
88
|
+
* `amountBaseUnits`), or null for any other receipt. Without a stored token
|
|
89
|
+
* for that denom (another runtime prepared it) `uixo` still reads as IXO;
|
|
90
|
+
* any other denom is shown in base units, unverified.
|
|
91
|
+
*/
|
|
92
|
+
export function bankReceiptSummary(
|
|
93
|
+
receipt: ActionReceiptV1 | null,
|
|
94
|
+
token: TransferToken | null,
|
|
95
|
+
): string | null {
|
|
96
|
+
if (!receipt || receipt.kind !== 'ixo.bank.send.v1') return null;
|
|
97
|
+
const amount =
|
|
98
|
+
receipt.amountBaseUnits && receipt.denom
|
|
99
|
+
? describeAmount(
|
|
100
|
+
receipt.amountBaseUnits,
|
|
101
|
+
token && token.denom === receipt.denom
|
|
102
|
+
? token
|
|
103
|
+
: receipt.denom === IXO_TOKEN.denom
|
|
104
|
+
? IXO_TOKEN
|
|
105
|
+
: {
|
|
106
|
+
denom: receipt.denom,
|
|
107
|
+
symbol: receipt.denom,
|
|
108
|
+
decimals: 0,
|
|
109
|
+
verified: false,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
: null;
|
|
113
|
+
const what = amount ? `Transfer of ${amount}` : 'Transfer';
|
|
114
|
+
if (receipt.height === null)
|
|
115
|
+
return `${what} was not included on chain${receipt.reason ? ` (${receipt.reason.replaceAll('_', ' ')})` : ''}. Transaction ${receipt.txHash}.`;
|
|
116
|
+
return `${what} ${receipt.code === 0 ? 'confirmed' : 'failed'} on chain. Transaction ${receipt.txHash}, block ${receipt.height}.`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What the model is told when Auth Hub refuses a transfer at preparation (400). */
|
|
120
|
+
const HUB_TRANSFER_REFUSALS: Record<string, string> = {
|
|
121
|
+
simulation_failed:
|
|
122
|
+
'Auth Hub simulated this transfer and it would fail on chain',
|
|
123
|
+
fee_above_ceiling:
|
|
124
|
+
"The network fee for this transfer is above this oracle's fee limit",
|
|
125
|
+
insufficient_funds:
|
|
126
|
+
'The account does not hold enough to cover this transfer and its network fee',
|
|
127
|
+
};
|
|
128
|
+
/** Hub codes that pass with time (a sequence race, the transfer path down). */
|
|
129
|
+
const HUB_TRANSIENT_CODES: Readonly<Record<number, readonly string[]>> = {
|
|
130
|
+
409: ['sequence_mismatch'],
|
|
131
|
+
503: ['transfer_unavailable'],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function hubErrorBody(
|
|
135
|
+
body: unknown,
|
|
136
|
+
): { error: string; reason?: string } | null {
|
|
137
|
+
if (!body || typeof body !== 'object' || !('error' in body)) return null;
|
|
138
|
+
const error = body.error;
|
|
139
|
+
if (typeof error !== 'string' || !/^[a-z][a-z0-9_]{0,63}$/.test(error))
|
|
140
|
+
return null;
|
|
141
|
+
const reason =
|
|
142
|
+
'reason' in body && typeof body.reason === 'string' && body.reason.trim()
|
|
143
|
+
? body.reason.trim().slice(0, 200)
|
|
144
|
+
: undefined;
|
|
145
|
+
return reason ? { error, reason } : { error };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A Hub refusal of a prepare (`{error, reason?}` with status 400, 409 or
|
|
150
|
+
* 503) as a refusal the model can relay, or null when the status and code
|
|
151
|
+
* are not one of those it maps:
|
|
152
|
+
* - 400 `simulation_failed`, `fee_above_ceiling`, `insufficient_funds`: the
|
|
153
|
+
* transfer as asked cannot go ahead (with the Hub's reason);
|
|
154
|
+
* - 409 `sequence_mismatch`, 503 `transfer_unavailable`: temporary, try
|
|
155
|
+
* again shortly;
|
|
156
|
+
* - any other 409: the account may not use this operation (deterministic,
|
|
157
|
+
* a retry gets the same answer).
|
|
158
|
+
*/
|
|
159
|
+
export function hubPrepareRefusal(
|
|
160
|
+
status: number,
|
|
161
|
+
body: unknown,
|
|
162
|
+
): HubActionRefused | null {
|
|
163
|
+
const parsed = hubErrorBody(body);
|
|
164
|
+
if (!parsed) return null;
|
|
165
|
+
const detail = parsed.reason ? `: ${parsed.reason}` : '';
|
|
166
|
+
if (status === 400 && Object.hasOwn(HUB_TRANSFER_REFUSALS, parsed.error))
|
|
167
|
+
return new HubActionRefused(
|
|
168
|
+
'refused',
|
|
169
|
+
`${HUB_TRANSFER_REFUSALS[parsed.error]}${detail}.`,
|
|
170
|
+
);
|
|
171
|
+
if (HUB_TRANSIENT_CODES[status]?.includes(parsed.error))
|
|
172
|
+
return new HubActionRefused(
|
|
173
|
+
'refused',
|
|
174
|
+
`Auth Hub is temporarily unavailable for this (${parsed.error}); try again shortly.`,
|
|
175
|
+
);
|
|
176
|
+
if (status === 409)
|
|
177
|
+
return new HubActionRefused(
|
|
178
|
+
'refused',
|
|
179
|
+
`This operation is not available for this account (${parsed.error}${detail}).`,
|
|
180
|
+
);
|
|
181
|
+
return null;
|
|
182
|
+
}
|