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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@
DYNAMIC_ENVIRONMENT_ID=your-environment-id-here
DYNAMIC_AUTH_TOKEN=dyn_your_token_here

# Populated automatically by the `setup` command — DO NOT edit or share.
# SOL_WALLET_KEY_SHARES holds your MPC key share; treat it like a private key.
# Set automatically by `setup`. The wallet address is non-sensitive and lives here.
# The MPC key share is sensitive: on macOS it is stored in the Keychain (not this
# file); elsewhere it falls back to SOL_WALLET_KEY_SHARES below.
SOL_WALLET_ADDRESS=
SOL_WALLET_KEY_SHARES=

# Password protecting the Dynamic-side key-share backup. Leave blank and `setup`
# will generate a strong random one and write it here; or pin your own before setup.
# Required to sign — back it up. Losing it means re-running `setup` for a new wallet.
# Per-session passphrase that encrypts the key share (AES-256-GCM). It is NEVER
# stored — `setup`/`encrypt-shares` prompt for it, and signing reads it from this
# env var (export it for the session) or prompts you. Back it up: lose it and the
# encrypted share can only be recovered via WALLET_BACKUP_PASSWORD (below).
# SUBS_KEYSTORE_PASSPHRASE=

# Recovery password for the key-share backup stored on Dynamic (setup enables
# backUpToDynamic). Leave blank and `setup` generates a strong random one (stored in
# the Keychain on macOS); or pin your own here. Must NOT equal DYNAMIC_ENVIRONMENT_ID.
# Back it up off-machine — without it the Dynamic backup can't be decrypted.
WALLET_BACKUP_PASSWORD=

# Optional: override Solana RPC (defaults to https://api.mainnet-beta.solana.com).
Expand All @@ -24,9 +32,6 @@ WALLET_BACKUP_PASSWORD=
# Solana mainnet USDC — replace with your token mint if using a different token
TOKEN_MINT=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v

# Optional: default merchant address for plan search
# MERCHANT_ADDRESS=

# Optional: SOL→USDC swap slippage tolerance, as a 0–1 fraction (default 0.1 = 10%).
# Swaps request an exact USDC output, so too tight a value makes the route fail with
# LessThanMinimumAmountOut (0x1778). Lower it for a tighter fill on larger swaps.
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pnpm subs subscribe <merchant_solana_address> 0
| Command | Description |
|---|---|
| `setup` | Create a Dynamic WaaS Solana wallet |
| `encrypt-shares` | Encrypt `SOL_WALLET_KEY_SHARES` at rest (needs `SUBS_KEYSTORE_PASSPHRASE`) |
| `secrets` | Move sensitive secrets from `.env` into the macOS Keychain & show where each lives |
| `balance` | Check SOL and USDC balance |
| `swap <usdc>` | Swap SOL → USDC |
| `send <recipient> <amount\|all> [SOL\|USDC\|<mint>]` | Send SOL or an SPL token |
Expand Down Expand Up @@ -98,3 +100,48 @@ is required.) Never commit tokens — keep them in your global `~/.npmrc` only.

Credentials are loaded from `.env` and never logged or transmitted beyond the Dynamic and Solana APIs.
Run `pnpm subs` without arguments to see the usage summary.

### Securing the MPC key share

`SOL_WALLET_KEY_SHARES` is the **customer-side half of MPC signing**. Combined with
your `DYNAMIC_AUTH_TOKEN` (and `WALLET_BACKUP_PASSWORD`), it confers signing
authority — treat it like a private key. It is *encrypted*, not hashed, because the
share must be recoverable to sign.

**Where things are stored**

| Value | Sensitivity | Stored in |
|---|---|---|
| `SOL_WALLET_ADDRESS`, `DYNAMIC_ENVIRONMENT_ID`, `TOKEN_MINT` | Non-sensitive | `.env` |
| `SOL_WALLET_KEY_SHARES`, `DYNAMIC_AUTH_TOKEN`, `WALLET_BACKUP_PASSWORD` | Sensitive | **macOS Keychain** (falls back to `.env` off-macOS) |
| `SUBS_KEYSTORE_PASSPHRASE` | Sensitive | **nowhere — per session** |

- **Encryption at rest:** the key share is wrapped with AES-256-GCM (unique salt + IV,
tamper-evident), key derived via PBKDF2-SHA256 (600k iterations) from the keystore
passphrase. Envelope: `enc:v1:<salt>:<iv>:<authTag>:<ciphertext>`.
- **Per-session passphrase:** `SUBS_KEYSTORE_PASSPHRASE` is **never stored**. `setup`
prompts you to set one; signing reads it from the env var (export it for the session)
or prompts interactively. Keeping it out of the Keychain means a Keychain compromise
alone can't decrypt the share.
- **`pnpm subs secrets`** moves any secrets sitting in `.env` into the Keychain.
**`pnpm subs encrypt-shares`** encrypts the share if you set the wallet up without a
passphrase.
- **Back up `WALLET_BACKUP_PASSWORD`** off-machine (e.g. a password manager): it's the
recovery key for the Dynamic-side backup. Losing both it and the local share = no recovery.

**Threat model — what this does and does not protect**

- ✅ A leaked `.env` contains **no signing material** — secrets are in the Keychain.
- ✅ A Keychain compromise alone can't decrypt the share — the passphrase is supplied
per session, not stored.
- ❌ Does **not** protect against code running in your session: to sign, the share is
decrypted into process memory and the passphrase is in the runtime env. Don't paste
`SUBS_KEYSTORE_PASSPHRASE` into chat or export it in an unattended agent session.
- 🔐 Real defense-in-depth is the **2-of-2 MPC threshold**: the client share alone
cannot sign — it also needs the Dynamic server share via `DYNAMIC_AUTH_TOKEN`.

> Also persist the **full** `walletMetadata` object (including
> `externalServerKeySharesBackupInfo`) — it's required to sign/export and is not
> recoverable from `fetchWalletMetadata`, which returns identity only. This skill
> re-fetches it per environment via `getWallets()`; a production service should
> cache it durably.
56 changes: 54 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,37 @@ Use this skill when the user asks to:
- Set up a wallet for Solana subscriptions

The underlying script is `subscriptions-skill.mjs` in this directory. It signs every
transaction with a Dynamic MPC wallet; key shares live in `.env` and are never logged.
transaction with a Dynamic MPC wallet. Sensitive secrets (the key share, API token,
and passphrases) are stored in the macOS Keychain when available — falling back to
`.env` otherwise — and are never logged.

---

## Network switching

The skill defaults to **mainnet**. When the user asks to use devnet (e.g. "use devnet",
"switch to devnet", "test on devnet"), run:

```bash
pnpm subs network devnet
```

To switch back to mainnet:

```bash
pnpm subs network mainnet
```

The choice is written to `.env` so it persists for all subsequent commands in the
session. On devnet, SOL can be funded via airdrop — remind the user:
```
solana airdrop 2 <address> --url devnet
```

To check the active network at any time:
```bash
pnpm subs network
```

---

Expand Down Expand Up @@ -176,12 +206,34 @@ otherwise). Closes the plan account and returns its rent to the merchant.

---

## Secrets & security

- **At-rest secrets** (key share, API token, `WALLET_BACKUP_PASSWORD`) live in the
**macOS Keychain** when available, not `.env`. `setup` writes them there.
- **`setup` backs the share up to Dynamic** (encrypted with `WALLET_BACKUP_PASSWORD`,
auto-generated if unset) so it's recoverable.
- The **keystore passphrase is per-session and never stored** — `setup` prompts you
to set one; signing reads it from `SUBS_KEYSTORE_PASSPHRASE` (export it) or prompts.
This keeps the decryption key in a different place than the share.

Helper commands:

```bash
pnpm subs secrets # move any secrets in .env into the Keychain & show locations
pnpm subs encrypt-shares # encrypt the key share if you created the wallet without a passphrase
```

- **Never paste the keystore passphrase into chat** or run signing commands unattended
with it exported. Encryption protects secrets at rest, not against code in the session.

---

## Handling common errors

| Error | Fix |
|---|---|
| `Missing credentials` | Copy `.env.example` → `.env` and fill in both values |
| `No Solana wallet configured` | Run `setup` first |
| `Key shares are encrypted but SUBS_KEYSTORE_PASSPHRASE is not set` | Export the passphrase used at setup before running |
| `no record of a prior credit` | Wallet needs SOL for gas — fund it |
| `0x205 alreadySubscribed` | Treated as success — subscription already active |
| `Invalid account owner` (right after subscribe) | RPC lag — wait a few seconds and retry `collect` |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"@solana-program/system": "0.12.0",
"@solana-program/token": "0.13.0",
"@solana/kit": "6.9.0",
"@solana/subscriptions": "0.2.0",
"@solana/subscriptions": "0.4.0-rc.2",
"@solana/web3.js": "1.98.4"
}
}
Loading