diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2a3ca9e..42742e2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,8 +9,8 @@ { "name": "molecule-desci", "source": "./", - "description": "DeSci molecule orchestration (aura-orchestrator skill: public or private/encrypted data-room uploads) backed by the molecule MCP server. V2 GraphQL surface, keyed on ipnftUid.", - "version": "0.2.0" + "description": "DeSci molecule orchestration (aura-orchestrator skill: public or private/encrypted data-room uploads) backed by the custody-free molecule MCP server - it crafts the requests/payloads, your wallet (Privy agentic wallet recommended, or your own key) signs/sends. V2 GraphQL surface, keyed on ipnftUid.", + "version": "0.3.0" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index fee1a33..c45c382 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "molecule-desci", - "description": "DeSci molecule orchestration for Molecule Labs in one skill (aura-orchestrator): POI registration + IP-NFT minting + project creation + x402-paid data-room file upload (public or client-side-encrypted) + announcement + transfer, backed by the `molecule` MCP server (Privy agentic wallet, full x402 payment flow, AES-256-GCM envelope crypto, ABI encoding, on-chain access conditions). V2 GraphQL surface, keyed on ipnftUid.", - "version": "0.2.0", + "description": "DeSci molecule orchestration for Molecule Labs in one skill (aura-orchestrator): POI registration + IP-NFT minting + project creation + x402-paid data-room file upload (public or client-side-encrypted) + announcement + transfer, backed by the custody-free `molecule` MCP server. V2 GraphQL surface, keyed on ipnftUid. The MCP server crafts requests and payloads (x402 prepare/submit, AES-256-GCM envelope crypto, ABI encoding, on-chain access conditions) but never holds a key, signs, or broadcasts - you bring a wallet / on chain rpc connector that does that. However, this plugin includes the privy-agentic-wallets skill (recommended for agentic use).", + "version": "0.3.0", "author": { "name": "Vladimir Demidov", "email": "vladimir@molecule.to" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index fd9a3e7..779889a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "molecule-desci", - "version": "0.2.0", - "description": "DeSci molecule orchestration for Molecule Labs in one skill (aura-orchestrator): POI registration + IP-NFT minting + project creation + x402-paid data-room file upload (public or client-side-encrypted) + announcement + transfer, backed by the `molecule` MCP server. V2 GraphQL surface, keyed on ipnftUid.", + "version": "0.3.0", + "description": "DeSci molecule orchestration for Molecule Labs in one skill (aura-orchestrator): POI registration + IP-NFT minting + project creation + x402-paid data-room file upload (public or client-side-encrypted) + announcement + transfer, backed by the custody-free `molecule` MCP server. V2 GraphQL surface, keyed on ipnftUid. The MCP server crafts requests and payloads (x402 prepare/submit, AES-256-GCM envelope crypto, ABI encoding, on-chain access conditions) but never holds a key, signs, or broadcasts - you bring a wallet / on chain rpc connector that does that. However, this plugin includes the privy-agentic-wallets skill (recommended for agentic use).", "skills": "./skills/", "mcpServers": "./.mcp.json" } diff --git a/README.md b/README.md index 59b530e..061ef05 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,21 @@ Packages the DeSci orchestration skill (+ a wallet helper) and the `molecule` MC installable plugin that works under **Claude Code** and **OpenAI Codex** (and any MCP host, via the server alone). -- **`aura-orchestrator`** — the whole molecule in one skill: POI registration → IP-NFT minting → project +- **`aura-orchestrator`** — the whole molecule labs stack in one skill: POI registration → IP-NFT minting → project creation → data-room file upload → announcement → transfer. V2 surface, keyed on `ipnftUid`. The file upload (Phase 4) is the only branch: choose **public** (plaintext) or **private** (client-side AES-256-GCM envelope-encrypted, access-controlled) — **x402 pays per call either way**. -- **`privy-agentic-wallets`** — helper for creating/managing the Privy server wallet (with a policy) that - signs payments and on-chain transactions. Run once if `PRIVY_WALLET_ID` is unset. -- **`molecule` MCP server** (`mcp/server.py`, Python/FastMCP, stdio) — Privy wallet ops, POI, Labs - GraphQL, the full x402 payment flow, S3 upload, AES-256-GCM envelope crypto, ABI encoding, on-chain - access conditions (`isAuthorizedSignerForIpnft`). +- **`privy-agentic-wallets`** — the **recommended** way to provision wallets that help signing for the molecule stack: + they are policy-guarded server wallets (no user interactions needed, ideal for autonomous agents). Alternatively you + can bring any key and RPC server you control to sign, submit and watch transactions. +- **`molecule` MCP server** (`mcp/server.py`, Python/FastMCP, stdio) — **custody-free**: it *crafts* the + requests/payloads (POI, Labs GraphQL, **x402 prepare/submit**, S3 upload, AES-256-GCM envelope crypto, + ABI encoding, on-chain access conditions, service-token sign-in) and runs only the non-signing HTTP + around them. It **never holds a key, signs, or broadcasts** - your wallet does that. -> **The MCP server is the portable core** — both harnesses speak MCP. Skills (`SKILL.md`) are a shared +See the [`skills/aura-orchestrator/references/wallet-signing.md`](skills/aura-orchestrator/references/wallet-signing.md) document for ready-to-use signing snippets (Privy first, then viem / ethers / eth-account). + +> **The MCP server is the portable core** — harnesses speak MCP. Skills (`SKILL.md`) are a shared > standard both now read. Only the *plugin manifest* differs per harness, so this package ships both > `.claude-plugin/` and `.codex-plugin/` manifests pointing at the same `skills/` and `.mcp.json`. @@ -42,12 +46,14 @@ The MCP server runs via **`uv run mcp/server.py`**, which reads the PEP 723 inli ## Environment variables -The server reads all config/secrets from the environment (never from tool args). Provide them however -your harness injects env into MCP subprocesses. Non-secrets: `MOLECULE_CLIENT_URL`, `MOLECULE_LABS_URL`, +The server reads all config from the environment (never from tool args). Non-secrets: `MOLECULE_CLIENT_URL`, `MOLECULE_LABS_URL`, `X402_GATEWAY_URL`, `ACCESS_RESOLVER_ADDRESS`, `IPNFT_CONTRACT_ADDRESS`, `CHAIN_ID`, `ENVIRONMENT`, -`EVM_WALLET_ADDRESS`, `EXPERIMENT_COST_CENTS`, `IPNFT_UID`. Secrets: `PRIVY_APP_ID`, `PRIVY_APP_SECRET`, -`PRIVY_WALLET_ID`, `POI_API_KEY`, `MOLECULE_API_KEY`, `MOLECULE_SERVICE_TOKEN`. See `mcp/README.md` for -the per-tool breakdown. +`EVM_WALLET_ADDRESS` (your operating wallet's **public** address), `EXPERIMENT_COST_CENTS`. + +Secrets: +`POI_API_KEY`, `MOLECULE_API_KEY`, `MOLECULE_SERVICE_TOKEN`. Either Your Privy agent wallet credentials +`PRIVY_APP_ID` / `PRIVY_APP_SECRET` / `PRIVY_WALLET_ID` or your own private key material (keep them wherever your wallet tooling reads them). + See [mcp/README.md](mcp/README.md) for the per-tool breakdown. --- @@ -83,15 +89,14 @@ MOLECULE_LABS_URL = "https://migration.graphql.api.molecule.xyz/graphql" X402_GATEWAY_URL = "https://…" CHAIN_ID = "84532" ENVIRONMENT = "migration" -EVM_WALLET_ADDRESS = "0x…" +EVM_WALLET_ADDRESS = "0x…" # your operating wallet's PUBLIC address — no private key here ACCESS_RESOLVER_ADDRESS = "0x…" -# secrets: -PRIVY_APP_ID = "…" -PRIVY_APP_SECRET = "…" -PRIVY_WALLET_ID = "…" +# secrets (NOT wallet keys — the MCP never signs): POI_API_KEY = "…" MOLECULE_API_KEY = "…" MOLECULE_SERVICE_TOKEN = "…" +# Your WALLET credentials (a Privy PRIVY_APP_ID/PRIVY_APP_SECRET/PRIVY_WALLET_ID — optional — or your +# own private key) live with YOUR signer/wallet tooling, NOT in the molecule MCP env. ``` or, equivalently: `codex mcp add molecule --env CHAIN_ID=84532 --env … -- uv run /abs/path/to/molecule-plugin/mcp/server.py` @@ -119,13 +124,15 @@ order; this is the cross-skill map. 1. **Env + MCP.** Install `uv`, register the plugin (Claude) or MCP server (Codex), and set the env vars above. Pick the surface with `MOLECULE_LABS_URL` / `X402_GATEWAY_URL` / `CHAIN_ID` / `ENVIRONMENT`. -2. **Wallet** → run **`privy-agentic-wallets`** *only if* `PRIVY_WALLET_ID` is unset. It creates a Privy - server wallet **with a policy** (single-chain + per-tx value cap); set the returned `PRIVY_WALLET_ID`. - Then **fund** that wallet: USDC on Base (x402 pays per call) + native gas on the mint chain. +2. **Wallet (your signer).** Provision the wallet that will sign Privy agentic wallets are recommended (check the [skills/privy-agentic-wallets/SKILL.md](skills/privy-agentic-wallets/SKILL.md)) skill to create policy-guarded server wallets; alternatively bring any key and EVM skill you + control and trust. Put its **public** address in `EVM_WALLET_ADDRESS`. Then **fund** it: USDC on Base (x402 pays + per call) + native gas on the mint chain. The MCP never sees the key — see + [`skills/aura-orchestrator/references/wallet-signing.md`](skills/aura-orchestrator/references/wallet-signing.md). 3. **Service token** (private uploads only) → ensure `MOLECULE_SERVICE_TOKEN` is set, or issue one with the - MCP `issue_service_token` tool. This is an **off-chain JWT** (issued by `generateServiceToken` after a - wallet signature — *not* an on-chain mint). The Phase 4 **private** variant uses it for the direct DEK - calls (`labs_generate_dek` / `labs_decrypt_dek`). Not needed for public uploads. + custody-free flow: `service_signin_message` → **sign the message with your wallet** → `service_token_create`. + This is an **off-chain JWT** (`generateServiceToken` verifies your signature — *not* an on-chain mint). + The Phase 4 **private** variant uses it for the direct DEK calls (`labs_generate_dek` / `labs_decrypt_dek`). + Not needed for public uploads. ### Step 1 — Run `aura-orchestrator`, choosing the upload visibility @@ -160,21 +167,29 @@ Every phase consumes the previous phase's output (`reservationId` → `ipnftUid` Run these **instead of** Phase 4 Steps A–C when the upload visibility is **private**: ``` -E0 labs_generate_dek (direct) → encryptedDek, dekHandle [no payment] -E1 encrypt_file → iv, contentHash, cipherBytes -E2 x402_pay initiateCreateOrUpdateFileV2 → uploadToken, uploadUrl [PAID] +E0 labs_generate_dek (service-token) → encryptedDek, dekHandle [no payment] +E1 encrypt_file → iv, contentHash, cipherBytes +E2 x402 initiateCreateOrUpdateFileV2 (prepare → sign → submit) → uploadToken, uploadUrl [PAID] E3 s3_upload (the .enc ciphertext) [no payment] E4 build_access_conditions (ipnft-signer, reservationId = tokenId) → json -E5 x402_pay finishCreateOrUpdateFileV2 (+ encryptionMetadata) [PAID] +E5 x402 finishCreateOrUpdateFileV2 (+ encryptionMetadata, prepare → sign → submit) [PAID] E6 labs_decrypt_dek (ipnftUid+filePath) → decrypt_file → verify SHA-256 [optional, no payment] ``` `contentLength` in E2 is the **ciphertext** size (`cipherBytes` from E1). The plaintext DEK never leaves -the MCP — only the opaque `dekHandle` is passed between E0→E1 and E6. +the MCP — only the opaque `dekHandle` is passed between E0→E1 and E6. The two **x402 paid** steps are +each *prepare → sign with your wallet → submit* (the MCP never signs). ## ⚠️ Running cost -`aura-orchestrator` Phases 3–6 perform **paid x402 mutations — real USDC on Base per call** — and -on-chain transactions (mint/transfer). They need a funded Privy wallet and a valid service token / API -key (the **private** upload variant also needs `MOLECULE_SERVICE_TOKEN`). For a no-spend smoke, use -only the compute/direct tools (`encrypt_file`/`decrypt_file`, `build_access_conditions`, `sha256_file`; -`labs_generate_dek` needs only a service token, no payment). +`aura-orchestrator` Phases 3–6 prepare **paid x402 mutations — real USDC on Base per call** — and +on-chain transactions (mint/transfer) that you must sign and broadcast using a wallet provider of your choice running on a funded account (e.g. a Privy agentic wallet or your own keypair) and a valid service token / API key (the +**private** upload variant also needs a `MOLECULE_SERVICE_TOKEN`). For no-spend smoke testing, use only the +compute tools (`prepare_transaction`, `x402_prepare` stops before you sign, `encrypt_file`/`decrypt_file`, +`build_access_conditions`, `sha256_file`; `labs_generate_dek` needs only a service token, no payment). + +## Privy Wallets Provenance + +We mostly copied over the original Privy skills. Here you find the originals: + +- [https://agents.privy.io/skill.md](the agent skill) +- [Privy's github repository](https://github.com/privy-io/privy-agentic-wallets-skill/blob/main/SKILL.md) \ No newline at end of file diff --git a/mcp/README.md b/mcp/README.md index d95c9ec..501eb2e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -1,11 +1,16 @@ # molecule-mcp A single **stdio MCP server** that backs the [`aura-orchestrator`](../aura-orchestrator/SKILL.md) -skill (POI → mint → project → public *or* private/encrypted data-room upload → announce → transfer) -and the [`privy-agentic-wallets`](../privy-agentic-wallets/SKILL.md) helper. Every `curl` / -`http_request` / `node -e` step in those skills is now a typed MCP tool, so the agent calls **one -tool per operation** -instead of hand-assembling shell commands, base64 dances, and EIP-712 payloads. +skill (POI → mint → project → public *or* private/encrypted data-room upload → announce → transfer). +Every `curl` / `http_request` / `node -e` *non-signing* step in that skill is a typed MCP tool, so the +agent calls **one tool per operation** instead of hand-assembling shell commands and base64 dances. + +**Custody-free by design.** This server **never holds a private key, signs a message, or broadcasts a +transaction.** It *crafts* the requests/payloads the molecule needs (transactions, EIP-712 typed-data, +GraphQL, x402 challenges, encryption) and runs only the non-signing HTTP around them. Every +wallet-dependent step is handed back to the **caller's wallet** — a Privy agentic wallet (the recommended +first option) or any key the caller controls — to sign/send; see +[`../aura-orchestrator/references/wallet-signing.md`](../aura-orchestrator/references/wallet-signing.md). - **Language:** Python (FastMCP) — chosen over Bun/Node so the plugin runs under **any** MCP-capable harness (Claude Code, Codex, …) with only a Python interpreter. @@ -13,8 +18,8 @@ instead of hand-assembling shell commands, base64 dances, and EIP-712 payloads. - **Deps:** `mcp`, `httpx`, `cryptography`, `eth-abi`, `eth-utils`, `eth-hash[pycryptodome]` Nothing is ever written to **stdout** except the JSON-RPC protocol (FastMCP owns stdout); all -diagnostics go to **stderr**. Secrets (`PRIVY_APP_SECRET`, the plaintext DEK, the service token, -API keys) are never logged or returned to the caller. +diagnostics go to **stderr**. The server holds **no wallet credentials**; the secrets it does read (the +service token, API keys) and the in-process plaintext DEK are never logged or returned to the caller. --- @@ -72,67 +77,85 @@ After registering, enable it in your harness (for Claude Code: add `"molecule"` The server reads all configuration from **environment variables**, which the harness injects into the MCP subprocess (for Claude Code, from `.claude/settings.json` non-secrets and -`.claude/settings.local.json` secrets). The skills therefore **never pass secrets as tool -arguments** — only file paths, queries, addresses, and the ephemeral `dekHandle`. +`.claude/settings.local.json` secrets). It reads **no wallet credentials** (no private key, no Privy +secret) — those stay with the caller's wallet. The skill **never passes secrets as tool arguments** — +only file paths, queries, public addresses, signatures the caller produced, and the ephemeral `dekHandle`. | Variable | Where | Used by | |----------|-------|---------| | `MOLECULE_CLIENT_URL` | settings.json | `poi_register` | -| `MOLECULE_LABS_URL` | settings.json | `labs_graphql`, `labs_generate_dek`, `labs_decrypt_dek`, `issue_service_token` | -| `X402_GATEWAY_URL` | settings.json | `x402_pay` | +| `MOLECULE_LABS_URL` | settings.json | `labs_graphql`, `labs_generate_dek`, `labs_decrypt_dek`, `service_signin_message`, `service_token_create` | +| `X402_GATEWAY_URL` | settings.json | `x402_prepare`, `x402_submit` | | `ACCESS_RESOLVER_ADDRESS` | settings.json | `build_access_conditions` | | `IPNFT_CONTRACT_ADDRESS` | settings.json | (skill body) | -| `CHAIN_ID` | settings.json | `privy_create_policy`, `privy_send_transaction`, `build_access_conditions` | +| `CHAIN_ID` | settings.json | `prepare_transaction`, `build_access_conditions` | | `ENVIRONMENT` | settings.json | `build_access_conditions` (base vs baseSepolia) | -| `EVM_WALLET_ADDRESS` | settings.json | wallet resolution + `x-wallet-address` | +| `EVM_WALLET_ADDRESS` | settings.json | default signer **public address** (x402 `from`, `x-wallet-address`) | | `EXPERIMENT_COST_CENTS` | settings.json | (skill body) | -| `PRIVY_APP_ID` | settings.local.json | all Privy tools (basic-auth user) | -| `PRIVY_APP_SECRET` | settings.local.json | all Privy tools (basic-auth pass) | -| `PRIVY_WALLET_ID` | settings.local.json | wallet that signs/sends | | `POI_API_KEY` | settings.local.json | `poi_register` | | `MOLECULE_API_KEY` | settings.local.json | `labs_graphql` (auth=`api-key`) | | `MOLECULE_SERVICE_TOKEN` | settings.local.json | `labs_graphql`/DEK tools (auth=`service-token`) | +**Not read by this server:** your wallet credentials. A Privy `PRIVY_APP_ID` / `PRIVY_APP_SECRET` / +`PRIVY_WALLET_ID` (recommended) or your own private key live with your **signer**, not here — the MCP +only ever needs the **public** `EVM_WALLET_ADDRESS`. + If a tool needs a variable that isn't set, it returns a clear error naming the missing variable(s) — it never guesses an endpoint or address. +#### Staging environment (Sepolia — default for new integrations) + +Use these values to get started. Contracts run on Sepolia (chain 11155111); the x402 gateway and +GraphQL API point at the staging stack. Secrets (`POI_API_KEY`, `MOLECULE_API_KEY`, +`MOLECULE_SERVICE_TOKEN`) must be obtained from Molecule Labs. + +| Variable | Staging value | +|----------|---------------| +| `MOLECULE_CLIENT_URL` | `https://testnet.molecule.xyz/` | +| `MOLECULE_LABS_URL` | `https://staging.graphql.api.molecule.xyz/graphql` | +| `IPNFT_CONTRACT_ADDRESS` | `0x152B444e60C526fe4434C721561a077269FcF61a` | +| `ACCESS_RESOLVER_ADDRESS` | `0xd9b492fd34b1579C052b2EA25970178B3011Ce6B` | +| `X402_GATEWAY_URL` | `https://zgnyn6izbk.execute-api.eu-central-2.amazonaws.com/prod` | +| `CHAIN_ID` | `11155111` | +| `ENVIRONMENT` | `staging` | +| `EXPERIMENT_COST_CENTS` | `1` | + ### Verify offline `.venv/bin/python smoke.py` lists all tools and exercises the pure-compute ones — no network or secrets required. It regression-checks `hex_to_uint256` and `abi_encode` against known-good values, -confirms `abi_encode` rejects non-`0x` bytes, builds an `ipnft-signer` access condition, and -round-trips AES-256-GCM encrypt/decrypt. +confirms `abi_encode` rejects non-`0x` bytes, builds an `ipnft-signer` access condition, validates +`prepare_transaction` normalization, and round-trips AES-256-GCM encrypt/decrypt. --- ## Tools -### Privy (wallet management, signing, sending) +### Wallet handoff (the MCP prepares; the caller's wallet signs/sends) -| Tool | Replaces | Returns | -|------|----------|---------| -| `privy_get_wallet_address` | aura `get_wallet_address`; x402 "resolve wallet" curl | `{ address, walletId }` | -| `privy_list_wallets` | aura Step 0b curl | wallet list | -| `privy_create_policy` | aura Step 0c curl | `{ policyId }` | -| `privy_create_wallet` | aura Step 0d curl | `{ walletId, address }` | -| `privy_sign_message` | aura `sign_message` (terms); service-token sign-in | `{ signature }` | -| `privy_sign_typed_data` | ad-hoc EIP-712 (x402 does this internally) | `{ signature }` | -| `privy_send_transaction` | aura `sign_and_send_transaction` (POI anchor, mint, transfer) | `{ txHash }` | +The server signs nothing — these tools craft what the caller signs/sends and accept the result back. -### Molecule HTTP +| Tool | Crafts / does | Returns | +|------|---------------|---------| +| `prepare_transaction` | normalize a tx request (POI anchor, mint, transfer) for the caller to sign + broadcast | `{ transaction:{to,data,value,valueWei,chainId,caip2}, note }` | +| `x402_prepare` | fetch the 402 challenge + build the EIP-712 the caller signs | `{ prepared:{endpoint,query,variables,accepted,resource,authorization,typedData} }` | +| `x402_submit` | post the caller's signed x402 payment (does NOT sign) | `{ data, errors, settlement }` | + +`x402_prepare` sends the unpaid request, decodes the `payment-required` challenge, and builds the EIP-712 +`TransferWithAuthorization` (standard camelCase `primaryType`) with `from = walletAddress` — then stops. +**The caller signs `prepared.typedData` with their own wallet** (Privy: remap `primaryType`→`primary_type`; +own key: sign as-is) and calls `x402_submit(prepared, signature)`, which base64-encodes the payment payload +and retries with the `PAYMENT-SIGNATURE` header. The single top-level GraphQL field in `query` **must +equal** `mutation`. + +### Molecule HTTP (non-signing) | Tool | Replaces | Returns | |------|----------|---------| | `poi_register` | aura Phase 1 POI curl/http_request | `{ poiTo, poiData, merkleRoot, response }` | -| `labs_graphql` | aura Steps 2,3,5,6,8 GraphQL; public sign-in queries | `{ data, errors }` | -| `x402_pay` | the **entire** P1–P7 flow for one mutation | `{ data, errors, settlement }` | +| `labs_graphql` | aura Steps 2,3,5,6,8 GraphQL; public queries | `{ data, errors }` | | `s3_upload` | aura Step 4/B image+file PUT; x402 E3 ciphertext PUT | `{ status, ok }` | -`x402_pay` sends the unpaid request, decodes the `payment-required` challenge, signs the EIP-712 -`TransferWithAuthorization` with the Privy wallet (standard camelCase `primaryType`), builds and -base64-encodes the payment payload, and retries with the `PAYMENT-SIGNATURE` header — all -internally. The single top-level GraphQL field in `query` **must equal** `mutation`. - ### DEK-aware (the plaintext DEK never leaves the server) | Tool | Replaces | Returns | @@ -144,9 +167,8 @@ These wrap the DEK mutations and stash the **plaintext DEK in server memory**, r `dekHandle` instead. The agent passes the handle to `encrypt_file` / `decrypt_file`, so the one-shot secret DEK never enters the conversation, a file, or a log. `labs_decrypt_dek` takes `ipnftUid`+`filePath` (data-room file, `{contractAddress}_{tokenId}`) or `tokenUri`+`agreementUrl` -(IPFS agreement) — matching `encryption.graphql`. Both DEK mutations are now x402-whitelisted, but -the tools default to `transport='direct'` (service-token) so the plaintext DEK stays in-process and -no payment is needed. +(IPFS agreement) — matching `encryption.graphql`. Both use `auth='service-token'` (a JWT, not a wallet +signature), so the plaintext DEK stays in-process and no x402 payment is needed. ### Crypto / encoding (pure compute) @@ -177,20 +199,23 @@ not a guarantee, so the server enforces it at the tool boundary, **non-overridab file the agent encrypted can never reach S3, regardless of `accessLevel` or which upload path the agent takes. Uploading the `.enc` ciphertext, the cover image, or a genuinely-public file is unaffected (different bytes / never encrypted). -- `build_access_conditions` records the IP-NFT **tokenId**. `x402_pay` then **refuses** - `finishCreateOrUpdateFileV2` for that tokenId when `accessLevel` is `PUBLIC` or `encryptionMetadata` - is missing — a molecule whose access conditions were built can only be finalized non-PUBLIC + encrypted. +- `build_access_conditions` records the IP-NFT **tokenId**. `x402_prepare` / `x402_submit` (and the direct + `labs_graphql` path) then **refuse** `finishCreateOrUpdateFileV2` for that tokenId when `accessLevel` is + `PUBLIC` or `encryptionMetadata` is missing — a molecule whose access conditions were built can only be + finalized non-PUBLIC + encrypted. The latch is process-local (cleared on subprocess restart, like the DEK store) and keyed on exact plaintext bytes + tokenId, so it has no false positives for legitimate public uploads or for a different molecule handled in the same session. -### Bootstrap +### Service token (off-chain JWT — the MCP prepares + exchanges; the caller signs) -| Tool | Replaces | Returns | -|------|----------|---------| -| `issue_service_token` | issue an off-chain JWT service token bound to the Privy AGENT wallet (3-step flow) | `{ token, tokenId, expiresAt }` | -| `issue_owner_service_token` | issue an off-chain JWT service token bound to the OWNER EOA (signs with `WALLET_PRIVATE_KEY`) | `{ token, tokenId, address, expiresAt }` | +| Tool | Crafts / does | Returns | +|------|---------------|---------| +| `service_signin_message` | fetch `getServiceSignInMessage` for a wallet (its address → the token's `adminAddress`) | `{ message, walletAddress, serviceName }` | +| `service_token_create` | exchange the caller's `personal_sign` of that message for the JWT via `generateServiceToken` | `{ token, tokenId, expiresAt }` | + +Between the two, **the caller `personal_sign`s the `message` with their own wallet** (eg Privy agent wallet, or their own key) — the MCP server itself never signs. Bind the token to whatever wallet is the IP-NFT's authorized signer. --- @@ -198,10 +223,10 @@ different molecule handled in the same session. | Behavior | Replicated from | |----------|-----------------| -| x402 challenge / payment header / `PAYMENT-SIGNATURE` | `desci-infra/lambda/x402-gateway-lambda/index.ts` | +| x402 challenge decode / payment header / `PAYMENT-SIGNATURE` (caller signs) | `desci-infra/lambda/x402-gateway-lambda/index.ts` | | x402 mutation whitelist | `desci-infra/lambda/x402-gateway-lambda/mutations.ts` | | AES-256-GCM envelope (12-byte IV, appended tag, plaintext hash) | `desci-ecosystem/packages/storage/src/lib/encryption/kms-envelope.ts` | | `accessControlConditions` (`isAuthorizedSignerForIpnft`) | `desci-infra/lambda/common/utils/access-control-conditions.ts` + `desci-infra/bruno/desci-labs/v2/25-finishEncryptedFileUploadV2.bru` | -| EIP-712 typed-data `primaryType` | `skills/privy-agentic-wallets/references/transactions.md` | +| EIP-712 `TransferWithAuthorization` typed-data (built by `x402_prepare`, **signed by the caller's wallet**) | `skills/aura-orchestrator/references/wallet-signing.md` | | GraphQL field shapes / `EncryptionMetadataInput` / `decryptDataKey` args | `desci-infra/graphql/schemas/{ip-hubs,encryption}.graphql` | | Request shapes & auth headers | `desci-infra/bruno/desci-labs/v2` + `desci-infra/bruno/service-auth` | diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index 6239d08..a8cbf64 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "molecule-mcp" -version = "1.1.0" -description = "stdio MCP server backing the aura-orchestrator skill (public or private/encrypted data-room uploads) — wraps Privy, POI, Labs GraphQL, the x402 payment flow, S3 uploads, AES-256-GCM envelope crypto, ABI encoding and on-chain access conditions (V2 surface, ipnftUid)." +version = "2.0.0" +description = "stdio MCP server backing the aura-orchestrator skill (public or private/encrypted data-room uploads). Custody-free: it CRAFTS requests/payloads (POI, Labs GraphQL, x402 prepare/submit, S3 uploads, AES-256-GCM envelope crypto, ABI encoding, on-chain access conditions, service-token sign-in) and never holds a key, signs, or broadcasts — the caller's wallet (Privy first, or its own key) does that. V2 surface, ipnftUid." requires-python = ">=3.10" dependencies = [ "mcp>=1.2.0", diff --git a/mcp/server.py b/mcp/server.py index afaea48..6b4d949 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -12,14 +12,30 @@ # /// """molecule-mcp — stdio MCP server for the Molecule DeSci skills. -Replaces every ``curl`` / ``http_request`` / ``node -e`` step in the -``aura-orchestrator`` skill (public and private/encrypted data-room uploads) -with a typed MCP tool, so the agent calls one tool per operation instead of -hand-assembling shell commands. - -Written in Python (FastMCP) and run over stdio so it works under any -MCP-capable harness (Claude Code, Codex, …) with only a Python interpreter — -no Bun/Node required. +CUSTODY-FREE BY DESIGN. This server NEVER holds a private key, signs a message, +directly authorizes a payment, or broadcasts an on-chain transaction. It only *crafts* +the requests, EIP-712 typed-data, calldata, and payloads that the Molecule protocol needs, and +performs the non-signing HTTP requests that surround them (POI registration, Labs +GraphQL, the x402 challenge fetch + paid submission, S3 uploads). Every step that +requires a wallet is handed back to the **caller** to sign/send with their own +signer — a Privy agentic wallet (the recommended first option) or any key they +control — and the signature / transaction hash is passed back in to continue. + +The split, concretely: + - prepare_transaction ....... returns {to, data, value, chainId} for the caller + to sign + broadcast with their wallet (POI anchor, + IP-NFT mint, NFT transfer). + - x402_prepare / x402_submit prepare returns the EIP-712 TransferWithAuthorization + the caller signs; submit takes that signature and + posts the paid request. The server signs nothing. + - service_signin_message / prepare returns the sign-in message the caller + service_token_create personal_signs; create exchanges that signature for + the Labs JWT. The server signs nothing. +The rest (abi_encode, encrypt_file/decrypt_file, build_access_conditions, the DEK +tools, sha256_file, hex_to_uint256) is pure compute or service-token HTTP. + +Written in Python (FastMCP) and run over stdio so it works under any MCP-capable +harness (Claude Code, Codex, …) with only a Python interpreter — no Bun/Node required. This server targets the **V2 GraphQL surface** (the one live on production), keyed on ``ipnftUid`` (``{contractAddress}_{tokenId}``). The retired OCL surface (``oclId``, @@ -27,17 +43,17 @@ is intentionally NOT supported here. Source-of-truth parity (these tools faithfully replicate the real backend): - - x402 payment flow ........ desci-infra/lambda/x402-gateway-lambda/index.ts - - x402 mutation whitelist .. desci-infra/lambda/x402-gateway-lambda/mutations.ts - - AES-256-GCM envelope ..... desci-ecosystem/packages/storage/src/lib/encryption/kms-envelope.ts - - access conditions ........ desci-infra/lambda/common/utils/access-control-conditions.ts - - GraphQL field shapes ..... desci-infra/graphql/schemas/{ip-hubs,encryption}.graphql - - request shapes / auth .... desci-infra/bruno/desci-labs/v2 + desci-infra/bruno/service-auth + - x402 challenge / payment header .. desci-infra/lambda/x402-gateway-lambda/index.ts + - x402 mutation whitelist ........... desci-infra/lambda/x402-gateway-lambda/mutations.ts + - AES-256-GCM envelope .............. desci-ecosystem/packages/storage/src/lib/encryption/kms-envelope.ts + - access conditions ................. desci-infra/lambda/common/utils/access-control-conditions.ts + - GraphQL field shapes .............. desci-infra/graphql/schemas/{ip-hubs,encryption}.graphql + - request shapes / auth ............. desci-infra/bruno/desci-labs/v2 + desci-infra/bruno/service-auth Transport: stdio. NOTHING is written to stdout except the JSON-RPC protocol — FastMCP owns stdout; all diagnostics go to stderr (see ``log``). Secrets -(PRIVY_APP_SECRET, the plaintext DEK, service tokens, API keys) are never -logged or returned to the caller. +(service tokens, API keys) are never logged or returned to the caller; the server +holds NO wallet credentials at all. """ from __future__ import annotations @@ -60,7 +76,6 @@ from mcp.server.fastmcp import FastMCP -PRIVY_BASE_URL = "https://api.privy.io" HTTP_TIMEOUT = 120.0 mcp = FastMCP("molecule") @@ -132,54 +147,23 @@ def _json_or_none(resp: httpx.Response) -> Any: # -------------------------------------------------------------------------- -# Privy +# wallet address (NEVER a key) — the address whose wallet the caller will use to +# sign. The server only ever needs the public address (for the EIP-3009 `from`, +# the IP-NFT minter/terms signer, and the service-token adminAddress). The key +# stays entirely with the caller. # -------------------------------------------------------------------------- -def _privy_auth() -> tuple[tuple[str, str], dict[str, str]]: - creds = require_env("PRIVY_APP_ID", "PRIVY_APP_SECRET") - return ( - (creds["PRIVY_APP_ID"], creds["PRIVY_APP_SECRET"]), - {"privy-app-id": creds["PRIVY_APP_ID"], "Content-Type": "application/json"}, - ) - - -def privy_rpc(wallet_id: str, body: dict[str, Any]) -> Any: - auth, headers = _privy_auth() - resp = _client.post( - f"{PRIVY_BASE_URL}/v1/wallets/{wallet_id}/rpc", - auth=auth, - headers=headers, - content=json.dumps(body), - ) - if resp.status_code >= 400: - raise ToolError(f"Privy RPC failed ({resp.status_code}): {resp.text[:500]}") - return _json_or_none(resp) - - -def resolve_wallet_id(explicit: str | None) -> str: - wid = explicit or env("PRIVY_WALLET_ID") - if not wid: - raise ToolError( - "No wallet id available. Pass walletId or set PRIVY_WALLET_ID. " - "Use privy_list_wallets / privy_create_wallet to obtain one." - ) - return wid - - -def get_wallet_address(wallet_id: str | None = None) -> str: - from_env = env("EVM_WALLET_ADDRESS") - if from_env: - return from_env - wid = resolve_wallet_id(wallet_id) - auth, headers = _privy_auth() - resp = _client.get(f"{PRIVY_BASE_URL}/v1/wallets/{wid}", auth=auth, headers=headers) - j = _json_or_none(resp) - if resp.status_code >= 400 or not (j and j.get("address")): +def resolve_address(explicit: str | None = None) -> str: + addr = explicit or env("EVM_WALLET_ADDRESS") + if not addr: raise ToolError( - f"Could not resolve wallet address ({resp.status_code}): {resp.text[:300]}" + "No wallet address available. Pass walletAddress, or set EVM_WALLET_ADDRESS " + "to the public address of the wallet that will sign (a Privy agentic wallet — " + "the recommended first option — or any key you control). The server never " + "needs the private key." ) - return j["address"] + return addr # -------------------------------------------------------------------------- @@ -303,7 +287,7 @@ def assert_confidential_finalize_ok(variables: dict[str, Any] | None) -> None: # -------------------------------------------------------------------------- -# Labs GraphQL (direct) +# Labs GraphQL (direct, non-signing HTTP) # -------------------------------------------------------------------------- LabsAuth = Literal["service-token", "api-key", "none"] @@ -360,9 +344,12 @@ def labs_graphql_call( # -------------------------------------------------------------------------- -# x402 payment flow (P1–P7) in one call. Mirrors x402-gateway-lambda exactly: -# P1 send -> P2 decode payment-required -> P3 wallet -> P4 nonce/validity -> -# P5 Privy EIP-712 sign -> P6 build+base64 header -> P7 retry PAYMENT-SIGNATURE +# x402 payment — PREPARE (build the EIP-712 the caller signs) and SUBMIT (post +# the caller's signature). The server signs NOTHING. Mirrors x402-gateway-lambda: +# prepare: P1 send -> P2 decode payment-required -> P3 wallet -> P4 nonce/validity +# -> build TransferWithAuthorization typed-data +# << caller signs typedData with their wallet (eth_signTypedData_v4) >> +# submit: P6 build+base64 PAYMENT-SIGNATURE header -> P7 retry # -------------------------------------------------------------------------- @@ -394,21 +381,20 @@ def _chain_id_from_network(network: str) -> int: raise ToolError(f'Cannot derive chainId from network "{network}".') -def run_x402_pay( +def _x402_prepare( mutation: str, query: str, variables: dict[str, Any] | None, + wallet_address: str, gateway_url: str | None, - wallet_id: str | None, ) -> dict[str, Any]: - # Fail-closed: never let a confidential IP-NFT be finalized as a public / - # plaintext file, even if the agent reaches this with the wrong variables. + # Fail-closed: never let a confidential IP-NFT be set up for a public / + # plaintext finalize, even at the prepare step. if mutation == "finishCreateOrUpdateFileV2": assert_confidential_finalize_ok(variables) gateway = gateway_url or env("X402_GATEWAY_URL") if not gateway: raise ToolError("X402_GATEWAY_URL is not set.") - wid = resolve_wallet_id(wallet_id) endpoint = f"{gateway.rstrip('/')}/x402/labs/{mutation}" body_str = json.dumps({"query": query, "variables": variables or {}}) @@ -439,36 +425,25 @@ def run_x402_pay( if not amount or not asset or not pay_to: raise ToolError(f'x402 challenge for "{mutation}" is missing amount/asset/payTo.') - # P3 — wallet address. EIP-3009 requires the authorization `from` to be the - # address whose key signs. Privy always signs with the wallet's own key, so a - # stale EVM_WALLET_ADDRESS that differs from the Privy wallet produces a - # signature the facilitator recovers to a different signer and rejects with a - # generic "Payment verification failed". Catch that here with a clear message. - wallet_address = get_wallet_address(wid) - _auth, _phdr = _privy_auth() - _wj = _json_or_none(_client.get(f"{PRIVY_BASE_URL}/v1/wallets/{wid}", auth=_auth, headers=_phdr)) - signer_address = (_wj or {}).get("address") - if signer_address and wallet_address.lower() != signer_address.lower(): - raise ToolError( - f"x402 payment would be rejected: the EIP-3009 `from` ({wallet_address}) " - f"does not match the Privy signing wallet {wid} ({signer_address}). The " - f"facilitator recovers the signer from the signature and fails verification " - f"when signer != from. Set EVM_WALLET_ADDRESS to {signer_address}, or point " - f"PRIVY_WALLET_ID at the {wallet_address} wallet." - ) - - # P4 — nonce, validAfter, validBefore. + # P3/P4 — `from` is the caller's wallet (EIP-3009 requires from == signer); + # fresh nonce + validity window. now = int(time.time()) nonce = "0x" + secrets.token_hex(32) valid_after = str(now - 600) valid_before = str(now + max_timeout) chain_id = _chain_id_from_network(network) - # P5 — EIP-712 TransferWithAuthorization signed by the Privy wallet. - # NOTE: Privy's wallet-RPC typed_data schema is snake_case all the way down — - # the primary type field is `primary_type`, not the EIP-712 `primaryType` - # (see EthereumSignTypedDataRpcInput.Params.TypedData in @privy-io/node). The - # API rejects camelCase `primaryType` with a 400. + # Standard EIP-712 TransferWithAuthorization (camelCase primaryType). The + # CALLER signs this with their wallet (Privy: remap primaryType->primary_type + # for Privy's wallet-RPC; a raw key signs it directly). The server does NOT. + authorization = { + "from": wallet_address, + "to": pay_to, + "value": amount, + "validAfter": valid_after, + "validBefore": valid_before, + "nonce": nonce, + } typed_data = { "types": { "EIP712Domain": [ @@ -486,57 +461,55 @@ def run_x402_pay( {"name": "nonce", "type": "bytes32"}, ], }, - "primary_type": "TransferWithAuthorization", + "primaryType": "TransferWithAuthorization", "domain": { "name": extra.get("name"), "version": extra.get("version"), "chainId": chain_id, "verifyingContract": asset, }, - "message": { - "from": wallet_address, - "to": pay_to, - "value": amount, - "validAfter": valid_after, - "validBefore": valid_before, - "nonce": nonce, - }, + "message": authorization, } - sign_res = privy_rpc( - wid, {"method": "eth_signTypedData_v4", "params": {"typed_data": typed_data}} - ) - signature = (sign_res or {}).get("data", {}).get("signature") + return { + "endpoint": endpoint, + "query": query, + "variables": variables or {}, + "accepted": accepted, + "resource": resource, + "network": network, + "chainId": chain_id, + "authorization": authorization, + "typedData": typed_data, + } + + +def _x402_submit(prepared: dict[str, Any], signature: str) -> dict[str, Any]: + if not isinstance(prepared, dict): + raise ToolError("`prepared` must be the object returned by x402_prepare.") + for k in ("endpoint", "query", "accepted", "authorization"): + if k not in prepared: + raise ToolError(f"`prepared` is missing '{k}' — pass the x402_prepare result verbatim.") if not signature: - raise ToolError( - f"Privy did not return a signature for the x402 payment. Raw: {json.dumps(sign_res)[:400]}" - ) + raise ToolError("`signature` (the caller's EIP-712 signature of prepared.typedData) is required.") + variables = prepared.get("variables") or {} + # Re-apply the fail-closed finalize guard at submit time. + if "finishCreateOrUpdateFileV2" in (prepared.get("query") or ""): + assert_confidential_finalize_ok(variables) # P6 — build the payment payload and base64-encode it. payment_payload = { "x402Version": 2, - "resource": resource, - "accepted": accepted, - "payload": { - "signature": signature, - "authorization": { - "from": wallet_address, - "to": pay_to, - "value": amount, - "validAfter": valid_after, - "validBefore": valid_before, - "nonce": nonce, - }, - }, + "resource": prepared.get("resource"), + "accepted": prepared["accepted"], + "payload": {"signature": signature, "authorization": prepared["authorization"]}, } payment_header = base64.b64encode(json.dumps(payment_payload).encode()).decode() + body_str = json.dumps({"query": prepared["query"], "variables": variables}) # P7 — retry with PAYMENT-SIGNATURE (the only header the gateway reads). paid_res = _client.post( - endpoint, - headers={ - "Content-Type": "application/json", - "PAYMENT-SIGNATURE": payment_header, - }, + prepared["endpoint"], + headers={"Content-Type": "application/json", "PAYMENT-SIGNATURE": payment_header}, content=body_str, ) paid = _json_or_none(paid_res) @@ -545,9 +518,7 @@ def run_x402_pay( f"x402 paid request returned non-JSON ({paid_res.status_code}): {paid_res.text[:500]}" ) settlement = {} - settle_hdr = paid_res.headers.get("x-payment-response") or paid_res.headers.get( - "payment-response" - ) + settle_hdr = paid_res.headers.get("x-payment-response") or paid_res.headers.get("payment-response") if settle_hdr: settlement["payment-response"] = settle_hdr return {"data": paid.get("data"), "errors": paid.get("errors"), "settlement": settlement} @@ -686,276 +657,95 @@ def abi_encode_impl(function_signature: str, args: list[Any]) -> str: # TOOLS # ========================================================================== -# ---- Privy: wallet management + signing + sending ----------------------- - - -@mcp.tool() -def privy_get_wallet_address(walletId: str | None = None) -> str: - """Resolve the agent wallet address. Returns $EVM_WALLET_ADDRESS if set, - otherwise looks up the Privy server wallet by id. Replaces aura's - get_wallet_address / 'resolve the wallet address' curl.""" - address = get_wallet_address(walletId) - return dump({"address": address, "walletId": walletId or env("PRIVY_WALLET_ID")}) - - -@mcp.tool() -def privy_list_wallets(chainType: str = "ethereum") -> str: - """List existing Privy server wallets (GET /v1/wallets). Use during wallet - setup to reuse an existing wallet.""" - auth, headers = _privy_auth() - resp = _client.get( - f"{PRIVY_BASE_URL}/v1/wallets", - params={"chain_type": chainType}, - auth=auth, - headers=headers, - ) - if resp.status_code >= 400: - raise ToolError(f"Privy list wallets failed ({resp.status_code}): {resp.text[:300]}") - return dump(_json_or_none(resp)) +# ---- Wallet handoff: the server prepares, the CALLER's wallet signs/sends ---- +# None of these tools sign or broadcast. They craft the exact request/typed-data +# the caller then signs and sends with their own wallet — a Privy agentic wallet +# (the recommended first option) or any key the caller controls. @mcp.tool() -def privy_create_policy( - name: str = "DeSci agent policy", - chainId: str | None = None, - maxValueWei: str = "10000000000000000", -) -> str: - """Create a restrictive DeSci agent policy: single-chain (CHAIN_ID) + a - per-tx value cap. Returns {policyId}.""" - cid = chainId or env("CHAIN_ID") - if not cid: - raise ToolError("chainId not provided and CHAIN_ID is not set.") - body = { - "version": "1.0", - "name": name, - "chain_type": "ethereum", - "rules": [ - { - "name": "Single chain only", - "method": "eth_sendTransaction", - "conditions": [ - {"field_source": "ethereum_transaction", "field": "chain_id", "operator": "eq", "value": cid} - ], - "action": "ALLOW", - }, - { - "name": "Per-tx value cap", - "method": "eth_sendTransaction", - "conditions": [ - {"field_source": "ethereum_transaction", "field": "value", "operator": "lte", "value": maxValueWei} - ], - "action": "ALLOW", - }, - ], - } - auth, headers = _privy_auth() - resp = _client.post(f"{PRIVY_BASE_URL}/v1/policies", auth=auth, headers=headers, content=json.dumps(body)) - if resp.status_code >= 400: - raise ToolError(f"Privy create policy failed ({resp.status_code}): {resp.text[:300]}") - j = _json_or_none(resp) - return dump({"policyId": (j or {}).get("id"), "policy": j}) - - -@mcp.tool() -def privy_create_wallet(policyIds: list[str] | None = None) -> str: - """Create a Privy server wallet, optionally attaching policy ids. Returns - {walletId, address}. After this, set PRIVY_WALLET_ID for future runs.""" - body: dict[str, Any] = {"chain_type": "ethereum"} - if policyIds: - body["policy_ids"] = policyIds - auth, headers = _privy_auth() - resp = _client.post(f"{PRIVY_BASE_URL}/v1/wallets", auth=auth, headers=headers, content=json.dumps(body)) - if resp.status_code >= 400: - raise ToolError(f"Privy create wallet failed ({resp.status_code}): {resp.text[:300]}") - j = _json_or_none(resp) - return dump({"walletId": (j or {}).get("id"), "address": (j or {}).get("address"), "wallet": j}) - - -@mcp.tool() -def privy_sign_message(message: str, walletId: str | None = None, encoding: Literal["utf-8", "hex"] = "utf-8") -> str: - """EIP-191 personal_sign via the Privy wallet. Used to sign the IP-NFT terms - message and the service-token sign-in message. Returns {signature}.""" - wid = resolve_wallet_id(walletId) - res = privy_rpc(wid, {"method": "personal_sign", "params": {"message": message, "encoding": encoding}}) - signature = (res or {}).get("data", {}).get("signature") - if not signature: - raise ToolError(f"No signature returned. Raw: {json.dumps(res)[:300]}") - return dump({"signature": signature}) - - -@mcp.tool() -def privy_sign_typed_data(typedData: dict, walletId: str | None = None) -> str: - """Generic eth_signTypedData_v4 via the Privy wallet. Pass the full EIP-712 - typed-data object using the standard camelCase `primaryType` key (Privy wraps - it as params.typed_data). x402_pay does this internally; use this only for - ad-hoc signing. Returns {signature}.""" - wid = resolve_wallet_id(walletId) - # Privy's wallet-RPC typed_data schema uses snake_case `primary_type`; accept - # the standard EIP-712 camelCase `primaryType` from callers and remap it. - if isinstance(typedData, dict) and "primaryType" in typedData and "primary_type" not in typedData: - typedData = {**typedData, "primary_type": typedData["primaryType"]} - typedData.pop("primaryType", None) - res = privy_rpc(wid, {"method": "eth_signTypedData_v4", "params": {"typed_data": typedData}}) - signature = (res or {}).get("data", {}).get("signature") - if not signature: - raise ToolError(f"No signature returned. Raw: {json.dumps(res)[:300]}") - return dump({"signature": signature}) - - -@mcp.tool() -def privy_send_transaction( +def prepare_transaction( to: str, data: str | None = None, value: str | None = None, chainId: str | None = None, - walletId: str | None = None, ) -> str: - """Send a transaction from the Privy wallet (eth_sendTransaction with - caip2 eip155:). Replaces aura's sign_and_send_transaction (POI - anchor, IP-NFT mint, NFT transfer). value is decimal wei (string). - Returns {txHash}.""" - wid = resolve_wallet_id(walletId) - cid = chainId or env("CHAIN_ID") + """Craft an Ethereum transaction request for the CALLER to sign + broadcast + with their own wallet (POI anchor, IP-NFT mint, NFT transfer). This server + does NOT sign or send — it only assembles and normalizes the fields. Build + `data` with abi_encode. value is decimal wei (or 0x hex); it is returned in + both decimal and hex. Returns {transaction:{to,data,value,valueWei,chainId, + caip2}, note}. Hand `transaction` to your wallet's send-transaction call + (Privy: eth_sendTransaction; raw key: sign + eth_sendRawTransaction) and feed + the resulting txHash back into the next step.""" + cid = str(chainId or env("CHAIN_ID") or "") if not cid: raise ToolError("chainId not provided and CHAIN_ID is not set.") - transaction: dict[str, Any] = {"to": to} + tx: dict[str, Any] = {"to": to, "chainId": int(cid), "caip2": f"eip155:{cid}"} if data: - transaction["data"] = data - if value: - # Privy's transaction.value must be hex-encoded wei ("0x…"); the tool's - # documented input is decimal wei, so convert (and pass hex through). + tx["data"] = data + if value is not None and str(value).strip() != "": v = str(value).strip() - transaction["value"] = v if v.startswith("0x") else hex(int(v)) - res = privy_rpc( - wid, - {"method": "eth_sendTransaction", "caip2": f"eip155:{cid}", "params": {"transaction": transaction}}, - ) - data_obj = (res or {}).get("data", {}) or {} - tx_hash = data_obj.get("hash") or data_obj.get("transaction_hash") or (res or {}).get("hash") - if not tx_hash: - raise ToolError(f"No tx hash returned. Raw: {json.dumps(res)[:400]}") - return dump({"txHash": tx_hash}) - - -# Public fallback RPC endpoints by chain id, used by privy_send_raw_transaction -# when neither rpcUrl nor EVM_RPC_URL is provided. -_DEFAULT_RPC_BY_CHAIN: dict[str, str] = { - "11155111": "https://ethereum-sepolia-rpc.publicnode.com", # Sepolia L1 -} - - -def _chain_rpc(rpc_url: str, method: str, params: list[Any]) -> Any: - """Minimal JSON-RPC call against an EVM node (live nonce + raw broadcast).""" - resp = _client.post( - rpc_url, - headers={"content-type": "application/json"}, - content=json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}), + wei = int(v, 16) if v.lower().startswith("0x") else int(v) + tx["value"] = str(wei) # decimal wei + tx["valueWei"] = hex(wei) # 0x hex wei (what most wallet RPCs want) + return dump( + { + "transaction": tx, + "note": ( + "Sign and broadcast this with YOUR wallet (Privy agentic wallet recommended; " + "or any key you control). This server never signs or sends. Then pass the " + "returned txHash to the next step." + ), + } ) - j = _json_or_none(resp) - if resp.status_code >= 400 or not isinstance(j, dict) or j.get("error") or "result" not in j: - err = j.get("error") if isinstance(j, dict) else None - raise ToolError( - f"EVM RPC {method} failed ({resp.status_code}): " - + (json.dumps(err) if err else resp.text[:300]) - ) - return j["result"] @mcp.tool() -def privy_send_raw_transaction( - to: str, - data: str | None = None, - value: str | None = None, - chainId: str | None = None, - walletId: str | None = None, - rpcUrl: str | None = None, - gasLimit: str | None = None, - maxFeePerGas: str | None = None, - maxPriorityFeePerGas: str | None = None, +def x402_prepare( + mutation: str, + query: str, + variables: dict | None = None, + walletAddress: str | None = None, + gatewayUrl: str | None = None, ) -> str: - """Sign with Privy (eth_signTransaction, SIGN-ONLY) then broadcast the raw tx - yourself via eth_sendRawTransaction against an EVM RPC. Use this for the IP-NFT - `safeTransferFrom` (Phase 6 transfer): Privy's eth_sendTransaction returns a - hash but never broadcasts safeTransferFrom for the agent wallet — the "phantom - hash" — even though mint/POI broadcast fine, so keep using privy_send_transaction - for those. Resolves the live `pending` nonce so the call is re-runnable. rpcUrl - falls back to EVM_RPC_URL, then a public node for known chains. value is decimal - wei (or 0x hex). Gas is auto-estimated (eth_estimateGas ×1.2) unless gasLimit is - passed — a flat default reverts mint out-of-gas; EIP-1559 fees default to 5/2 gwei. - Returns {txHash, nonce, from, gasLimit}.""" - wid = resolve_wallet_id(walletId) - cid = str(chainId or env("CHAIN_ID") or "") - if not cid: - raise ToolError("chainId not provided and CHAIN_ID is not set.") - rpc = rpcUrl or env("EVM_RPC_URL") or _DEFAULT_RPC_BY_CHAIN.get(cid) - if not rpc: - raise ToolError( - f"No EVM RPC endpoint for chainId {cid}. Pass rpcUrl or set EVM_RPC_URL." - ) - - # Resolve the SENDER (signer) address from the Privy wallet record directly. - # Do NOT use get_wallet_address(): it prefers EVM_WALLET_ADDRESS, which in the - # aura flow is the transfer RECIPIENT, not the sender — that would query the - # wrong account's nonce. - auth, headers = _privy_auth() - wj = _json_or_none(_client.get(f"{PRIVY_BASE_URL}/v1/wallets/{wid}", auth=auth, headers=headers)) - sender = (wj or {}).get("address") - if not sender: - raise ToolError(f"Could not resolve signer address for Privy wallet {wid}.") - - # Live pending nonce keeps the tool re-runnable after a stuck/failed attempt. - nonce = int(_chain_rpc(rpc, "eth_getTransactionCount", [sender, "pending"]), 16) - - val = "0x0" - if value: - v = str(value).strip() - val = v if v.startswith("0x") else hex(int(v)) - - # Gas limit: an explicit override wins; otherwise estimate with a 20% buffer. - # A flat default is unsafe — a transfer is ~51k but mintReservation needs - # ~176k, so a fixed 100k silently reverts OUT-OF-GAS on mint. Note that an - # eth_call simulation can still PASS in that window (it assumes a high gas - # cap), so it is a misleading signal — eth_estimateGas is the real check. - if gasLimit: - gas_limit_hex = gasLimit - else: - est_call: dict[str, Any] = {"from": sender, "to": to, "value": val} - if data: - est_call["data"] = data - try: - est = int(_chain_rpc(rpc, "eth_estimateGas", [est_call]), 16) - gas_limit_hex = hex(est * 12 // 10) # ×1.2 buffer - except ToolError: - gas_limit_hex = "0x61a80" # 400000 fallback (covers mint ~176k + transfers) - - transaction: dict[str, Any] = { - "to": to, - "value": val, - "chain_id": int(cid), - "nonce": nonce, - # EIP-1559 fee defaults from privy_transfer_v5.sh (5 gwei / 2 gwei); - # override per chain congestion via the params above. - "max_fee_per_gas": maxFeePerGas or "0x12a05f200", - "max_priority_fee_per_gas": maxPriorityFeePerGas or "0x77359400", - "gas_limit": gas_limit_hex, - "type": 2, - } - if data: - transaction["data"] = data - - res = privy_rpc( - wid, - {"chain_type": "ethereum", "method": "eth_signTransaction", "params": {"transaction": transaction}}, + """Prepare a paid x402 mutation WITHOUT signing: fetch the gateway's 402 + challenge and build the EIP-712 `TransferWithAuthorization` (EIP-3009 USDC) + that the CALLER must sign with their wallet. The single top-level GraphQL field + in `query` MUST equal `mutation`. `walletAddress` (or EVM_WALLET_ADDRESS) is the + EIP-3009 `from` and MUST equal the address that will sign. Returns a `prepared` + object — sign `prepared.typedData` with your wallet (Privy: eth_signTypedData_v4, + remapping primaryType->primary_type per Privy's RPC; raw key: sign_typed_data), + then call x402_submit(prepared, signature). Whitelisted mutations (V2 surface): + initiateCreateOrUpdateFileV2, finishCreateOrUpdateFileV2, createAnnouncementV2, + createProject, addProjectOwner, generateDataEncryptionKey, decryptDataKey. All + data-room args are keyed on ipnftUid ({contractAddress}_{tokenId}).""" + addr = resolve_address(walletAddress) + prepared = _x402_prepare(mutation, query, variables, addr, gatewayUrl) + return dump( + { + "prepared": prepared, + "next": ( + "Sign prepared.typedData with YOUR wallet (Privy agentic wallet recommended), " + "then call x402_submit with {prepared, signature}. This server signs nothing." + ), + } ) - signed = ((res or {}).get("data") or {}).get("signed_transaction") - if not signed: - raise ToolError(f"Privy returned no signed_transaction. Raw: {json.dumps(res)[:400]}") - tx_hash = _chain_rpc(rpc, "eth_sendRawTransaction", [signed]) - return dump({"txHash": tx_hash, "nonce": nonce, "from": sender, "gasLimit": gas_limit_hex}) + +@mcp.tool() +def x402_submit(prepared: dict, signature: str) -> str: + """Submit a prepared x402 mutation using the CALLER's signature. Pass the + `prepared` object returned by x402_prepare verbatim plus the EIP-712 + `signature` your wallet produced over prepared.typedData. This builds the + base64 PAYMENT-SIGNATURE header and posts the paid request — it does NOT sign. + Returns {data, errors, settlement}; read data. and check + isSuccess / error. (A response that reports isSuccess:false is a real business + error — surface it.)""" + return dump(_x402_submit(prepared, signature)) -# ---- Molecule HTTP ------------------------------------------------------- +# ---- Molecule HTTP (non-signing) ----------------------------------------- @mcp.tool() @@ -963,7 +753,8 @@ def poi_register(filePath: str, clientUrl: str | None = None, contentType: str = """Register a Proof of Invention: multipart POST to $MOLECULE_CLIENT_URL/api/v1/inventions (field name 'files', Bearer $POI_API_KEY). Returns the full response plus extracted - {poiTo, poiData, merkleRoot}.""" + {poiTo, poiData, merkleRoot}. (No wallet involved; the returned poiTo/poiData + is the on-chain anchor tx you then prepare_transaction + sign/send yourself.)""" creds = require_env("POI_API_KEY") base = clientUrl or env("MOLECULE_CLIENT_URL") if not base: @@ -1002,37 +793,18 @@ def labs_graphql( """POST a GraphQL query/mutation to $MOLECULE_LABS_URL. auth='api-key' sends x-api-key:$MOLECULE_API_KEY (aura mint flow). auth='service-token' sends x-service-token:$MOLECULE_SERVICE_TOKEN + x-wallet-address:$EVM_WALLET_ADDRESS - (private/encrypted upload). auth='none' for public sign-in queries. Returns {data, errors}. - Do NOT use for generateDataEncryptionKey/decryptDataKey — use - labs_generate_dek/labs_decrypt_dek so the plaintext DEK stays inside the server.""" - # Same fail-closed finalize guard as x402_pay, in case the finalize is ever - # routed through the direct Labs endpoint instead of the x402 gateway. + (private/encrypted upload). auth='none' for public queries. Returns {data, errors}. + This is non-signing HTTP only — it does not sign or send transactions. Do NOT use + for generateDataEncryptionKey/decryptDataKey — use labs_generate_dek/labs_decrypt_dek + so the plaintext DEK stays inside the server. For PAID mutations use + x402_prepare/x402_submit (the caller signs).""" + # Same fail-closed finalize guard as x402, in case a finalize is ever routed + # through the direct Labs endpoint instead of the x402 gateway. if "finishCreateOrUpdateFileV2" in query: assert_confidential_finalize_ok(variables) return dump(labs_graphql_call(query, variables or {}, auth, labsUrl)) -@mcp.tool() -def x402_pay( - mutation: str, - query: str, - variables: dict | None = None, - gatewayUrl: str | None = None, - walletId: str | None = None, -) -> str: - """Run the entire x402 payment flow (P1–P7) for ONE whitelisted mutation in a - single call: send -> decode the payment-required challenge -> sign the EIP-712 - TransferWithAuthorization with the Privy wallet -> retry with PAYMENT-SIGNATURE. - The single top-level GraphQL field in `query` MUST equal `mutation` (the - gateway's validateMutationQuery enforces this). Returns {data, errors, settlement}. - Whitelisted mutations (the V2 surface, from x402-gateway-lambda/mutations.ts): - initiateCreateOrUpdateFileV2, finishCreateOrUpdateFileV2, createAnnouncementV2, - createProject, addProjectOwner, generateDataEncryptionKey, decryptDataKey (any - other mutation 400s with 'not enabled for x402 gateway'). All data-room args are - keyed on ipnftUid ({contractAddress}_{tokenId}) — NOT oclId.""" - return dump(run_x402_pay(mutation, query, variables, gatewayUrl, walletId)) - - @mcp.tool() def s3_upload( uploadUrl: str, @@ -1042,8 +814,8 @@ def s3_upload( headers: dict | None = None, ) -> str: """PUT (or POST) a local file to a presigned S3 URL, applying all headers - returned by the initiate step plus Content-Type. NO x402 payment. Used for the - cover image, public file upload (Step B), and the encrypted ciphertext (E3). + returned by the initiate step plus Content-Type. No wallet / no payment. Used for + the cover image, public file upload (Step B), and the encrypted ciphertext (E3). Returns {status, ok}.""" data = Path(filePath).read_bytes() assert_not_confidential_plaintext(filePath, data) @@ -1059,32 +831,23 @@ def s3_upload( @mcp.tool() def labs_generate_dek( - transport: Literal["direct", "x402"] = "direct", auth: LabsAuth = "service-token", - gatewayUrl: str | None = None, labsUrl: str | None = None, - walletId: str | None = None, ) -> str: - """Call generateDataEncryptionKey and KEEP the plaintext DEK inside this - server. Returns {encryptedDek, encryptionSystem, dekHandle} — pass dekHandle to - encrypt_file. The plaintext DEK is NEVER returned to the agent. transport='direct' - (default, service-token) is the recommended path — it needs no payment and keeps - the DEK in-process. generateDataEncryptionKey IS now x402-whitelisted (mutations.ts), - so transport='x402' also works, but prefer 'direct' for DEK generation.""" + """Call generateDataEncryptionKey (direct, service-token) and KEEP the plaintext + DEK inside this server. Returns {encryptedDek, encryptionSystem, dekHandle} — pass + dekHandle to encrypt_file. The plaintext DEK is NEVER returned to the agent. This + is a service-token HTTP call (no wallet signature, no payment); the DEK stays + in-process. (generateDataEncryptionKey is also x402-whitelisted, but keep it direct + here so no payment is spent on a key fetch.)""" query = ( "mutation GenerateDataEncryptionKey { generateDataEncryptionKey { isSuccess " "plaintextDEK encryptedDek encryptionSystem error { message code retryable } } }" ) - if transport == "x402": - r = run_x402_pay("generateDataEncryptionKey", query, {}, gatewayUrl, walletId) - if r.get("errors"): - raise ToolError(f"generateDataEncryptionKey errors: {json.dumps(r['errors'])[:400]}") - result = (r.get("data") or {}).get("generateDataEncryptionKey") - else: - r = labs_graphql_call(query, {}, auth, labsUrl) - if r.get("errors"): - raise ToolError(f"generateDataEncryptionKey errors: {json.dumps(r['errors'])[:400]}") - result = (r.get("data") or {}).get("generateDataEncryptionKey") + r = labs_graphql_call(query, {}, auth, labsUrl) + if r.get("errors"): + raise ToolError(f"generateDataEncryptionKey errors: {json.dumps(r['errors'])[:400]}") + result = (r.get("data") or {}).get("generateDataEncryptionKey") if not result or not result.get("isSuccess") or not result.get("plaintextDEK"): raise ToolError( f"generateDataEncryptionKey did not succeed: {json.dumps((result or {}).get('error') or result)[:400]}" @@ -1105,25 +868,22 @@ def labs_decrypt_dek( ipnftUid: str | None = None, tokenUri: str | None = None, agreementUrl: str | None = None, - transport: Literal["direct", "x402"] = "direct", auth: LabsAuth = "service-token", - gatewayUrl: str | None = None, labsUrl: str | None = None, - walletId: str | None = None, serviceToken: str | None = None, walletAddress: str | None = None, ) -> str: - """Call decryptDataKey (the backend evaluates on-chain access conditions for - the caller) and KEEP the plaintext DEK inside this server. Returns - {iv, dekHandle, message} — pass dekHandle to decrypt_file. + """Call decryptDataKey (direct, service-token — the backend evaluates the + on-chain access conditions for the caller) and KEEP the plaintext DEK inside this + server. Returns {iv, dekHandle, message} — pass dekHandle to decrypt_file. The decryptDataKey mutation (encryption.graphql) accepts ipnftUid + filePath (a data-room file, format {contractAddress}_{tokenId}) or tokenUri + agreementUrl (an IPFS agreement). For a data-room file pass ipnftUid + filePath. ACCESS_DENIED - means the caller wallet fails the on-chain condition; LEGACY_ENCRYPTION means the - file predates the envelope flow. decryptDataKey IS x402-whitelisted, but - transport='direct' (service-token) is recommended so the plaintext DEK stays - in-process and no payment is needed.""" + means the caller (the service token's adminAddress) fails the on-chain condition; + LEGACY_ENCRYPTION means the file predates the envelope flow. Pass serviceToken / + walletAddress to act as a specific authorized wallet without swapping env. This is + a service-token HTTP call — no wallet signature, no payment.""" if not ipnftUid and not tokenUri: raise ToolError("Provide ipnftUid (data-room file) or tokenUri (IPFS agreement).") arg_decls, arg_uses, variables = [], [], {} @@ -1147,15 +907,10 @@ def labs_decrypt_dek( f"mutation DecryptDataKey({', '.join(arg_decls)}) {{ decryptDataKey({', '.join(arg_uses)}) " "{ isSuccess plaintextDEK iv message error { message code retryable } } }" ) - if transport == "x402": - r = run_x402_pay("decryptDataKey", query, variables, gatewayUrl, walletId) - result = (r.get("data") or {}).get("decryptDataKey") - else: - r = labs_graphql_call( - query, variables, auth, labsUrl, - service_token=serviceToken, wallet_address=walletAddress, - ) - result = (r.get("data") or {}).get("decryptDataKey") + r = labs_graphql_call( + query, variables, auth, labsUrl, service_token=serviceToken, wallet_address=walletAddress + ) + result = (r.get("data") or {}).get("decryptDataKey") if not result or not result.get("isSuccess") or not result.get("plaintextDEK"): # Surface backend status verbatim (ACCESS_DENIED / LEGACY_ENCRYPTION). return dump( @@ -1169,7 +924,7 @@ def labs_decrypt_dek( return dump({"iv": result.get("iv"), "dekHandle": handle, "message": result.get("message")}) -# ---- Crypto / encoding --------------------------------------------------- +# ---- Crypto / encoding (pure compute) ------------------------------------ @mcp.tool() @@ -1216,12 +971,13 @@ def hex_to_uint256(hex: str) -> str: @mcp.tool() def abi_encode(functionSignature: str, args: list) -> str: - """ABI-encode a Solidity function call to calldata. functionSignature is e.g. - 'mintReservation(address,uint256,string,string,bytes)' or - 'safeTransferFrom(address,address,uint256)'. Pass args in order: uint*/int* as + """ABI-encode a Solidity function call to calldata (pure compute — no signing). + functionSignature is e.g. 'mintReservation(address,uint256,string,string,bytes)' + or 'safeTransferFrom(address,address,uint256)'. Pass args in order: uint*/int* as decimal strings or ints; bytes/bytesN as 0x-prefixed hex (a non-0x string is rejected, NOT silently UTF-8 encoded); address as 0x + 40 hex; string as text. - Returns {calldata}.""" + Returns {calldata} — pass it as `data` to prepare_transaction, then sign/send with + your wallet.""" return dump({"calldata": abi_encode_impl(functionSignature, args)}) @@ -1257,23 +1013,24 @@ def build_access_conditions( return dump({"conditions": conditions, "json": json.dumps(conditions, separators=(",", ":"))}) -# ---- service token bootstrap -------------------------------------------- +# ---- Service token: PREPARE the sign-in message, then EXCHANGE the caller's +# signature for the JWT. The server signs nothing. --------------------- @mcp.tool() -def issue_service_token( - serviceName: str = "data-sync-service", - expiresIn: str = "720h", +def service_signin_message( walletAddress: str | None = None, - walletId: str | None = None, + serviceName: str = "data-sync-service", labsUrl: str | None = None, ) -> str: - """Issue a Labs JWT service token (off-chain credential — NOT an on-chain mint): - getServiceSignInMessage -> personal_sign (Privy) -> generateServiceToken. Returns - {token, tokenId, expiresAt} — set token as MOLECULE_SERVICE_TOKEN in - settings.local.json. The token is a secret; this server never logs it. Prefer - pre-setting MOLECULE_SERVICE_TOKEN over issuing per run.""" - addr = walletAddress or get_wallet_address(walletId) + """Step 1/2 of issuing a Labs JWT service token (off-chain credential — NOT an + on-chain mint). Fetch getServiceSignInMessage for the wallet that will own the + token's access (its address becomes the token's adminAddress, which the decrypt + evaluator substitutes into isAuthorizedSignerForIpnft). Returns {message, + walletAddress, serviceName}. Have YOUR wallet personal_sign (EIP-191) the exact + `message` (Privy agentic wallet recommended; or any key bound to walletAddress), + then call service_token_create with that signature. This server does NOT sign.""" + addr = resolve_address(walletAddress) msg = labs_graphql_call( "query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) " "{ getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) { message } }", @@ -1283,73 +1040,60 @@ def issue_service_token( ) message = ((msg.get("data") or {}).get("getServiceSignInMessage") or {}).get("message") if not message: - raise ToolError(f"getServiceSignInMessage returned no message: {json.dumps(msg.get('errors') or msg)[:300]}") - wid = resolve_wallet_id(walletId) - sig = privy_rpc(wid, {"method": "personal_sign", "params": {"message": message, "encoding": "utf-8"}}) - message_signature = (sig or {}).get("data", {}).get("signature") - if not message_signature: - raise ToolError("Privy did not return a signature for the sign-in message.") + raise ToolError( + f"getServiceSignInMessage returned no message: {json.dumps(msg.get('errors') or msg)[:300]}" + ) + return dump( + { + "message": message, + "walletAddress": addr, + "serviceName": serviceName, + "next": ( + "personal_sign this exact message with YOUR wallet (the one bound to " + f"{addr}), then call service_token_create(walletAddress, messageSignature)." + ), + } + ) + + +@mcp.tool() +def service_token_create( + walletAddress: str, + messageSignature: str, + serviceName: str = "data-sync-service", + expiresIn: str = "720h", + labsUrl: str | None = None, +) -> str: + """Step 2/2 of issuing a Labs JWT service token: exchange the caller's + personal_sign signature of the service_signin_message for the token via + generateServiceToken. The backend verifies the signature against walletAddress and + binds the token's adminAddress to it. Returns {token, tokenId, expiresAt} — set + `token` as MOLECULE_SERVICE_TOKEN (secret; this server never logs it), or pass it + per-call via labs_decrypt_dek(serviceToken=...) to act as that wallet. This server + does NOT sign — `messageSignature` must come from the caller's wallet.""" tok = labs_graphql_call( "mutation GenerateServiceToken($serviceName: String!, $expiresIn: String!, $walletAddress: String, $messageSignature: String) " "{ generateServiceToken(serviceName: $serviceName, expiresIn: $expiresIn, walletAddress: $walletAddress, messageSignature: $messageSignature) " "{ token tokenId serviceName expiresAt isSuccess message } }", - {"serviceName": serviceName, "expiresIn": expiresIn, "walletAddress": addr, "messageSignature": message_signature}, + { + "serviceName": serviceName, + "expiresIn": expiresIn, + "walletAddress": walletAddress, + "messageSignature": messageSignature, + }, "none", labsUrl, ) result = (tok.get("data") or {}).get("generateServiceToken") if not result or not result.get("isSuccess") or not result.get("token"): raise ToolError(f"generateServiceToken failed: {json.dumps(result or tok.get('errors'))[:300]}") - return dump({"token": result.get("token"), "tokenId": result.get("tokenId"), "expiresAt": result.get("expiresAt")}) - - -@mcp.tool() -def issue_owner_service_token( - ownerPrivateKey: str | None = None, - serviceName: str = "owner-data-access", - expiresIn: str = "720h", - labsUrl: str | None = None, -) -> str: - """Issue a Labs JWT service token (off-chain credential — NOT an on-chain mint) - bound to the OWNER (user's personal) wallet by signing getServiceSignInMessage - with the owner's raw private key (WALLET_PRIVATE_KEY by default). Unlike - issue_service_token (which signs via the Privy AGENT wallet), this binds the - token to the owner EOA — required because - decryptDataKey gates on the service token's adminAddress. Pass the returned - `token` to labs_decrypt_dek(serviceToken=...) to decrypt as the owner. Returns - {token, tokenId, address, expiresAt}. The private key never leaves this process.""" - try: - from eth_account import Account - from eth_account.messages import encode_defunct - except ImportError as e: # pragma: no cover - raise ToolError(f"eth-account is required for owner-key signing: {e}") - pk = ownerPrivateKey or env("WALLET_PRIVATE_KEY") - if not pk: - raise ToolError("Provide ownerPrivateKey or set WALLET_PRIVATE_KEY.") - acct = Account.from_key(pk) - msg_q = ("query GetServiceSignInMessage($w: String!, $s: String!) { " - "getServiceSignInMessage(walletAddress: $w, serviceName: $s) { message } }") - m = labs_graphql_call(msg_q, {"w": acct.address, "s": serviceName}, "api-key", labsUrl) - message = (((m.get("data") or {}).get("getServiceSignInMessage")) or {}).get("message") - if not message: - raise ToolError(f"getServiceSignInMessage failed: {json.dumps(m.get('errors') or m)[:300]}") - signature = Account.sign_message(encode_defunct(text=message), pk).signature.hex() - signature = signature if signature.startswith("0x") else "0x" + signature - tok_q = ("mutation GenerateServiceToken($s: String!, $e: String, $w: String, $m: String) { " - "generateServiceToken(serviceName: $s, expiresIn: $e, walletAddress: $w, messageSignature: $m) " - "{ token tokenId expiresAt isSuccess message } }") - t = labs_graphql_call( - tok_q, {"s": serviceName, "e": expiresIn, "w": acct.address, "m": signature}, "api-key", labsUrl + return dump( + { + "token": result.get("token"), + "tokenId": result.get("tokenId"), + "expiresAt": result.get("expiresAt"), + } ) - res = ((t.get("data") or {}).get("generateServiceToken")) or {} - if not res.get("isSuccess") or not res.get("token"): - raise ToolError(f"generateServiceToken failed: {json.dumps(res or t.get('errors'))[:400]}") - return dump({ - "token": res.get("token"), - "tokenId": res.get("tokenId"), - "address": acct.address, - "expiresAt": res.get("expiresAt"), - }) # -------------------------------------------------------------------------- @@ -1358,7 +1102,7 @@ def issue_owner_service_token( def main() -> None: - log("molecule-mcp ready (stdio)") + log("molecule-mcp ready (stdio) — custody-free: crafts payloads, never signs") mcp.run() diff --git a/mcp/smoke.py b/mcp/smoke.py index a9f8117..f0128bc 100644 --- a/mcp/smoke.py +++ b/mcp/smoke.py @@ -85,9 +85,22 @@ async def call(name, args): ok &= r["conditions"][0]["functionName"] == "isAuthorizedSignerForIpnft" and r["conditions"][0]["chain"] == "baseSepolia" print("ipnft-signer ok:", r["conditions"][0]["functionName"], r["conditions"][0]["chain"]) - r = await call("privy_get_wallet_address", {}) # env path, no network - ok &= r["address"] == "0xa2eC2967Da7bC51494F8a5427B9784Cb5a05cD3c" - print("privy_get_wallet_address (env, no net):", r) + # prepare_transaction is pure compute (no signing / no network): it only + # normalizes the fields the caller hands to their own wallet. + r = await call("prepare_transaction", { + "to": "0xa2eC2967Da7bC51494F8a5427B9784Cb5a05cD3c", + "data": "0xdeadbeef", + "value": "1000000000000000", + "chainId": "84532", + }) + tx = r["transaction"] + ok &= ( + tx["caip2"] == "eip155:84532" + and tx["value"] == "1000000000000000" + and tx["valueWei"] == "0x38d7ea4c68000" + and tx["data"] == "0xdeadbeef" + ) + print("prepare_transaction (pure, no signing):", tx) # round-trip encrypt/decrypt via dekHandle (no network: inject a fake DEK) import base64 as b64 diff --git a/skills/aura-orchestrator/SKILL.md b/skills/aura-orchestrator/SKILL.md index 2ff6725..c996881 100644 --- a/skills/aura-orchestrator/SKILL.md +++ b/skills/aura-orchestrator/SKILL.md @@ -1,7 +1,9 @@ --- name: aura-orchestrator -description: End-to-end DeSci molecule — POI registration, IP-NFT minting, Molecule authentication, project creation, file upload (public or private/encrypted), and announcement. Single-agent sequential execution, driven entirely through the `molecule` MCP server (no raw curl). +description: End-to-end DeSci molecule — POI registration, IP-NFT minting, Molecule authentication, project creation, file upload (public or private/encrypted), and announcement. Single-agent sequential execution. The `molecule` MCP server CRAFTS every request/payload (it is custody-free — it never holds a key, signs, or broadcasts); YOUR wallet (we recommend Privy agentic wallets or any key & rpc server you can use to interact with an EVM blockchain) does all signing/sending. +platforms: [macos, linux] metadata: + # Claude Code: these are injected into the MCP subprocess via settings.json / settings.local.json env_vars: - MOLECULE_CLIENT_URL - MOLECULE_LABS_URL @@ -11,52 +13,165 @@ metadata: - EVM_WALLET_ADDRESS - CHAIN_ID - EXPERIMENT_COST_CENTS - - PRIVY_APP_ID - - PRIVY_APP_SECRET - - PRIVY_WALLET_ID - POI_API_KEY - MOLECULE_API_KEY - MOLECULE_SERVICE_TOKEN + hermes: + tags: [desci, blockchain, ip-nft, molecule, x402, encryption, privy, web3] + category: web3 + requires_toolsets: [terminal] + config: + - key: MOLECULE_CLIENT_URL + description: "Molecule Labs client base URL" + default: "https://testnet.molecule.xyz/" + prompt: "Enter your Molecule Labs client URL (staging default: https://testnet.molecule.xyz/)" + - key: MOLECULE_LABS_URL + description: "Molecule Labs GraphQL API URL" + default: "https://staging.graphql.api.molecule.xyz/graphql" + prompt: "Enter the Molecule Labs API URL (staging default: https://staging.graphql.api.molecule.xyz/graphql)" + - key: IPNFT_CONTRACT_ADDRESS + description: "IP-NFT smart contract address on-chain" + default: "0x152B444e60C526fe4434C721561a077269FcF61a" + prompt: "Enter the IPNFT contract address (Sepolia staging: 0x152B444e60C526fe4434C721561a077269FcF61a)" + - key: ACCESS_RESOLVER_ADDRESS + description: "AccessResolver contract address used for encrypted file access conditions" + default: "0xd9b492fd34b1579C052b2EA25970178B3011Ce6B" + prompt: "Enter the AccessResolver contract address (Sepolia staging: 0xd9b492fd34b1579C052b2EA25970178B3011Ce6B)" + - key: X402_GATEWAY_URL + description: "x402 payment gateway URL" + default: "https://zgnyn6izbk.execute-api.eu-central-2.amazonaws.com/prod" + prompt: "Enter the x402 gateway URL (staging default: https://zgnyn6izbk.execute-api.eu-central-2.amazonaws.com/prod)" + - key: EVM_WALLET_ADDRESS + description: "Your operating wallet PUBLIC address — this is an address, NOT a private key" + prompt: "Enter your EVM wallet public address (0x...)" + - key: CHAIN_ID + description: "EVM chain ID (1=Ethereum mainnet, 8453=Base, 11155111=Sepolia, 84532=Base Sepolia)" + default: "11155111" + prompt: "Enter the chain ID (staging uses Sepolia: 11155111)" + - key: ENVIRONMENT + description: "Deployment environment tag read by build_access_conditions" + default: "staging" + prompt: "Enter the environment (staging)" + - key: EXPERIMENT_COST_CENTS + description: "Default experiment / funding cost in USD cents (used in non-interactive runs)" + default: "1" + prompt: "Enter the default experiment cost in cents (staging default: 1)" + - key: POI_API_KEY + description: "API key for Proof of Invention registration endpoint" + prompt: "Enter your POI API key" + - key: MOLECULE_API_KEY + description: "Molecule Labs API key for GraphQL mutations" + prompt: "Enter your Molecule API key" --- # Aura Orchestrator +## Prerequisites + +This skill requires the **`molecule` MCP server** to be running as a local stdio process before you +execute any step. The server is included in this repository at `mcp/server.py` and requires Python + `uv`. + +**Start the MCP server** (set `MOLECULE_PLUGIN_ROOT` to wherever you cloned / installed this repo): + +```bash +cd $MOLECULE_PLUGIN_ROOT && uv run mcp/server.py +``` + +Or configure it in your harness's MCP server block: + +```json +{ + "mcpServers": { + "molecule": { + "command": "uv", + "args": ["run", "/path/to/mol-labs-plugin/mcp/server.py"], + "env": { + "MOLECULE_CLIENT_URL": "https://testnet.molecule.xyz/", + "MOLECULE_LABS_URL": "https://staging.graphql.api.molecule.xyz/graphql", + "IPNFT_CONTRACT_ADDRESS": "0x152B444e60C526fe4434C721561a077269FcF61a", + "ACCESS_RESOLVER_ADDRESS": "0xd9b492fd34b1579C052b2EA25970178B3011Ce6B", + "X402_GATEWAY_URL": "https://zgnyn6izbk.execute-api.eu-central-2.amazonaws.com/prod", + "CHAIN_ID": "11155111", + "ENVIRONMENT": "staging", + "EXPERIMENT_COST_CENTS": "1", + "EVM_WALLET_ADDRESS": "", + "POI_API_KEY": "", + "MOLECULE_API_KEY": "", + "MOLECULE_SERVICE_TOKEN": "" + } + } + } +} +``` + +All `mcp__molecule__*` tool calls below are dispatched to this server. In Claude Code the tool prefix is +`mcp__molecule__`; in other harnesses the naming convention may differ — consult your harness docs +for how stdio MCP tools are addressed, and substitute accordingly. + +**`shared_cache`** is used throughout this skill to persist values across steps (reservation IDs, hashes, +wallet address, etc.). In Claude Code this is a built-in tool. In other harnesses, use whichever +key-value store your runtime provides (an in-memory dict, a scratch file, or your harness's equivalent) +— the important thing is that values written in one step are readable in later steps within the same run. + +**`read_file`** is used for PDF text extraction. In Claude Code this is a built-in tool. In Hermes or +other harnesses, use your runtime's file-reading or document-parsing tool. + +**Wallet / signing** either provision a Privy agentic wallet (recommended, check the bundled +`privy-agentic-wallets` skill), or reuse your own wallet skill / EOA / rpc logic. If you do, your harness must be able to call the wallet's +signing RPC (`eth_signTypedData_v4`, `personal_sign`, `eth_sendTransaction`). See +[references/wallet-signing.md](references/wallet-signing.md). + +**`MOLECULE_SERVICE_TOKEN`** (private uploads only) is a secret JWT — do NOT expose it via a config +prompt. Store it in your harness's secrets store and inject it as an env var into the MCP server process. + +--- + Complete DeSci molecule executed as one continuous sequence of tool calls. Do NOT stop, report progress, or output text between steps — execute ALL steps as one uninterrupted flow. - -Every network, on-chain, and crypto operation runs through the **`molecule` MCP server** -(`mcp/`). The only non-MCP tools used are `read_file` (PDF text extraction), -`shared_cache` (cross-step state), and `Bash` (waits/timestamps only — never curl). +(The one unavoidable interruption is signing: wherever the flow says **sign with your wallet**, hand +the prepared payload to your wallet, get the signature/txHash, and continue — see *Wallet & signing*.) + +The **`molecule` MCP server** (`mcp/`) is **custody-free**: it *crafts* the transactions, EIP-712 +typed-data, and HTTP payloads this molecule needs and runs only the non-signing HTTP around them — it +**never holds a private key, signs, or broadcasts**. Every wallet-dependent step is handed back to +**your wallet** to sign/send (a **Privy agentic wallet — the recommended first option** — or any key +you control), and you feed the signature / transaction hash back in to continue. See +[references/wallet-signing.md](references/wallet-signing.md) for ready-to-use signing snippets. +The only non-MCP tools used are `read_file` (PDF text extraction), `shared_cache` (cross-step state), +your wallet's signer, and `Bash` (waits/timestamps only — never curl). **SUPER IMPORTANT RULES:** -- POI registration is an **HTTP API call** (`mcp__molecule__poi_register`), NOT a smart contract call. Do NOT use `abi_encode` or `privy_send_transaction` for POI. +- **The MCP never signs or sends.** It is custody-free: it returns *prepared* payloads and you sign/broadcast them with **your wallet** (we recomment Privy agentic wallets, or a keypair that you control with a dedicated wallet / signing skill). Wherever a step says "sign with your wallet" / "sign & send with your wallet", do exactly that and feed the resulting `signature` / `txHash` into the next step. NEVER ask the MCP to sign or send, and NEVER put a private key in a tool argument — the MCP only ever needs a **public address**. See [references/wallet-signing.md](references/wallet-signing.md). +- POI registration is an **HTTP API call** (`mcp__molecule__poi_register`), NOT a smart contract call. Do NOT use `abi_encode` or `prepare_transaction` for POI. - Use `read_file` for PDFs — it has built-in PDF text extraction. NEVER use python, pip, pdftotext, or any shell tools for PDF reading. - Do NOT `read_file` on image/binary attachments (PNG, JPG, etc.). The upload flow only needs the `file_path` — pass the path directly to `mcp__molecule__s3_upload`. - Use `shared_cache` to persist all critical molecule values (IDs, hashes, tokens, the `dekHandle`). If you need a value from an earlier step, retrieve it from cache. - Follow every URL, contract address, and function signature in this document EXACTLY. Do NOT guess or fabricate alternatives. URLs and contract addresses come from `.env`, read by the MCP — never hardcode. -- Use the x402 payment flow for ALL Molecule mutations (project creation, file uploads, announcements, ownership) via `mcp__molecule__x402_pay` — one call runs the whole P1–P7 handshake. +- Use the **x402 prepare → sign → submit** flow for ALL Molecule paid mutations (project creation, file uploads, announcements, ownership): `mcp__molecule__x402_prepare` builds the EIP-712 the **caller** signs, then `mcp__molecule__x402_submit` posts that signature. The MCP signs nothing — see the *x402 Payment Flow* section. - For **private / confidential** files, use the Private / Encrypted Upload variant in Phase 4 (Steps E0–E5) **instead of** the public Steps A–C: generate a one-shot DEK (kept inside the MCP), AES-256-GCM encrypt locally, upload the ciphertext, and finish with `encryptionMetadata` + a non-PUBLIC `accessLevel`. NEVER upload a confidential file as plaintext or with `accessLevel: PUBLIC`. - **FAIL CLOSED — no public fallback for confidential files.** Once a file is chosen for the Private / Encrypted variant, if **any** step (E0 DEK generation, E1 encryption, E2/E3 ciphertext upload, E4 access conditions, E5 finalize, E6 verify) fails and you cannot fix it in-path, **ABORT the entire molecule and report the error.** Do **NOT** "recover" by running the public Steps A–C, do **NOT** re-upload with `accessLevel: PUBLIC`, and do **NOT** `s3_upload` the plaintext PDF — ever. A confidential file leaking to public is a far worse outcome than a failed run. This is also enforced in code: `encrypt_file` arms a non-overridable MCP guard that refuses to S3-upload that file's plaintext, and `build_access_conditions` arms a guard that refuses to finalize that IP-NFT as `PUBLIC` / without `encryptionMetadata`. Do not attempt to work around these guards — they are the safety net, not the plan. - The plaintext DEK is single-use and secret. It **never leaves the MCP** — `labs_generate_dek` returns only an opaque `dekHandle`. NEVER attempt to obtain, cache, or log the plaintext DEK. Only the wrapped `encryptedDek` and the ciphertext are persisted. - AES-256-GCM encrypt/decrypt is handled by `mcp__molecule__encrypt_file`/`decrypt_file` (it replicates the Labs Web Crypto `encryptFileWithKms`). PDF reading still uses `read_file` — never python/pip/pdftotext. - Phases executed sequentially without stopping or reporting intermediate progress. -## Environment Variables — most are required for wallet management, authentication, and NFT transfer; rows marked **Optional** are not. A required var, if missing, makes the relevant MCP tool terminate with an error naming it. **`WALLET_PRIVATE_KEY` is NOT required for the default flow** — the operating wallet is a Privy agentic wallet, so the skill issues its own service token via `mcp__molecule__issue_service_token` (no raw key). You only need `WALLET_PRIVATE_KEY` if the operating/owner wallet is a raw EOA signing through `mcp__molecule__issue_owner_service_token`. +## Environment Variables — the MCP reads these for non-signing HTTP, payload crafting, and your wallet's **public** address. The MCP holds **no private key and no wallet secret** — your wallet (a Privy agentic wallet, recommended, or your own key) keeps the key and does all signing. A required var, if missing, makes the relevant MCP tool terminate with an error naming it. | Variable | Description | |----------|-------------| -| `PRIVY_APP_ID` | Privy app identifier — basic-auth user for the Privy wallet RPC (used by every `mcp__molecule__privy_*` tool) | -| `PRIVY_APP_SECRET` | Privy secret key — basic-auth password for the Privy wallet RPC | -| `PRIVY_WALLET_ID` | Privy wallet ID (auto-detected or set after wallet creation) | -| `EVM_WALLET_ADDRESS` | Owner's personal wallet address for NFT transfer (optional — skip transfer if not set) | -| `MOLECULE_SERVICE_TOKEN` | **Private uploads only.** Off-chain JWT for the direct (non-x402) DEK generate/decrypt calls (`x-service-token`), bound to one wallet's address as its `adminAddress`. Not needed for public uploads. If missing/expired, issue one **bound to the operating wallet** — `mcp__molecule__issue_service_token` (Privy agentic wallet) or `mcp__molecule__issue_owner_service_token` (EOA) — see **Service Token** below. Secret — keep in `settings.local.json`. | -| `WALLET_PRIVATE_KEY` | **Optional — EOA service tokens only.** Raw private key of the user's EOA, used by `mcp__molecule__issue_owner_service_token` to sign the sign-in message locally and bind a service token to that EOA. Only needed when the operating/owner wallet is a plain EOA rather than a Privy agentic wallet. Secret — keep in `settings.local.json`; the key never leaves the MCP process. | - -**Note:** The MCP server reads all URLs, contract addresses, API keys, and secrets from the environment -(`.claude/settings.json` for non-secrets, `.claude/settings.local.json` for secrets), which Claude Code -injects into the MCP subprocess. The skill therefore passes only file paths, addresses, queries, and -non-secret values as tool arguments — never secrets. Switching between staging and production is a `.env` -edit only — never modify the skill body for environment changes. +| `EVM_WALLET_ADDRESS` | **Public address of your operating wallet** — the wallet that signs the terms, authorizes x402 payments, mints, and (until any transfer) owns the IP-NFT. The MCP uses it as the default signer identity (x402 `from`, service-token `x-wallet-address`) when you do not pass `walletAddress` explicitly. This is an address, **NOT a private key**. | +| `MOLECULE_SERVICE_TOKEN` | **Private uploads only.** Off-chain JWT for the direct (non-x402) DEK generate/decrypt calls (`x-service-token`), bound to one wallet's address as its `adminAddress`. Not needed for public uploads. If missing/expired, mint a fresh one bound to your operating wallet — `service_signin_message` → **sign with your wallet** → `service_token_create` (see **Service Token** below). Secret — keep in `settings.local.json`. | + +**Your wallet's private key NEVER goes to the MCP.** All signing happens in your wallet (Privy agentic +wallet — recommended first option — or any key you control); see *Wallet & signing* below and +[references/wallet-signing.md](references/wallet-signing.md). The MCP also reads URLs / contract +addresses / API keys from the environment (`MOLECULE_CLIENT_URL`, `MOLECULE_LABS_URL`, +`X402_GATEWAY_URL`, `ACCESS_RESOLVER_ADDRESS`, `IPNFT_CONTRACT_ADDRESS`, `CHAIN_ID`, `ENVIRONMENT`, +`POI_API_KEY`, `MOLECULE_API_KEY`) — see `mcp/README.md` for the per-tool breakdown. + +**Note:** Non-secrets live in `.claude/settings.json`, secrets in `.claude/settings.local.json`; Claude +Code injects both into the MCP subprocess. The skill passes only file paths, addresses, queries, and +non-secret values as tool arguments — **never a private key**. Switching between staging and production is +a `.env` edit only — never modify the skill body for environment changes. ## Input @@ -79,56 +194,43 @@ The "do not stop or report between steps" rule governs the **execution** flow (P 2. **Upload visibility — public or private/encrypted** — the Phase 4 path selector. Ask explicitly; present **public** as the pre-selected default but require the user to confirm. `public` → Phase 4 Steps A–C; `private/encrypted` → Phase 4 Private variant Steps E0–E6 (also needs `MOLECULE_SERVICE_TOKEN`). 3. **Organization** — the organization / lab name recorded on the IP-NFT (the `organization` field in Phase 2 Steps 2 & 5). There is **no default**; if the user is unsure, have them confirm an explicit value rather than inventing one from the document or cover image. 4. **Experiment / funding cost (USD)** — the funding amount in US dollars (e.g. `5000` → $5,000.00). Convert to integer cents for `funding_amount.value`: `experiment_cost_cents = round(USD × 100)`, kept with `"decimals": 2` (so $0.01 → `1`, $5,000 → `500000`). Only fall back to the `EXPERIMENT_COST_CENTS` env var when the run is fully non-interactive; in an interactive session always use the user's answer. +5. **Transfer recipient (OPTIONAL)** — the long-term owner wallet address to transfer the finished IP-NFT to in Phase 6. **Default: none** (the IP-NFT stays with the operating wallet and Phase 6 is skipped). Only set it if the user explicitly wants the IP-NFT handed off to a different wallet; this is **not** `EVM_WALLET_ADDRESS` (which is the operating/signer address). -You MAY confirm the auto-drafted title, symbol, and topic in the same prompt. Once these inputs are gathered, run Phases 0–6 as one uninterrupted sequence. Use the collected research lead, organization, and `experiment_cost_cents` wherever Phase 2 references `research_lead`, ``, and the funding amount, and the collected visibility to choose the Phase 4 path. +You MAY confirm the auto-drafted title, symbol, and topic in the same prompt. Once these inputs are gathered, run Phases 0–6 as one uninterrupted sequence. Use the collected research lead, organization, and `experiment_cost_cents` wherever Phase 2 references `research_lead`, ``, and the funding amount, the collected visibility to choose the Phase 4 path, and the optional transfer recipient as `owner_wallet` in Phase 6. -## Phase 0: Wallet Setup +## Phase 0: Wallet & signing setup -Before starting the molecule, verify that a Privy agentic wallet is available. If available respond with the wallet address. If not, create a new wallet with a restrictive policy and respond with the new wallet address and instructions to set `PRIVY_WALLET_ID` for future use. +This molecule needs a wallet you can **sign with** — the MCP is custody-free and never signs, so you +bring the signer. The MCP only ever needs the wallet's **public address**; the private key stays with you. -### Step 0a — Check for existing wallet +**Recommended first option: a Privy agentic wallet** — a server-side, policy-guarded wallet purpose-built +for autonomous agents. You may instead **bring your own key** (any EOA / signer you control). Either way, +you sign every prepared payload yourself; see [references/wallet-signing.md](references/wallet-signing.md). -``` -mcp__molecule__privy_get_wallet_address: {} -``` +### Step 0a — Pick / provision your signer -If this succeeds, the wallet is configured. Save the returned `address` as `wallet_address` and proceed to Phase 1. +- **Privy agentic wallet (recommended).** If you don't have one yet, provision it with the + **`privy-agentic-wallets`** skill (create a policy + server wallet, e.g. single-chain + a per-tx value + cap) and note its address. Signing how-to: [references/wallet-signing.md](references/wallet-signing.md) §A. +- **Bring your own key.** Use any wallet/library you control (viem, ethers, eth-account, a hardware + signer, …). Signing how-to: [references/wallet-signing.md](references/wallet-signing.md) §B. -If this fails (missing `PRIVY_WALLET_ID`), check for existing wallets. +### Step 0b — Record the operating address -### Step 0b — List existing wallets +Obtain your operating wallet's **public address** from your signer (Privy: fetch the wallet address; own +key: derive it), cache it, and make it the MCP's default identity: ``` -mcp__molecule__privy_list_wallets: - chainType: ethereum +shared_cache: { "operation": "put", "namespace": "molecule", "key": "wallet_address", "value": "" } ``` -If the response contains wallets, use the first one. Save its `id` as `wallet_id` and `address` as `wallet_address`. Report to the user: `Set PRIVY_WALLET_ID= to enable platform crypto tools.` - -If no wallets exist, create one. - -### Step 0c — Create a policy - -``` -mcp__molecule__privy_create_policy: - name: "DeSci agent policy" - maxValueWei: "10000000000000000" -``` +Ensure `EVM_WALLET_ADDRESS` equals this address (set it in `.claude/settings.json` for future runs). +Throughout this skill, pass this `wallet_address` as `walletAddress` to `x402_prepare`, +`service_signin_message`, and the service-token DEK calls so the MCP crafts payloads under your signer's +identity (the EIP-3009 `from`, the IP-NFT minter, and the service-token `adminAddress` must all be this +address). -(The policy is single-chain — pinned to `$CHAIN_ID` — with a 0.01 ETH per-tx value cap.) Save the returned `policyId`. - -### Step 0d — Create a wallet - -``` -mcp__molecule__privy_create_wallet: - policyIds: [""] -``` - -Save `walletId` as `wallet_id` and `address` as `wallet_address`. - -Report to the user: wallet created at `` with ID ``. The user must set `PRIVY_WALLET_ID=` in the environment for the Privy MCP tools (`privy_send_transaction`, `privy_sign_message`, `x402_pay`) to function. - -Save wallet details to `mint/wallet_info.json`. +Save wallet details (address + which signer) to `mint/wallet_info.json`. **NEVER write a private key to disk.** ## Phase 1: POI Registration @@ -187,14 +289,18 @@ Proceed immediately to Phase 2 — the merkle root is already in the POI respons ### Step 1 — Anchor POI on-chain +Prepare the anchor transaction, then **sign & send it with your wallet**: + ``` -mcp__molecule__privy_send_transaction: +mcp__molecule__prepare_transaction: to: data: chainId: $CHAIN_ID ``` -Save `txHash` as `poi_tx_hash`. (The `reservationId` was already derived from the `merkle_root` in the ID Chain section — it MUST be a large number, typically 50+ digits. Use it as `ipnftId` in ALL subsequent steps.) +Hand the returned `transaction` to your wallet to broadcast (Privy: `eth_sendTransaction`; own key: sign ++ `eth_sendRawTransaction` — see [references/wallet-signing.md](references/wallet-signing.md)). Save the +resulting `txHash` as `poi_tx_hash`. (The `reservationId` was already derived from the `merkle_root` in the ID Chain section — it MUST be a large number, typically 50+ digits. Use it as `ipnftId` in ALL subsequent steps.) Cache critical IDs immediately: @@ -306,14 +412,12 @@ mcp__molecule__labs_graphql: Save `message` from `data.getTermsMessage`. -### Step 7 — Sign terms +### Step 7 — Sign terms (with your wallet) -``` -mcp__molecule__privy_sign_message: - message: -``` - -Save `signature`. +Personal-sign (EIP-191) the **exact** `message` from Step 6 with **your wallet** (Privy: `personal_sign`; +own key: sign the message — see [references/wallet-signing.md](references/wallet-signing.md)). The MCP +does not sign this — the signature must come from the wallet whose address is the `minter` +(your `wallet_address`). Save the returned `signature`. ### Step 8 — Sign off metadata (get authorization) @@ -343,15 +447,19 @@ Save `calldata`. ### Step 10 — Mint IP-NFT on-chain +Prepare the mint transaction, then **sign & send it with your wallet**: + ``` -mcp__molecule__privy_send_transaction: +mcp__molecule__prepare_transaction: to: $IPNFT_CONTRACT_ADDRESS data: value: "1000000000000000" chainId: $CHAIN_ID ``` -The mint fee is 0.001 ETH (1000000000000000 wei). Save `txHash` as `mint_tx_hash`. +The mint fee is 0.001 ETH (1000000000000000 wei). Hand the returned `transaction` to your wallet to +broadcast (Privy: `eth_sendTransaction`; own key: sign + `eth_sendRawTransaction`). Save the resulting +`txHash` as `mint_tx_hash`. Save to `mint/metadata/mint_result.json`: - `reservation_id` (the large decimal — this IS the token_id) @@ -371,23 +479,47 @@ shared_cache: { "operation": "put", "namespace": "molecule", "key": "ipnft_symbo shared_cache: { "operation": "put", "namespace": "molecule", "key": "metadata_cid", "value": "" } ``` -## x402 Payment Flow (used by ALL mutations in Phases 3–6) +## x402 Payment Flow (used by ALL paid mutations in Phases 3–6) -Every Molecule mutation below is paid per call in USDC on Base — no API key or service token. The entire -P1–P7 handshake (send → decode the `payment-required` challenge → sign the EIP-712 -`TransferWithAuthorization` with the Privy wallet → retry with `PAYMENT-SIGNATURE`) is run **inside one -`mcp__molecule__x402_pay` call**: +Every Molecule mutation below is paid per call in USDC on Base — no API key or service token. The MCP +runs the non-signing parts (fetch the `payment-required` challenge → build the EIP-712 +`TransferWithAuthorization` → post the paid request) but **does NOT sign** — **your wallet signs the +EIP-712**. Three steps per paid call — this is the **"x402 paid call"** macro referenced throughout +Phases 3–6: + +**1) Prepare** — the MCP fetches the challenge and builds the typed-data: ``` -mcp__molecule__x402_pay: +mcp__molecule__x402_prepare: mutation: query: "" variables: { ... } + walletAddress: # your operating address = the EIP-3009 `from` ``` -It returns `{ data, errors, settlement }`; read `data.` and check `isSuccess` / `error`. +Returns `{ prepared }`, where `prepared.typedData` is the EIP-712 to sign. + +**2) Sign** — sign `prepared.typedData` with **your wallet** (EIP-712 / `eth_signTypedData_v4`). +**Privy:** remap the top-level `primaryType` → `primary_type` before calling Privy's wallet RPC (its +schema is snake_case). **Own key:** sign the standard typed-data as-is. See +[references/wallet-signing.md](references/wallet-signing.md). Keep the returned `signature`. -**Required env vars (read by the MCP):** `X402_GATEWAY_URL`, `PRIVY_APP_ID`, `PRIVY_APP_SECRET`, `PRIVY_WALLET_ID`. +**3) Submit** — the MCP posts the signed payment (it does not sign): + +``` +mcp__molecule__x402_submit: + prepared: + signature: +``` + +Returns `{ data, errors, settlement }`; read `data.` and check `isSuccess` / `error`. + +> **Shorthand:** where a phase below says *"x402 paid call: ``"* with a `query` + `variables`, +> run all three steps (prepare → sign → submit) with those exact `mutation` / `query` / `variables`, and +> `walletAddress: `. + +**Required env vars (read by the MCP):** `X402_GATEWAY_URL`. Step 2's signature is produced by **your** +wallet (Privy or your own key); the MCP holds no wallet credentials. ### GraphQL surface (V2, keyed on `ipnftUid`) @@ -398,19 +530,19 @@ Every paid step in Phases 3–6 uses the **V2** mutations keyed on `ipnftUid` (` surface (`oclId`, `initiateCreateOrUpdateFile`/`finishCreateOrUpdateFile`/`createAnnouncement`/`createLab`) is **not** used — it is not on production. -If `x402_pay` reports a mutation is not enabled / not whitelisted (HTTP 400 "not enabled for x402 +If `x402_prepare` reports a mutation is not enabled / not whitelisted (HTTP 400 "not enabled for x402 gateway", "No x402 challenge"), the gateway is misconfigured for this environment — surface the error and stop; do **not** improvise a different surface. A response that arrives but reports `isSuccess: false` is a real business error — surface it. --- -## Service Token (off-chain JWT — bind it to the operating wallet, Privy *or* EOA) +## Service Token (off-chain JWT — bind it to the wallet that owns IP-NFT access) `MOLECULE_SERVICE_TOKEN` is an **off-chain JWT** (issued by Labs `generateServiceToken`; *never* minted on-chain). It authenticates the **direct, non-x402** DEK calls — `labs_generate_dek` / `labs_decrypt_dek` -with `transport: direct`, `auth: service-token` — via the `x-service-token` header. **It is used only by -the Phase 4 Private / Encrypted variant and Phase 6 owner-decrypt; public uploads never touch it.** +with `auth: service-token` — via the `x-service-token` header. **It is used only by the Phase 4 Private / +Encrypted variant and Phase 6 owner-decrypt; public uploads never touch it.** **What the token is bound to (why the wallet matters).** The JWT payload carries an `adminAddress` — the single wallet the token represents (`token-manager-service.ts` `generateServiceToken`). On `decryptDataKey` @@ -421,37 +553,43 @@ ERC-6551 signer of — that IP-NFT.** Bind the token to whichever wallet is the at the moment of the call; a token bound to the wrong wallet authenticates fine but fails `ACCESS_DENIED` on decrypt. -**How issuance works (identical for both wallet types — no x402, no on-chain tx).** The MCP runs the same -three off-chain steps under the hood: -1. `getServiceSignInMessage(walletAddress, serviceName)` → a fixed sign-in message naming the wallet + service. -2. That wallet **signs the exact message** (EIP-191 `personal_sign`). -3. `generateServiceToken(serviceName, expiresIn, walletAddress, messageSignature)` → the backend recomputes - the message, `verifyMessage`s the signature against `walletAddress`, and on success sets - `adminAddress = walletAddress`. Returns `{ token, tokenId, expiresAt }`. - -Because step 2 is a standard ECDSA message signature, **a Privy agentic (embedded) wallet and a raw EOA are -interchangeable to the backend** — the only difference is *which key signs*. The MCP exposes one tool per -signer: - -| Operating wallet | Tool | Signs step 2 with | Binds token to | Needs | -|---|---|---|---|---| -| **Privy agentic wallet** | `mcp__molecule__issue_service_token` | Privy `personal_sign` (RPC) | the Privy wallet (`get_wallet_address`; pass `walletId`/`walletAddress` to target a non-default one) | `PRIVY_APP_ID`, `PRIVY_APP_SECRET`, `PRIVY_WALLET_ID` — **no** Privy login/session | -| **EOA (raw key)** | `mcp__molecule__issue_owner_service_token` | local `eth_account` sign with `WALLET_PRIVATE_KEY` | the EOA (`ownerPrivateKey` → its address) | `WALLET_PRIVATE_KEY` (or `ownerPrivateKey` arg) — the key never leaves the MCP | - -Both return `{ token, ... }`. Set the returned `token` as `MOLECULE_SERVICE_TOKEN` in -`.claude/settings.local.json` (it is a secret — the MCP never logs it, and neither should you), or pass it -per-call via the `serviceToken` override on `labs_generate_dek` / `labs_decrypt_dek` when you need a token -bound to a *different* wallet than the env default. +**How issuance works — the MCP prepares, YOUR wallet signs, the MCP exchanges (no x402, no on-chain tx):** + +1. **Prepare the sign-in message** (the MCP runs `getServiceSignInMessage`): + ``` + mcp__molecule__service_signin_message: + walletAddress: # e.g. your wallet_address + serviceName: data-sync-service + ``` + Returns `{ message }`. +2. **Sign it with that wallet** — EIP-191 `personal_sign` of the **exact** `message`. **Privy** + (recommended): `personal_sign` via the Privy wallet RPC. **Own key:** sign the message. See + [references/wallet-signing.md](references/wallet-signing.md). The signing wallet MUST be the one named + in `walletAddress` — that address becomes the token's `adminAddress`. +3. **Exchange the signature for the token** (the MCP runs `generateServiceToken`, which `verifyMessage`s + the signature against `walletAddress` and sets `adminAddress = walletAddress`): + ``` + mcp__molecule__service_token_create: + walletAddress: + messageSignature: + serviceName: data-sync-service + expiresIn: "720h" + ``` + Returns `{ token, tokenId, expiresAt }`. + +Because step 2 is a standard ECDSA message signature, **a Privy agentic wallet and a raw key are +interchangeable to the backend** — the only difference is which key signs. Set the returned `token` as +`MOLECULE_SERVICE_TOKEN` in `.claude/settings.local.json` (it is a secret — the MCP never logs it, and +neither should you), or pass it per-call via the `serviceToken` override on `labs_decrypt_dek` when you +need a token bound to a *different* wallet than the env default. **Selection rule (apply this everywhere a service token is needed):** 1. Identify the wallet that must be the IP-NFT's authorized signer for this call (the minter/owner, or a recursive Safe/Ownable/TBA signer of it). -2. **Privy agentic wallet → `issue_service_token`** (pass its `walletId`/`walletAddress` if it isn't the - default `PRIVY_WALLET_ID`). -3. **Plain EOA → `issue_owner_service_token`** (pass its key via `ownerPrivateKey`, or set - `WALLET_PRIVATE_KEY`). -4. Prefer a pre-set `MOLECULE_SERVICE_TOKEN` already bound to the right wallet over issuing per run; only - issue when it is missing/expired, or when you need a token bound to a different wallet than the env one. +2. Run `service_signin_message` → **sign with that wallet** (Privy agentic wallet first option; or its own + key) → `service_token_create`, all using that wallet's `walletAddress`. +3. Prefer a pre-set `MOLECULE_SERVICE_TOKEN` already bound to the right wallet over minting per run; only + mint when it is missing/expired, or when you need a token bound to a different wallet than the env one. --- @@ -467,12 +605,15 @@ Retrieve `reservationId` from cache if not in context: shared_cache: { "operation": "get", "namespace": "molecule", "key": "reservation_id" } ``` +**x402 paid call** — run prepare → **sign with your wallet** → submit (see *x402 Payment Flow*): ``` -mcp__molecule__x402_pay: +mcp__molecule__x402_prepare: mutation: createProject query: "mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { isSuccess message error { message code retryable } project { ipnftUid ipnftSymbol ipnftAddress ipnftTokenId } } }" variables: { "input": { "ipnftSymbol": "", "ipnftTokenId": "" } } + walletAddress: ``` +→ sign `prepared.typedData` with your wallet → `mcp__molecule__x402_submit: { prepared: , signature: }`. From `data.createProject.project` extract `ipnftUid` — every subsequent data-room call is keyed on it. @@ -500,12 +641,15 @@ mcp__molecule__sha256_file: ### Step A — Initiate upload (x402 paid) +**x402 paid call** — run prepare → **sign with your wallet** → submit (see *x402 Payment Flow*): ``` -mcp__molecule__x402_pay: +mcp__molecule__x402_prepare: mutation: initiateCreateOrUpdateFileV2 query: "mutation InitiateCreateOrUpdateFileV2($ipnftUid: String!, $contentType: String!, $contentLength: Int!) { initiateCreateOrUpdateFileV2(ipnftUid: $ipnftUid, contentType: $contentType, contentLength: $contentLength) { uploadToken uploadUrl uploadUrlExpiry method headers { key value } useMultipart isSuccess error { message code retryable } } }" variables: { "ipnftUid": "", "contentType": "application/pdf", "contentLength": } + walletAddress: ``` +→ sign `prepared.typedData` with your wallet → `mcp__molecule__x402_submit: { prepared: , signature: }`. From `data.initiateCreateOrUpdateFileV2` extract: `uploadToken`, `uploadUrl`, `method`, `headers`. @@ -567,12 +711,15 @@ science: Derive the category and tags from the research document content. For a typical research-PDF upload, default to category `science` (lowercase) with tag(s) like `Discovery` or `Validation` unless the document clearly fits another category. +**x402 paid call** — run prepare → **sign with your wallet** → submit (see *x402 Payment Flow*): ``` -mcp__molecule__x402_pay: +mcp__molecule__x402_prepare: mutation: finishCreateOrUpdateFileV2 query: "mutation FinishCreateOrUpdateFileV2($ipnftUid: String!, $uploadToken: String!, $path: String, $accessLevel: String!, $changeBy: String!, $description: String, $tags: [String!], $categories: [String!]) { finishCreateOrUpdateFileV2(ipnftUid: $ipnftUid, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy, description: $description, tags: $tags, categories: $categories) { datasetId contentHash version newHead isSuccess message error { message code retryable } } }" variables: { "ipnftUid": "", "uploadToken": "", "path": "", "accessLevel": "PUBLIC", "changeBy": "", "description": "", "categories": [""], "tags": [""] } + walletAddress: ``` +→ sign `prepared.typedData` with your wallet → `mcp__molecule__x402_submit: { prepared: , signature: }`. From `data.finishCreateOrUpdateFileV2` extract: `datasetId` (format: `did:odf:...`), `contentHash`. Cache: ``` @@ -586,20 +733,13 @@ shared_cache: { "operation": "put", "namespace": "molecule", "key": "dataset_id" Use this **instead of** Steps A–C when the file must be confidential. It is a faithful client-side replication of Labs **Onchain-Verified Envelope Encryption** (`encryptFileWithKms`) — same algorithm, IV size, tag handling, and `contentHash` rule (handled by `mcp__molecule__encrypt_file`). The backend never sees plaintext or the unwrapped key; it only stores the ciphertext, the KMS-wrapped DEK, and the on-chain access conditions. **Preconditions & invariants:** -- The DEK is generated by `mcp__molecule__labs_generate_dek` with **`transport: direct`** + `auth: service-token` (needs `MOLECULE_SERVICE_TOKEN` + the operating wallet's address). `generateDataEncryptionKey` is now x402-whitelisted (`desci-infra/lambda/x402-gateway-lambda/mutations.ts`), but keep it **direct** so the plaintext DEK stays in-process and no payment is spent on a key fetch. The service token here must be **bound to the operating wallet** — the wallet that minted and (until any Phase 6 transfer) owns this IP-NFT, i.e. its authorized signer. If it is missing/expired, issue a fresh one bound to that wallet — pick the tool by the operating wallet's type (full rule in **Service Token** above): - - **Privy agentic operating wallet** (the default in this skill — Phase 0/2 mint via Privy): - ``` - mcp__molecule__issue_service_token: - serviceName: data-sync-service - expiresIn: "720h" - ``` - - **EOA operating wallet** (when the minter/owner is a raw key, not Privy — needs `WALLET_PRIVATE_KEY`): +- The DEK is generated by `mcp__molecule__labs_generate_dek` with `auth: service-token` (needs `MOLECULE_SERVICE_TOKEN` + the operating wallet's address). This is a **direct service-token call — no wallet signature, no payment** — so the plaintext DEK stays in-process. The service token here must be **bound to the operating wallet** — the wallet that minted and (until any Phase 6 transfer) owns this IP-NFT, i.e. its authorized signer. If it is missing/expired, mint a fresh one bound to that wallet via the **Service Token** flow above — `service_signin_message` → **sign with your wallet** (Privy agentic wallet first option, or its own key) → `service_token_create`: ``` - mcp__molecule__issue_owner_service_token: + mcp__molecule__service_signin_message: + walletAddress: serviceName: data-sync-service - expiresIn: "720h" ``` - Set the returned `token` as `MOLECULE_SERVICE_TOKEN` in `.claude/settings.local.json`. Both tools run the same off-chain `getServiceSignInMessage` → message-sign → `generateServiceToken` flow (no Privy login, no on-chain mint, no x402) and differ only in which wallet signs — so the DEK flow is identical whether the operating wallet is Privy or an EOA. The token is a secret — the MCP never logs it, and neither should you. + → sign the returned `message` with your wallet → `mcp__molecule__service_token_create: { walletAddress: , messageSignature: , serviceName: data-sync-service, expiresIn: "720h" }`. Set the returned `token` as `MOLECULE_SERVICE_TOKEN` in `.claude/settings.local.json` (secret — the MCP never logs it, and neither should you). - `accessLevel` MUST be `ADMIN` (or `HOLDERS`) — valid values are `PUBLIC | HOLDERS | ADMIN`. Never `PUBLIC` for a confidential file. - **Production guard:** the backend verifies the caller is an authorized signer for the IP-NFT (`isAuthorizedSignerForIpnft`) on the configured `AccessResolver` chain before it will finalize an encrypted file. If the resolver is unreachable / not deployed on that chain, Step E5 fails with a clear error — surface that message verbatim and stop. - The plaintext DEK is **one-shot and secret** and **never leaves the MCP** — `labs_generate_dek` hands back only a `dekHandle`. Only `encryptedDek` (wrapped) and the ciphertext are persisted. @@ -609,10 +749,9 @@ Use this **instead of** Steps A–C when the file must be confidential. It is a ``` mcp__molecule__labs_generate_dek: - transport: direct auth: service-token ``` -Returns `encryptedDek` (base64), `encryptionSystem` (e.g. `"kms"` — echo it verbatim, never hardcode), and `dekHandle`. **The plaintext DEK is not returned** — it stays in the MCP, addressed by `dekHandle`. Cache the `dekHandle` if you need it later in this run. +(Uses `MOLECULE_SERVICE_TOKEN` + `EVM_WALLET_ADDRESS`; no wallet signature, no payment.) Returns `encryptedDek` (base64), `encryptionSystem` (e.g. `"kms"` — echo it verbatim, never hardcode), and `dekHandle`. **The plaintext DEK is not returned** — it stays in the MCP, addressed by `dekHandle`. Cache the `dekHandle` if you need it later in this run. ### Step E1 — Encrypt the file (replicates `encryptFileWithKms`) @@ -627,14 +766,15 @@ From the result save `iv` (base64), `contentHash` (hex), and `cipherBytes`. The ### Step E2 — Initiate upload with the **ciphertext** size (x402 paid) -Identical to public Step A, except `contentLength` MUST be the ciphertext size (`cipherBytes` from E1): +Identical to public Step A, except `contentLength` MUST be the ciphertext size (`cipherBytes` from E1). **x402 paid call** — prepare → **sign with your wallet** → submit: ``` -mcp__molecule__x402_pay: +mcp__molecule__x402_prepare: mutation: initiateCreateOrUpdateFileV2 query: "mutation InitiateCreateOrUpdateFileV2($ipnftUid: String!, $contentType: String!, $contentLength: Int!) { initiateCreateOrUpdateFileV2(ipnftUid: $ipnftUid, contentType: $contentType, contentLength: $contentLength) { uploadToken uploadUrl uploadUrlExpiry method headers { key value } useMultipart isSuccess error { message code retryable } } }" variables: { "ipnftUid": "", "contentType": "application/pdf", "contentLength": } + walletAddress: ``` -Extract `uploadToken`, `uploadUrl`, `method`, `headers`. +→ sign `prepared.typedData` with your wallet → `mcp__molecule__x402_submit: { prepared: , signature: }`. Extract `uploadToken`, `uploadUrl`, `method`, `headers`. ### Step E3 — PUT the ciphertext to S3 (direct, NO x402) @@ -676,12 +816,15 @@ Bash: date -u +%Y-%m-%dT%H:%M:%SZ | `iv` | `iv` from E1 (base64) | | `contentHash` | `contentHash` from E1 (hex SHA-256 of plaintext) | +**x402 paid call** — prepare → **sign with your wallet** → submit: ``` -mcp__molecule__x402_pay: +mcp__molecule__x402_prepare: mutation: finishCreateOrUpdateFileV2 query: "mutation FinishCreateOrUpdateFileV2($ipnftUid: String!, $uploadToken: String!, $path: String, $accessLevel: String!, $changeBy: String!, $description: String, $tags: [String!], $categories: [String!], $encryptionMetadata: EncryptionMetadataInput) { finishCreateOrUpdateFileV2(ipnftUid: $ipnftUid, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy, description: $description, tags: $tags, categories: $categories, encryptionMetadata: $encryptionMetadata) { datasetId contentHash version newHead isSuccess message error { message code retryable } } }" variables: { "ipnftUid": "", "uploadToken": "", "path": "", "accessLevel": "ADMIN", "changeBy": "", "description": "", "categories": [""], "tags": [""], "encryptionMetadata": { "encryptionSystem": "", "accessControlConditions": "", "encryptedBy": "", "encryptedAt": "", "encryptedDek": "", "iv": "", "contentHash": "" } } + walletAddress: ``` +→ sign `prepared.typedData` with your wallet → `mcp__molecule__x402_submit: { prepared: , signature: }`. From `data.finishCreateOrUpdateFileV2` extract `datasetId` (`did:odf:...`) and `contentHash`, then cache: ``` @@ -697,12 +840,11 @@ To confirm an authorized caller can recover the file, fetch the DEK (the plainte mcp__molecule__labs_decrypt_dek: ipnftUid: "" filePath: "" - transport: direct auth: service-token ``` Returns `iv` and a fresh `dekHandle` on success. A `LEGACY_ENCRYPTION` message means the file predates the envelope flow; `ACCESS_DENIED` means the decrypt caller does not satisfy the on-chain `isAuthorizedSignerForIpnft` condition. -**IMPORTANT — the decrypt caller is NOT the `x-wallet-address` header.** When a service token is present (it always is here), the backend substitutes the **service token's `adminAddress`** for `:userAddress` (`appsync-resolver-labs-lambda/index.ts` `case "decryptDataKey"` → `serviceContext.adminAddress`; evaluated by `services/condition-evaluator.ts`). So to decrypt *as* a given wallet you must present a `MOLECULE_SERVICE_TOKEN` **bound to that wallet** — issue one for a Privy agentic wallet with `mcp__molecule__issue_service_token`, or for an EOA with `mcp__molecule__issue_owner_service_token` (signs the sign-in message with `WALLET_PRIVATE_KEY`); see **Service Token** for the wallet-type selection rule. Pass it via the per-call `serviceToken` override: +**IMPORTANT — the decrypt caller is NOT the `x-wallet-address` header.** When a service token is present (it always is here), the backend substitutes the **service token's `adminAddress`** for `:userAddress` (`appsync-resolver-labs-lambda/index.ts` `case "decryptDataKey"` → `serviceContext.adminAddress`; evaluated by `services/condition-evaluator.ts`). So to decrypt *as* a given wallet you must present a `MOLECULE_SERVICE_TOKEN` **bound to that wallet** — mint one via `service_signin_message` → **sign with that wallet** (Privy agentic wallet first option, or its own key) → `service_token_create`, all using that wallet's `walletAddress` (see **Service Token** for the selection rule). Pass it via the per-call `serviceToken` override: ``` mcp__molecule__labs_decrypt_dek: ipnftUid: "" @@ -722,12 +864,15 @@ The returned `plaintextSha256` MUST equal the `contentHash` from E1 — that con ## Phase 5: Create Announcement (via x402) +**x402 paid call** — run prepare → **sign with your wallet** → submit (see *x402 Payment Flow*): ``` -mcp__molecule__x402_pay: +mcp__molecule__x402_prepare: mutation: createAnnouncementV2 query: "mutation CreateAnnouncementV2($ipnftUid: String!, $headline: String!, $body: String!, $attachments: [String!]) { createAnnouncementV2(ipnftUid: $ipnftUid, headline: $headline, body: $body, attachments: $attachments) { isSuccess message error { message code retryable } } }" variables: { "ipnftUid": "", "headline": "", "body": "<markdown body>", "attachments": ["<datasetId from upload>"] } + walletAddress: <wallet_address> ``` +→ sign `prepared.typedData` with your wallet → `mcp__molecule__x402_submit: { prepared: <prepared>, signature: <signature> }`. ### External Posting Copy Rules (Phase 5 body + any Beach.science post) @@ -741,15 +886,17 @@ When composing any user-facing markdown that describes the registration (the `bo ## Phase 6: NFT Transfer and Co-Ownership -Transfer the minted IP-NFT to the owner's personal wallet and add them as a project co-owner. +Optionally transfer the minted IP-NFT to a different long-term owner wallet and add them as a project +co-owner. -**Skip this phase entirely** if `EVM_WALLET_ADDRESS` is not set or equals the agent's `wallet_address`. +**Skip this phase entirely** unless the user wants the IP-NFT transferred to a *different* wallet than the +operating wallet that minted it. (`EVM_WALLET_ADDRESS` is now the **operating/signer** address, not the +recipient — do NOT treat it as the transfer target.) -### Step A — Check owner wallet +### Step A — Determine the recipient -The owner wallet address is: `$EVM_WALLET_ADDRESS` - -If this equals `wallet_address`, skip to Output — no transfer needed. Otherwise save it as `owner_wallet`. +Use the recipient ("long-term owner") wallet the user supplied for this run as `owner_wallet`. If none was +provided, or it equals `wallet_address` (the operating wallet), **skip to Output** — no transfer needed. ### Step B — ABI-encode ERC-721 transfer @@ -764,31 +911,40 @@ mcp__molecule__abi_encode: Save `calldata`. -### Step C — Transfer IP-NFT on-chain +### Step C — Transfer IP-NFT on-chain (sign & send with your wallet) -Use **`privy_send_raw_transaction`** here, **not** `privy_send_transaction`. For `safeTransferFrom` from the agent wallet, Privy's `eth_sendTransaction` returns a hash but never broadcasts it (the "phantom hash") — even though mint and POI broadcast fine on the same wallet. `privy_send_raw_transaction` signs sign-only via Privy `eth_signTransaction` and broadcasts the raw tx itself, resolving the live `pending` nonce (so it is re-runnable after a stuck attempt). It uses `EVM_RPC_URL` (falling back to a public node for known chains); pass `rpcUrl` to override. +Prepare the transfer transaction, then sign + broadcast it with **your wallet**: ``` -mcp__molecule__privy_send_raw_transaction: +mcp__molecule__prepare_transaction: to: $IPNFT_CONTRACT_ADDRESS data: <calldata from step B> chainId: $CHAIN_ID ``` -Save `txHash` as `transfer_tx_hash`. +> **Send it sign-only + self-broadcast (raw), not via a managed send.** For `safeTransferFrom`, **Privy's** +> managed `eth_sendTransaction` returns a hash but never broadcasts it (the "phantom hash") — even though +> mint and POI broadcast fine. So with a **Privy** wallet, use `eth_signTransaction` (sign only) then +> broadcast the raw tx yourself via `eth_sendRawTransaction`, resolving the live `pending` nonce (re-runnable +> after a stuck attempt). With your **own key**, you already sign + `eth_sendRawTransaction`. See +> [references/wallet-signing.md](references/wallet-signing.md) §C. + +Save the resulting `txHash` as `transfer_tx_hash`. ### Step D — Add owner as project co-owner (via x402) -`addProjectOwner` takes `ipnftUid` + `ownerAddress` (per `graphql/schemas/ip-hubs.graphql` and `bruno/desci-labs/v2/2-addProjectOwner.bru`): +`addProjectOwner` takes `ipnftUid` + `ownerAddress` (per `graphql/schemas/ip-hubs.graphql` and `bruno/desci-labs/v2/2-addProjectOwner.bru`). **x402 paid call** — prepare → **sign with your wallet** → submit: ``` -mcp__molecule__x402_pay: +mcp__molecule__x402_prepare: mutation: addProjectOwner query: "mutation AddProjectOwner($ipnftUid: String!, $ownerAddress: String!) { addProjectOwner(ipnftUid: $ipnftUid, ownerAddress: $ownerAddress) { isSuccess message error { message code retryable } } }" variables: { "ipnftUid": "<ipnft_uid>", "ownerAddress": "<owner_wallet>" } + walletAddress: <wallet_address> ``` +→ sign `prepared.typedData` with your wallet → `mcp__molecule__x402_submit: { prepared: <prepared>, signature: <signature> }`. -Note the Step C `safeTransferFrom` already moved the IP-NFT (and thus owner role) on-chain; `addProjectOwner` additionally whitelists `<owner_wallet>` in the project's off-chain owner list. +Note the Step C `safeTransferFrom` already moved the IP-NFT (and thus owner role) on-chain; `addProjectOwner` additionally whitelists `<owner_wallet>` in the project's off-chain owner list. (The x402 payment in this step is still authorized by your **operating** wallet — `walletAddress: <wallet_address>` — even though the IP-NFT now belongs to `owner_wallet`.) ### Step E — Owner decrypt access (private / encrypted uploads only) @@ -797,10 +953,7 @@ Skip for public uploads. For a **private** upload (Phase 4 Private variant), the - the Step C `safeTransferFrom` above — once the owner holds the IP-NFT they ARE the authorized signer (the common path); **or** - if the IP-NFT was not transferred to them, make the file's `encryptionMetadata.accessControlConditions` an **OR** that also authorizes the owner (Lit unified format `[cond, {"operator":"or"}, cond]`, e.g. OR a second `isAuthorizedSignerForIpnft(:userAddress, <a tokenId the owner owns>)`). The evaluator supports boolean operators but only contract-call conditions (no bare address-equality), so the owner must be an authorized signer of *some* IP-NFT. -The owner then decrypts by presenting a service token **bound to the owner's wallet** (no env swap needed — use the per-call `serviceToken` override on `labs_decrypt_dek`, as in Step E6). Pick the issuing tool by the owner wallet's type (see **Service Token**): - -- **Owner holds an EOA** → `mcp__molecule__issue_owner_service_token: {}` (signs with `WALLET_PRIVATE_KEY`). -- **Owner holds a Privy agentic wallet** → `mcp__molecule__issue_service_token: { walletId: "<owner's Privy wallet id>" }` (omit `walletId` only if the owner wallet is the default `PRIVY_WALLET_ID`). +The owner then decrypts by presenting a service token **bound to the owner's wallet** (no env swap needed — use the per-call `serviceToken` override on `labs_decrypt_dek`, as in Step E6). Mint that token with the owner's wallet doing the signing: `service_signin_message: { walletAddress: <owner_wallet> }` → **owner signs the message** with their wallet (Privy agentic wallet first option, or its own key) → `service_token_create: { walletAddress: <owner_wallet>, messageSignature: <signature> }`. Either way the token's `adminAddress` must be the owner's address — that is what the decrypt evaluator substitutes into `isAuthorizedSignerForIpnft`. diff --git a/skills/aura-orchestrator/references/wallet-signing.md b/skills/aura-orchestrator/references/wallet-signing.md new file mode 100644 index 0000000..a8f4911 --- /dev/null +++ b/skills/aura-orchestrator/references/wallet-signing.md @@ -0,0 +1,135 @@ +# Wallet & signing — how to sign the molecule's prepared payloads + +The `molecule` MCP server is **custody-free**: it crafts transactions, EIP-712 typed-data, and sign-in +messages, but it **never holds a key, signs, or broadcasts**. Every wallet-dependent step hands you a +*prepared payload*; **you** sign/send it with your own wallet and feed the `signature` / `txHash` back in. + +This file shows how to do that with a **Privy agentic wallet (the recommended first option)** and with +**your own key**. Pick whichever you run; the molecule flow is identical either way. + +> The MCP only ever needs your wallet's **public address** (`EVM_WALLET_ADDRESS`, or the `walletAddress` +> argument). **Never** put a private key in an MCP tool argument. + +There are exactly three handoff shapes (see §C): a **transaction** (`prepare_transaction` → you send), an +**x402 payment** (`x402_prepare` → you sign EIP-712 → `x402_submit`), and a **service-token sign-in** +(`service_signin_message` → you personal_sign → `service_token_create`). + +--- + +## §A — Privy agentic wallet (recommended) + +A Privy *server wallet* signs server-side with no user interaction — ideal for autonomous agents. Provision +one (policy + wallet) with the **`privy-agentic-wallets`** skill, then drive it over the Privy REST API +(`POST https://api.privy.io/v1/wallets/{WALLET_ID}/rpc`, HTTP basic auth `PRIVY_APP_ID:PRIVY_APP_SECRET`, +header `privy-app-id: $PRIVY_APP_ID`). Docs: https://docs.privy.io/guide/server-wallets + +**Get the wallet address** (for `EVM_WALLET_ADDRESS` / `walletAddress`): +`GET /v1/wallets/{WALLET_ID}` → `.address`. + +**personal_sign** (terms message, service-token sign-in): +```jsonc +// POST /v1/wallets/{WALLET_ID}/rpc +{ "method": "personal_sign", "params": { "message": "<message>", "encoding": "utf-8" } } +// -> data.signature +``` + +**EIP-712 sign typed data** (x402 `prepared.typedData`). IMPORTANT: Privy's RPC schema is snake_case — +rename the top-level `primaryType` to `primary_type` before sending: +```jsonc +// take prepared.typedData, then: td.primary_type = td.primaryType; delete td.primaryType +// POST /v1/wallets/{WALLET_ID}/rpc +{ "method": "eth_signTypedData_v4", "params": { "typed_data": { /* ...td with primary_type... */ } } } +// -> data.signature (pass this to x402_submit) +``` + +**Send a transaction** (POI anchor, mint — `prepare_transaction.transaction`): +```jsonc +// POST /v1/wallets/{WALLET_ID}/rpc +{ "method": "eth_sendTransaction", + "caip2": "eip155:<chainId>", + "params": { "transaction": { "to": "<to>", "data": "<data>", "value": "<valueWei 0x…>" } } } +// -> data.hash (your txHash) +``` + +**Sign-only + self-broadcast** (use for the IP-NFT `safeTransferFrom` transfer — see §C phantom-hash note): +```jsonc +// 1) POST /v1/wallets/{WALLET_ID}/rpc +{ "method": "eth_signTransaction", + "params": { "transaction": { "to": "<to>", "data": "<data>", "value": "0x0", + "chain_id": <chainId>, "nonce": <pending nonce>, "type": 2, + "gas_limit": "0x…", "max_fee_per_gas": "0x…", "max_priority_fee_per_gas": "0x…" } } } +// -> data.signed_transaction +// 2) broadcast it yourself: JSON-RPC eth_sendRawTransaction(signed_transaction) against your EVM node +``` + +Privy policies (spending caps, chain allowlists) still apply to every signature — keep them on. Full REST +details live in the `privy-agentic-wallets` skill (`references/transactions.md`, `references/policies.md`). + +--- + +## §B — Bring your own key (any EOA / signer) + +Use any wallet/library you control. The key stays with you; the MCP never sees it. Examples sign the same +three payload shapes. + +**viem** (https://viem.sh): +```ts +import { createWalletClient, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +const account = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`); +const client = createWalletClient({ account, chain: baseSepolia, transport: http(RPC_URL) }); + +// account.address -> EVM_WALLET_ADDRESS / walletAddress +const sig = await account.signMessage({ message: prepared_message }); // personal_sign +const x402sig = await account.signTypedData(prepared.typedData); // EIP-712 (camelCase OK) +const txHash = await client.sendTransaction({ to, data, value: BigInt(valueWei) }); // tx +``` + +**ethers v6** (https://docs.ethers.org): +```ts +import { Wallet, JsonRpcProvider } from "ethers"; +const wallet = new Wallet(process.env.WALLET_PRIVATE_KEY!, new JsonRpcProvider(RPC_URL)); +const sig = await wallet.signMessage(prepared_message); // personal_sign +const x402sig = await wallet.signTypedData(td.domain, { TransferWithAuthorization: td.types.TransferWithAuthorization }, td.message); +const tx = await wallet.sendTransaction({ to, data, value: BigInt(valueWei) }); // tx -> tx.hash +``` + +**Python — eth-account** (https://eth-account.readthedocs.io): +```python +from eth_account import Account +from eth_account.messages import encode_defunct, encode_typed_data + +acct = Account.from_key(WALLET_PRIVATE_KEY) # acct.address -> EVM_WALLET_ADDRESS +sig = Account.sign_message(encode_defunct(text=message), WALLET_PRIVATE_KEY).signature.hex() +x402sig = Account.sign_message(encode_typed_data(full_message=prepared["typedData"]), WALLET_PRIVATE_KEY).signature.hex() +signed = acct.sign_transaction({ "to": to, "data": data, "value": int(valueWei, 16), + "nonce": nonce, "chainId": chain_id, "type": 2, + "gas": gas, "maxFeePerGas": fee, "maxPriorityFeePerGas": tip }) +# broadcast signed.raw_transaction via eth_sendRawTransaction +``` +(eth-account integer fields must be ints; for the x402 typed-data, coerce the `value`/`validAfter`/ +`validBefore`/`nonce`-style string fields to ints as your library requires.) + +--- + +## §C — The handoff contract (per prepared payload) + +**1. Transaction** — `prepare_transaction(to, data?, value?, chainId?)` → `{ transaction }`. +Send `transaction` with your wallet (§A `eth_sendTransaction` / §B `sendTransaction`); record the `txHash`. + +**2. x402 payment** — `x402_prepare(mutation, query, variables, walletAddress)` → `{ prepared }`. +Sign `prepared.typedData` (EIP-712) with your wallet — **Privy: remap `primaryType`→`primary_type`**; own +key: sign as-is. Then `x402_submit(prepared, signature)` → `{ data, errors, settlement }`. The EIP-3009 +`from` inside `prepared.typedData` **must equal the signer** (your `walletAddress`). + +**3. Service-token sign-in** — `service_signin_message(walletAddress, serviceName)` → `{ message }`. +`personal_sign` the exact `message` with the wallet named in `walletAddress` (that address becomes the +token's `adminAddress`). Then `service_token_create(walletAddress, messageSignature, …)` → `{ token }`. + +**Phantom-hash note (IP-NFT transfer).** For `safeTransferFrom`, **Privy's** managed `eth_sendTransaction` +returns a hash but never broadcasts it — even though mint/POI broadcast fine. So for the Phase 6 transfer, +use **sign-only + self-broadcast** (§A `eth_signTransaction` → `eth_sendRawTransaction`), resolving the live +`pending` nonce so it is re-runnable. With your own key you already sign + `eth_sendRawTransaction`, so this +is a non-issue. diff --git a/skills/privy-agentic-wallets/SKILL.md b/skills/privy-agentic-wallets/SKILL.md index 06e56be..04d9a15 100644 --- a/skills/privy-agentic-wallets/SKILL.md +++ b/skills/privy-agentic-wallets/SKILL.md @@ -237,4 +237,4 @@ Extended chains: `cosmos`, `stellar`, `sui`, `aptos`, `tron`, `bitcoin-segwit`, - setup.md — Dashboard setup, getting credentials - wallets.md — Wallet creation and management - policies.md — Policy rules and conditions -- transactions.md — Transaction execution examples +- transactions.md — Transaction execution examples \ No newline at end of file