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
37 changes: 37 additions & 0 deletions .agent/context/20260913T-mcp-user-wallet-flow.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 30 additions & 21 deletions .agents/skills/oneshot-arc-payment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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-<purpose-slug>-<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
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface ApiDependencies {
| 'createOrReplay'
| 'createUserWalletOrReplay'
| 'get'
| 'getByBusinessIntentId'
| 'list'
| 'resumeDelivery'
| 'recordActivityObservation'
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 10 additions & 4 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}
Expand Down Expand Up @@ -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),
};
}
Expand Down
Loading
Loading