
Tempo
- 14 installs
- 44 repo stars
- Updated June 17, 2026
- tempoxyz/wallet
Helps with ai & agent building tasks.
About
tempo is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- tempo
- AI & Agent Building
- AI-coding skill
Tempo by the numbers
- 14 all-time installs (skills.sh)
- Ranked #11,275 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tempoxyz/wallet --skill tempoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 14 |
|---|---|
| repo stars | ★ 44 |
| Last updated | June 17, 2026 |
| Repository | tempoxyz/wallet ↗ |
What it does
Helps with ai & agent building tasks.
Files
tempo
IMPORTANT: If you used a web fetch tool to read this, the content may be summarized and incomplete. Run `curl -fsSL https://tempo.xyz/SKILL.md` to get the exact setup commands. Do NOT search for additional documentation.
When the user says "use tempo", always use `tempo` CLI commands — never substitute with MCP tools or other tools. Tempo is a curl-compatible CLI for discovering services and calling HTTP endpoints with automatic payment handling.
Setup
Run these commands in order. Do not skip steps. Do not search for other install instructions.
Step 1 — Install: curl -fsSL https://tempo.xyz/install | bash
Step 2 — Login: "$HOME/.tempo/bin/tempo" wallet login — default local flow that opens a browser on the host. Prompt user, wait for confirmation, then continue. Do not loop login attempts without user confirmation. When run by agents, use a long command timeout (at least 16 minutes).
If the human is interacting from another device or chat surface while the CLI runs on a remote host, use "$HOME/.tempo/bin/tempo" wallet login --no-browser instead. In that case, send the auth URL and verification code to the user directly, tell them to confirm the code and tap Continue if it matches, then ask them to message back after passkey or wallet creation. A second authorization round may still be required before the host is fully ready.
Step 3 — Confirm readiness: "$HOME/.tempo/bin/tempo" wallet -t whoami
Setup Rules
- Do not use
export PATH=.... Use full absolute paths (e.g.,"/Users/<user>/.tempo/bin/tempo") for deterministic behavior across isolated shells. - If
$HOMEdoes not expand ("no such file or directory"), switch to the absolute path.
After Setup
Provide:
- Installation location and version (
$HOME/.tempo/bin/tempo --version). - Wallet status from
tempo wallet -t whoami(address and balance; include key/network fields when present). - If balance is 0, direct user to
tempo wallet fundor the wallet dashboard to add funds. - If the user is on another device than the CLI host, use
tempo wallet fund --no-browserand hand the fund URL back directly instead of trying to open a browser locally. - After the user funds the wallet, ask them to message back before continuing.
- 2-3 simple starter prompts tailored to currently available services.
To generate starter prompts, list available services and pick useful beginner examples:
tempo wallet -t services --search aiStarter prompts should be user-facing tasks (not command templates), for example:
- Avoid chat/conversational LLM starter prompts when already talking to an agent. Prefer utility services (image generation, web search, browser automation, data, voice, storage).
- "Generate a dog image with a blue background and save it as
dog.png." - "Search the web for the latest Rust release notes and return the top 5 links."
- "Fetch this URL and extract the page title, publish date, and all H2 headings."
Use Services
tempo wallet -t whoami
tempo wallet -t services --search <query>
tempo wallet -t services <SERVICE_ID>
tempo request -t -X POST --json '{"input":"..."}' <SERVICE_URL>/<ENDPOINT_PATH>- Select
SERVICE_IDfrom search results that best matches user intent. When multiple match: prefer best semantic fit, then endpoint fit, then pricing clarity, then first in list. - Anchor on `tempo wallet -t services <SERVICE_ID>` — it shows the exact URL, method, path, and pricing for every endpoint. Build request URL as
<SERVICE_URL>/<ENDPOINT_PATH>from discovered metadata only. - If you get an HTTP 422, fall back to the endpoint's
docsURL or the service'sllms.txtfor exact field names. - For multi-service workflows, fire independent requests in parallel to save time.
Request Templates
# JSON POST
tempo request -t --dry-run -X POST --json '{"input":"..."}' <SERVICE_URL>/<ENDPOINT_PATH>
tempo request -t -X POST --json '{"input":"..."}' <SERVICE_URL>/<ENDPOINT_PATH>
# GET
tempo request -t -X GET <SERVICE_URL>/<ENDPOINT_PATH>Response Handling
- Return result payload to user directly when request succeeds.
- If response contains a file URL (e.g., image generation), download it locally:
curl -fsSL "<url>" -o <filename>. - If response is a usage/auth readiness error, run
tempo wallet loginand retry once. - If response indicates payment/funding limit issues, report clearly and stop.
- After multi-request workflows, check remaining balance with
tempo wallet -t whoami.
Wallet-Backed Cards
Use tempo cards for virtual cards backed by Tempo wallet balances. Keep the skill lean and treat CLI help as the source of truth for flags:
tempo cards -t --help
tempo cards -t customers --help
tempo cards -t approve --helpPointers:
- Configure Bridge/Stripe keys with
cards config ...or env vars; env vars win over$TEMPO_HOME/wallet/cards.toml. SeeAGENTS.mdfor the exact env names. - Bridge onboarding lives under
cards customers: create/get/list/delete, hosted ToS, KYC, and customer transfers. - Stripe Issuing lives at top-level
cards create|list|get|update|freeze|unfreeze|cancel, pluscardholders,transactions, andauthorizations. - On-chain issuer permission lives in
cards approveandcards allowance; runapprove --dry-runbefore submitting. - For repo work, inspect
crates/tempo-cards/src/commands/cards/,crates/tempo-cards/src/args.rs, andcrates/tempo-cards/tests/cards.rs.
Rules
- Always discover URL/path before request; never guess endpoint paths.
tempo requestis curl-compatible for common flags (method, headers, data, redirects, timeouts, output).- Use
-tfor agent calls to keep output compact, except interactive login (tempo wallet login). - Use
--dry-runbefore potentially expensive requests. - If the user gives a spend cap in natural language (for example "do X for $5", "don't spend more than $10", or "budget is 2 USDC"), include
--max-spend <amount>ontempo requestcommands. For non-CLI contexts, useTEMPO_MAX_SPEND. - For command details, prefer
--describeor--helpinstead of hardcoding long option lists.
Common Issues
| Issue | Cause | Fix |
|---|---|---|
tempo: command not found | CLI not installed | Run `curl -fsSL https://tempo.xyz/install \ |
| "legacy V1 keychain signature is no longer accepted, use V2" | Outdated tempo launcher or extensions | Reinstall tempo: `curl -fsSL https://tempo.xyz/install \ |
| "access key does not exist" | Key not provisioned on-chain, or stale key after reinstall | Run tempo wallet logout --yes, then tempo wallet login to provision a fresh key. |
ready=false or No wallet configured | Wallet not logged in | Run tempo wallet login, wait for user completion, then rerun tempo wallet -t whoami. |
| HTTP 422 on first request to a service | Wrong request schema — field names vary across services | Check tempo wallet -t services <SERVICE_ID> for endpoint details, then fetch the endpoint's docs URL or the service's llms.txt for exact field names and types. |
| Balance is 0, insufficient funds, or spending limit exceeded | Wallet needs funding or limit hit | Run tempo wallet fund or direct user to the wallet dashboard. Report clearly and stop if limit is exceeded. |
| Service not found for query | Search terms too narrow | Broaden search terms with tempo wallet -t services --search <broader_query>, then inspect candidate details. |
| Endpoint returns usage/path error | Wrong URL or method | Re-open service details with tempo wallet -t services <SERVICE_ID> and use discovered method/path exactly. |
| Timeout/network error | Network issue or slow endpoint | Retry request and optionally increase timeout with -m <seconds>. |
# Packages to ignore (internal-only, no tags or releases)
ignore = ["tempo-sign", "tempo-test"]
[changelog]
# "per-crate" - CHANGELOG.md in each package
# "root" - Single CHANGELOG.md at workspace root (implies unified versioning)
format = "root"
# AI-assisted changelog generation
[ai]
command = "claude -p --append-system-prompt 'CRITICAL: Your response must start with --- on the very first line. Output ONLY the raw markdown changelog with YAML frontmatter. No preamble, no explanation, no code fences, no commentary before or after. The first three characters of your response must be ---.'"
Changelogs
This folder contains changelog files that describe changes to be released.
Adding a changelog
Run changelogs add to create a new changelog file.
File format
Changelog files are markdown with YAML frontmatter:
---
package-name: minor
other-package: patch
---
Description of the changes made.Releasing
Run changelogs version to apply version bumps and generate changelogs.
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
cooldown:
default-days: 7
groups:
cargo-weekly:
applies-to: "version-updates"
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
cooldown:
default-days: 7
groups:
actions-weekly:
applies-to: "version-updates"
patterns: ["*"]
update-types: ["minor", "patch"]
name: Build
on:
push:
tags:
- "v*"
- "tempo-wallet@*"
- "tempo-request@*"
- "tempo-cards@*"
env:
CARGO_TERM_COLOR: always
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
parse-tag:
name: Parse tag
permissions:
contents: read
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.parse.outputs.packages }}
version: ${{ steps.parse.outputs.version }}
release_tag: ${{ steps.parse.outputs.release_tag }}
steps:
- name: Parse release scope and version from tag
id: parse
run: |
TAG="${GITHUB_REF#refs/tags/}"
if [[ "$TAG" == v* ]]; then
VERSION="${TAG#v}"
PACKAGES='["tempo-wallet","tempo-request","tempo-cards"]'
RELEASE_TAG="$TAG"
elif [[ "$TAG" == *"@"* ]]; then
PACKAGE="${TAG%%@*}"
VERSION="${TAG#*@}"
RELEASE_TAG="$TAG"
if [[ "$PACKAGE" == "tempo-wallet" || "$PACKAGE" == "tempo-request" || "$PACKAGE" == "tempo-cards" ]]; then
PACKAGES="[\"${PACKAGE}\"]"
else
echo "Unsupported package in tag: ${PACKAGE}" >&2
exit 1
fi
else
echo "Unsupported tag format: ${TAG}" >&2
exit 1
fi
{
echo "packages=${PACKAGES}"
echo "version=${VERSION}"
echo "release_tag=${RELEASE_TAG}"
} >> "$GITHUB_OUTPUT"
echo "Tag: ${TAG}, packages: ${PACKAGES}, version: ${VERSION}, release tag: ${RELEASE_TAG}"
build:
name: Build ${{ matrix.package }} (${{ matrix.build.target }})
permissions:
contents: read
# Required by actions/attest for keyless Sigstore signing.
id-token: write
attestations: write
needs: [parse-tag]
runs-on: ${{ matrix.build.os }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.parse-tag.outputs.packages) }}
build:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
suffix: linux-amd64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
suffix: linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
suffix: darwin-amd64
- os: macos-latest
target: aarch64-apple-darwin
suffix: darwin-arm64
env:
PACKAGE: ${{ matrix.package }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
target: ${{ matrix.build.target }}
- name: Build release binary
run: cargo build --locked --release --target ${{ matrix.build.target }} -p ${{ env.PACKAGE }}
- run: strip target/${{ matrix.build.target }}/release/${{ env.PACKAGE }}
- run: mv target/${{ matrix.build.target }}/release/${{ env.PACKAGE }} ${{ env.PACKAGE }}-${{ matrix.build.suffix }}
- name: Generate sha256
shell: bash
run: |
set -euo pipefail
BIN="${PACKAGE}-${{ matrix.build.suffix }}"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$BIN" > "$BIN.sha256"
else
shasum -a 256 "$BIN" > "$BIN.sha256"
fi
cat "$BIN.sha256"
- name: Generate SBOM (SPDX-JSON)
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
file: ${{ env.PACKAGE }}-${{ matrix.build.suffix }}
format: spdx-json
output-file: ${{ env.PACKAGE }}-${{ matrix.build.suffix }}.spdx.json
upload-artifact: false
upload-release-assets: false
- name: Attest SBOM
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-path: ${{ env.PACKAGE }}-${{ matrix.build.suffix }}
sbom-path: ${{ env.PACKAGE }}-${{ matrix.build.suffix }}.spdx.json
- name: Attest build provenance (SLSA v1)
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-path: ${{ env.PACKAGE }}-${{ matrix.build.suffix }}
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign binary with cosign (keyless)
shell: bash
run: |
set -euo pipefail
BIN="${PACKAGE}-${{ matrix.build.suffix }}"
cosign sign-blob --yes \
--bundle "${BIN}.sigstore.json" \
"$BIN"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ env.PACKAGE }}-${{ matrix.build.suffix }}
path: |
${{ env.PACKAGE }}-${{ matrix.build.suffix }}
${{ env.PACKAGE }}-${{ matrix.build.suffix }}.sha256
${{ env.PACKAGE }}-${{ matrix.build.suffix }}.spdx.json
${{ env.PACKAGE }}-${{ matrix.build.suffix }}.sigstore.json
if-no-files-found: error
publish:
name: Publish ${{ matrix.package }}
environment: release
permissions:
contents: write
needs: [parse-tag, build]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.parse-tag.outputs.packages) }}
env:
PACKAGE: ${{ matrix.package }}
VERSION: ${{ needs.parse-tag.outputs.version }}
RELEASE_TAG: ${{ needs.parse-tag.outputs.release_tag }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: artifacts
pattern: ${{ env.PACKAGE }}-*
merge-multiple: true
- name: Wait for GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${RELEASE_TAG}"
for i in $(seq 1 40); do
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release ${TAG} found"
exit 0
fi
echo "Waiting for release ${TAG}... (attempt ${i}/40)"
sleep 15
done
echo "Release ${TAG} was not created in time" >&2
exit 1
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${RELEASE_TAG}"
gh release upload "$TAG" artifacts/* --clobber
- name: Build release signer
run: cargo build --release -p tempo-sign
- name: Sign release binaries
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
echo -n "$RELEASE_SIGNING_KEY" | base64 -d > /tmp/release.key
URL_PREFIX="https://cli.tempo.xyz/extensions/${PACKAGE}"
declare -A PKG_DESC=(
[tempo-wallet]="Manage your Tempo Wallet"
[tempo-request]="Make an HTTP request"
[tempo-cards]="Issue and manage Tempo wallet-backed cards"
)
SIGN_ARGS=(
--key-file /tmp/release.key
--artifacts-dir artifacts
--version "$VERSION"
--base-url "$URL_PREFIX"
--description "${PKG_DESC[$PACKAGE]}"
--output "artifacts/${PACKAGE}-manifest.json"
)
if [ "$PACKAGE" = "tempo-request" ] && [ -f "SKILL.md" ]; then
SKILL_SHA256=$(sha256sum "SKILL.md" | cut -d' ' -f1)
SIGN_ARGS+=(
--skill "${URL_PREFIX}/v${VERSION}/SKILL.md"
--skill-sha256 "$SKILL_SHA256"
--skill-file "SKILL.md"
)
fi
./target/release/tempo-sign sign "${SIGN_ARGS[@]}"
rm /tmp/release.key
- name: Upload to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_ENDPOINT_URL: https://${{ secrets.CF_ACCOUNT_ID }}.r2.cloudflarestorage.com
run: |
aws configure set default.s3.multipart_threshold 200MB
PREFIX="extensions/${PACKAGE}"
VTAG="v${VERSION}"
# Binaries (latest + versioned)
for f in "artifacts/${PACKAGE}"-linux-* "artifacts/${PACKAGE}"-darwin-*; do
[ -f "$f" ] || continue
BASENAME=$(basename "$f")
aws s3 cp "$f" "s3://tempo-cli/${PREFIX}/${BASENAME}" --endpoint-url "$AWS_ENDPOINT_URL"
aws s3 cp "$f" "s3://tempo-cli/${PREFIX}/${VTAG}/${BASENAME}" --endpoint-url "$AWS_ENDPOINT_URL"
done
# Signed manifest (latest + versioned)
aws s3 cp "artifacts/${PACKAGE}-manifest.json" "s3://tempo-cli/${PREFIX}/manifest.json" --endpoint-url "$AWS_ENDPOINT_URL"
aws s3 cp "artifacts/${PACKAGE}-manifest.json" "s3://tempo-cli/${PREFIX}/${VTAG}/manifest.json" --endpoint-url "$AWS_ENDPOINT_URL"
# SKILL.md (versioned snapshot, only for tempo-request)
if [ "$PACKAGE" = "tempo-request" ] && [ -f "SKILL.md" ]; then
aws s3 cp "SKILL.md" "s3://tempo-cli/${PREFIX}/${VTAG}/SKILL.md" --endpoint-url "$AWS_ENDPOINT_URL" --content-type "text/markdown"
fi
# VERSION
echo "${VERSION}" | aws s3 cp - "s3://tempo-cli/${PREFIX}/VERSION" --endpoint-url "$AWS_ENDPOINT_URL" --content-type "text/plain"
echo "Released ${PACKAGE}@${VERSION}"
name: Changelog Generate
# pull_request_target runs in the context of the base branch, giving access
# to secrets even for fork PRs. This is safe here because the workflow
# definition comes from the base branch (can't be modified by the fork) and
# we never execute code from the PR — we only read the diff.
on:
pull_request_target:
types: [labeled]
concurrency: ${{ github.workflow }}-${{ github.event.number }}
permissions: {}
jobs:
generate:
if: startsWith(github.event.label.name, 'changelog:')
runs-on: ubuntu-latest
environment: release
permissions: {}
steps:
- name: Determine PR source
id: source
env:
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
THIS_REPO: ${{ github.repository }}
run: |
if [ "$HEAD_REPO" = "$THIS_REPO" ]; then
echo "same_repo=true" >> "$GITHUB_OUTPUT"
else
echo "same_repo=false" >> "$GITHUB_OUTPUT"
fi
- name: Validate branch ref
if: steps.source.outputs.same_repo == 'true'
id: ref
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail
REF="$HEAD_REF"
if [[ ! "$REF" =~ ^[A-Za-z0-9._/-]+$ ]]; then
echo "Invalid branch ref: $REF" >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Mint scoped app token
if: steps.source.outputs.same_repo == 'true'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_BOT_APP_ID || secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: tempoxyz
repositories: wallet
permission-contents: write
permission-pull-requests: write
permission-metadata: read
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: steps.source.outputs.same_repo == 'true'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Fetch base branch for diff comparison
if: steps.source.outputs.same_repo == 'true'
env:
BASE_REF: ${{ github.base_ref }}
run: |
git fetch origin "$BASE_REF"
- name: Check for existing changelog
if: steps.source.outputs.same_repo == 'true'
id: existing
env:
BASE_REF: ${{ github.base_ref }}
run: |
if git diff "origin/${BASE_REF}...HEAD" --name-only | grep -q '^\.changelog/.*\.md$'; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Install changelogs
if: steps.source.outputs.same_repo == 'true' && steps.existing.outputs.found == 'false'
run: |
EXPECTED_SHA256="34bca37144e400d167f936d83c092da4b032591a74ae8c0175c3a42d716cc54c"
CHANGELOGS_BIN="$RUNNER_TEMP/changelogs"
curl -fsSL "https://github.com/tempoxyz/changelogs/releases/download/changelogs%400.6.2/changelogs-linux-amd64" -o "$CHANGELOGS_BIN"
ACTUAL_SHA256=$(sha256sum "$CHANGELOGS_BIN" | cut -d' ' -f1)
if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then
echo "::error::changelogs checksum mismatch: expected $EXPECTED_SHA256, got $ACTUAL_SHA256"
exit 1
fi
chmod +x "$CHANGELOGS_BIN"
- name: Install claude
if: steps.source.outputs.same_repo == 'true' && steps.existing.outputs.found == 'false'
run: npm install -g @anthropic-ai/claude-code@1.0.3
- name: Extract bump level from label
if: steps.source.outputs.same_repo == 'true' && steps.existing.outputs.found == 'false'
id: bump
run: echo "level=${LABEL#changelog:}" >> "$GITHUB_OUTPUT"
env:
LABEL: ${{ github.event.label.name }}
- name: Generate changelog
if: steps.source.outputs.same_repo == 'true' && steps.existing.outputs.found == 'false'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
BUMP_LEVEL: ${{ steps.bump.outputs.level }}
BASE_REF: ${{ github.base_ref }}
run: |
CHANGELOGS_BIN="$RUNNER_TEMP/changelogs"
cat > /tmp/changelog-instructions.md << 'PROMPT'
Generate a changelog entry for this git diff.
Available packages: {packages}
Respond with ONLY a markdown file in this exact format (no explanation, no code fences):
---
<package-name>: BUMP_LEVEL
---
Brief description of changes.
Rules:
- Replace <package-name> with actual package names from the list above
- Include ONLY packages that had actual code changes in the frontmatter
- Use "BUMP_LEVEL" as the bump level for all packages. Do not use a higher bump level.
- Keep the summary concise (1-3 sentences)
- Do NOT wrap the output in code fences
Git diff:
{diff}
PROMPT
sed -i "s/BUMP_LEVEL/$BUMP_LEVEL/g" /tmp/changelog-instructions.md
"$CHANGELOGS_BIN" add --ai "claude -p" --ref "origin/${BASE_REF}" \
--instructions "$(cat /tmp/changelog-instructions.md)"
- name: Commit and push changelog
if: steps.source.outputs.same_repo == 'true' && steps.existing.outputs.found == 'false'
env:
VALIDATED_REF: ${{ steps.ref.outputs.ref }}
APP_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .changelog/
git commit -m "chore: add changelog"
git push "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${VALIDATED_REF}"
pr-feedback:
name: PR feedback
needs: generate
if: always() && startsWith(github.event.label.name, 'changelog:')
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment for fork PRs
if: github.event.pull_request.head.repo.full_name != github.repository
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.number }}
REPO: ${{ github.repository }}
run: |
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "Changelog auto-generation is only supported for same-repo branches. For fork PRs, please add a changelog file manually under .changelog/."
- name: Remove label
if: always()
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.number }}
REPO: ${{ github.repository }}
LABEL: ${{ github.event.label.name }}
run: gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$LABEL"
name: Changelog
on:
pull_request:
types: [opened, synchronize]
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: read
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Run changelog action (same-repo PRs)
if: github.event.pull_request.head.repo.full_name == github.repository
uses: tempoxyz/changelogs/check@de0250123a1d70a2b64a458bd5efcf313986df7a # changelogs@0.6.3 + unified PR title + install from source
- name: Verify changelog entry exists
if: github.event.pull_request.head.repo.full_name != github.repository
env:
BASE_REF: ${{ github.base_ref }}
run: |
CHANGELOGS=$(git diff --name-only "origin/${BASE_REF}...HEAD" -- '.changelog/*.md' | grep -v config.toml || true)
if [ -z "$CHANGELOGS" ]; then
echo "::error::A changelog entry is required for this PR. Add a .changelog/*.md file."
exit 1
fi
name: Lint
permissions: {}
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
RUST_BACKTRACE: full
jobs:
clippy:
name: clippy
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # nightly
with:
components: clippy
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Run clippy
run: cargo clippy --workspace --all-targets --all-features --locked
env:
RUSTFLAGS: -D warnings
fmt:
name: fmt
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # nightly
with:
components: rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
tempo-lints:
name: tempo-lints
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
env:
TEMPO_LINTS_REF: 0dbe62767b6e15963a652f5476bc0b8ae111d6fc
TEMPO_LINTS_PNPM_VERSION: 10.28.1
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "lts/*"
- name: Run Tempo Lints
run: |
set -euo pipefail
# post-comment: false (emit annotations only; do not write to PRs from this fork-safe job)
corepack enable
corepack prepare "pnpm@${TEMPO_LINTS_PNPM_VERSION}" --activate
lints_dir="$RUNNER_TEMP/tempo-lints"
git init "$lints_dir"
git -C "$lints_dir" remote add origin https://github.com/tempoxyz/lints.git
git -C "$lints_dir" fetch --depth 1 origin "$TEMPO_LINTS_REF"
git -C "$lints_dir" checkout --detach FETCH_HEAD
pnpm --dir "$lints_dir" install --frozen-lockfile
pnpm --dir "$lints_dir" exec tsx "$lints_dir/bin/tempo-lints.ts" rust "$GITHUB_WORKSPACE" --github-action
typos:
name: typos
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: crate-ci/typos@7b04f660f4ee4f048d18fd341887cf28dfbedfe2 # v1.46.3
deny:
name: deny
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: taiki-e/install-action@8f531eaecd1898bc3da7d104ad91bee98d1b97bd # v2.79.9
with:
tool: cargo-deny
- name: Run cargo deny
run: cargo deny check
lint-success:
name: lint success
runs-on: ubuntu-latest
if: always()
permissions: {}
needs:
- clippy
- fmt
- tempo-lints
- typos
- deny
timeout-minutes: 30
steps:
- name: Decide whether the needed jobs succeeded or failed
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
with:
jobs: ${{ toJSON(needs) }}
name: PR Audit
on:
pull_request:
types: [labeled]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
jobs:
pr-audit:
if: >-
github.event_name == 'pull_request' ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
)
uses: tempoxyz/gh-actions/.github/workflows/pr-audit.yml@1a49d3e9f9983a55832d7163faa970b7ba3c1af9
secrets:
EVENTS_KEY: ${{ secrets.EVENTS_KEY }}
EVENTS_CERT: ${{ secrets.EVENTS_CERT }}
EVENTS_ARGS: ${{ secrets.EVENTS_ARGS }}
name: Release
on:
push:
branches: [main]
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions: {}
env:
CARGO_TERM_COLOR: always
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
release:
runs-on: ubuntu-latest
environment: release
permissions: {}
env:
RELEASE_BOT_APP_ID: ${{ vars.RELEASE_BOT_APP_ID || secrets.RELEASE_BOT_APP_ID }}
RELEASE_BOT_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
steps:
- name: Check release bot credentials
id: release-bot
run: |
if [ -z "${RELEASE_BOT_APP_ID}" ] || [ -z "${RELEASE_BOT_PRIVATE_KEY}" ]; then
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::notice::Release bot credentials are not configured; skipping release PR automation."
else
echo "configured=true" >> "$GITHUB_OUTPUT"
fi
- name: Mint scoped app token
if: steps.release-bot.outputs.configured == 'true'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ env.RELEASE_BOT_APP_ID }}
private-key: ${{ env.RELEASE_BOT_PRIVATE_KEY }}
owner: tempoxyz
repositories: wallet
permission-contents: write
permission-pull-requests: write
permission-metadata: read
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: steps.release-bot.outputs.configured == 'true'
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- uses: tempoxyz/changelogs@de0250123a1d70a2b64a458bd5efcf313986df7a # changelogs@0.6.3 + unified PR title + install from source
if: steps.release-bot.outputs.configured == 'true'
id: changelogs
with:
conventional-commit: true
github-token: ${{ steps.app-token.outputs.token }}
- name: Update Cargo.lock on release PR
if: steps.changelogs.outputs.pullRequestNumber != ''
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
git fetch origin changelog-release/main
git checkout changelog-release/main
cargo update --workspace
if ! git diff --quiet Cargo.lock; then
git add Cargo.lock
git commit -m "chore: update Cargo.lock"
git push "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:changelog-release/main
fi
name: Skill
on:
push:
branches: [main]
paths:
- SKILL.md
permissions:
contents: read
jobs:
upload:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Upload SKILL.md to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_ENDPOINT_URL: https://${{ secrets.CF_ACCOUNT_ID }}.r2.cloudflarestorage.com
run: |
aws s3 cp "SKILL.md" "s3://tempo-cli/SKILL.md" --endpoint-url "$AWS_ENDPOINT_URL" --content-type "text/markdown"
echo "✓ Uploaded SKILL.md"
name: Test
permissions: {}
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
test:
name: test
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Run tests
run: cargo test --workspace --all-features --locked
docs:
name: docs
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Build docs
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --all-features --no-deps --locked
test-success:
name: test success
runs-on: ubuntu-latest
if: always()
permissions: {}
needs:
- test
- docs
timeout-minutes: 30
steps:
- name: Decide whether the needed jobs succeeded or failed
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
with:
jobs: ${{ toJSON(needs) }}
name: Workflow Validation
permissions: {}
on:
push:
branches: [main, master]
paths:
- .github/workflows/**
pull_request:
branches: [main, master]
paths:
- .github/workflows/**
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install actionlint
run: |
set -euo pipefail
version=1.7.8
expected_sha256="be92c2652ab7b6d08425428797ceabeb16e31a781c07bc388456b4e592f3e36a"
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${version}/actionlint_${version}_linux_amd64.tar.gz" -o /tmp/actionlint.tgz
actual_sha256=$(sha256sum /tmp/actionlint.tgz | cut -d' ' -f1)
if [ "$actual_sha256" != "$expected_sha256" ]; then
echo "::error::actionlint checksum mismatch: expected $expected_sha256, got $actual_sha256"
exit 1
fi
tar -xzf /tmp/actionlint.tgz -C /tmp
sudo mv /tmp/actionlint /usr/local/bin/actionlint
- name: Run actionlint
run: actionlint -color
workflow-policy:
name: workflow policy
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Enforce workflow security and fork-safety invariants
run: |
set -euo pipefail
echo "Checking for mutable action refs..."
if grep -REn "uses:[[:space:]]+[^#]+@(main|master)$" .github/workflows; then
echo "::error::Found mutable action ref (@main/@master). Pin to a commit SHA."
exit 1
fi
echo "Checking changelog fork behavior..."
grep -n "if: github.event.pull_request.head.repo.full_name != github.repository" .github/workflows/changelog.yml >/dev/null
grep -n "if: github.event.pull_request.head.repo.full_name == github.repository" .github/workflows/changelog.yml >/dev/null
echo "Checking tempo-lints fork-safe mode..."
grep -n "post-comment:[[:space:]]\+false" .github/workflows/lint.yml >/dev/null
echo "Checking pr-audit pinning and trust gate..."
if grep -n "tempoxyz/gh-actions/.github/workflows/pr-audit.yml@main" .github/workflows/pr-audit.yml; then
echo "::error::pr-audit reusable workflow must be pinned to a commit SHA."
exit 1
fi
grep -n "contains(fromJson('\[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"\]'), github.event.comment.author_association)" .github/workflows/pr-audit.yml >/dev/null
echo "Checking pull_request_target hardening..."
grep -n "pull_request_target:" .github/workflows/changelog-generate.yml >/dev/null
grep -n "if: steps.source.outputs.same_repo == 'true'" .github/workflows/changelog-generate.yml >/dev/null
# The push must go to the validated branch ref via an authenticated URL
# (App-token), since persist-credentials=false on the checkout step.
grep -n "git push \"https://x-access-token:\${APP_TOKEN}@github.com/\${GITHUB_REPOSITORY}.git\" \"HEAD:" .github/workflows/changelog-generate.yml >/dev/null
if grep -n "git checkout -b \$\{\{ github.event.pull_request.head.ref \}\}" .github/workflows/changelog-generate.yml; then
echo "::error::Unsafe branch checkout pattern detected in changelog generation workflow."
exit 1
fi
echo "Workflow policy checks passed."
# Rust
/target
**/*.rs.bk
*.pdb
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Configuration (contains private keys)
config.toml
keys.toml
# Build artifacts
*.tar.gz
# Eval (promptfoo output and old run artifacts)
eval/runs/
eval/output/
# Logs
*.log
# OS
Thumbs.db
# Node
node_modules/
package-lock.json
.ai/
!.ai/skills/
.env
.env.*
!.env.example
AGENTS.md
Repository Overview
This is a Cargo workspace containing 5 crates under crates/, providing a command-line HTTP client with built-in MPP payment support, wallet identity management, wallet-backed cards, and a release signing tool. The top-level tempo launcher lives in the main tempo repo (tempo/crates/ext/).
Supported Payment Protocols:
- Machine Payments Protocol (MPP) - Open protocol for HTTP-native machine-to-machine payments
Workspace Structure
The root Cargo.toml is workspace-only (no package). All dependencies are declared as [workspace.dependencies] in the root and consumed via dep.workspace = true in each crate. All crates live under crates/:
crates/tempo-common/ — package tempo-common (library)
Shared library used by tempo-wallet, tempo-request, and tempo-cards. Contains core logic:
crates/tempo-common/src/lib.rs- Module declarations (analytics, cli, config, error, keys, network, payment, security)crates/tempo-common/src/analytics.rs- Opt-out telemetry (PostHog)crates/tempo-common/src/config.rs- Configuration file handlingcrates/tempo-common/src/error.rs- Error types (ConfigError, TempoError, etc.)crates/tempo-common/src/network.rs- Network definitions (NetworkId), explorer config, RPCcrates/tempo-common/src/security.rs- Security utilities (safe logging, sanitization, redaction)crates/tempo-common/src/cli/- Shared CLI infrastructuremod.rs- Re-exports (parse_cli, GlobalArgs, run_cli, run_main, Verbosity)args.rs- GlobalArgs, parse_clicontext.rs-Contextstruct (Config, NetworkId, Keystore, Analytics, OutputFormat, Verbosity)exit_codes.rs- Process exit codes (ExitCode enum)format.rs- Value formatting helpers (amounts, durations, timestamps)output.rs- OutputFormat, structured output helpersrunner.rs- CLI lifecycle (run_cli, run_main)runtime.rs- Tracing, color mode, error renderingterminal.rs- Terminal output helpers (hyperlinks, field formatting, truncation, sanitization)tracking.rs- Analytics tracking (track_command, track_result)verbosity.rs- Verbosity configurationcrates/tempo-common/src/keys/- Key storage (model, I/O), signer resolution, authorizationmod.rs,model.rs,keystore.rs,io.rs,signer.rs,authorization.rscrates/tempo-common/src/payment/- Payment error classification and session managementmod.rs- (classify, session)classify.rs- Payment error classification and extractionsession/- Channel persistence and channel management (channel.rs, close.rs, store.rs, tx.rs)
crates/tempo-wallet/ — package tempo-wallet, binary tempo-wallet
Wallet identity and custody extension, plus session/service management. Source organized by module directories:
crates/tempo-wallet/src/main.rs- CLI entry pointcrates/tempo-wallet/src/args.rs- clap definitions (Cli, Commands, SessionCommands, ServicesCommands)crates/tempo-wallet/src/app.rs- Command dispatch: context building, command routing, analyticscrates/tempo-wallet/src/analytics.rs- Wallet-specific analytics events and payloadscrates/tempo-wallet/src/prompt.rs- Interactive prompt helperscrates/tempo-wallet/src/wallet/- Wallet account types (balances, keys, spending limits) and on-chain queriesmod.rs,types.rs,query.rs,render.rscrates/tempo-wallet/src/commands/- Command implementations (all take&Contextas first arg)login.rs- Login command (passkey authentication flow)logout.rs- Logout commandwhoami.rs- Whoami commandkeys.rs- Key listing, balance and spending limit queriesfund/- Fund command (browser-based flow)sessions/- Session management (list, close, sync, render)services/- Service directory (client, model, render)sign.rs- Sign MPP payment challengescompletions.rs- Shell completionscrates/tempo-wallet/tests/- Integration tests (black-box CLI testing via assert_cmd)
crates/tempo-cards/ — package tempo-cards, binary tempo-cards
Wallet-backed card extension invoked as tempo cards ... via the launcher's tempo-<name> discovery. Covers Bridge customers/KYC/ToS, Stripe Issuing cards/cardholders/transactions/authorizations, and the on-chain USDC approval/allowance for the cards issuer.
crates/tempo-cards/src/main.rs- CLI entry pointcrates/tempo-cards/src/args.rs- clap definitions (Cli + CardsCommands and subcommand enums)crates/tempo-cards/src/app.rs- Command dispatch and analytics taggingcrates/tempo-cards/src/commands/cards/- Implementation (mod.rs, client.rs, config.rs, approval.rs)crates/tempo-cards/tests/- Integration tests (mocked Bridge + Stripe via axum)
crates/tempo-request/ — package tempo-request, binary tempo-request
HTTP client with built-in MPP payment support. Source organized by module directories:
crates/tempo-request/src/main.rs- CLI entry pointcrates/tempo-request/src/args.rs- clap definitions (Cli, QueryArgs)crates/tempo-request/src/app.rs- Command dispatchcrates/tempo-request/src/analytics.rs- Request-specific analytics events and payloadscrates/tempo-request/src/query/- Query flow (request prep, output, challenge parsing, SSE, analytics)mod.rs,analytics.rs,challenge.rs,headers.rs,output.rs,payload.rs,prepare.rs,sse.rscrates/tempo-request/src/http/- HTTP client and request handlingmod.rs,client.rs,fmt.rs,response.rscrates/tempo-request/src/payment/- Payment flows (charge + session)mod.rs,charge.rs,router.rssession/- Session-based payment (flow.rs, open.rs, persist.rs, streaming.rs, voucher.rs)
crates/tempo-sign/ — package tempo-sign, binary tempo-sign
Lightweight release manifest signing tool for authenticating build artifacts.
crates/tempo-sign/src/main.rs- Signing tool source
Packages: tempo-common, tempo-wallet, tempo-request, tempo-cards, tempo-sign
Commands
make build # Build debug binary
make release # Build optimized release binary
make test # Run all tests (uses mocks, no network required)
make check # Run fmt check, clippy, tests, and doc
make fix # Auto-fix formatting and clippy warnings
make install # Install CLI binaries to ~/.tempo/bin
make uninstall # Uninstall CLI binaries
make run ARGS="<url>" # Run tempo-wallet with argumentsAgent Suggestions
When the user explicitly says "ask the oracle" to check a value, run tempo-request against OpenRouter and explicitly tell the user which model was used in the response.
Example:
tempo-request -v -X POST --json '{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"what is 1+1"}]}' https://openrouter.mpp.tempo.xyz/v1/chat/completions | jqCRITICAL: Pre-Commit Requirements
Before Every Commit, You MUST:
1. ✅ Check: make check - ZERO issues
Pull Request Guidelines
When creating pull requests:
1. Always include the PR link in your response after creating a PR 2. Format as a clickable link: [#123](https://github.com/tempoxyz/wallet/pull/123) 3. When creating multiple PRs, provide a summary table with all links
Example summary format:
| PR | Title | Link |
|----|-------|------|
| 1 | feat: add feature X | [#123](https://github.com/tempoxyz/wallet/pull/123) |
| 2 | fix: resolve issue Y | [#124](https://github.com/tempoxyz/wallet/pull/124) |Code Style Guidelines
Rust Conventions
- Edition: Rust 2021
- Error handling: Prefer typed
TempoErrorboundaries and source-carrying variants (*Source) where a concrete underlying error exists - Async runtime: Tokio (minimal features: macros, rt-multi-thread, signal)
- Serialization: Serde with derive macros
Imports
- Group imports: std → external crates → crate modules
- Use
use crate::for internal module imports - Use
use tempo_common::for shared library imports
use std::path::PathBuf;
use clap::Parser;
use tempo_common::config::Config;
use tempo_common::error::TempoError;
fn run() -> Result<(), TempoError> {
Ok(())
}Error Handling Pattern
use thiserror::Error;
#[derive(Error, Debug)]
pub enum TempoError {
#[error("failed to parse: {0}")]
ParseError(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}Module Organization
- Each module should have a clear single responsibility
- Use
mod.rsfor modules with submodules - Shared logic goes in
crates/tempo-common/src/ - All commands go in
crates/tempo-wallet/src/commands/
Testing
- Integration tests in
tests/use assert_cmd for black-box CLI testing - Use
TestConfigBuilderfor setting up test configurations - Use
test_command(&temp)helper to create properly configured CLI commands
use crate::common::{TestConfigBuilder, test_command};
#[test]
fn test_something() {
let temp_dir = TestConfigBuilder::new().build();
let mut cmd = test_command(&temp_dir);
cmd.arg("--help");
cmd.assert().success();
}CLI Patterns (clap)
- Use derive macros for argument parsing
- Flatten
GlobalArgsfromtempo_common::clifor shared flags - Group related args with
help_heading - Support short aliases (
-v) and long aliases (--verbose)
#[derive(Parser, Debug)]
pub struct Cli {
#[command(flatten)]
pub global: GlobalArgs,
#[command(subcommand)]
pub command: Option<Commands>,
}Making Changes
Before Starting Any Code Changes:
- [ ] Check existing patterns in similar files
- [ ] Identify affected tests
Adding New Features
1. Add shared logic in crates/tempo-common/src/ 2. Add CLI flags in the appropriate binary's src/args.rs 3. Implement commands in the appropriate binary's src/commands/ 4. Add tests: unit tests in source files, integration tests in each crate's tests/
Dependencies
Key External Crates
| Crate | Purpose |
|---|---|
clap | CLI argument parsing |
alloy | EVM interactions and signing (minimal features) |
reqwest | HTTP client |
serde / serde_json / toml | Serialization |
tokio | Async runtime (minimal features) |
mpp | Machine Payments Protocol SDK |
Adding Dependencies
Add to [workspace.dependencies] in the root Cargo.toml, then reference with dep.workspace = true in the crate's Cargo.toml:
# Root Cargo.toml
[workspace.dependencies]
new-crate = "1.0"
# Crate Cargo.toml
[dependencies]
new-crate.workspace = trueEnvironment Variables
| Variable | Description |
|---|---|
TEMPO_HOME | Override data directory (default: ~/.tempo) |
TEMPO_RPC_URL | Override RPC endpoint |
TEMPO_AUTH_URL | Override auth server URL |
TEMPO_SERVICES_URL | Override service directory API URL |
TEMPO_NO_TELEMETRY | Disable telemetry |
TEMPO_PRIVATE_KEY | Provide a private key directly for payment (bypasses wallet login and keychain; ephemeral) |
TEMPO_BRIDGE_API_KEY / BRIDGE_API_KEY | Bridge API key for tempo cards customers ... |
TEMPO_BRIDGE_API_URL | Override Bridge API base URL for card tests/integration |
TEMPO_STRIPE_API_KEY / STRIPE_SECRET_KEY / STRIPE_API_KEY | Stripe API key for tempo cards ... Issuing commands |
TEMPO_STRIPE_API_URL | Override Stripe API base URL for card tests/integration |
Data Locations
All data lives under $TEMPO_HOME (default: ~/.tempo):
~/.tempo/
├── config.toml # Shared config (RPC overrides, telemetry)
└── wallet/
├── keys.toml # Wallet keys (mode 0600)
├── cards.toml # Bridge and Stripe API keys for wallet-backed cards (mode 0600)
└── channels.db # Persisted payment channel state (SQLite)- Private keys: macOS Keychain (macOS) or inline in
keys.toml(Linux) - Card provider keys: env vars take precedence; saved keys live in
cards.tomlviatempo cards config ...
Configuration Structure
struct Config {
tempo_rpc: Option<String>, // Typed RPC override for Tempo mainnet
moderato_rpc: Option<String>, // Typed RPC override for Moderato testnet
rpc: HashMap<String, String>, // General RPC overrides by network id
}Wallet Fields (`keys.toml`):
wallet_type—"local"or"passkey"wallet_address— On-chain wallet address (the fundable address)chain_id— Chain ID this key is authorized forkey_type— Signature type ("secp256k1","p256", or"webauthn")key_address— Address of the signing keykey— Signing key private key stored inline; file is written with mode 0600key_authorization— RLP-encoded on-chain authorization proof for this keyexpiry— Unix timestamp for key authorization expirylimits— Array of{ currency: "0x...", limit: "..." }
Key Selection: Deterministic: passkey > first key with key > first key (lexicographically). The old active field was removed.
Network Resolution Priority: 1. TEMPO_RPC_URL env var (overrides everything) 2. Typed overrides (tempo_rpc, moderato_rpc) take precedence 3. General [rpc] table as fallback 4. Default RPC if no override
Built-in Networks: tempo (chain 4217, mainnet), tempo-moderato (chain 42431, testnet)
Built-in Tokens: USDC (mainnet), pathUSD (testnet)
Documentation
Architecture
Tempo CLI is a multi-crate workspace providing a command-line HTTP client with built-in MPP payment support, wallet identity management, and a release signing tool. The top-level tempo launcher lives in the main tempo repo (tempo/crates/ext/).
Crate Layering
tempo-wallet (wallet identity/custody + sessions/services/transfer)
└── tempo-common (shared library)
tempo-request (HTTP client + payment)
└── tempo-common (shared library)
tempo-sign (release signing, standalone)
tempo-test (shared test infrastructure, dev-only)tempo-common is the shared foundation. tempo-wallet and tempo-request are independent binaries that both depend on it. tempo-sign is a standalone build tool. tempo-test provides mock servers, fixture builders, and assertion helpers used by integration tests across crates.
tempo-common — Shared Library
Dependency flows top-down; lower layers never import from higher ones.
src/
├── cli/ — shared CLI infrastructure
│ ├── args.rs — GlobalArgs, parse_cli
│ ├── context.rs — Context struct (Config, NetworkId, Keystore, Analytics, OutputFormat, Verbosity)
│ ├── exit_codes.rs — process exit codes (ExitCode enum)
│ ├── format.rs — value formatting helpers (amounts, durations, timestamps)
│ ├── output.rs — OutputFormat, structured output helpers
│ ├── runner.rs — CLI lifecycle (run_cli, run_main)
│ ├── runtime.rs — tracing setup, color mode, error rendering
│ ├── terminal.rs — terminal output helpers (hyperlinks, field formatting, sanitization)
│ ├── tracking.rs — analytics tracking (track_command, track_result)
│ └── verbosity.rs — verbosity configuration
├── keys/ — key storage, signing, authorization
│ ├── authorization.rs — on-chain key authorization proofs
│ ├── io.rs — key file I/O (read/write keys.toml)
│ ├── keystore.rs — Keystore struct, key selection logic
│ ├── model.rs — key data model (KeyEntry, WalletType, KeyType)
│ └── signer.rs — signer resolution (EOA vs keychain)
├── payment/ — payment error classification and session management
│ ├── classify.rs — payment error classification and extraction
│ └── session/ — channel persistence, queries, close, tx signing
│ ├── channel.rs — on-chain channel queries (balance, state, grace period)
│ ├── close/
│ │ ├── cooperative.rs — cooperative (off-chain) channel close
│ │ └── onchain.rs — payer-initiated on-chain requestClose → withdraw
│ ├── store/
│ │ ├── model.rs — domain model (ChannelRecord, ChannelStatus, PendingClose)
│ │ └── storage.rs — SQLite persistence (open, insert, update, query)
│ └── tx.rs — Tempo transaction submission
├── analytics.rs — opt-out telemetry (PostHog)
├── config.rs — configuration file handling
├── error.rs — error types (ConfigError, TempoError, PaymentError, etc.)
├── lib.rs — module declarations and tempo_home()
├── network.rs — chain definitions (Tempo, Moderato), explorer config, RPC
└── security.rs — security utilities (safe logging, sanitization, redaction)tempo-wallet — Wallet Binary
Wallet identity and custody operations: login, key management, sessions, services, and transfers.
src/
├── main.rs — entry point
├── args.rs — Cli struct (flattens GlobalArgs), Commands, SessionCommands, ServicesCommands
├── app.rs — build Context, dispatch commands, track analytics
├── analytics.rs — wallet-specific analytics events and payloads
├── prompt.rs — interactive prompt helpers
├── wallet/
│ ├── types.rs — wallet account types (balances, spending limits)
│ ├── query.rs — on-chain wallet queries
│ └── render.rs — wallet info rendering
└── commands/
├── login.rs — passkey authentication flow
├── logout.rs — disconnect wallet
├── whoami.rs — wallet status, balances, keys
├── keys.rs — key listing with balance and spending limit queries
├── transfer.rs — TIP-20 token transfers (amount, token, recipient, fee estimation)
├── auth.rs — shared browser/authentication utilities
├── debug.rs — debug info collection for support tickets
├── completions.rs — shell completions
├── fund/ — fund command (browser-based faucet/bridge flow)
├── sessions/ — session management
│ ├── list.rs — list active sessions (local + orphaned on-chain)
│ ├── close.rs — close sessions (cooperative, on-chain, finalize)
│ ├── sync.rs — sync local state with on-chain
│ ├── render.rs — session table rendering
│ └── util.rs — shared session helpers
└── services/ — MPP service directory
├── client.rs — service directory API client
├── model.rs — service data model
└── render.rs — service listing renderingtempo-request — HTTP Client Binary
HTTP client with built-in MPP payment support. Handles 402 Payment Required challenges natively.
src/
├── main.rs — entry point
├── args.rs — Cli struct (flattens GlobalArgs), QueryArgs
├── app.rs — dispatch to request command
├── analytics.rs — request-specific analytics events and payloads
├── query/ — query command flow
│ ├── analytics.rs — query analytics tracking
│ ├── challenge.rs — 402 challenge detection and dispatch
│ ├── headers.rs — request header construction
│ ├── output.rs — response output formatting
│ ├── payload.rs — request body handling (--json, --data, stdin)
│ ├── prepare.rs — request preparation (URL, method, headers, body)
│ └── sse.rs — Server-Sent Events streaming
├── http/ — HTTP client and response handling
│ ├── client.rs — reqwest client construction
│ ├── fmt.rs — verbose HTTP formatting (-v output)
│ └── response.rs — response wrapper (status, headers, body)
└── payment/ — payment flows
├── challenge.rs — shared challenge parsing helpers
├── charge.rs — one-shot on-chain charge payment
├── lock.rs — per-origin file locking for channel operations
├── router.rs — payment mode dispatch (charge vs session)
├── types.rs — shared types (ResolvedChallenge, PaymentResult)
└── session/ — session-based payment
├── flow.rs — stage-driven session orchestration
├── open.rs — channel opening and initial credential handshake
├── voucher.rs — off-chain voucher signing and transport
├── streaming.rs — SSE streaming with per-token voucher top-ups
├── persist.rs — session persistence (save/update channel records)
├── receipt.rs — session receipt validation
└── error_map.rs — HTTP rejection → PaymentRejected mappingtempo-sign — Release Signing Tool
Standalone tool for generating signed release manifests to authenticate build artifacts.
src/
├── main.rs — entry point
├── args.rs — CLI argument definitions (generate-key, sign, verify)
├── error.rs — signing-specific error types
├── key.rs — minisign keypair generation and loading
├── manifest.rs — release manifest construction
└── sign.rs — manifest signing and verificationtempo-test — Test Infrastructure
Shared test infrastructure used by integration tests across crates. Not published.
src/
├── lib.rs — re-exports all modules
├── assert.rs — assertion helpers for CLI output
├── command.rs — test_command builder with proper config
├── fixture.rs — TestConfigBuilder for test setup
└── mock.rs — mock HTTP/payment serversPayment Flows
Charge (One-Shot)
Implemented in tempo-request/src/payment/charge.rs. Handles single-request on-chain settlement.
1. The server responds with HTTP 402 and a WWW-Authenticate header describing the payment terms. 2. The challenge is parsed via the mpp crate. 3. A signed transaction is built using mpp::TempoProvider and submitted on-chain. 4. The request is retried with an Authorization header containing the payment credential (transaction hash).
This mode requires no persistent state — each request is independently settled.
Session (Channel)
Session orchestration is implemented in tempo-request/src/payment/session/. Shared session infrastructure (persistence, channel queries, close operations, tx signing) lives in tempo-common/src/payment/session/.
handle_session_request is stage-driven with explicit boundaries:
1. Challenge stage — parses/validates the challenge and resolves normalized session identity. 2. Deposit stage — derives deposit policy and wallet-balance clamp behavior. 3. Reuse stage — discovers/revalidates reusable channels (local plus on-chain identity checks). 4. Open stage — performs channel open and initial credential handshake. 5. Request stage — executes the paid request and receipt persistence.
Session invariants are intentionally strict:
- Session challenge
methodDetails.chainIdis required; missingchainIdis rejected. - Paid SSE requests fail closed on stream timeout/retry exhaustion/incomplete termination.
- Persisted channel
cumulative_amountis monotonic and must never decrease.
Session HTTP rejection mapping is centralized in error_map.rs so flow.rs, open.rs, and streaming.rs share one sanitization and length-bounding policy for server-derived PaymentRejected.reason text.
Voucher Transport
1. Voucher updates are attempted with HEAD first. 2. Fallback to POST when HEAD is unsupported (405/501) or transport fails. 3. Voucher/top-up submissions use a dedicated reqwest client handle (separate from stream response reading) while preserving the same transport policy as the primary request client.
Streaming voucher retries are managed by an explicit coordinator in streaming.rs that owns pending-voucher state, retry counters, and stall-timeout backoff progression.
Channel Lifecycle
1. On first request, a channel is opened on-chain with a deposit. 2. Subsequent requests exchange off-chain vouchers — signed cumulative amounts — instead of on-chain transactions. 3. SSE streaming is supported: per-token voucher top-ups are issued as streamed data arrives. 4. Channel state persists across CLI invocations in a SQLite database (channels.db). 5. Channels can be closed explicitly. Local rows track explicit lifecycle state (active, closing, finalizable, finalized). Orphaned channels and close readiness are derived from on-chain state when needed.
Channel Close Timing
1. requestClose() starts the escrow grace window. 2. withdraw() is attempted when now >= closeRequestedAt + gracePeriod. 3. The CLI does not currently add an extra cushion beyond contract grace by default.
Receipt Policy
- Missing or invalid
Payment-Receipton otherwise successful paid responses emits warnings. - Runtime requests are not failed solely for missing/invalid receipts.
Typed Error Boundary Pattern
Error handling follows a typed-boundary model:
1. Prefer source-carrying variants (*Source) when an underlying error object exists. 2. Preserve user-facing wording stability at CLI boundaries by keeping display strings deterministic. 3. Reserve free-form string reasons for business-rule rejections where no concrete source error exists.
Compatibility exceptions are explicit and regression-tested:
- Payment classification keeps
NetworkError::Http(...)as an opaque fallback for unmatched provider errors. - Router network mismatch intentionally uses
PaymentError::ChallengeSchemawith the preserved wording:Server requested network '...' but --network is '...'.
Wallet Types
Passkey
Browser-based WebAuthn wallet created via Tempo's passkey flow (login.rs). Authentication is delegated to the browser; the wallet address and key authorization are stored locally.
Local
Locally generated or imported secp256k1 private key. The private key is stored inline in a mode-0600 keys.toml file.
Signing Modes
Determined by the relationship between wallet_address and key_address (tempo-common/src/keys/signer.rs):
- Direct EOA signing — when the wallet address equals the key address, transactions are signed directly.
- Keychain (smart wallet) signing — otherwise, transactions are signed with the authorized sub-key and include the on-chain key authorization proof.
Key selection is deterministic: passkey → first key with inline key → first key (lexicographic).
Channel Persistence
- SQLite database stored at
$TEMPO_HOME/wallet/channels.db(default:~/.tempo/wallet/channels.db). - Keyed by
channel_idwith an origin index for reuse lookups. ChannelRecordstores channel state: channel ID, cumulative amount, deposit, payer/payee/token identity, and challenge echo data.- No fixed TTL is enforced; channels have no implicit expiry in local persistence.
- Pending closes are tracked separately for grace-period finalization.
- Monotonic channel accounting is enforced at storage update boundaries (
update_channel_cumulative_floor).
mpp Boundary Guarantees
Protocol-critical behavior delegated to mpp is locked with local boundary tests so upstream changes cannot silently alter client conformance.
1. EIP-712 voucher signatures are verified as domain-bound to chain_id and verifying_contract. 2. Voucher verification is locked to canonical 65-byte signatures, and compact ERC-2098 signatures are normalized to canonical form at the local boundary before verification. 3. Unknown-field tolerance is verified for session request, credential payload, and receipt parsing. 4. RFC 9457 extension-field passthrough is verified in local problem parsing.
Boundary tests live in crates/tempo-request/tests/mpp_boundary.rs.
Client Scope Boundaries
This repository is a client/reference wallet implementation. It enforces client-side requirements from the session spec and intentionally does not implement server-only operational MUSTs.
Server-side concerns explicitly out of scope include voucher rate limiting/anti-DoS policy, challenge-to-voucher audit trail persistence, receipt issuance guarantees, and per-session server accounting durability semantics.
Extension Framework
This repo produces extension binaries (tempo-wallet, tempo-request) that are managed by the tempo launcher in the main tempo repo (tempo/crates/ext/). The launcher provides install, update, remove, and auto-update lifecycle management. tempo-sign is a build-time tool used in CI to produce signed release manifests — it is never distributed to end users.
How Extensions Are Discovered
When a user runs tempo wallet ..., the launcher:
1. Looks for tempo-wallet next to its own binary (exe_dir). 2. Falls back to $TEMPO_HOME/bin or ~/.tempo/bin. 3. Searches PATH. 4. If not found, attempts auto-install from the default manifest URL.
Release Lifecycle
Releases are triggered by pushing a git tag matching tempo-wallet@<version> or tempo-request@<version>:
1. Build (.github/workflows/build.yml) — Cross-compiles release binaries for four targets: linux-amd64, linux-arm64, darwin-amd64, darwin-arm64. Artifacts are uploaded to the GitHub Release.
2. Sign — The publish job builds tempo-sign from source, then signs every binary in the artifacts directory. tempo-sign produces a release manifest (manifest.json) containing:
version— semver version stringdescription— short extension descriptionbinaries— per-platform map of{ url, sha256, signature }skill/skill_sha256/skill_signature— optional agent skill file metadata
3. Upload — Signed binaries and the manifest are uploaded to Cloudflare R2 at s3://tempo-cli/extensions/<package>/:
- Latest:
extensions/tempo-wallet/manifest.json,extensions/tempo-wallet/tempo-wallet-darwin-arm64 - Versioned:
extensions/tempo-wallet/v0.1.5/manifest.json,extensions/tempo-wallet/v0.1.5/tempo-wallet-darwin-arm64 - A
VERSIONfile is written containing the latest version string.
Binaries are served via https://cli.tempo.xyz/extensions/<package>/....
Binary Signing and Verification
Signing uses minisign (Ed25519-based):
- Signing (
tempo-sign) — Each binary is signed with a minisign secret key stored as a CI secret (RELEASE_SIGNING_KEY). The trusted comment includesfile:<platform-binary-name>andversion:<version>tab-separated tokens.
- Verification (
tempo/crates/ext/src/installer/verify.rs) — On install, the launcher:
1. Downloads the binary and computes its SHA-256 digest. 2. Compares the digest against the manifest's sha256 field. 3. Verifies the minisign signature using the hardcoded public key. 4. Checks that the signature's trusted comment contains the expected file: and version: tokens — this prevents cross-extension substitution and version replay attacks.
The hardcoded public key lives in the launcher source: RWTtoEUPuapAfh06rC7BZLjm1hG40/lsVAA/2afN88FZ8/Fdk97LzJDf.
Both the manifest URL base and public key can be overridden via TEMPO_EXT_BASE_URL and TEMPO_EXT_PUBLIC_KEY for testing.
Auto-Update
When the launcher dispatches to an already-installed extension, it checks for updates at most once every 6 hours (controlled by the registry's checked_at timestamp):
1. Fetches manifest.json from the default URL. 2. If the manifest version is strictly newer (semver comparison), downloads, verifies, and replaces the binary. 3. On failure, the existing binary is always used — auto-update never blocks execution. 4. Auto-update is disabled when TEMPO_HOME is set (managed/test environments).
Version pinning (tempo add wallet 1.0.0) disables auto-install but still checks and notifies the user when a newer version is available.
Agent Skill Distribution
Extensions can optionally bundle an agent skill file (SKILL.md) for coding assistants. During install:
1. The skill file URL, SHA-256, and signature are read from the release manifest. 2. The file is downloaded, checksum-verified, and signature-verified (same minisign flow as binaries, with a skill:<package> trusted comment). 3. The skill is installed into every detected coding assistant's skills/tempo-<extension>/SKILL.md directory (Claude Code, Amp, Cursor, Copilot, Windsurf, etc.).
Currently only tempo-request ships a skill file (the SKILL.md at the repo root).
Extension Registry
The launcher persists extension state in $TEMPO_HOME/extensions.json (or ~/Library/Application Support/tempo/extensions.json on macOS):
{
"extensions": {
"wallet": {
"checked_at": 1710864000,
"installed_version": "0.1.5",
"pinned": false,
"description": "Manage your Tempo Wallet"
}
}
}The registry is not file-locked — concurrent tempo invocations use last-writer-wins, which is acceptable since the data is limited to timestamps and version strings.
Manifest URL Convention
The default manifest URL follows the pattern:
https://cli.tempo.xyz/extensions/tempo-<extension>/manifest.jsonFor a specific version:
https://cli.tempo.xyz/extensions/tempo-<extension>/v<version>/manifest.jsonuse std::process::Command;
fn main() {
// Git SHA: prefer CI-provided env, then invoke git, fallback to "unknown"
let git_sha = std::env::var("TEMPO_GIT_SHA").unwrap_or_else(|_| {
Command::new("git")
.args(["rev-parse", "--short=7", "HEAD"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string())
});
// Build date: prefer CI-provided env, fallback to current UTC via `date`
let build_date = std::env::var("TEMPO_BUILD_DATE").unwrap_or_else(|_| {
Command::new("date")
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string())
});
// Build profile: prefer CI-provided env, otherwise "local" for dev builds
let profile =
std::env::var("TEMPO_BUILD_PROFILE").unwrap_or_else(|_| "local".to_string());
println!("cargo:rustc-env=TEMPO_GIT_SHA={git_sha}");
println!("cargo:rustc-env=TEMPO_BUILD_DATE={build_date}");
println!("cargo:rustc-env=TEMPO_BUILD_PROFILE={profile}");
// Re-run when build script, git HEAD, or CI env vars change
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../.git/HEAD");
if let Ok(head) = std::fs::read_to_string("../../.git/HEAD") {
if let Some(ref_path) = head.strip_prefix("ref: ") {
println!("cargo:rerun-if-changed=../../.git/{}", ref_path.trim());
}
}
println!("cargo:rerun-if-env-changed=TEMPO_GIT_SHA");
println!("cargo:rerun-if-env-changed=TEMPO_BUILD_DATE");
println!("cargo:rerun-if-env-changed=TEMPO_BUILD_PROFILE");
}
[workspace]
members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.4.3"
[workspace.lints]
[workspace.lints.clippy]
dbg-macro = "warn"
manual-string-new = "warn"
uninlined-format-args = "warn"
use-self = "warn"
redundant-clone = "warn"
default-constructed-unit-structs = "allow"
[workspace.lints.rust]
rust-2018-idioms = "warn"
unreachable-pub = "warn"
unused-must-use = "warn"
redundant-lifetimes = "warn"
unnameable-types = "warn"
[workspace.lints.rustdoc]
all = "warn"
[workspace.dependencies]
alloy = { version = "2.0", default-features = false, features = ["std", "providers", "provider-http", "reqwest", "signer-local", "sol-types", "contract", "rpc-types"] }
base64 = "0.22"
clap = { version = "4.6", features = ["derive", "env"] }
clap_complete = "4.6"
colored = "3.1"
dirs = "6.0"
minisign-verify = "0.2"
fs2 = "0.4"
futures = "0.3"
getrandom = "0.4"
hex = "0.4"
minisign = "0.9"
hostname = "0.4"
mpp = { version = "0.10.0", features = ["tempo", "evm", "utils", "client", "server"] }
posthog-rs = "0.5.3"
qrcode = { version = "0.14", default-features = false }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "http2", "json"] }
rusqlite = { version = "0.39", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.11"
tempfile = "3.27"
tempo-primitives = { version = "1.6", features = ["serde"] }
thiserror = "2.0"
time = "0.3"
tokio = { version = "1.52", features = ["macros", "rt-multi-thread", "signal"] }
toon-format = "0.4"
toml = "1.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2.5"
urlencoding = "2.1"
webbrowser = "1"
zeroize = { version = "1", features = ["serde"] }
# dev-dependencies
assert_cmd = "2.2"
tempo-test = { path = "crates/tempo-test" }
axum = "0.8"
predicates = "3.1"
serial_test = "3.2"
Changelog
0.4.3 (2026-06-02)
Patch Changes
- Add the standalone
tempo-cardsextension for wallet-backed card configuration, customer management, approvals, and provider-backed card workflows. (by @letstokenize, #480) - Preserve both halves of an oversized session channel log scan when an RPC requires a smaller
eth_getLogsrange, avoiding skipped upper-half ranges during wallet session recovery. (by @EfeBaranDurmaz, #462) - Pass the selected login network through wallet authentication requests so testnet CLI logins stay on testnet instead of falling back to mainnet browser state. (by @BrendanRyan, #456)
- Fixed
tempo requestfailing on 402 responses that offer multipletempo-method payment challenges (e.g. moderato + mainnet on the same endpoint). The CLI now decodes every offered challenge and picks the first one the wallet can actually satisfy — matching--network(when set) and the keystore's(chain_id, currency). When no challenge matches, a newNoCompatibleChallengeerror lists the offered and held options and suggeststempo wallet fund/tempo wallet logininstead of failing later with a cryptic "No key configured" message. (by @BrendanRyan, #477) - Redact query parameters from paid-success analytics events so payment flows do not report URL secrets after a 402 challenge succeeds. (by @DimitryFiuse, #458)
0.4.2 (2026-05-04)
Patch Changes
- Bump posthog-rs 0.5.0 → 0.5.3 (drops reqwest 0.11/rustls 0.21 chain), remove stale advisory ignores, add dependabot update grouping.
- Surface access-key spending limit reverts returned by Tempo RPC as a specific wallet error with guidance to refresh or re-authorize the spending limit.
- Preserve both the MPP client attribution ID and the configured signing mode when handling zero-amount charge payments.
- Pin all GitHub Actions to commit SHAs, fix template injection in CI workflows, scope permissions per-job, replace curl|sh with checksum-verified binary download, add Dependabot cooldown, and suppress unfixable transitive advisories in deny.toml.
0.4.1 (2026-04-10)
Patch Changes
- Pass
signing_modetoTempoProviderin the zero-amount charge path, matching the paid charge path. Without this, the provider defaults to Direct mode and ignores keychain configuration.
0.4.0 (2026-04-07)
Minor Changes
- Added zero-amount proof credential support for identity flows. When a server issues a charge challenge with
amount="0", the wallet now signs an EIP-712 proof credential instead of building an on-chain transaction, enabling authentication without moving funds. UpgradedmppSDK to v0.9.0.
Unreleased
Patch Changes
- Fix gas estimation failure when
feePayer: trueby using the real fee token address foreth_estimateGasinstead of the zero address. The final signed transaction still usesAddress::ZEROfor server-side sponsor co-signing.
0.3.0 (2026-03-31)
Minor Changes
- Upgraded
mppSDK to v0.8.3, adding support for split payments. Charges withsplitsin the 402 challenge are now handled transparently via multi-transfer AA transactions.
0.2.2 (2026-03-27)
Patch Changes
- Add a remote-host-friendly
--no-browserpath fortempo wallet loginandtempo wallet fund, including CLI guidance that agents can relay to a user approving wallet actions from another device.
0.2.1 (2026-03-26)
Patch Changes
- Bump to the latest
mpp-rsmain, including upstream security fixes for payment bypass, replay, fee-payer manipulation, and session/channel griefing vulnerabilities across Tempo and Stripe flows. The update also refreshes the Tempo client dependency graph and switches Tempo gas estimation to a request-based API while preserving existing wallet/request behavior. - Stabilize voucher HEAD fallback tests by serializing cases that share a process-global unsupported-origin cache.
- Preserve non-2xx response bodies when fetching the service directory so CLI errors include upstream details instead of only the HTTP status.
- Add
chain_idtotempo wallet transferoutput and render submitted transaction hashes as explorer hyperlinks, with a plain URL fallback when terminal hyperlinks are unsupported.
0.2.0 (2026-03-24)
Minor Changes
- Add
tempo wallet refreshto refresh passkey/access-key authorization without a full relogin, including improved login/refresh command flow and validation messaging for expired or stale authorizations. - Enforce strict session
Payment-Receipthandling across all session flows, including reused persisted sessions that were previously permissive: reject successful paid responses that omit or malformedly encode receipts, require validspentsemantics for response/header/event receipts, preserve conservative local channel state when strict top-up receipt validation fails after a paid response, and extend integration coverage for strict open/top-up/streaming receipt failure paths.
Patch Changes
- Add
--max-spend/TEMPO_MAX_SPENDhard cap for cumulative session spend, enforced at challenge time, on session reuse, at channel open, and during streaming top-ups. Also reconcile on-chain channel state after cooperative-close 5xx failures before falling back to payer-side close, and fix session reuse to reject candidates from a different origin. - Track server-reported
Payment-Receipt.spentfor session state and use it as the close target instead of the payer-signed cumulative ceiling. This adds persistedserver_spentsupport and updates request/session close flows so cooperative close settles the amount the server actually reports as spent. - Fix session top-up which was completely broken due to five compounding bugs: ABI mismatch (topUp used uint128 instead of uint256 producing wrong function selector), voucher cumulative amount was incorrectly clamped to available balance preventing top-up from ever triggering, AmountExceedsDeposit problem type was not handled alongside InsufficientBalance, stale challenge echo was used for top-up requests causing server rejection, and missing requiredTopUp field in server response caused a hard failure instead of computing the value from local state. Also signals ChannelInvalidated when on-chain top-up fails so the caller can re-open a new channel.
- Added
help_heading = "Network"to the--networkCLI argument for improved help output organization. - Only purge local credentials/session state for inactive access-key errors after confirming key state on-chain. This prevents destructive cleanup on transient or misclassified failures and improves error classification for charge/session payment paths.
- Fix hyperlink sanitization tests to correctly extract and validate only the display text portion of OSC 8 hyperlink sequences, preventing false failures in OSC 8-capable terminals.
- Harden paid session SSE handling for real-world providers by improving retry/stream behavior and session state synchronization across open, voucher, and streaming flows. This reduces false failures and makes strict payment processing more resilient to provider response quirks.
- Improve
tempo wallet sessions closeoutput by printing a blank line before per-channel progress logs for better readability during multi-session close operations. - Slim the service list schema to replace full endpoint details with an
endpoint_countfield, reducing payload size. Adds a test to enforce the summary-only structure.
0.1.5 (2026-03-18)
Patch Changes
- Remove hardcoded credentials and tokens from default RPC URLs, auth URLs, and services API URL. Unhide the
--networkCLI flag and update its help text. Improve changelog generation by embedding full format instructions in the AI prompt. - Adds an
accepted_cumulativefield toChannelRecordto track the server-confirmed accepted amount separately from the payer-signed ceiling (cumulative_amount). Cooperative close now uses the accepted amount instead of the signing ceiling to avoid overcharging the payer, with a DB migration and monotonic update logic throughout the storage and request layers. - Fix cooperative session close by selecting the correct WWW-Authenticate challenge when the proxy returns multiple headers (charge and session intents). Parse problem+json error details from close failures to surface actionable messages instead of generic errors.
- Improve close command progress output: add status messages when closing local sessions, sessions by URL, and orphaned channels, and refactor
finalize_closed_channelsto use iterator filtering with a count message before finalizing.
Unreleased
Patch Changes
- Improve open-source readiness across docs and metadata: fix contributor setup instructions, replace dead example links, add a security policy document, and refresh the root README structure.
- Remove embedded routing/rate-limit tokens from built-in Tempo default RPC and wallet auth URLs, using token-free base endpoints instead.
0.1.4 (2026-03-18)
Patch Changes
- Tighten charge payment provisioning retry to only fire on auth/payment (401–403) and server error (5xx) status codes, avoiding wasteful retries on unrelated API errors like 400 body validation. Show full server response body in payment rejection errors instead of extracting a single JSON field. Ensure all retry paths surface the original error on retry failure.
0.1.4 (2026-03-18)
0.1.3 (2026-03-18)
Patch Changes
- Fix charge payment failing with "access key does not exist" when the signing key is not yet provisioned on-chain. The server-side rejection retry only triggered on HTTP 401-403, but the server returns other status codes for keychain errors.
0.1.3 (2026-03-18)
Patch Changes
- Fix charge payment failing with "access key does not exist" when the signing key is not yet provisioned on-chain. The server-side rejection retry only triggered on HTTP 401-403, but the server returns other status codes for keychain errors.
0.1.2 (2026-03-18)
Patch Changes
- Persist channel state when channel open fails to prevent orphaned on-chain funds.
- When the open transaction is sent to the server but the server returns an error, the channel may already exist on-chain. Previously the channel state was lost, making the deposited funds unrecoverable. Now the channel record is persisted before returning the error, so future runs can discover and close it.
- Fix session deposit and key-auth retry error handling.
- Preserve original error when key-authorization retry also fails, instead of showing misleading retry errors (e.g.
KeyAlreadyExists). - Ensure session deposit covers at least the per-request cost, preventing channels from opening with insufficient funds.
- Fail early before opening a channel if wallet balance is too low to cover the request cost.
0.1.2 (2026-03-18)
0.1.1 (2026-03-18)
Patch Changes
- Removed the "all" amount option from the transfer command, along with the associated
query_balancehelper andbalanceOfinterface method. Theresolve_amountfunction is now synchronous. - Migrated the
fundcommand to a browser-based flow, replacing the previous faucet/bridge implementations with a simple browser-open + balance polling approach. Added network name aliases (mainnet,testnet,moderato) forNetworkIdparsing, removed theqrcodedependency, updated install paths from~/.local/binto~/.tempo/bin, and removed--no-waitand--dry-runflags from the fund command.
0.1.1 (2026-03-17)
Patch Changes
- Simplified optimistic key provisioning by removing the
query_key_statusandprepare_provisioning_retryfunctions, instead retrying directly withwith_key_authorization()on any error. Added support for mergedWWW-Authenticatechallenges (RFC 9110 §11.6.1) by splitting and selecting the first supported payment method. Fixedlist_channelsto exclude localhost origins and removed the realm-vs-origin validation check.
Contributing to Tempo CLI
Thanks for your interest in contributing! This guide covers everything you need to build, test, and submit changes.
Table of Contents
- Prerequisites
- Pull Requests
- Build & Test
- Pre-Commit Checklist
- Linting
- Project Structure
- Adding a New Feature
- Testing
- Writing Documentation
- Environment Variables
Prerequisites
- Rust (edition 2021)
git clone git@github.com:tempoxyz/wallet.git
cd wallet
make build
make testPull Requests
Titles
Use Conventional Commits with an optional scope:
<type>(<scope>): <short description>Types: feat, fix, perf, refactor, docs, test, chore
Examples:
fix(request): preserve receipt schema for malformed headersrefactor(common): centralize output formatting helpers
Descriptions
Keep it short: what changed and why.
Do:
- Write 1–3 sentences summarizing behavior changes
- Explain why if the diff is not self-evident
- Link related issue(s) when available
Don't:
- Paste file lists from the diff
- Add long stale sections (“Files Changed”, “Implementation Details”)
- Pad with filler language
Build & Test
make build # Debug build
make release # Optimized release build
make test # Run all tests (uses mocks, no network required)
make check # fmt + clippy + test + doc
make fix # Auto-fix formatting and clippy warnings
make coverage # Generate code coverage (requires cargo-llvm-cov)
make install # Install binaries to ~/.tempo/bin
make uninstall # Uninstall binaries
make run ARGS="<url>" # Run tempo-wallet with arguments
make clean # cargo cleanPre-Commit Checklist
Before every commit, run:
make checkThis runs cargo fmt --check, cargo clippy -D warnings, all tests, and doc generation. Everything must pass with zero warnings.
Linting
This project uses Tempo lints for additional code quality checks beyond clippy:
npm install # Install lint tooling (first time only)
npm run lint # Run lintsNote: Usenpm(notpnpm) — the@tempoxyz/lintspackage uses build scripts that pnpm v10 blocks.
To suppress a lint for a specific line:
// ast-grep-ignore: no-unwrap-in-lib
let value = something.unwrap();Project Structure
crates/
├── tempo-common/ # Shared library for all extension binaries
│ └── src/
│ ├── lib.rs # Module declarations
│ ├── analytics.rs # Opt-out telemetry (PostHog)
│ ├── config.rs # Configuration file handling
│ ├── error.rs # Error types (ConfigError, TempoError)
│ ├── network.rs # Network definitions (Tempo, Moderato), explorer URLs, RPC
│ ├── security.rs # Security utilities (sanitization, redaction)
│ ├── cli/ # Shared CLI infrastructure
│ │ ├── args.rs # GlobalArgs, parse_cli
│ │ ├── context.rs # Context struct (shared app state for all commands)
│ │ ├── exit_codes.rs # Process exit codes
│ │ ├── format.rs # Value formatting helpers (amounts, durations)
│ │ ├── output.rs # OutputFormat, structured output helpers
│ │ ├── runner.rs # CLI lifecycle (run_cli, run_main)
│ │ ├── runtime.rs # Tracing, color mode, error rendering
│ │ ├── terminal.rs # Terminal output helpers (hyperlinks, sanitization)
│ │ ├── tracking.rs # Analytics tracking (track_command, track_result)
│ │ └── verbosity.rs # Verbosity configuration
│ ├── keys/ # Key storage, signing, authorization
│ └── payment/ # Payment error classification and session management
│ ├── classify.rs # Payment error classification
│ └── session/ # Channel persistence (SQLite), channel queries, close, tx
├── tempo-wallet/ # Wallet identity, custody, sessions, services, and signing
│ ├── src/
│ │ ├── main.rs # CLI entry point
│ │ ├── args.rs # clap definitions (Cli, Commands)
│ │ ├── app.rs # Command dispatch
│ │ ├── analytics.rs # Wallet-specific analytics events
│ │ ├── prompt.rs # Interactive prompt helpers
│ │ ├── wallet/ # Wallet account types, on-chain queries, rendering
│ │ └── commands/ # Command implementations
│ │ ├── login.rs, logout.rs, whoami.rs, keys.rs, sign.rs, completions.rs
│ │ ├── fund/ # Fund wallet (browser-based flow)
│ │ ├── sessions/ # Session management (list, close, sync)
│ │ └── services/ # Service directory (client, model, render)
│ └── tests/ # Integration tests (assert_cmd)
├── tempo-request/ # HTTP client with automatic MPP payment
│ ├── src/
│ │ ├── main.rs # CLI entry point
│ │ ├── args.rs # clap definitions (Cli, QueryArgs)
│ │ ├── app.rs # Command dispatch
│ │ ├── analytics.rs # Request-specific analytics events
│ │ ├── query/ # Query flow (challenge parsing, request prep, output, SSE, analytics)
│ │ ├── http/ # HTTP client, response handling, formatting
│ │ └── payment/ # Payment flows (charge, session, router)
│ └── tests/ # Integration tests (assert_cmd)
└── tempo-sign/ # Release manifest signing tool
└── src/main.rsScope: CLI-Only
This repository is a Cargo workspace with binary crates and one internal shared library (tempo-common). Internal modules are crate-private and not a stable public API. Please do not depend on any crate as a library — all supported behavior is exposed via the CLI.
Key Conventions
Imports — group as std → external crates → crate/tempo_common modules:
use std::path::PathBuf;
use clap::Parser;
use tempo_common::config::Config;
use tempo_common::error::TempoError;
fn run() -> Result<(), TempoError> {
Ok(())
}Error handling — TempoError (thiserror) for typed boundaries; prefer source-carrying variants (*Source) when a concrete underlying error exists.
Modules — each module has a single responsibility. Shared logic goes in tempo-common. All commands go in tempo-wallet/src/commands/.
Dependencies — declared in [workspace.dependencies] in root Cargo.toml, referenced with dep.workspace = true in each crate.
Adding a New Feature
1. Add shared logic in crates/tempo-common/src/ if used by multiple binaries 2. Add CLI flags/commands in the appropriate binary's src/args.rs 3. Implement commands in the appropriate binary's src/commands/ 4. Add tests: unit tests in source files, integration tests in the relevant crate's tests/ directory 5. Run make check — zero warnings required
Testing
- Unit tests live in source files (
#[cfg(test)] mod tests) - Integration tests in each crate's
tests/directory useassert_cmdfor black-box CLI testing - Use
TestConfigBuilderandtest_command()helpers to set up test configurations - Coverage:
make coveragegenerates an lcov report (requirescargo-llvm-covandllvm-tools-preview)
Writing Documentation
Keep documentation in sync with the CLI. After changing flags, commands, or behavior:
1. Run cargo run -p <crate> -- --help (and subcommand --help) to verify help text is accurate 2. Update README.md if user-facing behavior changed 3. Check that AGENTS.md still reflects the current module layout and conventions
Environment Variables
| Variable | Description |
|---|---|
TEMPO_RPC_URL | Override RPC endpoint |
TEMPO_AUTH_URL | Override auth server URL |
TEMPO_SERVICES_URL | Override service directory API URL |
POSTHOG_API_KEY | PostHog key used to enable telemetry (can be injected at build time in CI or set at runtime) |
TEMPO_NO_TELEMETRY | Disable telemetry |
RUST_LOG | Override tracing filter (e.g., debug, info) |
NO_COLOR | Disable colored output (also disabled when stdout is not a terminal) |
TEMPO_PRIVATE_KEY | (hidden) Provide a private key directly for payment — bypasses wallet login and keychain |
TEMPO_TEST_EVENTS | (internal) Test hook — path to a file where analytics events are appended for assertion |
include!("../../build_shared.rs");
[package]
name = "tempo-cards"
version.workspace = true
edition = "2021"
description = "Issue and manage Tempo wallet-backed cards"
license = "MIT OR Apache-2.0"
repository = "https://github.com/tempoxyz/wallet"
publish = false
readme = "README.md"
keywords = ["cards", "cli", "payments", "stripe", "bridge"]
categories = ["command-line-utilities"]
[lints]
workspace = true
[[bin]]
name = "tempo-cards"
path = "src/main.rs"
[dependencies]
tempo-common = { path = "../tempo-common" }
tempo-primitives.workspace = true
alloy.workspace = true
base64.workspace = true
clap.workspace = true
getrandom.workspace = true
hex.workspace = true
mpp.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
toml.workspace = true
tracing.workspace = true
url.workspace = true
urlencoding.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
axum.workspace = true
rusqlite.workspace = true
serde_json.workspace = true
serial_test.workspace = true
tempfile.workspace = true
tempo-test.workspace = true
tokio.workspace = true
url.workspace = true
tempo-cards
Wallet-backed cards extension for the Tempo CLI. Invoked as tempo cards ... via the launcher's tempo-<name> discovery, or directly as tempo-cards ....
Provides:
- Bridge customer onboarding — create customers, hosted ToS / KYC links, transfer history.
- Stripe Issuing — create / list / retrieve / update / freeze / unfreeze / cancel virtual cards, cardholders, transactions, and authorizations.
- On-chain approval — approve the card issuer to spend wallet USDC on Tempo (
approve/allowance).
Commands
| Command | Description |
|---|---|
tempo cards config bridge-api-key <key> | Save Bridge API key |
tempo cards config stripe-api-key <key> | Save Stripe API key |
tempo cards config show | Show current card configuration |
tempo cards customers create -f -l -e | Create a Bridge customer |
tempo cards customers tos-acceptance-link <id> | Hosted ToS link |
tempo cards customers kyc-link <id> --endorsement cards | Hosted KYC link |
tempo cards customers get/list/delete/transfers | Manage customers |
tempo cards create --cardholder <id> | Issue a virtual card backed by your wallet |
tempo cards list/get/update/freeze/unfreeze/cancel | Manage cards |
tempo cards cardholders list/get | Manage cardholders |
tempo cards transactions list/get | Manage transactions |
tempo cards authorizations list/get | Manage authorizations |
| `tempo cards approve --amount <USDC \ | max>` |
tempo cards allowance | Show current issuer allowance |
Configuration
API keys can come from env vars or be persisted to ~/.tempo/wallet/cards.toml (mode 0600) via tempo cards config.
| Variable | Purpose |
|---|---|
TEMPO_BRIDGE_API_KEY / BRIDGE_API_KEY | Bridge API key |
TEMPO_BRIDGE_API_URL | Override Bridge API base URL (testing) |
TEMPO_STRIPE_API_KEY / STRIPE_SECRET_KEY / STRIPE_API_KEY | Stripe API key |
TEMPO_STRIPE_API_URL | Override Stripe API base URL (testing) |
Example end-to-end flow
tempo cards config bridge-api-key sk-test-...
tempo cards config stripe-api-key sk_test_...
tempo cards customers create -f John -l Doe -e john@example.com
tempo cards customers tos-acceptance-link <bridge-customer-id>
tempo cards customers kyc-link <bridge-customer-id> --endorsement cards
tempo cards create \
--cardholder <stripe-cardholder-id> \
--bridge-customer-id <bridge-customer-id>
tempo cards approve --amount maxLicense
Dual-licensed under Apache 2.0 and MIT.
//! Application entry point: build context, dispatch command, flush analytics.
use crate::{
args::{CardsCommands, Cli},
commands::cards,
};
use tempo_common::error::TempoError;
/// Run the tempo-cards application.
pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> {
let command = if let Some(c) = cli.command.take() {
c
} else {
use clap::CommandFactory;
return Cli::command().print_help().map_err(Into::into);
};
tempo_common::cli::run_cli(
&cli.global,
&["tempo_cards"],
"tempo-cards",
|ctx| async move {
let cmd_name = command_name(&command);
let result = cards::run(&ctx, Some(command)).await;
(cmd_name, result)
},
)
.await
}
/// Derive a short analytics-friendly name from a parsed command.
const fn command_name(command: &CardsCommands) -> &'static str {
match command {
CardsCommands::Config { .. } => "cards config",
CardsCommands::Customers { .. } => "cards customers",
CardsCommands::Create { .. } => "cards create",
CardsCommands::List { .. } => "cards list",
CardsCommands::Get { .. } => "cards get",
CardsCommands::Update { .. } => "cards update",
CardsCommands::Freeze { .. } => "cards freeze",
CardsCommands::Unfreeze { .. } => "cards unfreeze",
CardsCommands::Cancel { .. } => "cards cancel",
CardsCommands::Cardholders { .. } => "cards cardholders",
CardsCommands::Transactions { .. } => "cards transactions",
CardsCommands::Authorizations { .. } => "cards authorizations",
CardsCommands::Approve { .. } => "cards approve",
CardsCommands::Allowance { .. } => "cards allowance",
}
}
//! CLI argument definitions and parsing.
use clap::{Parser, Subcommand, ValueEnum};
/// Long version string including git commit, build date, and profile.
const LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("TEMPO_GIT_SHA"),
" ",
env!("TEMPO_BUILD_DATE"),
" ",
env!("TEMPO_BUILD_PROFILE"),
")"
);
#[derive(Parser, Debug)]
#[command(name = "tempo cards")]
#[command(about = "Issue and manage Tempo wallet-backed cards", long_about = None)]
#[command(version = LONG_VERSION)]
#[command(override_usage = "\n tempo cards <COMMAND> [OPTIONS]")]
pub(crate) struct Cli {
#[command(subcommand)]
pub command: Option<CardsCommands>,
#[command(flatten)]
pub global: tempo_common::cli::GlobalArgs,
}
#[derive(Subcommand, Debug)]
pub(crate) enum CardsCommands {
/// Configure card API keys
#[command(name = "config", arg_required_else_help = true)]
Config {
#[command(subcommand)]
command: CardsConfigCommands,
},
/// Manage Bridge customers for ToS, KYC, and card endorsement onboarding
#[command(name = "customers", arg_required_else_help = true)]
Customers {
#[command(subcommand)]
command: CardsCustomerCommands,
},
/// Create a virtual Stripe Issuing card backed by a Tempo wallet
Create {
/// Stripe Issuing cardholder ID returned after Bridge onboarding
#[arg(long)]
cardholder: String,
/// Tempo wallet address backing the card (defaults to current wallet)
#[arg(long)]
wallet_address: Option<String>,
/// Stripe idempotency key for safe retries (defaults to wallet/cardholder pair)
#[arg(long)]
idempotency_key: Option<String>,
/// Bridge customer ID to store in Stripe metadata
#[arg(long)]
bridge_customer_id: Option<String>,
},
/// List Stripe Issuing cards
List {
/// Only return cards belonging to this cardholder
#[arg(long)]
cardholder: Option<String>,
/// Only return cards with this status
#[arg(long, value_enum)]
status: Option<IssuingCardStatus>,
/// Only return cards with this type
#[arg(long = "type", value_enum)]
card_type: Option<IssuingCardType>,
/// Only return cards with these last four digits
#[arg(long)]
last4: Option<String>,
/// Maximum number of cards to return (1-100)
#[arg(long)]
limit: Option<u32>,
/// Pagination cursor
#[arg(long)]
starting_after: Option<String>,
/// Pagination cursor
#[arg(long)]
ending_before: Option<String>,
},
/// Retrieve a Stripe Issuing card
Get {
/// Stripe Issuing card ID
id: String,
},
/// Update a Stripe Issuing card status
Update {
/// Stripe Issuing card ID
id: String,
/// New card status
#[arg(long, value_enum)]
status: IssuingCardStatus,
/// Required by Stripe when canceling a lost or stolen card
#[arg(long, value_enum)]
cancellation_reason: Option<CardCancellationReason>,
},
/// Freeze a Stripe Issuing card by setting status to inactive
Freeze {
/// Stripe Issuing card ID
id: String,
},
/// Unfreeze a Stripe Issuing card by setting status to active
Unfreeze {
/// Stripe Issuing card ID
id: String,
},
/// Cancel a Stripe Issuing card
Cancel {
/// Stripe Issuing card ID
id: String,
/// Reason when canceling a lost or stolen card
#[arg(long, value_enum)]
cancellation_reason: Option<CardCancellationReason>,
},
/// Manage Stripe Issuing cardholders
#[command(name = "cardholders", arg_required_else_help = true)]
Cardholders {
#[command(subcommand)]
command: CardsCardholderCommands,
},
/// Manage Stripe Issuing transactions
#[command(name = "transactions", arg_required_else_help = true)]
Transactions {
#[command(subcommand)]
command: CardsTransactionCommands,
},
/// Manage Stripe Issuing authorizations
#[command(name = "authorizations", arg_required_else_help = true)]
Authorizations {
#[command(subcommand)]
command: CardsAuthorizationCommands,
},
/// Approve the card issuer to spend wallet USDC on Tempo
Approve {
/// Amount in human units, or "max" for unlimited allowance
#[arg(long)]
amount: String,
/// Issuer spender address (defaults to Tempo cards issuer on mainnet)
#[arg(long)]
spender: Option<String>,
/// Pay fees in a different token (default: USDC)
#[arg(long)]
fee_token: Option<String>,
/// Show plan without sending a transaction
#[arg(long)]
dry_run: bool,
},
/// Show current card issuer allowance for wallet USDC
Allowance {
/// Issuer spender address (defaults to Tempo cards issuer on mainnet)
#[arg(long)]
spender: Option<String>,
/// Wallet address to query (defaults to current wallet)
#[arg(long)]
wallet_address: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum CardsConfigCommands {
/// Save your Bridge API key
BridgeApiKey {
/// Bridge API key (sk-live-... or sk-test-...)
api_key: String,
},
/// Save your Stripe API key
StripeApiKey {
/// Stripe API key (sk_live_... or sk_test_...)
api_key: String,
},
/// Show current card configuration
Show,
}
#[derive(Subcommand, Debug)]
pub(crate) enum CardsCustomerCommands {
/// Create a new Bridge customer
Create {
/// Customer type
#[arg(long = "type", value_enum, default_value_t = BridgeCustomerType::Individual)]
customer_type: BridgeCustomerType,
/// First name
#[arg(short = 'f', long)]
first_name: String,
/// Last name
#[arg(short = 'l', long)]
last_name: String,
/// Email address
#[arg(short = 'e', long)]
email: String,
},
/// Get a Bridge customer by ID
Get {
/// Bridge customer ID
id: String,
},
/// List Bridge customers
List,
/// Delete a Bridge customer
Delete {
/// Bridge customer ID
id: String,
},
/// Create a hosted ToS acceptance link for new customer creation
TosLink,
/// Get a hosted ToS acceptance link for an existing customer
TosAcceptanceLink {
/// Bridge customer ID
id: String,
},
/// Get a hosted KYC link for an existing customer
KycLink {
/// Bridge customer ID
id: String,
/// Endorsement type (for cards, use "cards")
#[arg(long)]
endorsement: Option<String>,
/// Redirect URI after KYC completion
#[arg(long)]
redirect_uri: Option<String>,
},
/// List transfers for a Bridge customer
Transfers {
/// Bridge customer ID
id: String,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum CardsCardholderCommands {
/// List Stripe Issuing cardholders
List {
/// Only return cardholders with this email
#[arg(long)]
email: Option<String>,
/// Only return cardholders with this status
#[arg(long, value_enum)]
status: Option<CardholderStatus>,
/// Only return cardholders with this type
#[arg(long = "type", value_enum)]
cardholder_type: Option<CardholderType>,
/// Maximum number of cardholders to return (1-100)
#[arg(long)]
limit: Option<u32>,
/// Pagination cursor
#[arg(long)]
starting_after: Option<String>,
/// Pagination cursor
#[arg(long)]
ending_before: Option<String>,
},
/// Retrieve a Stripe Issuing cardholder
Get {
/// Stripe Issuing cardholder ID
id: String,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum CardsTransactionCommands {
/// List Stripe Issuing transactions
List {
/// Only return transactions for this card
#[arg(long)]
card: Option<String>,
/// Only return transactions for this cardholder
#[arg(long)]
cardholder: Option<String>,
/// Only return transactions with this type
#[arg(long = "type", value_enum)]
transaction_type: Option<TransactionType>,
/// Maximum number of transactions to return (1-100)
#[arg(long)]
limit: Option<u32>,
/// Pagination cursor
#[arg(long)]
starting_after: Option<String>,
/// Pagination cursor
#[arg(long)]
ending_before: Option<String>,
},
/// Retrieve a Stripe Issuing transaction
Get {
/// Stripe Issuing transaction ID
id: String,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum CardsAuthorizationCommands {
/// List Stripe Issuing authorizations
List {
/// Only return authorizations for this card
#[arg(long)]
card: Option<String>,
/// Only return authorizations for this cardholder
#[arg(long)]
cardholder: Option<String>,
/// Only return authorizations with this status
#[arg(long, value_enum)]
status: Option<AuthorizationStatus>,
/// Maximum number of authorizations to return (1-100)
#[arg(long)]
limit: Option<u32>,
/// Pagination cursor
#[arg(long)]
starting_after: Option<String>,
/// Pagination cursor
#[arg(long)]
ending_before: Option<String>,
},
/// Retrieve a Stripe Issuing authorization
Get {
/// Stripe Issuing authorization ID
id: String,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum BridgeCustomerType {
Individual,
Business,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum IssuingCardStatus {
Active,
Inactive,
Canceled,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum IssuingCardType {
Virtual,
Physical,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CardCancellationReason {
Lost,
Stolen,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CardholderStatus {
Active,
Inactive,
Blocked,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CardholderType {
Individual,
Company,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum TransactionType {
Capture,
Refund,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum AuthorizationStatus {
Pending,
Closed,
Reversed,
Expired,
}
impl BridgeCustomerType {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Individual => "individual",
Self::Business => "business",
}
}
}
impl IssuingCardStatus {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Inactive => "inactive",
Self::Canceled => "canceled",
}
}
}
impl IssuingCardType {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Virtual => "virtual",
Self::Physical => "physical",
}
}
}
impl CardCancellationReason {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Lost => "lost",
Self::Stolen => "stolen",
}
}
}
impl CardholderStatus {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Inactive => "inactive",
Self::Blocked => "blocked",
}
}
}
impl CardholderType {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Individual => "individual",
Self::Company => "company",
}
}
}
impl TransactionType {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Capture => "capture",
Self::Refund => "refund",
}
}
}
impl AuthorizationStatus {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Closed => "closed",
Self::Reversed => "reversed",
Self::Expired => "expired",
}
}
}
impl std::fmt::Display for BridgeCustomerType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
//! Wallet approval and allowance helpers for card spend.
use alloy::{
primitives::{
address,
utils::{format_units, parse_units, ParseUnits},
Address, Bytes, TxKind, U256,
},
providers::ProviderBuilder,
sol,
sol_types::SolCall,
};
use serde::Serialize;
use tempo_primitives::transaction::Call;
use tempo_common::{
cli::{context::Context, output},
error::{ConfigError, InputError, NetworkError, TempoError},
network::NetworkId,
payment::session::submit_tempo_tx,
};
sol! {
#[sol(rpc)]
interface ITIP20Cards {
function approve(address spender, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
}
}
/// Tempo mainnet card issuer spender from the wallet-backed cards flow.
const TEMPO_CARDS_ISSUER: Address = address!("3e8f24b686aa8c036038f7d557b70e6ce0e7b56b");
#[derive(Debug, Serialize)]
struct ApprovalResponse {
status: &'static str,
wallet: String,
spender: String,
token: String,
symbol: &'static str,
amount: String,
amount_atomic: String,
#[serde(skip_serializing_if = "Option::is_none")]
tx_hash: Option<String>,
}
#[derive(Debug, Serialize)]
struct AllowanceResponse {
wallet: String,
spender: String,
token: String,
symbol: &'static str,
allowance: String,
allowance_atomic: String,
}
pub(super) async fn approve(
ctx: &Context,
amount: String,
spender_input: Option<String>,
fee_token_input: Option<String>,
dry_run: bool,
) -> Result<(), TempoError> {
ctx.keys.ensure_key_for_network(ctx.network)?;
let token = ctx.network.token();
let spender = resolve_spender(ctx.network, spender_input.as_deref())?;
let wallet = ctx.keys.signer(ctx.network)?;
let from = wallet.from;
let (amount_atomic, amount_human) = parse_allowance_amount(&amount, token.decimals)?;
let fee_token = fee_token_input.map_or_else(
|| Ok(token.address),
|input| {
tempo_common::security::parse_address_input(&input, "fee token")
.map_err(TempoError::from)
},
)?;
if dry_run {
let response = ApprovalResponse {
status: "dry_run",
wallet: format!("{from:#x}"),
spender: format!("{spender:#x}"),
token: format!("{:#x}", token.address),
symbol: token.symbol,
amount: amount_human,
amount_atomic: amount_atomic.to_string(),
tx_hash: None,
};
return output::emit_by_format(ctx.output_format, &response, || {
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(())
});
}
let approve_data = Bytes::from(
ITIP20Cards::approveCall {
spender,
amount: amount_atomic,
}
.abi_encode(),
);
let calls = vec![Call {
to: TxKind::Call(token.address),
value: U256::ZERO,
input: approve_data,
}];
let rpc_url = ctx.config.rpc_url(ctx.network);
let tempo_provider =
alloy::providers::RootProvider::<mpp::client::TempoNetwork>::new_http(rpc_url);
let tx_hash = submit_tempo_tx(
&tempo_provider,
&wallet,
ctx.network.chain_id(),
fee_token,
from,
calls,
)
.await?;
let response = ApprovalResponse {
status: "success",
wallet: format!("{from:#x}"),
spender: format!("{spender:#x}"),
token: format!("{:#x}", token.address),
symbol: token.symbol,
amount: amount_human,
amount_atomic: amount_atomic.to_string(),
tx_hash: Some(tx_hash.clone()),
};
output::emit_by_format(ctx.output_format, &response, || {
eprintln!("Approved card issuer spend.");
eprintln!(" TX: {tx_hash}");
eprintln!(" {}", ctx.network.tx_url(&tx_hash));
Ok(())
})
}
pub(super) async fn allowance(
ctx: &Context,
spender_input: Option<String>,
wallet_address_input: Option<String>,
) -> Result<(), TempoError> {
let token = ctx.network.token();
let spender = resolve_spender(ctx.network, spender_input.as_deref())?;
let owner = if let Some(input) = wallet_address_input {
tempo_common::security::parse_address_input(&input, "wallet address")?
} else {
ctx.keys
.key_for_network(ctx.network)
.and_then(|entry| entry.wallet_address_parsed())
.ok_or_else(|| {
ConfigError::Missing(
"No wallet configured. Run `tempo wallet login` or pass --wallet-address."
.to_string(),
)
})?
};
let rpc_url = ctx.config.rpc_url(ctx.network);
let provider = ProviderBuilder::new().connect_http(rpc_url);
let contract = ITIP20Cards::new(token.address, provider);
let allowance = contract
.allowance(owner, spender)
.call()
.await
.map_err(|source| NetworkError::RpcSource {
operation: "query card issuer allowance",
source: Box::new(source),
})?;
let allowance_human = format_units(allowance, token.decimals).expect("decimals <= 77");
let response = AllowanceResponse {
wallet: format!("{owner:#x}"),
spender: format!("{spender:#x}"),
token: format!("{:#x}", token.address),
symbol: token.symbol,
allowance: allowance_human,
allowance_atomic: allowance.to_string(),
};
output::emit_by_format(ctx.output_format, &response, || {
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(())
})
}
fn resolve_spender(network: NetworkId, input: Option<&str>) -> Result<Address, TempoError> {
if let Some(input) = input {
return tempo_common::security::parse_address_input(input, "spender").map_err(Into::into);
}
match network {
NetworkId::Tempo => Ok(TEMPO_CARDS_ISSUER),
NetworkId::TempoModerato => Err(ConfigError::Missing(
"No default card issuer spender is configured for tempo-moderato. Pass --spender."
.to_string(),
)
.into()),
}
}
fn parse_allowance_amount(input: &str, decimals: u8) -> Result<(U256, String), TempoError> {
if matches!(input, "max" | "MAX" | "unlimited" | "UNLIMITED") {
return Ok((U256::MAX, "max".to_string()));
}
let parsed = parse_units(input, decimals)
.map_err(|_| InputError::InvalidHexInput(format!("Invalid amount: '{input}'")))?;
let amount = match parsed {
ParseUnits::U256(value) => value,
ParseUnits::I256(value) => {
if value.is_negative() {
return Err(
InputError::InvalidHexInput("Amount must be positive.".to_string()).into(),
);
}
value.into_raw()
}
};
if amount.is_zero() {
return Err(
InputError::InvalidHexInput("Amount must be greater than zero.".to_string()).into(),
);
}
Ok((amount, input.to_string()))
}
//! CLI command implementations.
pub(crate) mod cards;
#![allow(
clippy::cast_possible_truncation,
clippy::items_after_statements,
clippy::manual_let_else,
clippy::needless_pass_by_value,
clippy::option_if_let_else,
clippy::redundant_pub_crate,
clippy::struct_field_names,
clippy::too_many_lines,
clippy::unnecessary_wraps,
clippy::unused_async
)]
//! CLI entry point for `tempo-cards`.
mod app;
mod args;
mod commands;
use crate::args::Cli;
#[tokio::main]
async fn main() {
let cli: Cli = tempo_common::cli::parse_cli();
let output_format = cli.global.resolve_output_format();
let result = app::run(cli).await;
tempo_common::cli::run_main(output_format, result);
}
//! Common test utilities for tempo-cards CLI tests.
//!
//! Keep this module minimal so all test targets can include it without lint allowances.
/// Create a test command for tempo-cards with proper environment variables set.
pub(crate) fn test_command(temp_dir: &tempfile::TempDir) -> std::process::Command {
tempo_test::make_test_command(
assert_cmd::cargo::cargo_bin!("tempo-cards").to_path_buf(),
temp_dir,
)
}
[package]
name = "tempo-common"
version.workspace = true
edition = "2021"
description = "Shared library for Tempo CLI extensions"
license = "MIT OR Apache-2.0"
repository = "https://github.com/tempoxyz/wallet"
publish = false
readme = "../../README.md"
keywords = ["http", "cli", "payments", "mpp"]
categories = ["command-line-utilities"]
[lints]
workspace = true
[dependencies]
alloy.workspace = true
clap.workspace = true
colored.workspace = true
dirs.workspace = true
getrandom.workspace = true
hex.workspace = true
hostname.workspace = true
mpp.workspace = true
posthog-rs.workspace = true
reqwest.workspace = true
rusqlite.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
tempo-primitives.workspace = true
thiserror.workspace = true
time.workspace = true
tokio.workspace = true
toon-format.workspace = true
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
url.workspace = true
zeroize.workspace = true
[dev-dependencies]
axum.workspace = true
serial_test.workspace = true
//! Shared CLI infrastructure for Tempo extension binaries.
mod args;
pub mod context;
pub(crate) mod exit_codes;
pub mod output;
mod runner;
pub mod runtime;
pub mod tracking;
pub mod verbosity;
pub mod format;
pub mod terminal;
pub use args::{parse_cli, GlobalArgs};
pub use runner::{run_cli, run_main};
pub use verbosity::Verbosity;
//! Payment handling: shared types, error classification, and session management.
pub mod classify;
pub mod session;
pub use classify::*;
include!("../../build_shared.rs");