From 0ef1a5dbe756fa94dd5e50671387d6f40c2103ae Mon Sep 17 00:00:00 2001 From: SuPuHe Date: Sun, 13 Sep 2026 13:16:11 +0200 Subject: [PATCH 1/3] feat: route MCP payments through user wallets --- .../context/20260913T-mcp-user-wallet-flow.md | 37 ++ .agents/skills/oneshot-arc-payment/SKILL.md | 51 ++- apps/api/src/app.ts | 16 + apps/api/src/config.ts | 14 +- apps/api/src/mcp.ts | 383 ++++++++++++----- apps/api/src/runtime.ts | 10 +- apps/api/test/api.integration.test.ts | 27 +- apps/api/test/config.test.ts | 14 + apps/api/test/mcp.test.ts | 386 ++++++++---------- apps/web/browser/p5.spec.ts | 24 +- apps/web/src/components/JobWorkspace.tsx | 72 ++-- apps/web/src/components/McpDocsPage.tsx | 24 +- apps/web/test/components.test.tsx | 31 +- docs/MCP_ARC_PAYMENT.md | 99 +++-- .../PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md | 6 + packages/storage-postgres/src/jobs.ts | 14 + 16 files changed, 722 insertions(+), 486 deletions(-) create mode 100644 .agent/context/20260913T-mcp-user-wallet-flow.md diff --git a/.agent/context/20260913T-mcp-user-wallet-flow.md b/.agent/context/20260913T-mcp-user-wallet-flow.md new file mode 100644 index 0000000..69f55ce --- /dev/null +++ b/.agent/context/20260913T-mcp-user-wallet-flow.md @@ -0,0 +1,37 @@ +# MCP user-wallet payment flow + +## Goal + +Make personal Arc Testnet payments non-custodial: OneShot prepares a durable +intent and quote, the connected Privy/MetaMask wallet sends USDC directly to +the recipient, and OneShot verifies the returned transaction hash against the +Arc receipt and exact USDC Transfer log. + +## Scope + +- Active MCP tools are `arc_payment` (prepare) and `arc_payment_submit` + (bind/verify hash). +- The active MCP path uses `USER_WALLET` jobs and never a server signer. +- The web workspace refuses to fall back to the server-wallet payment path when + no browser wallet is connected. +- The old corporate autonomous-agent server-wallet configuration is retained + with `НЕ УДАЛЯТЬ` comments but is not wired into the active personal flow. + +## Safety invariants + +- The payer wallet is durably bound before a hash is accepted. +- The returned calldata pins Arc Testnet USDC, recipient, amount, and payer. +- A transaction hash is recorded once; a different hash is rejected. +- Receipt verification checks chain, token contract, payer, recipient, amount, + receipt status, and the matching Transfer log. +- UNKNOWN is reconciled with the same hash; no replacement transaction is + submitted by OneShot. + +## Acceptance + +- MCP prepare returns a quote, payer-bound job, exact ERC-20 calldata, and + `next_action: SIGN`. +- MCP submit returns COMMITTED only after exact receipt verification. +- Duplicate prepare and submit calls replay the same durable payment. +- Focused API and web tests, typecheck, lint, formatting, and diff checks pass. +- No live payment or production deployment is performed in this change. diff --git a/.agents/skills/oneshot-arc-payment/SKILL.md b/.agents/skills/oneshot-arc-payment/SKILL.md index 41cc431..f854bb4 100644 --- a/.agents/skills/oneshot-arc-payment/SKILL.md +++ b/.agents/skills/oneshot-arc-payment/SKILL.md @@ -12,8 +12,9 @@ description: > Pay once, safely, through OneShot. This skill is written for any agent (primary or delegated) whose MCP client is already connected to the OneShot -MCP endpoint. It never handles keys: the payer is OneShot's policy-bound -server wallet, and the bearer token lives only in the MCP client config. +MCP endpoint. It never handles keys: the payer is the user's connected Privy +embedded/external wallet or MetaMask wallet, and the bearer token lives only in +the MCP client config. ## Prerequisites (user-provided, never invented) @@ -24,46 +25,54 @@ server wallet, and the bearer token lives only in the MCP client config. If either is missing, stop and ask the operator. Do not guess values. -## Tool contract: `arc_payment` +## Tool contract: `arc_payment` then `arc_payment_submit` Input (all fields required, strict): - `request_key` — generate this yourself before the first call as `report--<8 random hex>`. Never ask the user for it. Retain and reuse the exact value for every retry of that payment. +- `payer_wallet` — the connected EVM wallet address selected by the user in + Privy or MetaMask. Never invent it or substitute a server wallet. - `recipient` — `0x`-prefixed 40-hex EVM address on Arc Testnet. - `amount_usdc` — canonical decimal string, up to 6 decimals, greater than zero (for example `1` or `0.25`; `1` USDC = `1000000` atomic units). - `purpose` — short non-secret payment purpose (max 256 chars). -Output: `state` (`AUTHORIZING | READY | SUBMITTING | COMMITTED | FAILED_SAFE | -UNKNOWN | REJECTED`), `replayed`, `payer.mode` (`SERVER_PRIVY`), -`amount_atomic`, optional `settlement.transaction_hash` and -`settlement.explorer_url`, and `next_action` -(`WAIT | CHECK_STATUS | VIEW_PROOF | FIX_REQUEST`). +Output: `state` (`READY | SUBMITTING | COMMITTED | FAILED_SAFE | UNKNOWN | +REJECTED`), `replayed`, `payer.mode` (`USER_WALLET`), `amount_atomic`, and an +exact `transaction` object for the Arc USDC transfer. The agent must show that +transaction to the user or hand it to the connected wallet; this tool never +broadcasts it. + +After the wallet returns a transaction hash, call `arc_payment_submit` with the +returned `business_intent_id` and that exact hash. OneShot binds the hash, +checks the receipt and exact USDC `Transfer` log, and returns the durable state. ## How to execute a payment -1. Generate the `request_key`, then call `arc_payment` once with that key and - the exact recipient, amount, and purpose the user approved. -2. If `state` is `COMMITTED`, report `settlement.transaction_hash` and its +1. Generate the `request_key`, then call `arc_payment` once with that key, the + exact payer wallet, recipient, amount, and purpose the user approved. +2. Ask the user to review the returned calldata and sign/broadcast it with + Privy or MetaMask. Do not create a replacement transaction. +3. Call `arc_payment_submit` with the returned `business_intent_id` and the + hash returned by the wallet. +4. If `state` is `COMMITTED`, report `settlement.transaction_hash` and its `explorer_url` (ArcScan). Done. -3. If `state` is `SUBMITTING`/`AUTHORIZING`/`READY`, wait for the user or poll - by repeating the exact same call: it is a replay and returns the same - intent with fresh authoritative state. Never create a second key. -4. If `state` is `UNKNOWN`, repeat the same call to check status. UNKNOWN is - not failure: it never justifies a replacement payment or a new key. -5. If the tool returns the conflict error ("already belongs to a different +5. If `state` is `UNKNOWN`, repeat `arc_payment_submit` with the same hash. + UNKNOWN is not failure: it never justifies a replacement payment or a new + key. +6. If the tool returns the conflict error ("already belongs to a different payment"), the key was reused with changed fields. Stop and report the conflict; do not replace an uncertain payment. -6. If `state` is `FAILED_SAFE` or `REJECTED`, report it and stop. Do not retry +7. If `state` is `FAILED_SAFE` or `REJECTED`, report it and stop. Do not retry with a different key or amount. ## Delegating (outsourcing) the payment to another agent -- Hand the delegate only the task arguments: endpoint URL, `recipient`, - `amount_usdc`, `purpose`, and this skill. The delegate generates and retains - the request key. +- Hand the delegate only the task arguments: endpoint URL, `payer_wallet`, + `recipient`, `amount_usdc`, `purpose`, and this skill. The delegate generates + and retains the request key. - The delegate must use its own MCP client configuration; the bearer token must not travel through prompts, task payloads, logs, or screenshots. - One request key funds exactly one intent. Each delegate generates one key per diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8b26638..671f33b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -57,6 +57,7 @@ export interface ApiDependencies { | 'createOrReplay' | 'createUserWalletOrReplay' | 'get' + | 'getByBusinessIntentId' | 'list' | 'resumeDelivery' | 'recordActivityObservation' @@ -248,8 +249,23 @@ export function buildApi(dependencies: ApiDependencies) { if (dependencies.mcp) { app.all('/mcp', async (request, reply) => { + if (!dependencies.jobs || !dependencies.supplier) { + sendError( + reply, + 503, + 'NOT_READY', + 'MCP user-wallet payments are not configured', + correlationFor(request), + ); + return; + } const mcpHandler = createArcPaymentMcpHandler({ ledger: dependencies.ledger, + jobs: dependencies.jobs, + supplier: dependencies.supplier, + ...(dependencies.userWalletVerifier + ? { userWalletVerifier: dependencies.userWalletVerifier } + : {}), config: { ...dependencies.mcp!, workspaceId: requestWorkspaces.get(request) ?? dependencies.mcp!.workspaceId, diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index d8476c9..c4f1973 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -30,7 +30,8 @@ export interface ApiRuntimeConfig { readonly mcp?: { readonly bearerToken?: string; readonly workspaceId: string; - readonly payerWallet: string; + /** НЕ УДАЛЯТЬ: disabled corporate server-wallet mode only. */ + readonly payerWallet?: string; readonly waitMs: number; }; } @@ -93,14 +94,19 @@ function mcpConfig(environment: NodeJS.ProcessEnv, workspaceId: string): ApiRunt if (bearerToken && bearerToken.length < 32) { throw new Error('Environment variable ONESHOT_MCP_BEARER_TOKEN must be at least 32 characters'); } - const payerWallet = required(environment, 'ONESHOT_MCP_PAYER_ADDRESS').toLowerCase(); - if (!/^0x[0-9a-f]{40}$/u.test(payerWallet)) { + /* + * НЕ УДАЛЯТЬ: this optional value belongs only to the disabled corporate + * server-wallet mode. Personal MCP payments bind the wallet supplied by the + * user and never read this address. + */ + const payerWallet = environment.ONESHOT_MCP_PAYER_ADDRESS?.trim().toLowerCase(); + if (payerWallet && !/^0x[0-9a-f]{40}$/u.test(payerWallet)) { throw new Error('Invalid environment variable: ONESHOT_MCP_PAYER_ADDRESS'); } return { ...(bearerToken ? { bearerToken } : {}), workspaceId, - payerWallet, + ...(payerWallet ? { payerWallet } : {}), waitMs: integer(environment, 'ONESHOT_MCP_WAIT_MS', 2_500, 0, 5_000), }; } diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts index c14e447..04e64ee 100644 --- a/apps/api/src/mcp.ts +++ b/apps/api/src/mcp.ts @@ -1,14 +1,28 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { createHash } from 'node:crypto'; import { McpServer, createMcpHandler, type McpHttpHandler } from '@modelcontextprotocol/server'; -import { asEvmAddress, ContractValidationError, type IntentResponse } from '@oneshot/contracts'; -import type { IntentLedger } from '@oneshot/storage-postgres'; +import { + asBlockNumber, + asBusinessIntentId, + asEvmAddress, + asProviderReferenceId, + asTransactionHash, + ContractValidationError, + type JobView, + type SupplierPort, +} from '@oneshot/contracts'; +import { derivedJobId } from '@oneshot/domain'; +import type { IntentLedger, JobLedger } from '@oneshot/storage-postgres'; import { z } from 'zod'; +import type { UserWalletVerificationPort } from './user-wallet.js'; const ARC_NETWORK = 'eip155:5042002' as const; +const ARC_CHAIN_ID = 5042002 as const; +const ARC_USDC = '0x3600000000000000000000000000000000000000' as const; const USDC_DECIMALS = 6; const REQUEST_KEY_MAX_LENGTH = 128; +const TRANSFER_SELECTOR = 'a9059cbb'; -const inputSchema = z.strictObject({ +const prepareInputSchema = z.strictObject({ request_key: z .string() .min(1) @@ -16,6 +30,10 @@ const inputSchema = z.strictObject({ .describe( 'Generate automatically as report--<8 random hex>; reuse it exactly for retries and never ask the user for it', ), + payer_wallet: z + .string() + .regex(/^0x[0-9a-fA-F]{40}$/u) + .describe('The connected Privy embedded or external EVM wallet that will sign the payment'), recipient: z .string() .regex(/^0x[0-9a-fA-F]{40}$/u) @@ -24,8 +42,22 @@ const inputSchema = z.strictObject({ purpose: z.string().min(1).max(256).describe('Short non-secret payment purpose'), }); +const submitInputSchema = z.strictObject({ + business_intent_id: z + .string() + .regex(/^intent_[0-9a-f]{64}$/u) + .describe('The business_intent_id returned by the prepare call'), + transaction_hash: z + .string() + .regex(/^0x[0-9a-fA-F]{64}$/u) + .describe( + 'The hash returned by Privy or MetaMask after the user signed and broadcast the transfer', + ), +}); + const outputSchema = z.strictObject({ request_key: z.string(), + job_id: z.string(), business_intent_id: z.string(), state: z.enum([ 'AUTHORIZING', @@ -37,7 +69,7 @@ const outputSchema = z.strictObject({ 'REJECTED', ]), payer: z.strictObject({ - mode: z.literal('SERVER_PRIVY'), + mode: z.literal('USER_WALLET'), wallet_address: z.string(), }), recipient: z.string(), @@ -46,6 +78,24 @@ const outputSchema = z.strictObject({ asset: z.literal('USDC'), network: z.literal(ARC_NETWORK), replayed: z.boolean(), + quote: z.strictObject({ + supplier_id: z.literal('team-report-v1'), + order_reference: z.string(), + recipient: z.string(), + amount_atomic: z.string(), + asset: z.literal('USDC'), + network: z.literal(ARC_NETWORK), + expires_at: z.string(), + }), + transaction: z + .strictObject({ + chain_id: z.literal(ARC_CHAIN_ID), + from: z.string(), + to: z.string(), + data: z.string(), + value: z.literal('0x0'), + }) + .optional(), settlement: z .strictObject({ transaction_hash: z.string(), @@ -53,19 +103,33 @@ const outputSchema = z.strictObject({ explorer_url: z.string(), }) .optional(), - next_action: z.enum(['WAIT', 'CHECK_STATUS', 'VIEW_PROOF', 'FIX_REQUEST']), + next_action: z.enum(['SIGN', 'CHECK_STATUS', 'VIEW_PROOF', 'FIX_REQUEST']), }); export interface ArcPaymentMcpConfig { readonly workspaceId: string; - readonly payerWallet: string; readonly submissionsDisabled?: boolean; + + /** + * НЕ УДАЛЯТЬ: legacy corporate autonomous-agent server-wallet configuration. + * It is intentionally not consumed by the active personal user-wallet MCP flow. + */ + readonly payerWallet?: string; + /** НЕ УДАЛЯТЬ: retained only for the disabled legacy server-wallet mode. */ readonly waitMs?: number; - readonly pollMs?: number; } export interface ArcPaymentMcpDependencies { - readonly ledger: Pick; + readonly ledger: Pick< + IntentLedger, + | 'beginUserWalletSubmission' + | 'recordUserWalletTransaction' + | 'completeSubmission' + | 'markUserWalletUnknown' + >; + readonly jobs: Pick; + readonly supplier: SupplierPort; + readonly userWalletVerifier?: UserWalletVerificationPort; readonly config: ArcPaymentMcpConfig; } @@ -82,6 +146,10 @@ function boundedText(value: string, name: string, maximum: number): string { return value.normalize('NFC'); } +/** + * Retained for compatibility with the old server-wallet intent identity. The + * active MCP flow uses the resumable user-wallet job identity instead. + */ export function arcPaymentBusinessIntentId(workspaceId: string, requestKey: string): string { const workspace = boundedText(workspaceId, 'workspace_id', 128); const key = boundedText(requestKey, 'request_key', REQUEST_KEY_MAX_LENGTH); @@ -110,48 +178,77 @@ export function parseUsdcAmount(value: string): { }; } +function formatUsdcAmount(amountAtomic: string): string { + const atomic = BigInt(amountAtomic); + const whole = atomic / 10n ** 6n; + const fraction = (atomic % 10n ** 6n).toString(10).padStart(6, '0'); + return `${whole.toString(10)}.${fraction}`; +} + +function transferCalldata(recipient: string, amountAtomic: string): string { + const addressWord = recipient.slice(2).padStart(64, '0'); + const amountWord = BigInt(amountAtomic).toString(16).padStart(64, '0'); + return `0x${TRANSFER_SELECTOR}${addressWord}${amountWord}`; +} + function nextAction( - state: IntentResponse['state'], -): 'WAIT' | 'CHECK_STATUS' | 'VIEW_PROOF' | 'FIX_REQUEST' { + state: JobView['payment_state'], +): 'SIGN' | 'CHECK_STATUS' | 'VIEW_PROOF' | 'FIX_REQUEST' { + if (state === 'READY') return 'SIGN'; if (state === 'COMMITTED') return 'VIEW_PROOF'; - if (state === 'UNKNOWN') return 'CHECK_STATUS'; - if (state === 'FAILED_SAFE' || state === 'REJECTED') return 'FIX_REQUEST'; - return 'WAIT'; + if (state === 'UNKNOWN' || state === 'SUBMITTING') return 'CHECK_STATUS'; + return 'FIX_REQUEST'; +} + +function transactionFor(job: JobView) { + const payment = job.user_payment; + if (!payment) throw new Error('User-wallet payment binding is missing'); + if (payment.token_contract !== ARC_USDC) { + throw new Error('User-wallet payment is bound to an unsupported token contract'); + } + return { + chain_id: ARC_CHAIN_ID, + from: payment.payer_wallet, + to: payment.token_contract, + data: transferCalldata(payment.recipient, payment.amount_atomic), + value: '0x0' as const, + }; } -function resultView( - requestKey: string, - amountUsdc: string, - payerWallet: string, - intent: IntentResponse, - replayed: boolean, -) { +function resultView(requestKey: string, job: JobView, replayed: boolean) { + const payment = job.user_payment; + if (!payment) throw new Error('User-wallet payment binding is missing'); return { request_key: requestKey, - business_intent_id: intent.business_intent_id, - state: intent.state, + job_id: job.job_id, + business_intent_id: job.business_intent_id, + state: job.payment_state, payer: { - mode: 'SERVER_PRIVY' as const, - wallet_address: payerWallet, + mode: 'USER_WALLET' as const, + wallet_address: payment.payer_wallet, }, - recipient: intent.recipient, - amount_usdc: amountUsdc, - amount_atomic: intent.amount_atomic, - asset: intent.asset, - network: intent.network, + recipient: payment.recipient, + amount_usdc: formatUsdcAmount(payment.amount_atomic), + amount_atomic: payment.amount_atomic, + asset: 'USDC' as const, + network: payment.network, replayed, - ...(intent.settlement + quote: job.supplier, + ...(job.payment_state !== 'COMMITTED' && job.payment_state !== 'FAILED_SAFE' + ? { transaction: transactionFor(job) } + : {}), + ...(job.settlement ? { settlement: { - transaction_hash: intent.settlement.transaction_hash, - block_number: intent.settlement.block_number, + transaction_hash: job.settlement.transaction_hash, + block_number: job.settlement.block_number, explorer_url: - intent.settlement.explorer_url ?? - `https://testnet.arcscan.app/tx/${intent.settlement.transaction_hash}`, + job.settlement.explorer_url ?? + `https://testnet.arcscan.app/tx/${job.settlement.transaction_hash}`, }, } : {}), - next_action: nextAction(intent.state), + next_action: nextAction(job.payment_state), }; } @@ -162,48 +259,29 @@ function toolError(message: string) { }; } -async function latestIntent( - ledger: ArcPaymentMcpDependencies['ledger'], - initial: IntentResponse, - waitMs: number, - pollMs: number, -): Promise { - if (waitMs <= 0 || ['COMMITTED', 'FAILED_SAFE', 'UNKNOWN', 'REJECTED'].includes(initial.state)) { - return initial; - } - const deadline = Date.now() + waitMs; - let current = initial; - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, deadline - Date.now()))); - current = (await ledger.getIntent(initial.business_intent_id)) ?? current; - if (['COMMITTED', 'FAILED_SAFE', 'UNKNOWN', 'REJECTED'].includes(current.state)) break; - } - return current; +function jsonResult(output: ReturnType) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(output) }], + structuredContent: output, + }; } export function createArcPaymentMcpHandler({ ledger, + jobs, + supplier, + userWalletVerifier, config, }: ArcPaymentMcpDependencies): McpHttpHandler { - const payerWallet = asEvmAddress(config.payerWallet); - const waitMs = config.waitMs ?? 2_500; - const pollMs = config.pollMs ?? 250; - if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 5_000) { - throw new Error('MCP wait must be an integer from 0 to 5000 milliseconds'); - } - if (!Number.isSafeInteger(pollMs) || pollMs < 10 || pollMs > 1_000) { - throw new Error('MCP poll interval must be an integer from 10 to 1000 milliseconds'); - } - return createMcpHandler(() => { - const server = new McpServer({ name: 'oneshot-arc-payments', version: '1.0.0' }); + const server = new McpServer({ name: 'oneshot-arc-payments', version: '2.0.0' }); server.registerTool( 'arc_payment', { - title: 'Arc USDC payment', + title: 'Prepare Arc USDC payment', description: - 'Create or replay an approved Arc Testnet USDC payment through the policy-bound Privy server wallet. Generate request_key automatically; never ask the user for it.', - inputSchema, + 'Create or replay a payer-bound Arc Testnet USDC payment. The user must review and sign the returned ERC-20 transaction with the connected Privy or MetaMask wallet. This tool never uses a server wallet and never broadcasts a transaction.', + inputSchema: prepareInputSchema, outputSchema, annotations: { readOnlyHint: false, @@ -212,47 +290,168 @@ export function createArcPaymentMcpHandler({ openWorldHint: true, }, }, - async ({ request_key, recipient, amount_usdc, purpose }) => { + async ({ request_key, payer_wallet, recipient, amount_usdc, purpose }) => { try { const requestKey = boundedText(request_key, 'request_key', REQUEST_KEY_MAX_LENGTH); if (config.submissionsDisabled) { - return toolError('Arc payment submission is disabled for this deployment.'); + return toolError('Arc payment preparation is disabled for this deployment.'); } const amount = parseUsdcAmount(amount_usdc); - const result = await ledger.createOrReplay( - { - business_intent_id: arcPaymentBusinessIntentId(config.workspaceId, requestKey), - recipient: asEvmAddress(recipient), - amount_atomic: amount.atomic, - asset: 'USDC', - network: ARC_NETWORK, - purpose: boundedText(purpose, 'purpose', 256), - }, - randomUUID(), + const parsedPayer = asEvmAddress(payer_wallet); + const parsedRecipient = asEvmAddress(recipient); + const jobRequest = { + task_key: requestKey, + tool_id: 'team-report-v1' as const, + report_subject: boundedText(purpose, 'purpose', 256), + recipient: parsedRecipient, + amount_atomic: amount.atomic, + }; + const jobId = derivedJobId(config.workspaceId, jobRequest); + const supplierOrder = await supplier.createOrder(jobRequest, jobId); + const result = await jobs.createUserWalletOrReplay({ + workspaceId: config.workspaceId, + request: { ...jobRequest, payer_wallet: parsedPayer }, + supplierOrder, + correlationId: `mcp-${requestKey.slice(0, 124)}`, + }); + if (result.kind === 'TASK_PAYLOAD_CONFLICT') { + return toolError( + 'The request key already belongs to a different user-wallet payment. Reuse the original immutable fields and payer wallet.', + ); + } + return jsonResult(resultView(requestKey, result.job, result.kind === 'REPLAYED')); + } catch (error) { + if (error instanceof ContractValidationError) return toolError(error.message); + throw error; + } + }, + ); + + server.registerTool( + 'arc_payment_submit', + { + title: 'Verify signed Arc USDC payment', + description: + 'Bind the transaction hash returned by the user wallet to the prepared payment, verify the Arc receipt and exact USDC Transfer log, and return the durable payment status. This tool never submits or retries a transaction.', + inputSchema: submitInputSchema, + outputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ business_intent_id, transaction_hash }) => { + try { + if (config.submissionsDisabled) { + return toolError('Arc payment verification is disabled for this deployment.'); + } + if (!userWalletVerifier) { + return toolError( + 'Arc receipt verification is not configured. Set ONESHOT_ARC_RPC_URL before enabling user-wallet MCP payments.', + ); + } + const businessIntentId = asBusinessIntentId(business_intent_id); + const transactionHash = asTransactionHash(transaction_hash); + const job = await jobs.getByBusinessIntentId(config.workspaceId, businessIntentId); + if (!job) return toolError('The prepared payment was not found in this workspace.'); + if (job.payment_mode !== 'USER_WALLET' || !job.user_payment) { + return toolError('The prepared payment is not configured for a user wallet.'); + } + + const begun = await ledger.beginUserWalletSubmission( + job.business_intent_id, + job.user_payment.payer_wallet, + `mcp-submit-${transactionHash.slice(2, 18)}`, ); - if (result.kind === 'INTENT_PAYLOAD_CONFLICT') { + if (!begun.begun) { + if (begun.currentState === 'COMMITTED' || begun.currentState === 'FAILED_SAFE') { + const current = await jobs.getByBusinessIntentId( + config.workspaceId, + job.business_intent_id, + ); + return current + ? jsonResult(resultView(job.task_key, current, true)) + : toolError('The completed payment could not be read back from durable storage.'); + } + return toolError( + begun.reason === 'NOT_USER_WALLET' + ? 'The payer wallet does not match the durable user-wallet authorization.' + : 'This user-wallet payment is no longer available for verification.', + ); + } + if (begun.transactionHash && begun.transactionHash !== transactionHash) { return toolError( - 'The request key already belongs to a different payment. Reuse the original immutable fields.', + 'A different transaction hash is already bound to this payment; do not submit another transaction.', ); } - const intent = await latestIntent(ledger, result.intent, waitMs, pollMs); - const output = resultView( - requestKey, - amount.decimal, - payerWallet, - intent, - result.kind === 'REPLAY_IDENTICAL', + const recorded = await ledger.recordUserWalletTransaction( + begun.attemptId, + transactionHash, ); - return { - content: [{ type: 'text' as const, text: JSON.stringify(output) }], - structuredContent: output, - }; + if (recorded === 'CONFLICT' || recorded === 'NOT_FOUND') { + return toolError( + 'The transaction hash could not be bound to the durable payment attempt.', + ); + } + + let verification; + try { + verification = await userWalletVerifier.verify({ + transactionHash, + walletAddress: job.user_payment.payer_wallet, + recipient: job.user_payment.recipient, + amountAtomic: job.user_payment.amount_atomic, + }); + } catch { + return toolError( + 'Arc receipt verification is temporarily unavailable; the hash is recorded and no retry was submitted.', + ); + } + + if (verification.kind === 'CONFIRMED') { + await ledger.completeSubmission(job.business_intent_id, begun.attemptId, { + kind: 'CONFIRMED', + provider_reference_id: asProviderReferenceId(`user-wallet:${transactionHash}`), + transaction_hash: asTransactionHash(verification.transactionHash), + block_number: asBlockNumber(verification.blockNumber), + transfer_log_index: verification.transferLogIndex, + verified_by: 'ARC_RPC_EXACT_TRANSFER', + }); + } else if (verification.kind === 'FINAL_REVERT') { + await ledger.completeSubmission(job.business_intent_id, begun.attemptId, { + kind: 'DEFINITELY_NOT_SUBMITTED', + reason: verification.reason, + }); + } else { + await ledger.markUserWalletUnknown( + job.business_intent_id, + begun.attemptId, + verification.kind === 'PENDING' + ? 'User wallet transaction is not final; receipt is not available yet' + : verification.reason, + ); + } + const updated = await jobs.getByBusinessIntentId( + config.workspaceId, + job.business_intent_id, + ); + if (!updated) return toolError('Updated payment could not be read from durable storage.'); + return jsonResult(resultView(job.task_key, updated, false)); } catch (error) { if (error instanceof ContractValidationError) return toolError(error.message); throw error; } }, ); + + /* + * НЕ УДАЛЯТЬ: the former policy-bound Privy server-wallet MCP handler is + * intentionally disabled. Corporate autonomous-agent settlement may be + * restored later as a separate explicitly selected mode. It must never be + * used as a fallback for personal Privy/MetaMask payments. + */ return server; }); } diff --git a/apps/api/src/runtime.ts b/apps/api/src/runtime.ts index 40afb9c..a53b2dd 100644 --- a/apps/api/src/runtime.ts +++ b/apps/api/src/runtime.ts @@ -49,11 +49,12 @@ export async function startApiRuntime(config: ApiRuntimeConfig): Promise new Date(), nextAttemptId: randomUUID }); + const supplier = new TeamReportSupplier(); const mcpCredentials = new McpCredentialStore(pool); const app = buildApi({ ledger, jobs, - supplier: new TeamReportSupplier(), + supplier, ...(config.walletActivity ? { walletActivity: new StudioWalletActivityPort(config.walletActivity) } : {}), @@ -84,9 +85,12 @@ export async function startApiRuntime(config: ApiRuntimeConfig): Promise { it('converges parallel MCP calls on one durable intent and zero direct settlements', async () => { const mcpToken = 'integration-mcp-token-with-32-characters'; const requestKey = 'integration-arc-payment'; + const jobs = new JobLedger(pool, { + now: () => new Date('2026-09-07T12:00:00.000Z'), + nextAttemptId: () => `http-user-wallet-attempt-${++attempts}`, + }); const app = buildApi({ ledger: ledger(), + jobs, + supplier: new TeamReportSupplier(), authenticator: staticBearerAuthenticator('integration-token'), mcp: { authenticator: staticBearerAuthenticator(mcpToken), workspaceId: 'integration-mcp-workspace', - payerWallet: '0x1111111111111111111111111111111111111111', waitMs: 0, }, }); @@ -129,6 +131,7 @@ describePostgres('durable HTTP API', () => { name: 'arc_payment', arguments: { request_key: requestKey, + payer_wallet: '0x1111111111111111111111111111111111111111', recipient: '0x2222222222222222222222222222222222222222', amount_usdc: amount, purpose: 'One integration payment', @@ -139,7 +142,13 @@ describePostgres('durable HTTP API', () => { const responses = await Promise.all(Array.from({ length: 10 }, () => call())); expect(responses.every((response) => response.statusCode === 200)).toBe(true); - const businessIntentId = arcPaymentBusinessIntentId('integration-mcp-workspace', requestKey); + const businessIntentId = derivedBusinessIntentId('integration-mcp-workspace', { + task_key: requestKey, + tool_id: 'team-report-v1', + report_subject: 'One integration payment', + recipient: '0x2222222222222222222222222222222222222222', + amount_atomic: '1000000', + }); const counts = await pool.query<{ intents: string; attempts: string; diff --git a/apps/api/test/config.test.ts b/apps/api/test/config.test.ts index b9df5bd..fa68c06 100644 --- a/apps/api/test/config.test.ts +++ b/apps/api/test/config.test.ts @@ -97,6 +97,20 @@ describe('API runtime configuration', () => { }); }); + it('allows active user-wallet MCP without a server payer address', () => { + expect( + loadApiRuntimeConfig({ + ...base, + ONESHOT_WORKSPACE_ID: 'user-wallet-mcp-workspace', + ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', + }).mcp, + ).toEqual({ + bearerToken: 'mcp-token-with-at-least-thirty-two-characters', + workspaceId: 'user-wallet-mcp-workspace', + waitMs: 2500, + }); + }); + it('fails closed on partial MCP configuration', () => { expect(() => loadApiRuntimeConfig({ diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts index 9192c8c..c149e4c 100644 --- a/apps/api/test/mcp.test.ts +++ b/apps/api/test/mcp.test.ts @@ -1,5 +1,4 @@ -import type { IntentResponse } from '@oneshot/contracts'; -import type { CreateIntentResult } from '@oneshot/storage-postgres'; +import type { JobView } from '@oneshot/contracts'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildApi, staticBearerAuthenticator, type ApiDependencies } from '../src/index.js'; import { arcPaymentBusinessIntentId, parseUsdcAmount } from '../src/mcp.js'; @@ -9,50 +8,121 @@ const SERVICE_TOKEN = 'service-test-token'; const REQUEST_KEY = 'arc-demo-payment-1'; const RECIPIENT = '0x292d3fca76142e0c6136b934563f3a0750b633eb'; const PAYER = '0x1111111111111111111111111111111111111111'; +const TRANSACTION_HASH = `0x${'1'.repeat(64)}`; -function intent(overrides: Partial = {}): IntentResponse { +function userWalletJob(overrides: Partial = {}): JobView { return { - business_intent_id: arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY), - recipient: RECIPIENT, - amount_atomic: '1000000', - asset: 'USDC', - network: 'eip155:5042002', - purpose: 'Pay for one report', - payload_fingerprint: 'a'.repeat(64), - state: 'AUTHORIZING', - version: 1, - attempts: [], - evidence: [], + job_id: `job_${'a'.repeat(64)}`, + task_key: REQUEST_KEY, + tool_id: 'team-report-v1', + business_intent_id: `intent_${'b'.repeat(64)}`, + supplier: { + supplier_id: 'team-report-v1', + order_reference: 'team_report_mcp_payment', + recipient: RECIPIENT, + amount_atomic: '1000000', + asset: 'USDC', + network: 'eip155:5042002', + expires_at: '2099-09-12T12:00:00.000Z', + }, + payment_state: 'READY', + payment_mode: 'USER_WALLET', + user_payment: { + chain_id: 5042002, + network: 'eip155:5042002', + token_contract: '0x3600000000000000000000000000000000000000', + payer_wallet: PAYER, + recipient: RECIPIENT, + amount_atomic: '1000000', + }, + delivery_state: 'NOT_REQUESTED', + created_at: '2026-09-13T10:00:00.000Z', + updated_at: '2026-09-13T10:00:00.000Z', ...overrides, }; } -function ledger(createOrReplay = vi.fn<() => Promise>()) { - return { - createOrReplay, - getIntent: vi.fn(async () => undefined), - enqueueReconciliation: vi.fn(), - getRecoveryView: vi.fn(), - getSystemMetrics: vi.fn(), - ping: vi.fn(), - beginUserWalletSubmission: vi.fn(), - recordUserWalletTransaction: vi.fn(), - completeSubmission: vi.fn(), - markUserWalletUnknown: vi.fn(), +function app() { + let saved = userWalletJob(); + const verified = vi.fn(async () => ({ + kind: 'CONFIRMED' as const, + transactionHash: TRANSACTION_HASH, + blockNumber: '123', + transferLogIndex: 7, + })); + const ledger = { + async beginUserWalletSubmission() { + return { + begun: true as const, + intent: {} as never, + attemptId: 'attempt-mcp-payment', + state: 'SUBMITTING' as const, + version: 2, + }; + }, + async recordUserWalletTransaction() { + return 'RECORDED' as const; + }, + async completeSubmission() { + saved = userWalletJob({ + payment_state: 'COMMITTED', + user_payment: { ...saved.user_payment!, transaction_hash: TRANSACTION_HASH }, + settlement: { + provider_reference_id: `user-wallet:${TRANSACTION_HASH}`, + transaction_hash: TRANSACTION_HASH, + block_number: '123', + transfer_log_index: 7, + }, + }); + return { completed: true as const, state: 'COMMITTED' as const, version: 3 }; + }, + async markUserWalletUnknown() { + saved = userWalletJob({ payment_state: 'UNKNOWN' }); + return { completed: true as const, state: 'UNKNOWN' as const, version: 3 }; + }, } as unknown as ApiDependencies['ledger']; -} - -function app(createOrReplay: ApiDependencies['ledger']['createOrReplay']) { - return buildApi({ - ledger: ledger(createOrReplay), + const jobs = { + async createUserWalletOrReplay() { + const replayed = saved.payment_state !== 'READY'; + return { kind: replayed ? ('REPLAYED' as const) : ('ACCEPTED' as const), job: saved }; + }, + async getByBusinessIntentId() { + return saved; + }, + } as unknown as ApiDependencies['jobs']; + const supplier = { + async createOrder(request: { readonly recipient: string; readonly amount_atomic: string }) { + return { + supplier_id: 'team-report-v1' as const, + order_reference: 'team_report_mcp_payment', + recipient: request.recipient, + amount_atomic: request.amount_atomic, + asset: 'USDC' as const, + network: 'eip155:5042002' as const, + expires_at: '2099-09-12T12:00:00.000Z', + supplier_payload_fingerprint: 'a'.repeat(64), + }; + }, + async fulfillOrder() { + throw new Error('not used'); + }, + async getResult() { + return null; + }, + }; + const server = buildApi({ + ledger, + jobs, + supplier, + userWalletVerifier: { verify: verified }, authenticator: staticBearerAuthenticator(SERVICE_TOKEN), mcp: { authenticator: staticBearerAuthenticator(MCP_TOKEN), workspaceId: 'mcp-demo-workspace', - payerWallet: PAYER, waitMs: 0, }, }); + return { server, verified }; } const rpcHeaders = (token = MCP_TOKEN) => ({ @@ -63,7 +133,7 @@ const rpcHeaders = (token = MCP_TOKEN) => ({ }); async function rpc( - server: ReturnType, + server: ReturnType, body: Record, token = MCP_TOKEN, ) { @@ -80,27 +150,9 @@ function rpcBody(response: Awaited>) { return JSON.parse(data); } -function toolRequest(id: number, overrides: Record = {}) { - return { - jsonrpc: '2.0', - id, - method: 'tools/call', - params: { - name: 'arc_payment', - arguments: { - request_key: REQUEST_KEY, - recipient: RECIPIENT, - amount_usdc: '1', - purpose: 'Pay for one report', - ...overrides, - }, - }, - }; -} - afterEach(() => vi.restoreAllMocks()); -describe('MCP arc_payment', () => { +describe('MCP user-wallet Arc payment', () => { it('converts USDC decimal strings without floating point', () => { expect(parseUsdcAmount('1')).toEqual({ atomic: '1000000', decimal: '1.000000' }); expect(parseUsdcAmount('0.000001')).toEqual({ atomic: '1', decimal: '0.000001' }); @@ -108,41 +160,15 @@ describe('MCP arc_payment', () => { expect(() => parseUsdcAmount('0')).toThrow('greater than zero'); }); - it('derives one stable intent ID from workspace and request key', () => { + it('retains the stable legacy identity helper without using a server payer', () => { const first = arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY); expect(arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY)).toBe(first); expect(arcPaymentBusinessIntentId('another-workspace', REQUEST_KEY)).not.toBe(first); expect(first).toMatch(/^intent_[0-9a-f]{64}$/u); }); - it('derives the intent from the personal bearer workspace', async () => { - let seenId = ''; - const createOrReplay = vi.fn(async (request: unknown): Promise => { - seenId = (request as IntentResponse).business_intent_id; - return { kind: 'ACCEPTED', intent: intent(request as Partial) }; - }); - const server = buildApi({ - ledger: ledger(createOrReplay), - authenticator: staticBearerAuthenticator(SERVICE_TOKEN), - mcp: { - authenticator: { - async authenticate() { - return { decision: 'AUTHORIZED' as const, workspaceId: 'privy_alice' }; - }, - }, - workspaceId: 'legacy-workspace', - payerWallet: PAYER, - waitMs: 0, - }, - }); - - await rpc(server, toolRequest(1)); - expect(seenId).toBe(arcPaymentBusinessIntentId('privy_alice', REQUEST_KEY)); - await server.close(); - }); - - it('isolates the MCP credential and lists exactly one tool', async () => { - const server = app(vi.fn(async () => ({ kind: 'ACCEPTED', intent: intent() }))); + it('lists prepare and submit tools and rejects non-MCP credentials', async () => { + const { server } = app(); const missing = await server.inject({ method: 'POST', url: '/mcp', @@ -150,157 +176,75 @@ describe('MCP arc_payment', () => { payload: { jsonrpc: '2.0', id: 1, method: 'tools/list' }, }); expect(missing.statusCode).toBe(401); - expect( - (await rpc(server, { jsonrpc: '2.0', id: 2, method: 'tools/list' }, SERVICE_TOKEN)) - .statusCode, - ).toBe(401); - - const initialized = await rpc(server, { - jsonrpc: '2.0', - id: 3, - method: 'initialize', - params: { - protocolVersion: '2025-06-18', - capabilities: {}, - clientInfo: { name: 'oneshot-test', version: '1.0.0' }, - }, - }); - expect(rpcBody(initialized).result.serverInfo.name).toBe('oneshot-arc-payments'); - const listed = await rpc(server, { jsonrpc: '2.0', id: 3, method: 'tools/list' }); + const listed = await rpc(server, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); expect(listed.statusCode).toBe(200); - const tools = rpcBody(listed).result.tools as Array<{ - name: string; - inputSchema: { properties: { request_key: { description: string } } }; - }>; - expect(tools.map((tool) => tool.name)).toEqual(['arc_payment']); - expect(tools[0]?.inputSchema.properties.request_key.description).toContain( - 'never ask the user', - ); - - const apiAttempt = await server.inject({ - method: 'POST', - url: '/v1/intents', - headers: { authorization: `Bearer ${MCP_TOKEN}` }, - payload: { - business_intent_id: 'mcp-cannot-call-api', - recipient: RECIPIENT, - amount_atomic: '1', - asset: 'USDC', - network: 'eip155:5042002', - purpose: 'Denied', - }, - }); - expect(apiAttempt.statusCode).toBe(401); + expect( + (rpcBody(listed).result.tools as Array<{ name: string }>).map((tool) => tool.name), + ).toEqual(['arc_payment', 'arc_payment_submit']); await server.close(); }); - it('creates and replays the same durable intent with authoritative status', async () => { - let saved: IntentResponse | undefined; - const createOrReplay = vi.fn(async (request: unknown): Promise => { - const next = { ...intent(), ...(request as object) } as IntentResponse; - if (!saved) { - saved = next; - return { kind: 'ACCEPTED', intent: saved }; - } - return JSON.stringify(request) === - JSON.stringify({ - business_intent_id: saved.business_intent_id, - recipient: saved.recipient, - amount_atomic: saved.amount_atomic, - asset: saved.asset, - network: saved.network, - purpose: saved.purpose, - }) - ? { kind: 'REPLAY_IDENTICAL', intent: saved } - : { kind: 'INTENT_PAYLOAD_CONFLICT', intent: saved }; - }); - const server = app(createOrReplay); - - const first = rpcBody(await rpc(server, toolRequest(1))).result; - const replay = rpcBody(await rpc(server, toolRequest(2))).result; - expect(first.structuredContent).toMatchObject({ - business_intent_id: arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY), - state: 'AUTHORIZING', - payer: { mode: 'SERVER_PRIVY', wallet_address: PAYER }, + it('prepares a payer-bound direct transfer and verifies the same signed hash', async () => { + const { server, verified } = app(); + const prepared = rpcBody( + await rpc(server, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'arc_payment', + arguments: { + request_key: REQUEST_KEY, + payer_wallet: PAYER, + recipient: RECIPIENT, + amount_usdc: '1', + purpose: 'Pay for one report', + }, + }, + }), + ).result.structuredContent; + expect(prepared).toMatchObject({ + state: 'READY', + payer: { mode: 'USER_WALLET', wallet_address: PAYER }, amount_usdc: '1.000000', amount_atomic: '1000000', - replayed: false, - next_action: 'WAIT', - }); - expect(replay.structuredContent).toMatchObject({ - business_intent_id: first.structuredContent.business_intent_id, - replayed: true, - }); - await server.close(); - }); - - it('makes sequential and parallel redelivery converge on one intent identity', async () => { - const ids = new Set(); - const createOrReplay = vi.fn(async (request: unknown): Promise => { - const value = request as IntentResponse; - ids.add(value.business_intent_id); - return { - kind: ids.size === 1 ? 'REPLAY_IDENTICAL' : 'INTENT_PAYLOAD_CONFLICT', - intent: intent(value), - }; + next_action: 'SIGN', + transaction: { + chain_id: 5042002, + from: PAYER, + to: '0x3600000000000000000000000000000000000000', + value: '0x0', + }, }); - const server = app(createOrReplay); - for (let index = 0; index < 10; index += 1) await rpc(server, toolRequest(index)); - await Promise.all( - Array.from({ length: 10 }, (_, index) => rpc(server, toolRequest(index + 10))), + expect(prepared.transaction.data).toBe( + `0xa9059cbb${RECIPIENT.slice(2).padStart(64, '0')}${'f4240'.padStart(64, '0')}`, ); - expect(ids).toEqual(new Set([arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY)])); - expect(createOrReplay).toHaveBeenCalledTimes(20); - await server.close(); - }); - - it('accepts agent-generated keys and rejects immutable-payload conflicts', async () => { - const createOrReplay = vi - .fn<(request: unknown) => Promise>() - .mockImplementationOnce(async (request) => ({ - kind: 'ACCEPTED', - intent: intent(request as Partial), - })) - .mockImplementationOnce(async () => ({ - kind: 'INTENT_PAYLOAD_CONFLICT', - intent: intent(), - })); - const server = app(createOrReplay); - - const generatedKey = 'report-werwerwe-63368792'; - const accepted = rpcBody(await rpc(server, toolRequest(1, { request_key: generatedKey }))) - .result.structuredContent; - expect(accepted.business_intent_id).toBe( - arcPaymentBusinessIntentId('mcp-demo-workspace', generatedKey), - ); - - const conflict = rpcBody(await rpc(server, toolRequest(2, { amount_usdc: '1.000001' }))); - expect(conflict.result.isError).toBe(true); - expect(conflict.result.content[0].text).toContain('different payment'); - expect(createOrReplay).toHaveBeenCalledTimes(2); - await server.close(); - }); - it('returns stored Arc proof for a committed replay', async () => { - const committed = intent({ - state: 'COMMITTED', - settlement: { - provider_reference_id: 'privy-reference', - transaction_hash: `0x${'1'.repeat(64)}`, - block_number: '123', - transfer_log_index: 0, - }, - }); - const server = app(vi.fn(async () => ({ kind: 'REPLAY_IDENTICAL', intent: committed }))); - const result = rpcBody(await rpc(server, toolRequest(1))).result.structuredContent; - expect(result).toMatchObject({ + const submitted = rpcBody( + await rpc(server, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'arc_payment_submit', + arguments: { + business_intent_id: prepared.business_intent_id, + transaction_hash: TRANSACTION_HASH, + }, + }, + }), + ).result.structuredContent; + expect(submitted).toMatchObject({ state: 'COMMITTED', - replayed: true, + payer: { mode: 'USER_WALLET', wallet_address: PAYER }, next_action: 'VIEW_PROOF', - settlement: { - transaction_hash: committed.settlement?.transaction_hash, - explorer_url: `https://testnet.arcscan.app/tx/${committed.settlement?.transaction_hash}`, - }, + settlement: { transaction_hash: TRANSACTION_HASH, block_number: '123' }, + }); + expect(verified).toHaveBeenCalledWith({ + transactionHash: TRANSACTION_HASH, + walletAddress: PAYER, + recipient: RECIPIENT, + amountAtomic: '1000000', }); await server.close(); }); diff --git a/apps/web/browser/p5.spec.ts b/apps/web/browser/p5.spec.ts index 2471bf5..c0411ae 100644 --- a/apps/web/browser/p5.spec.ts +++ b/apps/web/browser/p5.spec.ts @@ -120,8 +120,8 @@ test.describe('resumable job workspace', () => { await expect(page.getByText('Payment evidence', { exact: true })).toHaveCount(0); }); - test('starts one job and resumes only its original supplier delivery', async ({ page }) => { - const calls = await mockJobApi(page, { startDelayMs: 1000, resumeDelayMs: 1000 }); + test('requires a connected user wallet before approving a direct payment', async ({ page }) => { + const calls = await mockJobApi(page); await page.goto('/app'); await unlockWorkspace(page); await page.getByRole('tab', { name: 'Payment services' }).click(); @@ -145,24 +145,10 @@ test.describe('resumable job workspace', () => { .locator('button') .last(); await approve.click(); - await expect(approve).toBeDisabled(); - await expect(approve).toHaveText('Starting request…'); - await expect.poll(() => calls.filter((call) => call === 'POST /v1/jobs')).toHaveLength(1); - await expect(page.getByRole('status')).toContainText('Payment authorization is queued'); - await expect(page.getByRole('heading', { name: 'Request accepted' })).toBeVisible(); - await expect(page.getByText('2.500000 USDC')).toBeVisible(); - await page.getByText('Show supplier details').click(); - await expect(page.getByText('team_report_order_browser')).toBeVisible(); - await page.getByRole('tab', { name: 'Requests' }).click(); - await expect(page.getByRole('link', { name: 'View the ArcScan transaction' })).toHaveAttribute( - 'href', - `https://testnet.arcscan.app/tx/0x${'c'.repeat(64)}`, + await expect(page.getByRole('status')).toContainText( + 'Connect a Privy or MetaMask wallet before approving', ); - const resume = page.locator('.job-list li').first().locator('button').nth(1); - await resume.click(); - await expect(resume).toBeDisabled(); - await expect(resume).toHaveText('Resuming…'); - await expect(page.getByText('Recovered original supplier report.')).toBeVisible(); + expect(calls).not.toContain('POST /v1/jobs'); }); test('shows activity as read-only evidence and keeps the cabinet responsive', async ({ diff --git a/apps/web/src/components/JobWorkspace.tsx b/apps/web/src/components/JobWorkspace.tsx index 8763e52..1549e22 100644 --- a/apps/web/src/components/JobWorkspace.tsx +++ b/apps/web/src/components/JobWorkspace.tsx @@ -200,15 +200,13 @@ export function JobWorkspace(props: { setStarting(true); const userWallet = props.userWallet; if (!userWallet) { - try { - const job = await props.client.start(jobRequest); - setApprovedJob(job); - setNotice('Request accepted. Payment authorization is queued.'); - } catch { - setNotice('The request was not started. Keep the same request key when retrying.'); - } finally { - setStarting(false); - } + /* + * НЕ УДАЛЯТЬ: the old server-wallet UI handoff is intentionally disabled + * for personal payments. Corporate autonomous agents may use that mode + * through a separately selected backend integration. + */ + setNotice('Connect a Privy or MetaMask wallet before approving this direct Arc payment.'); + setStarting(false); return; } setWalletAttempted(false); @@ -404,22 +402,19 @@ export function JobWorkspace(props: {
Request for your agent

- Send this exact request to POST /v1/jobs only after approval. Reuse its task key when - resuming. The amount is in USDC atomic units, not dollars; network fees are separate. + Send this exact request to POST /v1/jobs/user-wallet/prepare only after approval and + add the connected payer_wallet. Reuse its task key when resuming. The amount is in + USDC atomic units, not dollars; network fees are separate.

{JSON.stringify(request(), null, 2)}

- {props.userWallet - ? 'Nothing has been paid yet. Approval prepares a durable intent, then your connected wallet shows the exact USDC transfer for confirmation. OneShot never uses a server wallet for this report.' - : 'Nothing has been paid yet. Approval queues the existing server-wallet payment path for this test composition.'} + Nothing has been paid yet. Approval prepares a durable intent, then your connected + wallet shows the exact USDC transfer for confirmation. OneShot never uses a server + wallet for this report.

)} @@ -430,29 +425,22 @@ export function JobWorkspace(props: { )} {approvedJob && ( <> - - {props.userWallet && ( - <> -

- Payment state: {approvedJob.payment_state}. Payer:{' '} - - {approvedJob.user_payment?.payer_wallet ?? 'connected wallet'} - -

- {paymentHash && approvedJob.payment_state !== 'COMMITTED' && ( - - )} - + +

+ Payment state: {approvedJob.payment_state}. Payer:{' '} + + {approvedJob.user_payment?.payer_wallet ?? 'connected wallet'} + +

+ {paymentHash && approvedJob.payment_state !== 'COMMITTED' && ( + )}