@ixo/oracle-runtime-workers 0.22.0 → 0.23.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/README.md +15 -13
- package/package.json +10 -4
- package/src/action-proposals/policy.ts +54 -0
- package/src/action-proposals/reconcile.ts +87 -0
- package/src/action-proposals/store.ts +863 -0
- package/src/action-proposals/topic-budget.ts +84 -0
- package/src/action-proposals/topic-chain.ts +272 -0
- package/src/action-proposals/topic-effects.ts +44 -0
- package/src/action-proposals/topic-errors.ts +79 -0
- package/src/action-proposals/topic-history.ts +267 -0
- package/src/action-proposals/topic-matrix.ts +292 -0
- package/src/action-proposals/topic-prepare.ts +299 -0
- package/src/action-proposals/topic-store.ts +999 -0
- package/src/action-proposals/topic-verify.ts +279 -0
- package/src/action-proposals/verify.ts +223 -0
- package/src/channels/auth.ts +6 -1
- package/src/channels/contract.ts +4 -0
- package/src/channels/turns.ts +1 -0
- package/src/core/env.ts +89 -5
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +2 -0
- package/src/core/runtime-context.ts +12 -0
- package/src/delivery/plan.ts +43 -2
- package/src/delivery/schema.ts +25 -1
- package/src/delivery/types.ts +13 -2
- package/src/do/ambient.ts +6 -0
- package/src/do/contracts.ts +39 -0
- package/src/do/user-oracle-do.ts +1253 -7
- package/src/hub-actions/client.ts +163 -0
- package/src/hub-actions/request.ts +110 -0
- package/src/hub-actions/store.ts +485 -0
- package/src/matrix/gateway-do.ts +176 -0
- package/src/matrix/reply-parts.ts +8 -2
- package/src/plugin-api/types.ts +54 -0
- package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
- package/src/plugins/action-proposals/evidence.ts +67 -0
- package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
- package/src/plugins/index.ts +6 -0
- package/src/shell/app.ts +195 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { withTopicReadBudget } from './topic-budget';
|
|
2
|
+
import { canonicalizeIxoJson } from '@ixo/ixo-transaction/action-proposals';
|
|
3
|
+
import {
|
|
4
|
+
digestTopicEffect,
|
|
5
|
+
type TopicActionProposal,
|
|
6
|
+
type ExpectedTopicEffect,
|
|
7
|
+
type TopicReceiptReferences,
|
|
8
|
+
} from '@ixo/ixo-transaction/topic-proposals';
|
|
9
|
+
import { assertCreatePlanUnchanged } from '@ixo/ixo-transaction/topic-create-plan';
|
|
10
|
+
import {
|
|
11
|
+
TopicMatrixEventSchema,
|
|
12
|
+
assertTopicRoomUnchanged,
|
|
13
|
+
readTopicEvent,
|
|
14
|
+
type TopicMatrixReader,
|
|
15
|
+
} from './topic-matrix';
|
|
16
|
+
import { readTopicAuthority } from './topic-history';
|
|
17
|
+
import { TopicChainReader } from './topic-chain';
|
|
18
|
+
import {
|
|
19
|
+
digestTopicProposalEffect,
|
|
20
|
+
type TopicProposalStore,
|
|
21
|
+
} from './topic-store';
|
|
22
|
+
import {
|
|
23
|
+
ActionProposalConflict,
|
|
24
|
+
TRANSACTION_CARD_BOUND_MISMATCH,
|
|
25
|
+
TRANSACTION_REJECTIONS,
|
|
26
|
+
} from './store';
|
|
27
|
+
import { TopicReadUnavailable } from './topic-errors';
|
|
28
|
+
import { verifyTopicGrantTransaction } from './verify';
|
|
29
|
+
export type TopicVerificationContext = {
|
|
30
|
+
reader: TopicMatrixReader;
|
|
31
|
+
blocksync: string | undefined;
|
|
32
|
+
rpc: string | undefined;
|
|
33
|
+
fetcher?: typeof fetch;
|
|
34
|
+
onAttempt?: () => void;
|
|
35
|
+
/** An outer wall budget (the alarm tick's) every read here also obeys. */
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
};
|
|
38
|
+
export async function assertTopicDependencies(
|
|
39
|
+
proposal: TopicActionProposal,
|
|
40
|
+
ctx: TopicVerificationContext,
|
|
41
|
+
) {
|
|
42
|
+
return withTopicReadBudget(
|
|
43
|
+
ctx.reader,
|
|
44
|
+
ctx.fetcher ?? fetch,
|
|
45
|
+
(reader, fetcher) =>
|
|
46
|
+
assertTopicDependenciesWithinBudget(proposal, {
|
|
47
|
+
...ctx,
|
|
48
|
+
reader,
|
|
49
|
+
fetcher,
|
|
50
|
+
}),
|
|
51
|
+
ctx.signal,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
async function assertTopicDependenciesWithinBudget(
|
|
55
|
+
proposal: TopicActionProposal,
|
|
56
|
+
ctx: TopicVerificationContext,
|
|
57
|
+
) {
|
|
58
|
+
const room = await assertTopicRoomUnchanged(
|
|
59
|
+
proposal,
|
|
60
|
+
ctx.reader,
|
|
61
|
+
ctx.blocksync,
|
|
62
|
+
ctx.fetcher,
|
|
63
|
+
);
|
|
64
|
+
if (proposal.action.name === 'create_topic') {
|
|
65
|
+
try {
|
|
66
|
+
await assertCreatePlanUnchanged(proposal.action.plan);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// The plan builder's own refusals describe the plan's internals; what
|
|
69
|
+
// the owner is told is that the reviewed plan no longer builds the same.
|
|
70
|
+
console.warn(
|
|
71
|
+
`[topic-actions] ${proposal.id} creation plan check: ${error instanceof Error ? error.message : String(error)}`,
|
|
72
|
+
);
|
|
73
|
+
throw new ActionProposalConflict(
|
|
74
|
+
'The reviewed topic creation plan changed',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!ctx.rpc)
|
|
80
|
+
throw new TopicReadUnavailable('Trusted chain endpoint unavailable');
|
|
81
|
+
const action = proposal.action;
|
|
82
|
+
// The room state just read is the state the authority is judged on: one
|
|
83
|
+
// state read, not two.
|
|
84
|
+
const topic = await readTopicAuthority(
|
|
85
|
+
ctx.reader,
|
|
86
|
+
action.roomId,
|
|
87
|
+
action.topicId,
|
|
88
|
+
action.owner.matrixUserId,
|
|
89
|
+
room.state,
|
|
90
|
+
);
|
|
91
|
+
const chain = new TopicChainReader(ctx.rpc, action.chainId, ctx.fetcher);
|
|
92
|
+
const collection = await chain.collection(
|
|
93
|
+
action.entityDid,
|
|
94
|
+
action.collectionId,
|
|
95
|
+
action.signerAddress,
|
|
96
|
+
);
|
|
97
|
+
// One authorization query per grant, all at once: the ten second budget
|
|
98
|
+
// has to cover the topic history and the collection reads as well.
|
|
99
|
+
const authorizations = await Promise.all(
|
|
100
|
+
action.grants.map((grant) =>
|
|
101
|
+
chain.authorization(action.adminAddress, grant.address, grant.role),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
const current: TopicActionProposal['action']['dependencies'] = [
|
|
105
|
+
...topic.dependencies,
|
|
106
|
+
...collection.dependencies,
|
|
107
|
+
...authorizations.map((a) => a.dependency),
|
|
108
|
+
];
|
|
109
|
+
for (const dep of action.dependencies.filter((d) => d.kind !== 'matrix'))
|
|
110
|
+
if (
|
|
111
|
+
!current.some(
|
|
112
|
+
(d) =>
|
|
113
|
+
d.kind === dep.kind && d.key === dep.key && d.digest === dep.digest,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
// A fact the card was sealed on moved: a conflict the Portal can show
|
|
117
|
+
// and retry after a re-read, not a server fault.
|
|
118
|
+
throw new ActionProposalConflict('Topic or chain authority changed');
|
|
119
|
+
}
|
|
120
|
+
export async function verifyTopicReference(
|
|
121
|
+
proposal: TopicActionProposal,
|
|
122
|
+
effect: ExpectedTopicEffect,
|
|
123
|
+
reference: TopicReceiptReferences['references'][number],
|
|
124
|
+
ctx: TopicVerificationContext,
|
|
125
|
+
) {
|
|
126
|
+
if (effect.kind === 'chain') {
|
|
127
|
+
if (reference.kind !== 'chain' || !ctx.rpc)
|
|
128
|
+
throw new ActionProposalConflict('Expected a chain reference');
|
|
129
|
+
const verified = await verifyTopicGrantTransaction(
|
|
130
|
+
effect,
|
|
131
|
+
reference.transactionHash,
|
|
132
|
+
ctx.rpc,
|
|
133
|
+
ctx.fetcher,
|
|
134
|
+
);
|
|
135
|
+
return {
|
|
136
|
+
kind: 'chain' as const,
|
|
137
|
+
chainId: effect.chainId,
|
|
138
|
+
...verified,
|
|
139
|
+
messageDigest: await digestTopicEffect(effect),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (reference.kind !== 'matrix')
|
|
143
|
+
throw new ActionProposalConflict('Expected a Matrix reference');
|
|
144
|
+
const event = await readTopicEvent(
|
|
145
|
+
ctx.reader,
|
|
146
|
+
effect.roomId,
|
|
147
|
+
reference.eventId,
|
|
148
|
+
);
|
|
149
|
+
if (
|
|
150
|
+
event.event_id !== reference.eventId ||
|
|
151
|
+
event.room_id !== effect.roomId ||
|
|
152
|
+
event.sender !== effect.sender ||
|
|
153
|
+
event.type !== effect.eventType ||
|
|
154
|
+
event.state_key !== effect.stateKey ||
|
|
155
|
+
canonicalizeIxoJson(event.content) !== canonicalizeIxoJson(effect.content)
|
|
156
|
+
)
|
|
157
|
+
throw new ActionProposalConflict(
|
|
158
|
+
'Matrix receipt does not match the reviewed effect',
|
|
159
|
+
);
|
|
160
|
+
if (reference.step === 'snapshot') {
|
|
161
|
+
const state = await ctx.reader.state(effect.roomId);
|
|
162
|
+
const { z } = await import('zod');
|
|
163
|
+
const events = z.array(TopicMatrixEventSchema).parse(state);
|
|
164
|
+
if (
|
|
165
|
+
!events.some(
|
|
166
|
+
(e) =>
|
|
167
|
+
e.event_id === event.event_id &&
|
|
168
|
+
e.state_key === effect.stateKey &&
|
|
169
|
+
e.type === effect.eventType,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
throw new ActionProposalConflict(
|
|
173
|
+
'Topic snapshot was replaced before verification',
|
|
174
|
+
);
|
|
175
|
+
if (proposal.action.name === 'create_topic') {
|
|
176
|
+
const current = await readTopicAuthority(
|
|
177
|
+
ctx.reader,
|
|
178
|
+
effect.roomId,
|
|
179
|
+
proposal.action.plan.identity.topicId,
|
|
180
|
+
effect.sender,
|
|
181
|
+
);
|
|
182
|
+
if (
|
|
183
|
+
current.authority.revision !==
|
|
184
|
+
proposal.action.plan.identity.operationId ||
|
|
185
|
+
current.authority.contractBodyHash !== proposal.action.plan.bodyHash
|
|
186
|
+
)
|
|
187
|
+
throw new ActionProposalConflict(
|
|
188
|
+
'Created topic is not canonically readable',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
kind: 'matrix' as const,
|
|
194
|
+
roomId: effect.roomId,
|
|
195
|
+
eventId: event.event_id,
|
|
196
|
+
sender: event.sender,
|
|
197
|
+
eventType: event.type,
|
|
198
|
+
...(event.state_key !== undefined ? { stateKey: event.state_key } : {}),
|
|
199
|
+
contentDigest: await digestTopicProposalEffect(event.content),
|
|
200
|
+
verifiedAt: new Date().toISOString(),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export async function reconcileTopicReferences(
|
|
204
|
+
store: TopicProposalStore,
|
|
205
|
+
p: TopicActionProposal,
|
|
206
|
+
input: TopicReceiptReferences,
|
|
207
|
+
ctx: TopicVerificationContext,
|
|
208
|
+
verificationClaimed = false,
|
|
209
|
+
) {
|
|
210
|
+
let newReference = false;
|
|
211
|
+
for (const reference of input.references) {
|
|
212
|
+
newReference ||= !(await store.hasReference(
|
|
213
|
+
p.ownerDid,
|
|
214
|
+
p.id,
|
|
215
|
+
reference.step,
|
|
216
|
+
));
|
|
217
|
+
await store.recordReference(p.ownerDid, p.id, reference);
|
|
218
|
+
}
|
|
219
|
+
const claimed =
|
|
220
|
+
verificationClaimed || (await store.claimVerification(p.ownerDid, p.id));
|
|
221
|
+
if (!claimed && !newReference)
|
|
222
|
+
return (await store.get(p.ownerDid, p.id)) ?? p;
|
|
223
|
+
ctx.onAttempt?.();
|
|
224
|
+
return withTopicReadBudget(
|
|
225
|
+
ctx.reader,
|
|
226
|
+
ctx.fetcher ?? fetch,
|
|
227
|
+
(reader, fetcher) =>
|
|
228
|
+
reconcileWithinBudget(store, p, input, { ...ctx, reader, fetcher }),
|
|
229
|
+
ctx.signal,
|
|
230
|
+
).catch(async () => (await store.get(p.ownerDid, p.id)) ?? p);
|
|
231
|
+
}
|
|
232
|
+
async function reconcileWithinBudget(
|
|
233
|
+
store: TopicProposalStore,
|
|
234
|
+
p: TopicActionProposal,
|
|
235
|
+
input: TopicReceiptReferences,
|
|
236
|
+
ctx: TopicVerificationContext,
|
|
237
|
+
) {
|
|
238
|
+
let current = p;
|
|
239
|
+
const all = [
|
|
240
|
+
...input.references,
|
|
241
|
+
...(await store.references(p.ownerDid, p.id)).filter(
|
|
242
|
+
(r) => !input.references.some((s) => s.step === r.step),
|
|
243
|
+
),
|
|
244
|
+
];
|
|
245
|
+
for (const reference of all) {
|
|
246
|
+
const effect = await store.recordReference(p.ownerDid, p.id, reference);
|
|
247
|
+
try {
|
|
248
|
+
const receipt = await verifyTopicReference(
|
|
249
|
+
current,
|
|
250
|
+
effect,
|
|
251
|
+
reference,
|
|
252
|
+
ctx,
|
|
253
|
+
);
|
|
254
|
+
current = await store.verifyStep(
|
|
255
|
+
p.ownerDid,
|
|
256
|
+
p.id,
|
|
257
|
+
reference.step,
|
|
258
|
+
receipt,
|
|
259
|
+
);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (reference.kind === 'chain' && error instanceof Error) {
|
|
262
|
+
if (error.message === TRANSACTION_CARD_BOUND_MISMATCH)
|
|
263
|
+
// The transaction carries THIS card's memo and other messages: an
|
|
264
|
+
// effect the owner bound to the card went wrong. It stays listed
|
|
265
|
+
// and the card is blocked, never re-permitted over it.
|
|
266
|
+
await store.blockExecution(p.ownerDid, p.id, error.message);
|
|
267
|
+
else if (TRANSACTION_REJECTIONS.has(error.message))
|
|
268
|
+
// Provably another transaction (the chain holds it under another
|
|
269
|
+
// card's memo or none): set aside so the owner can record the
|
|
270
|
+
// right one.
|
|
271
|
+
await store.rejectReference(p.ownerDid, p.id, reference);
|
|
272
|
+
}
|
|
273
|
+
// A read that failed or a transaction not indexed yet is retried on
|
|
274
|
+
// the cooldown.
|
|
275
|
+
current = (await store.get(p.ownerDid, p.id)) ?? current;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return current;
|
|
279
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import protobuf from 'protobufjs/minimal.js';
|
|
2
|
+
import { boundedBytes } from '../core/domain-context/transport';
|
|
3
|
+
import {
|
|
4
|
+
TxRaw,
|
|
5
|
+
TxBody,
|
|
6
|
+
} from '@ixo/impactxclient-sdk/module/codegen/cosmos/tx/v1beta1/tx.js';
|
|
7
|
+
import { MsgSend } from '@ixo/impactxclient-sdk/module/codegen/cosmos/bank/v1beta1/tx.js';
|
|
8
|
+
import { MsgTransferToken } from '@ixo/impactxclient-sdk/module/codegen/ixo/token/v1beta1/tx.js';
|
|
9
|
+
import type { ExpectedTopicEffect } from '@ixo/ixo-transaction/topic-proposals';
|
|
10
|
+
import type { ActionProposal } from '@ixo/ixo-transaction/action-proposals';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
const TxResponse = z.object({
|
|
14
|
+
result: z.object({
|
|
15
|
+
hash: z.string().regex(/^[a-fA-F0-9]{64}$/),
|
|
16
|
+
height: z.string().regex(/^[1-9]\d*$/),
|
|
17
|
+
tx: z.string().min(1).max(1_000_000),
|
|
18
|
+
tx_result: z.object({ code: z.number().int().nonnegative() }),
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
const StatusResponse = z.object({
|
|
22
|
+
result: z.object({ node_info: z.object({ network: z.string().min(1) }) }),
|
|
23
|
+
});
|
|
24
|
+
const decode64 = (value: string) =>
|
|
25
|
+
Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
|
|
26
|
+
const hex = (bytes: Uint8Array) =>
|
|
27
|
+
Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
28
|
+
const equal = (a: Uint8Array, b: Uint8Array) =>
|
|
29
|
+
a.length === b.length && a.every((v, i) => v === b[i]);
|
|
30
|
+
export type VerifiedActionTransaction = {
|
|
31
|
+
transactionHash: string;
|
|
32
|
+
height: number;
|
|
33
|
+
code: number;
|
|
34
|
+
verifiedAt: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How long a chain RPC's answer to `/status` is trusted per isolate: the
|
|
39
|
+
* network an origin serves does not change between lookups, and `/status`
|
|
40
|
+
* would otherwise be half of all verification traffic.
|
|
41
|
+
*/
|
|
42
|
+
export const CHAIN_NETWORK_CACHE_MS = 5 * 60_000;
|
|
43
|
+
const chainNetworks = new Map<string, { network: string; at: number }>();
|
|
44
|
+
/** Tests only: forget every cached `/status` answer. */
|
|
45
|
+
export function resetChainNetworkCache(): void {
|
|
46
|
+
chainNetworks.clear();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The network (chain id) an RPC origin serves, from `/status`, cached per
|
|
50
|
+
* isolate for `CHAIN_NETWORK_CACHE_MS`. Only a parsed answer is cached; a
|
|
51
|
+
* failed read is retried on the next lookup.
|
|
52
|
+
*/
|
|
53
|
+
export async function chainNetwork(
|
|
54
|
+
origin: string,
|
|
55
|
+
readStatus: () => Promise<unknown>,
|
|
56
|
+
now = Date.now(),
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const cached = chainNetworks.get(origin);
|
|
59
|
+
if (cached && now - cached.at < CHAIN_NETWORK_CACHE_MS) return cached.network;
|
|
60
|
+
const network = StatusResponse.parse(await readStatus()).result.node_info
|
|
61
|
+
.network;
|
|
62
|
+
chainNetworks.set(origin, { network, at: now });
|
|
63
|
+
return network;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The node does not hold the transaction (yet): not indexed, or not
|
|
68
|
+
* broadcast. CometBFT answers `/tx` for an unknown hash with a JSON-RPC
|
|
69
|
+
* error whose text says "not found" (HTTP 200 or 500 by version).
|
|
70
|
+
*/
|
|
71
|
+
export const TRANSACTION_NOT_FOUND = 'Transaction not found';
|
|
72
|
+
function isTransactionNotFound(text: string): boolean {
|
|
73
|
+
try {
|
|
74
|
+
const body = JSON.parse(text) as {
|
|
75
|
+
error?: { message?: unknown; data?: unknown };
|
|
76
|
+
};
|
|
77
|
+
return (
|
|
78
|
+
!!body.error &&
|
|
79
|
+
/not found/i.test(`${body.error.message ?? ''} ${body.error.data ?? ''}`)
|
|
80
|
+
);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function verifyActionTransaction(
|
|
87
|
+
proposal: ActionProposal,
|
|
88
|
+
transactionHash: string,
|
|
89
|
+
rpcOrigin: string,
|
|
90
|
+
fetcher: typeof fetch = fetch,
|
|
91
|
+
/** The caller's wall budget (the alarm's); each read also stops at 10 s. */
|
|
92
|
+
signal?: AbortSignal,
|
|
93
|
+
): Promise<VerifiedActionTransaction> {
|
|
94
|
+
return verifyTransaction(
|
|
95
|
+
proposal.action.chainId,
|
|
96
|
+
proposal.action.inputs.messages.map((expected) => {
|
|
97
|
+
const value =
|
|
98
|
+
expected.typeUrl === '/cosmos.bank.v1beta1.MsgSend'
|
|
99
|
+
? MsgSend.encode(
|
|
100
|
+
MsgSend.fromJSON(expected.value),
|
|
101
|
+
new protobuf.Writer(),
|
|
102
|
+
).finish()
|
|
103
|
+
: expected.typeUrl === '/ixo.token.v1beta1.MsgTransferToken'
|
|
104
|
+
? MsgTransferToken.encode(
|
|
105
|
+
MsgTransferToken.fromJSON(expected.value),
|
|
106
|
+
new protobuf.Writer(),
|
|
107
|
+
).finish()
|
|
108
|
+
: undefined;
|
|
109
|
+
if (!value) throw new Error('Unsupported message');
|
|
110
|
+
return { typeUrl: expected.typeUrl, value };
|
|
111
|
+
}),
|
|
112
|
+
proposal.id,
|
|
113
|
+
transactionHash,
|
|
114
|
+
rpcOrigin,
|
|
115
|
+
fetcher,
|
|
116
|
+
signal,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
export async function verifyTopicGrantTransaction(
|
|
120
|
+
effect: Extract<ExpectedTopicEffect, { kind: 'chain' }>,
|
|
121
|
+
transactionHash: string,
|
|
122
|
+
rpcOrigin: string,
|
|
123
|
+
fetcher: typeof fetch = fetch,
|
|
124
|
+
signal?: AbortSignal,
|
|
125
|
+
): Promise<VerifiedActionTransaction> {
|
|
126
|
+
return verifyTransaction(
|
|
127
|
+
effect.chainId,
|
|
128
|
+
effect.messages.map((m) => ({
|
|
129
|
+
typeUrl: m.typeUrl,
|
|
130
|
+
value: decode64(m.valueBase64),
|
|
131
|
+
})),
|
|
132
|
+
effect.memo,
|
|
133
|
+
transactionHash,
|
|
134
|
+
rpcOrigin,
|
|
135
|
+
fetcher,
|
|
136
|
+
signal,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
async function verifyTransaction(
|
|
140
|
+
chainId: string,
|
|
141
|
+
messages: { typeUrl: string; value: Uint8Array }[],
|
|
142
|
+
memo: string,
|
|
143
|
+
transactionHash: string,
|
|
144
|
+
rpcOrigin: string,
|
|
145
|
+
fetcher: typeof fetch,
|
|
146
|
+
signal?: AbortSignal,
|
|
147
|
+
): Promise<VerifiedActionTransaction> {
|
|
148
|
+
const origin = new URL(rpcOrigin);
|
|
149
|
+
if (
|
|
150
|
+
origin.protocol !== 'https:' ||
|
|
151
|
+
origin.username ||
|
|
152
|
+
origin.password ||
|
|
153
|
+
origin.search ||
|
|
154
|
+
origin.hash
|
|
155
|
+
)
|
|
156
|
+
throw new Error('Expected configured HTTPS RPC origin');
|
|
157
|
+
const request = async (path: string, notFound = false) => {
|
|
158
|
+
const response = await fetcher(
|
|
159
|
+
new URL(path, `${origin.href.replace(/\/$/, '')}/`),
|
|
160
|
+
{
|
|
161
|
+
redirect: 'error',
|
|
162
|
+
signal: signal
|
|
163
|
+
? AbortSignal.any([signal, AbortSignal.timeout(10_000)])
|
|
164
|
+
: AbortSignal.timeout(10_000),
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
if (notFound && response.status === 404)
|
|
168
|
+
throw new Error(TRANSACTION_NOT_FOUND);
|
|
169
|
+
if (!response.ok && !notFound)
|
|
170
|
+
throw new Error('Chain verification unavailable');
|
|
171
|
+
const text = new TextDecoder().decode(
|
|
172
|
+
await boundedBytes(response, 1_500_000),
|
|
173
|
+
);
|
|
174
|
+
if (notFound && isTransactionNotFound(text))
|
|
175
|
+
throw new Error(TRANSACTION_NOT_FOUND);
|
|
176
|
+
if (!response.ok) throw new Error('Chain verification unavailable');
|
|
177
|
+
return JSON.parse(text) as unknown;
|
|
178
|
+
};
|
|
179
|
+
const network = await chainNetwork(origin.href, () => request('status'));
|
|
180
|
+
if (network !== chainId) throw new Error('Chain mismatch');
|
|
181
|
+
const tx = TxResponse.parse(
|
|
182
|
+
await request(
|
|
183
|
+
`tx?hash=0x${transactionHash.toUpperCase()}&prove=false`,
|
|
184
|
+
true,
|
|
185
|
+
),
|
|
186
|
+
).result;
|
|
187
|
+
const bytes = decode64(tx.tx);
|
|
188
|
+
const digest = hex(
|
|
189
|
+
new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)),
|
|
190
|
+
);
|
|
191
|
+
if (
|
|
192
|
+
digest !== transactionHash.toLowerCase() ||
|
|
193
|
+
tx.hash.toLowerCase() !== digest
|
|
194
|
+
)
|
|
195
|
+
throw new Error('Transaction hash mismatch');
|
|
196
|
+
const raw = TxRaw.decode(bytes);
|
|
197
|
+
if (raw.signatures.length === 0) throw new Error('Unsigned transaction');
|
|
198
|
+
const body = TxBody.decode(raw.bodyBytes);
|
|
199
|
+
// The memo binds the transaction to its card (the card id for a transfer,
|
|
200
|
+
// the frozen topic memo for a grant): a transaction signed for another
|
|
201
|
+
// card, or with no card at all, never verifies this one.
|
|
202
|
+
if (body.memo !== memo) throw new Error('Transaction memo mismatch');
|
|
203
|
+
if (body.messages.length !== messages.length)
|
|
204
|
+
throw new Error('Transaction subject mismatch');
|
|
205
|
+
for (let i = 0; i < messages.length; i++) {
|
|
206
|
+
const actual = body.messages[i]!;
|
|
207
|
+
const expected = messages[i]!;
|
|
208
|
+
if (
|
|
209
|
+
actual.typeUrl !== expected.typeUrl ||
|
|
210
|
+
!equal(actual.value, expected.value)
|
|
211
|
+
)
|
|
212
|
+
throw new Error('Transaction subject mismatch');
|
|
213
|
+
}
|
|
214
|
+
const height = Number(tx.height);
|
|
215
|
+
if (!Number.isSafeInteger(height))
|
|
216
|
+
throw new Error('Invalid transaction height');
|
|
217
|
+
return {
|
|
218
|
+
transactionHash: transactionHash.toUpperCase(),
|
|
219
|
+
height,
|
|
220
|
+
code: tx.tx_result.code,
|
|
221
|
+
verifiedAt: new Date().toISOString(),
|
|
222
|
+
};
|
|
223
|
+
}
|
package/src/channels/auth.ts
CHANGED
|
@@ -238,7 +238,12 @@ export function channelAuthConfig(
|
|
|
238
238
|
const BindingVerdict = z.strictObject({ active: z.boolean() });
|
|
239
239
|
|
|
240
240
|
export async function assertActiveChannelBinding(
|
|
241
|
-
identity: TurnIdentity,
|
|
241
|
+
identity: Pick<TurnIdentity, 'userDid'> & {
|
|
242
|
+
channel?: Pick<
|
|
243
|
+
NonNullable<TurnIdentity['channel']>,
|
|
244
|
+
'provider' | 'bindingId' | 'bindingRevision'
|
|
245
|
+
>;
|
|
246
|
+
},
|
|
242
247
|
env: Pick<
|
|
243
248
|
OracleWorkerEnv,
|
|
244
249
|
'ORACLE_DID' | 'AUTH_HUB_CHANNEL_SERVICE_KEY' | 'AUTH_HUB' | 'AUTH_HUB_URL'
|
package/src/channels/contract.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ActionOutcomeWire } from '@ixo/ixo-transaction/topic-proposals';
|
|
2
|
+
import type { ActionOutcomeV1 } from '@ixo/action-protocol';
|
|
1
3
|
import { z } from 'zod';
|
|
2
4
|
import type { ReplyPlan } from '../delivery/types';
|
|
3
5
|
|
|
@@ -76,6 +78,8 @@ export interface ChannelTurnResponse {
|
|
|
76
78
|
* message for gateways that predate plans.
|
|
77
79
|
*/
|
|
78
80
|
plan?: ReplyPlan;
|
|
81
|
+
actionOutcomes?: ActionOutcomeWire[];
|
|
82
|
+
hubActionOutcomes?: ActionOutcomeV1[];
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
export type ChannelErrorStatus =
|
package/src/channels/turns.ts
CHANGED
package/src/core/env.ts
CHANGED
|
@@ -73,7 +73,56 @@ const secureUrlSchema = z.string().refine((raw) => isSecureUrl(raw), {
|
|
|
73
73
|
message: 'must be an https URL (http only on localhost)',
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* An https base URL for a chain RPC (a path such as `/rpc/` is fine): no
|
|
78
|
+
* credentials, query or fragment, which the verifier would refuse at the
|
|
79
|
+
* first lookup otherwise.
|
|
80
|
+
*/
|
|
81
|
+
const rpcOriginSchema = z.string().refine(
|
|
82
|
+
(raw) => {
|
|
83
|
+
try {
|
|
84
|
+
const url = new URL(raw);
|
|
85
|
+
return (
|
|
86
|
+
url.protocol === 'https:' &&
|
|
87
|
+
!url.username &&
|
|
88
|
+
!url.password &&
|
|
89
|
+
!url.search &&
|
|
90
|
+
!url.hash
|
|
91
|
+
);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{ message: 'must be an https URL without credentials, query or fragment' },
|
|
97
|
+
);
|
|
98
|
+
/** The Portal origin action links point at: https, host only. */
|
|
99
|
+
const portalOriginSchema = z.string().refine(
|
|
100
|
+
(raw) => {
|
|
101
|
+
try {
|
|
102
|
+
const url = new URL(raw);
|
|
103
|
+
return (
|
|
104
|
+
url.protocol === 'https:' &&
|
|
105
|
+
url.pathname === '/' &&
|
|
106
|
+
!url.username &&
|
|
107
|
+
!url.password &&
|
|
108
|
+
!url.search &&
|
|
109
|
+
!url.hash
|
|
110
|
+
);
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{ message: 'must be an https origin with no path, query or fragment' },
|
|
116
|
+
);
|
|
117
|
+
|
|
76
118
|
export const baseEnvSchema = z.object({
|
|
119
|
+
// --- action proposals (src/action-proposals, docs/action-proposals.md) ---
|
|
120
|
+
ACTION_PROPOSALS_RPC_TESTNET: rpcOriginSchema.optional(),
|
|
121
|
+
ACTION_PROPOSALS_RPC_DEVNET: rpcOriginSchema.optional(),
|
|
122
|
+
ACTION_PROPOSALS_RPC_MAINNET: rpcOriginSchema.optional(),
|
|
123
|
+
ACTION_PROPOSALS_ENABLED: z.enum(['true', 'false']).default('false'),
|
|
124
|
+
ACTION_TOPIC_PROPOSALS_ENABLED: z.enum(['true', 'false']).default('false'),
|
|
125
|
+
ACTION_PROPOSALS_PORTAL_ORIGIN: portalOriginSchema.optional(),
|
|
77
126
|
// --- identity -----------------------------------------------------------
|
|
78
127
|
ORACLE_NAME: z.string().min(1),
|
|
79
128
|
/** UCAN audience — the DID users address invocations/delegations to. */
|
|
@@ -454,11 +503,10 @@ export function validateEnv(
|
|
|
454
503
|
): ValidateEnvResult {
|
|
455
504
|
const result = schema.safeParse(env);
|
|
456
505
|
if (result.success) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
config:
|
|
460
|
-
errors: []
|
|
461
|
-
};
|
|
506
|
+
const errors = actionProposalEnvErrors(result.data);
|
|
507
|
+
return errors.length
|
|
508
|
+
? { valid: false, config: {}, errors }
|
|
509
|
+
: { valid: true, config: result.data, errors: [] };
|
|
462
510
|
}
|
|
463
511
|
|
|
464
512
|
const errors: ValidateEnvError[] = result.error.issues.map((issue) => {
|
|
@@ -470,3 +518,39 @@ export function validateEnv(
|
|
|
470
518
|
|
|
471
519
|
return { valid: false, config: {}, errors };
|
|
472
520
|
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Switching action proposals on without the settings they run on is a
|
|
524
|
+
* configuration error, not a runtime clarification: a card could be
|
|
525
|
+
* prepared with no link to review it at, or signed and never verified.
|
|
526
|
+
*/
|
|
527
|
+
function actionProposalEnvErrors(
|
|
528
|
+
config: Record<string, unknown>,
|
|
529
|
+
): ValidateEnvError[] {
|
|
530
|
+
const errors: ValidateEnvError[] = [];
|
|
531
|
+
const enabled = config['ACTION_PROPOSALS_ENABLED'] === 'true';
|
|
532
|
+
if (enabled && !config['ACTION_PROPOSALS_PORTAL_ORIGIN'])
|
|
533
|
+
errors.push({
|
|
534
|
+
plugin: 'core',
|
|
535
|
+
field: 'ACTION_PROPOSALS_PORTAL_ORIGIN',
|
|
536
|
+
message: 'required when ACTION_PROPOSALS_ENABLED is true',
|
|
537
|
+
});
|
|
538
|
+
if (
|
|
539
|
+
enabled &&
|
|
540
|
+
!config['ACTION_PROPOSALS_RPC_DEVNET'] &&
|
|
541
|
+
!config['ACTION_PROPOSALS_RPC_TESTNET']
|
|
542
|
+
)
|
|
543
|
+
errors.push({
|
|
544
|
+
plugin: 'core',
|
|
545
|
+
field: 'ACTION_PROPOSALS_RPC_TESTNET',
|
|
546
|
+
message:
|
|
547
|
+
'ACTION_PROPOSALS_RPC_DEVNET or ACTION_PROPOSALS_RPC_TESTNET is required when ACTION_PROPOSALS_ENABLED is true',
|
|
548
|
+
});
|
|
549
|
+
if (config['ACTION_TOPIC_PROPOSALS_ENABLED'] === 'true' && !enabled)
|
|
550
|
+
errors.push({
|
|
551
|
+
plugin: 'core',
|
|
552
|
+
field: 'ACTION_TOPIC_PROPOSALS_ENABLED',
|
|
553
|
+
message: 'requires ACTION_PROPOSALS_ENABLED=true',
|
|
554
|
+
});
|
|
555
|
+
return errors;
|
|
556
|
+
}
|
|
@@ -42,6 +42,14 @@ export const mainAgentRequestContextSchema = z.object({
|
|
|
42
42
|
id: z.string(),
|
|
43
43
|
client: z.enum(['portal', 'matrix', 'slack', 'channel']),
|
|
44
44
|
requestId: z.string(),
|
|
45
|
+
runId: z.string().optional(),
|
|
46
|
+
channel: z
|
|
47
|
+
.object({
|
|
48
|
+
provider: z.string(),
|
|
49
|
+
bindingId: z.string(),
|
|
50
|
+
bindingRevision: z.number().int().positive(),
|
|
51
|
+
})
|
|
52
|
+
.optional(),
|
|
45
53
|
wsId: z.string().optional(),
|
|
46
54
|
roomId: z.string().optional(),
|
|
47
55
|
}),
|
package/src/core/main-agent.ts
CHANGED
|
@@ -300,6 +300,8 @@ export async function createMainAgent(
|
|
|
300
300
|
id: requestCtx.session.id,
|
|
301
301
|
client: requestCtx.session.client,
|
|
302
302
|
requestId: requestCtx.session.requestId,
|
|
303
|
+
runId: requestCtx.session.runId,
|
|
304
|
+
channel: requestCtx.session.channel,
|
|
303
305
|
wsId: requestCtx.session.wsId,
|
|
304
306
|
roomId: requestCtx.session.roomId,
|
|
305
307
|
...(surface ? { surface } : {}),
|