Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions .agents/skills/oneshot-arc-payment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<optional-random-secret-at-least-32-characters>
# 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

Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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),
};
Expand Down
20 changes: 16 additions & 4 deletions apps/api/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export async function startApiRuntime(config: ApiRuntimeConfig): Promise<ApiRunt
},
]),
workspaceId: config.mcp.workspaceId,
...(config.mcp.signingAppUrl ? { signingAppUrl: config.mcp.signingAppUrl } : {}),
submissionsDisabled: config.submissionsDisabled,
/*
* НЕ УДАЛЯТЬ: config.mcp.payerWallet and config.mcp.waitMs are
Expand Down
2 changes: 2 additions & 0 deletions apps/api/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,14 @@ describe('API runtime configuration', () => {
...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,
});
Expand Down
1 change: 1 addition & 0 deletions apps/api/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<ActivityResponse | null>(null);
const [activityError, setActivityError] = useState<string | null>(null);
Expand Down Expand Up @@ -283,6 +286,7 @@ function CabinetPage(props: {
<div className="panel-stack">
<JobWorkspace
client={props.jobClient}
{...(props.mcpJobId ? { initialJobId: props.mcpJobId } : {})}
{...(props.userWallet ? { userWallet: props.userWallet } : {})}
onSelectIntent={selectRequest}
/>
Expand Down Expand Up @@ -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}
/>
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/api/job-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ export class JobApiClient {
return response.ok ? ((await responseJson<JobListResponse>(response))?.jobs ?? []) : [];
}

async get(jobId: string): Promise<JobView> {
const response = await this.#fetch(`${this.#baseUrl}/v1/jobs/${encodeURIComponent(jobId)}`, {
headers: this.#headers(),
});
const body = await responseJson<JobView>(response);
if (!response.ok || !body) throw new Error('Could not load the job status');
return body;
}

async start(request: CreateJobRequest): Promise<JobView> {
const response = await this.#fetch(`${this.#baseUrl}/v1/jobs`, {
method: 'POST',
Expand Down
99 changes: 98 additions & 1 deletion apps/web/src/components/JobWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
function waitForPaymentCheck(): Promise<void> {
Expand Down Expand Up @@ -118,6 +122,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('');
Expand All @@ -131,6 +136,7 @@ export function JobWorkspace(props: {
const [paymentHash, setPaymentHash] = useState<string | null>(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;
Expand Down Expand Up @@ -189,6 +195,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<void> {
if (!quote) return;
const jobRequest = request();
Expand Down Expand Up @@ -300,11 +339,58 @@ export function JobWorkspace(props: {
}
}

async function signMcpPayment(): Promise<void> {
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 (
<section
className="panel job-workspace"
aria-label="Direct Arc payment"
aria-busy={quoteLoading || starting}
aria-busy={quoteLoading || starting || mcpJobLoading}
>
<header>
<p className="eyebrow">DIRECT PAYMENT</p>
Expand Down Expand Up @@ -431,6 +517,17 @@ export function JobWorkspace(props: {
{approvedJob.user_payment?.payer_wallet ?? 'connected wallet'}
</span>
</p>
{props.initialJobId &&
mcpPaymentStillSignable(approvedJob) &&
approvedJob.user_payment && (
<button
type="button"
onClick={() => void signMcpPayment()}
disabled={starting || mcpJobLoading || walletAttempted}
>
{starting ? 'Waiting for wallet…' : 'Confirm and sign in wallet'}
</button>
)}
{paymentHash && approvedJob.payment_state !== 'COMMITTED' && (
<button
type="button"
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/McpDocsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,14 @@ export function McpDocsPage(props: { readonly theme: Theme; readonly onToggleThe
The agent generates a request key from the purpose plus eight random hex characters; you
do not need to provide or copy it.
</li>
<li>Call arc_payment once and show the returned transaction request to the user.</li>
<li>Have the user sign and broadcast it, then call arc_payment_submit with the hash.</li>
<li>Call arc_payment once and give the returned signing_url to the user.</li>
<li>
The user opens the link, reviews the prepared request, and signs it in Privy or
MetaMask.
</li>
<li>
Use the returned hash with arc_payment_submit when the agent receives it separately.
</li>
<li>When the state is COMMITTED, open its ArcScan proof and compare the transfer.</li>
</ol>
<pre className="docs-code" aria-label="arc_payment tool input">
Expand Down
Loading
Loading