139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
|
|
/**
|
||
|
|
* Execution Orchestrator (EO).
|
||
|
|
* Consumes intents, allocates nonces, submits txs via chain adapters, stores intent_id -> step -> tx_hash.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { validateAndPlan } from '../trpe/trpe.js';
|
||
|
|
import type { IntentRequest, Intent, Execution, ExecutionStepResult, PlannedStep } from '../intent/types.js';
|
||
|
|
import { nonceService } from '../nonce-service/nonce-service.js';
|
||
|
|
import { getAdapter } from '../chain-adapters/get-adapter.js';
|
||
|
|
import { recordAudit } from '../audit/audit-store.js';
|
||
|
|
import { v4 as uuidv4 } from 'uuid';
|
||
|
|
|
||
|
|
const intents = new Map<string, Intent>();
|
||
|
|
const executions = new Map<string, Execution>();
|
||
|
|
const intentIdByKey = new Map<string, string>(); // idempotency_key -> intent_id
|
||
|
|
|
||
|
|
export function createIntent(request: IntentRequest): Intent {
|
||
|
|
const idempotencyKey = request.idempotency_key;
|
||
|
|
if (idempotencyKey && intentIdByKey.has(idempotencyKey)) {
|
||
|
|
const existing = intents.get(intentIdByKey.get(idempotencyKey)!);
|
||
|
|
if (existing) return existing;
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = validateAndPlan(request);
|
||
|
|
if (!result.valid) {
|
||
|
|
throw new Error(result.error ?? 'Validation failed');
|
||
|
|
}
|
||
|
|
|
||
|
|
const intentId = `intent-${uuidv4()}`;
|
||
|
|
const now = new Date().toISOString();
|
||
|
|
const intent: Intent = {
|
||
|
|
intent_id: intentId,
|
||
|
|
status: 'planned',
|
||
|
|
request,
|
||
|
|
planned_steps: result.planned_steps,
|
||
|
|
created_at: now,
|
||
|
|
updated_at: now,
|
||
|
|
};
|
||
|
|
intents.set(intentId, intent);
|
||
|
|
if (idempotencyKey) intentIdByKey.set(idempotencyKey, intentId);
|
||
|
|
return intent;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getIntent(intentId: string): Intent | undefined {
|
||
|
|
return intents.get(intentId);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Execute planned steps: for MVP we simulate submission (no real wallet/signer).
|
||
|
|
* In production EO would:
|
||
|
|
* - Use real EVM/Tezos signers (wallet service or HSM)
|
||
|
|
* - Replace placeholder txs with adapter.sendTransaction(signedTxHex)
|
||
|
|
* - Plug in bridge-specific executors (IBridgeExecutor) for CCIP, Wrap, AlltraAdapter
|
||
|
|
* - Implement retries, timeouts, idempotency keys per hop
|
||
|
|
*/
|
||
|
|
export async function executeIntent(intentId: string): Promise<Execution> {
|
||
|
|
const intent = intents.get(intentId);
|
||
|
|
if (!intent) throw new Error('Intent not found');
|
||
|
|
if (intent.status !== 'planned') {
|
||
|
|
const existing = executions.get(`${intentId}-exec`);
|
||
|
|
if (existing) return existing;
|
||
|
|
throw new Error(`Intent not in planned state: ${intent.status}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const executionId = `exec-${uuidv4()}`;
|
||
|
|
const now = new Date().toISOString();
|
||
|
|
const steps: ExecutionStepResult[] = intent.planned_steps.map((s) => ({
|
||
|
|
step_index: s.step_index,
|
||
|
|
step_type: s.step_type,
|
||
|
|
chain_id: s.chain_id,
|
||
|
|
status: 'pending' as const,
|
||
|
|
}));
|
||
|
|
const execution: Execution = {
|
||
|
|
execution_id: executionId,
|
||
|
|
intent_id: intentId,
|
||
|
|
status: 'submitting',
|
||
|
|
submitted_txs: [],
|
||
|
|
steps,
|
||
|
|
created_at: now,
|
||
|
|
updated_at: now,
|
||
|
|
};
|
||
|
|
executions.set(executionId, execution);
|
||
|
|
executions.set(`${intentId}-exec`, execution);
|
||
|
|
intent.status = 'executing';
|
||
|
|
intent.updated_at = now;
|
||
|
|
|
||
|
|
// MVP: use placeholder when WALLET_ADDRESS not set. In production, set WALLET_ADDRESS and SIGNER_ENABLED=true.
|
||
|
|
const wallet = process.env.WALLET_ADDRESS || '0x0000000000000000000000000000000000000001';
|
||
|
|
const usePlaceholder = !process.env.WALLET_ADDRESS;
|
||
|
|
if (process.env.SIGNER_ENABLED === 'true' && usePlaceholder) {
|
||
|
|
throw new Error('SIGNER_ENABLED=true requires WALLET_ADDRESS to be set');
|
||
|
|
}
|
||
|
|
const lane = 'default';
|
||
|
|
|
||
|
|
for (const step of intent.planned_steps) {
|
||
|
|
const adapter = getAdapter(step.chain_id);
|
||
|
|
const nonce = nonceService.getNextNonce(step.chain_id, wallet, lane);
|
||
|
|
// In production: build and sign tx, then adapter.sendTransaction(signedTxHex)
|
||
|
|
const placeholderTxHash = `0x${Buffer.from(`${executionId}-${step.step_index}`).toString('hex').padEnd(64, '0')}`;
|
||
|
|
nonceService.trackPending(step.chain_id, wallet, lane, placeholderTxHash);
|
||
|
|
|
||
|
|
execution.submitted_txs.push({ step_index: step.step_index, chain_id: step.chain_id, tx_hash: placeholderTxHash });
|
||
|
|
const stepResult = execution.steps.find((s) => s.step_index === step.step_index);
|
||
|
|
if (stepResult) {
|
||
|
|
stepResult.tx_hash = placeholderTxHash;
|
||
|
|
stepResult.status = 'submitted';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
execution.status = 'completed';
|
||
|
|
execution.updated_at = new Date().toISOString();
|
||
|
|
intent.updated_at = execution.updated_at;
|
||
|
|
intent.status = 'completed';
|
||
|
|
|
||
|
|
recordAudit({
|
||
|
|
intent_id: intentId,
|
||
|
|
execution_id: executionId,
|
||
|
|
created_at: execution.created_at,
|
||
|
|
status: 'completed',
|
||
|
|
hops: execution.steps.map((s) => ({
|
||
|
|
step_index: s.step_index,
|
||
|
|
chain_id: s.chain_id,
|
||
|
|
action: s.step_type,
|
||
|
|
tx_hash: s.tx_hash,
|
||
|
|
status: s.status ?? 'submitted',
|
||
|
|
})),
|
||
|
|
});
|
||
|
|
|
||
|
|
return execution;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getExecution(executionId: string): Execution | undefined {
|
||
|
|
return executions.get(executionId);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getExecutionByIntent(intentId: string): Execution | undefined {
|
||
|
|
return executions.get(`${intentId}-exec`);
|
||
|
|
}
|