From 2a16abce1a1aee6043c7cd1e78f7252f02ceb4f4 Mon Sep 17 00:00:00 2001 From: SuPuHe Date: Sun, 13 Sep 2026 15:37:10 +0200 Subject: [PATCH] feat: add MCP wallet signing handoff link --- .agents/skills/oneshot-arc-payment/SKILL.md | 19 ++-- .env.example | 2 + apps/api/src/config.ts | 5 + apps/api/src/mcp.ts | 20 +++- apps/api/src/runtime.ts | 1 + apps/api/test/config.test.ts | 2 + apps/api/test/mcp.test.ts | 1 + apps/web/src/App.tsx | 7 +- apps/web/src/components/JobWorkspace.tsx | 107 ++++++++++++++++++-- apps/web/src/components/McpDocsPage.tsx | 10 +- apps/web/src/main.tsx | 5 +- apps/web/test/components.test.tsx | 66 ++++++++++++ docs/MCP_ARC_PAYMENT.md | 11 +- 13 files changed, 229 insertions(+), 27 deletions(-) diff --git a/.agents/skills/oneshot-arc-payment/SKILL.md b/.agents/skills/oneshot-arc-payment/SKILL.md index f854bb4..435356c 100644 --- a/.agents/skills/oneshot-arc-payment/SKILL.md +++ b/.agents/skills/oneshot-arc-payment/SKILL.md @@ -40,10 +40,11 @@ Input (all fields required, strict): - `purpose` — short non-secret payment purpose (max 256 chars). 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. +REJECTED`), `replayed`, `payer.mode` (`USER_WALLET`), `amount_atomic`, an +exact `transaction` object for the Arc USDC transfer, and a `signing_url`. Give +the user the signing URL: it opens the authenticated OneShot wallet handoff, +which loads the prepared payment and asks the user to review and sign it. The +tool never broadcasts a transaction. 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, @@ -53,10 +54,12 @@ checks the receipt and exact USDC `Transfer` log, and returns the durable state. 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. +2. Give the user the returned `signing_url`. The user must be signed into the + matching OneShot workspace, review the recipient and amount, and click the + wallet confirmation. Do not create a replacement transaction. +3. The wallet handoff records the hash through the normal user-wallet API. If + the agent receives the hash separately, call `arc_payment_submit` with the + returned `business_intent_id` and that exact hash. 4. If `state` is `COMMITTED`, report `settlement.transaction_hash` and its `explorer_url` (ArcScan). Done. 5. If `state` is `UNKNOWN`, repeat `arc_payment_submit` with the same hash. diff --git a/.env.example b/.env.example index f5b8956..94c3da9 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ ONESHOT_API_RATE_LIMIT_WINDOW_MS=60000 # One-tool MCP. Each Privy user generates a workspace-bound bearer in Profile. # The optional deployment bearer keeps one operator-controlled client working. # ONESHOT_MCP_BEARER_TOKEN= +# Public frontend route returned by MCP for explicit wallet signing. +# ONESHOT_APP_URL=https://oneshot.kapustazh.dev/app # ONESHOT_MCP_PAYER_ADDRESS=0x<40-hex-privy-server-wallet-address> # ONESHOT_MCP_WAIT_MS=2500 diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index c4f1973..a43ed57 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -30,6 +30,8 @@ export interface ApiRuntimeConfig { readonly mcp?: { readonly bearerToken?: string; readonly workspaceId: string; + /** Public frontend route where a user can sign a prepared MCP payment. */ + readonly signingAppUrl?: string; /** НЕ УДАЛЯТЬ: disabled corporate server-wallet mode only. */ readonly payerWallet?: string; readonly waitMs: number; @@ -83,6 +85,7 @@ function optionalHttpsUrl(environment: NodeJS.ProcessEnv, name: string): string function mcpConfig(environment: NodeJS.ProcessEnv, workspaceId: string): ApiRuntimeConfig['mcp'] { const names = [ 'ONESHOT_MCP_BEARER_TOKEN', + 'ONESHOT_APP_URL', 'ONESHOT_MCP_PAYER_ADDRESS', 'ONESHOT_MCP_WAIT_MS', ] as const; @@ -103,9 +106,11 @@ function mcpConfig(environment: NodeJS.ProcessEnv, workspaceId: string): ApiRunt if (payerWallet && !/^0x[0-9a-f]{40}$/u.test(payerWallet)) { throw new Error('Invalid environment variable: ONESHOT_MCP_PAYER_ADDRESS'); } + const signingAppUrl = optionalHttpsUrl(environment, 'ONESHOT_APP_URL'); return { ...(bearerToken ? { bearerToken } : {}), workspaceId, + ...(signingAppUrl ? { signingAppUrl } : {}), ...(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 4a6cd4b..9fbd331 100644 --- a/apps/api/src/mcp.ts +++ b/apps/api/src/mcp.ts @@ -21,6 +21,7 @@ const ARC_USDC = '0x3600000000000000000000000000000000000000' as const; const USDC_DECIMALS = 6; const REQUEST_KEY_MAX_LENGTH = 128; const TRANSFER_SELECTOR = 'a9059cbb'; +const DEFAULT_SIGNING_APP_URL = 'https://oneshot.kapustazh.dev/app'; const prepareInputSchema = z.strictObject({ request_key: z @@ -59,6 +60,7 @@ const outputSchema = z.strictObject({ request_key: z.string(), job_id: z.string(), business_intent_id: z.string(), + signing_url: z.string().url(), state: z.enum([ 'AUTHORIZING', 'READY', @@ -109,6 +111,8 @@ const outputSchema = z.strictObject({ export interface ArcPaymentMcpConfig { readonly workspaceId: string; readonly submissionsDisabled?: boolean; + /** Public frontend route used to hand a prepared payment to the user's wallet. */ + readonly signingAppUrl?: string; /** * НЕ УДАЛЯТЬ: legacy corporate autonomous-agent server-wallet configuration. @@ -215,13 +219,19 @@ function transactionFor(job: JobView) { }; } -function resultView(requestKey: string, job: JobView, replayed: boolean) { +function resultView( + requestKey: string, + job: JobView, + replayed: boolean, + signingAppUrl = DEFAULT_SIGNING_APP_URL, +) { const payment = job.user_payment; if (!payment) throw new Error('User-wallet payment binding is missing'); return { request_key: requestKey, job_id: job.job_id, business_intent_id: job.business_intent_id, + signing_url: `${signingAppUrl}?mcp_job_id=${encodeURIComponent(job.job_id)}`, state: job.payment_state, payer: { mode: 'USER_WALLET' as const, @@ -319,7 +329,9 @@ export function createArcPaymentMcpHandler({ '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')); + return jsonResult( + resultView(requestKey, result.job, result.kind === 'REPLAYED', config.signingAppUrl), + ); } catch (error) { if (error instanceof ContractValidationError) { if (error.message.includes('Supplier task payload conflicts')) { @@ -379,7 +391,7 @@ export function createArcPaymentMcpHandler({ job.business_intent_id, ); return current - ? jsonResult(resultView(job.task_key, current, true)) + ? jsonResult(resultView(job.task_key, current, true, config.signingAppUrl)) : toolError('The completed payment could not be read back from durable storage.'); } return toolError( @@ -445,7 +457,7 @@ export function createArcPaymentMcpHandler({ 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)); + return jsonResult(resultView(job.task_key, updated, false, config.signingAppUrl)); } catch (error) { if (error instanceof ContractValidationError) return toolError(error.message); throw error; diff --git a/apps/api/src/runtime.ts b/apps/api/src/runtime.ts index a53b2dd..5b62370 100644 --- a/apps/api/src/runtime.ts +++ b/apps/api/src/runtime.ts @@ -85,6 +85,7 @@ export async function startApiRuntime(config: ApiRuntimeConfig): Promise { ...base, ONESHOT_WORKSPACE_ID: 'mcp-demo-workspace', ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', + ONESHOT_APP_URL: 'https://oneshot.example/app', ONESHOT_MCP_PAYER_ADDRESS: '0x1111111111111111111111111111111111111111', ONESHOT_MCP_WAIT_MS: '500', }); expect(config.mcp).toEqual({ bearerToken: 'mcp-token-with-at-least-thirty-two-characters', workspaceId: 'mcp-demo-workspace', + signingAppUrl: 'https://oneshot.example/app', payerWallet: '0x1111111111111111111111111111111111111111', waitMs: 500, }); diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts index c149e4c..85bbda7 100644 --- a/apps/api/test/mcp.test.ts +++ b/apps/api/test/mcp.test.ts @@ -205,6 +205,7 @@ describe('MCP user-wallet Arc payment', () => { ).result.structuredContent; expect(prepared).toMatchObject({ state: 'READY', + signing_url: `https://oneshot.kapustazh.dev/app?mcp_job_id=${prepared.job_id}`, payer: { mode: 'USER_WALLET', wallet_address: PAYER }, amount_usdc: '1.000000', amount_atomic: '1000000', diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1d8d545..edb994b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -45,6 +45,8 @@ export interface AppProps { readonly recoveryClient?: RecoveryClient; readonly useOperatorSession?: UseOperatorSession; readonly userWallet?: UserWalletSession; + /** Optional prepared MCP job to open directly in the wallet-signing workspace. */ + readonly mcpJobId?: string; /** main.tsx passes the browser route; omitted preserves legacy test composition. */ readonly route?: string; } @@ -142,10 +144,11 @@ function CabinetPage(props: { readonly theme: Theme; readonly onToggleTheme: () => void; readonly userWallet?: UserWalletSession; + readonly mcpJobId?: string; }) { const [section, setSection] = useState< 'overview' | 'services' | 'requests' | 'protection' | 'profile' - >('overview'); + >(props.mcpJobId ? 'services' : 'overview'); const [intentId, setIntentId] = useState(''); const [activity, setActivity] = useState(null); const [activityError, setActivityError] = useState(null); @@ -283,6 +286,7 @@ function CabinetPage(props: {
@@ -373,6 +377,7 @@ export function App(props: AppProps = {}) { settlementClient={settlementClient} recoveryClient={recoveryClient} {...(props.userWallet ? { userWallet: props.userWallet } : {})} + {...(props.mcpJobId ? { mcpJobId: props.mcpJobId } : {})} theme={theme} onToggleTheme={toggleTheme} /> diff --git a/apps/web/src/components/JobWorkspace.tsx b/apps/web/src/components/JobWorkspace.tsx index 11744dc..efcd2b5 100644 --- a/apps/web/src/components/JobWorkspace.tsx +++ b/apps/web/src/components/JobWorkspace.tsx @@ -35,6 +35,10 @@ function explorerHref(transactionHash: string | undefined): string | undefined { : undefined; } +function mcpPaymentStillSignable(job: JobView): boolean { + return job.payment_state === 'READY' && Date.parse(job.supplier.expires_at) > Date.now(); +} + const USER_WALLET_PAYMENT_CHECK_DELAY_MS = 500; const USER_WALLET_PAYMENT_CHECK_ATTEMPTS = 30; const RESULT_REFRESH_DELAY_MS = 1000; @@ -144,6 +148,7 @@ export function JobWorkspace(props: { readonly client: JobApiClient; readonly userWallet?: UserWalletSession; readonly onSelectIntent: (id: string) => void; + readonly initialJobId?: string; }) { const [subject, setSubject] = useState(''); const [recipient, setRecipient] = useState(''); @@ -157,6 +162,7 @@ export function JobWorkspace(props: { const [paymentHash, setPaymentHash] = useState(null); const [walletAttempted, setWalletAttempted] = useState(false); const [paymentChecking, setPaymentChecking] = useState(false); + const [mcpJobLoading, setMcpJobLoading] = useState(Boolean(props.initialJobId)); const [notice, setNotice] = useState(''); const generatedTaskKey = subject.trim() ? `report-${subjectSlug(subject)}-${runSuffix}` : ''; const taskKey = customTaskKey.trim() || generatedTaskKey; @@ -215,6 +221,39 @@ export function JobWorkspace(props: { } } + useEffect(() => { + if (!props.initialJobId) return; + let cancelled = false; + setMcpJobLoading(true); + void (async () => { + try { + const job = await props.client.get(props.initialJobId!); + if (job.payment_mode !== 'USER_WALLET' || !job.user_payment) { + throw new Error('The linked payment is not a user-wallet payment'); + } + if (cancelled) return; + setApprovedJob(job); + if (job.user_payment.transaction_hash) setPaymentHash(job.user_payment.transaction_hash); + setNotice( + mcpPaymentStillSignable(job) + ? 'MCP payment is ready. Review the details, then confirm the wallet signature.' + : job.payment_state === 'READY' + ? 'This MCP signing link has expired. Create a new approved payment instead.' + : `This MCP payment is already ${job.payment_state}.`, + ); + } catch { + if (!cancelled) { + setNotice('The MCP payment link is invalid, expired, or belongs to another workspace.'); + } + } finally { + if (!cancelled) setMcpJobLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [props.client, props.initialJobId]); + async function start(): Promise { if (!quote) return; const jobRequest = request(); @@ -326,11 +365,58 @@ export function JobWorkspace(props: { } } + async function signMcpPayment(): Promise { + const job = approvedJob; + const userWallet = props.userWallet; + const payment = job?.user_payment; + if (!job || !userWallet || !payment || job.payment_state !== 'READY') return; + setStarting(true); + setWalletAttempted(true); + let submittedHash: string | null = null; + try { + const payerWallet = userWallet.address ?? (await userWallet.connect()); + if (!payerWallet) throw new Error('No Ethereum wallet is connected'); + if (payerWallet.toLowerCase() !== payment.payer_wallet.toLowerCase()) { + throw new Error('The connected wallet does not match the prepared payer wallet'); + } + setNotice('Review the recipient and amount in your wallet, then confirm the transaction.'); + const transactionHash = await userWallet.sendTransfer(payment); + submittedHash = transactionHash; + setPaymentHash(transactionHash); + const initial = await props.client.submitUserWalletPayment(job.job_id, transactionHash); + setApprovedJob(initial); + setPaymentChecking(initial.payment_state === 'UNKNOWN'); + const updated = await resolveUserWalletPayment( + props.client, + job.job_id, + transactionHash, + initial, + ); + setApprovedJob(updated); + setNotice( + updated.payment_state === 'COMMITTED' + ? 'Payment confirmed from your connected wallet.' + : updated.payment_state === 'UNKNOWN' + ? 'Transaction recorded but not final. Use the same transaction check if needed.' + : `Payment state: ${updated.payment_state}.`, + ); + } catch { + setNotice( + submittedHash + ? 'The transaction hash is recorded. Use Check payment to verify it; do not pay again.' + : 'No transaction hash was returned. Do not approve another payment until you confirm the wallet status.', + ); + } finally { + setPaymentChecking(false); + setStarting(false); + } + } + return (

DIRECT PAYMENT

@@ -457,6 +543,17 @@ export function JobWorkspace(props: { {approvedJob.user_payment?.payer_wallet ?? 'connected wallet'}

+ {props.initialJobId && + mcpPaymentStillSignable(approvedJob) && + approvedJob.user_payment && ( + + )} {paymentHash && approvedJob.payment_state !== 'COMMITTED' && (