@tempoxyz/horcrux-viem
v0.1.1
Published
A viem account backed by Horcrux remote transaction signing
Keywords
Readme
Horcrux viem account
An ESM TypeScript package for Node.js 22+, tested with viem 2.56.8. Accounts accept
either a wallet UUID or EVM address. The transaction account signs through POST /v1/wallets/{id}/sign.
viem handles transaction preparation and chain RPC. No Privy credentials belong
in the consuming service.
See the runtime agent guide and generated OpenAPI for the server contract and recovery rules. This adapter supports EIP-1559 and Tempo, not the server's Solana/Tron variants.
Build and test
From this directory:
npm ci
npm run build
npm run check
npm test
npm run test:integrationFormatting and linting follow viem 2.56.5's Biome configuration:
two-space indentation, single quotes, and no optional semicolons. Run
npm run format to apply fixes; npm run check builds the package and checks
style and types (including the integration test's public package import).
After the first public release, install with
npm install @tempoxyz/horcrux-viem [email protected]. Before release, build and install
from a local checkout with npm install /path/to/horcrux/sdk/viem [email protected].
The integration command builds the package and explicitly runs both ignored
Rust HTTP tests. Each starts the real Axum router with an in-memory database,
test-only authentication, and a mock Privy HTTP server. Tests import the built
package and check EIP-1559 and native Tempo signatures/fields, a lost HTTP response,
cached replay, conflicting key reuse, and unauthenticated access. Rust assertions
verify that Privy was called only once and exactly three state events were saved
per operation. They use no live wallet, credentials, funds, or chain RPC. CI runs
these tests with --include-ignored; a plain cargo test does not run them.
Tempo sponsorship account
await horcruxTempoAccount(...) discovers the wallet address through Horcrux and
returns an address-pinned viem LocalAccount; no separate address configuration is needed.
Alternatively, horcruxTempoAccount({ address, ... }) returns the account synchronously
without any discovery request. horcruxAccount supports the same two modes. Supply
exactly one of walletId or address.
It uses /v1/wallets/{id}/sign-hash for relay raw signing, Tempo fee-payer
transactions (including MPP), and ordinary Tempo sender transactions. Applications
only construct the account and pass it to their existing viem integration:
import { horcruxTempoAccount } from '@tempoxyz/horcrux-viem'
const account = await horcruxTempoAccount({
url: env.HORCRUX_URL,
walletId: env.HORCRUX_FEE_PAYER_WALLET_ID,
auth: {
type: 'cloudflare-access',
clientId: env.HORCRUX_ACCESS_CLIENT_ID,
clientSecret: env.HORCRUX_ACCESS_CLIENT_SECRET,
},
onError: (error) => reportSigningFailure(error),
})For address mode, replace walletId above with
address: env.HORCRUX_FEE_PAYER_ADDRESS and remove await. Horcrux resolves the
address during signing; grants, policies and recovery remain scoped to the canonical
wallet UUID. Ambiguous addresses fail closed, including inactive or ungranted
duplicates. Address mode requires a server with address-reference support; there
is no discovery fallback. Deploy the server migration before adopting address mode.
Initialize UUID-based accounts in a request-capable context, not at Worker module
scope. Discovery validates the requested wallet ID, active status, EVM family and
address before returning. A discovery failure makes no signing request and is
reported as WALLET_DISCOVERY_FAILED, without a signing recovery key.
Signing uses one POST, with no pre-sign GET. The server checks grants and active status before replay or dispatch and verifies the provider binding at dispatch. The SDK verifies the returned signature against the exact digest and pinned signer, and preserves the sender's signature when adding sponsorship. It rejects bigint validity timestamps before serialization; use numeric Unix seconds. HTTPS is required except on loopback. It never funds, broadcasts, falls back to a key, or signs personal messages/typed data. The caller remains responsible for transaction authorization, fee limits and storage. Raw-hash policies cannot inspect transaction fields.
Unlike horcruxAccount, this account makes no automatic transport retry. Its
digest-derived idempotency key survives account reconstruction and matches the
original Tempo API adapter (tempo-api:hash:v1: plus the keccak256 of the canonical
JSON hash request). Keep the exact original transaction when recovering; changing
fees, chain, nonce, sender or token produces a different digest and operation.
onError receives a sanitized HorcruxError with the recovery key and available
request ID/status/code. Reconcile unknown outcomes before explicitly replaying.
Publishing
Releases use npm's trusted publishing
from .github/workflows/publish-sdk.yml. No npm publishing token is stored in
GitHub. The workflow runs only by manual dispatch on main, requires successful
CI for the exact commit, checks the requested version, and rebuilds/tests the SDK.
The publish job uses the npm-publish GitHub environment, restricted to the main
branch. No environment reviewer approval is currently required.
Only dist, package metadata, and this README are packed; prepack builds fresh
JavaScript and declarations for both local packing and publishing.
An npm organization owner must complete the initial setup before CI can publish:
- Confirm publishing rights to the npm
@tempoxyzscope and the intended public name@tempoxyz/horcrux-viem. - If the package does not exist, bootstrap its first version from a reviewed
maincheckout after CI passes. Insdk/viem, runnpm ci,npm run check,npm test, andnpm pack --dry-run; inspect the file list, then use an authorized npm login with 2FA to runnpm publish. This is a public release. - In the package's npm Settings → Trusted publishing, add GitHub Actions with
organization
tempoxyz, repositoryhorcrux, workflow filenamepublish-sdk.yml, and environment namenpm-publish. Allow directnpm publish, not only staged publishing. These settings live on npm, not in this repository. - Once OIDC publishing works, select “Require two-factor authentication and disallow tokens” in npm's publishing access settings.
For subsequent releases, update the SDK version and lockfile together with
npm version <version> --no-git-tag-version in this directory and merge the PR.
After CI passes on main, dispatch Publish SDK with that exact version:
gh workflow run publish-sdk.yml --repo tempoxyz/horcrux --ref main -f version=0.1.1The version is an example, not an automatic bump. An already-published version cannot be overwritten. Use GitHub-hosted runners: npm OIDC does not support the Depot runners used by regular CI. Public packages from this private repository can use OIDC, but npm does not generate provenance for private source repositories. This workflow neither deploys Horcrux nor changes repository visibility.
Authentication
Configure the server's authentication listeners
first. Without authentication configuration, the binary rejects /v1 requests.
For deployed services, set auth to either KubernetesAuth or CloudflareAccessAuth.
Its type selects the method; fields from the two methods cannot be mixed.
The Kubernetes getToken callback runs once per operation; Cloudflare credentials
are passed directly. A transport retry uses the same credentials, body, and request key.
For Kubernetes, read the projected service-account token each time so automatic replacement is picked up. The mounted token must have the configured Horcrux audience, not the default Kubernetes API audience:
import { readFile } from 'node:fs/promises'
import { horcruxAccount } from '@tempoxyz/horcrux-viem'
const account = await horcruxAccount({
url: 'https://horcrux.internal.example.com',
walletId: process.env.HORCRUX_WALLET_ID!,
auth: {
type: 'kubernetes',
getToken: async () =>
(await readFile('/var/run/secrets/horcrux/token', 'utf8')).trim(),
},
})For Workers, store a Cloudflare Access service token's Client ID and Client Secret in Worker secrets. Pass the Worker environment's values directly:
const account = await horcruxAccount({
url: 'https://horcrux.example.com',
walletId: env.HORCRUX_WALLET_ID,
auth: {
type: 'cloudflare-access',
clientId: env.CF_ACCESS_CLIENT_ID,
clientSecret: env.CF_ACCESS_CLIENT_SECRET,
},
})This sends CF-Access-Client-Id and CF-Access-Client-Secret, without an
Authorization header. Cloudflare validates the credentials; Horcrux validates
Cloudflare's signed assertion. Do not send raw credentials directly to the origin
and expect them to authenticate. Replacing a secret does not change the service's
identity or wallet access. Do not log credentials or request headers.
Local development
On your development machine, set DATABASE_URL to a fresh disposable PostgreSQL
database, then run cargo run --locked --example local_auth from the repository
root. Discard this example database when finished; never use an environment's
application database. Then use:
const account = await horcruxAccount({
url: 'http://127.0.0.1:3040',
walletId: '4396bb1d-a4ef-4ad0-b6d5-9388bc796270',
auth: { type: 'none' },
})This explicitly sends no credentials. It is restricted to loopback hostnames,
does not bypass a server's authentication, and cannot be combined with credentials.
The example uses one fixed identity and a disposable wallet. It supports discovery
and API validation but returns 503 SIGNING_NOT_CONFIGURED for signing; it does
not read Privy secrets or broadcast. Never expose it through a proxy or tunnel.
For fully isolated tests, use the existing fetch option to supply mock responses.
Real Kubernetes authentication test
The ordinary Rust auth tests use synthetic signed tokens. Enable the
kubernetes-integration Cargo feature to compile the ignored
kubernetes_auth_over_http test. It uses real Kubernetes discovery, keys,
HTTPS certificates, and tokens, then runs this SDK over HTTP against the normal
Horcrux authentication middleware. PostgreSQL is disposable and Privy is mocked.
Set DATABASE_URL to the disposable instance described in the root README.
Tested with k3s v1.36.4+k3s1 and a host-network pod with a projected token. The SDK runs outside the pod, reading a private copy of that token. File replacement uses a second real TokenRequest token; this checks SDK rereading, not kubelet's timed rotation. Expiry and other malformed claims remain covered by synthetic-token tests.
Use only a disposable single-node cluster. The manifest creates payments
service accounts and grants anonymous read access to the public token-discovery
endpoints; do not apply it to an existing shared cluster. For the localhost k3s
recipe, start the server with these additional arguments:
--tls-san 127.0.0.1 \
--kube-apiserver-arg anonymous-auth=true \
--kube-apiserver-arg service-account-issuer=https://127.0.0.1:6443 \
--kube-apiserver-arg service-account-jwks-uri=https://127.0.0.1:6443/openid/v1/jwksPoint KUBECONFIG at that cluster and run from the repository root. Set
K3S_DATA_DIR to its data directory (normally /var/lib/rancher/k3s):
umask 077
export HORCRUX_KUBERNETES_TEST_DIR="$PWD/.local/kubernetes-auth-test"
mkdir -p "$HORCRUX_KUBERNETES_TEST_DIR"
kubectl apply -f examples/kubernetes-auth.yaml
kubectl wait -n payments pod/horcrux-auth-test --for=condition=Ready --timeout=120s
sudo cat "$K3S_DATA_DIR/server/tls/server-ca.crt" > "$HORCRUX_KUBERNETES_TEST_DIR/ca.pem"
sudo cat "$K3S_DATA_DIR/server/tls/client-ca.crt" > "$HORCRUX_KUBERNETES_TEST_DIR/wrong-ca.pem"
printf '%s\n' 'https://127.0.0.1:6443' > "$HORCRUX_KUBERNETES_TEST_DIR/issuer.txt"
kubectl exec -n payments horcrux-auth-test -- cat /var/run/secrets/horcrux/token > "$HORCRUX_KUBERNETES_TEST_DIR/valid.token"
kubectl create token settlement -n payments --audience=horcrux --duration=10m --bound-object-kind=Pod --bound-object-name=horcrux-auth-test > "$HORCRUX_KUBERNETES_TEST_DIR/rotated.token"
kubectl create token settlement -n payments --audience=not-horcrux --duration=10m > "$HORCRUX_KUBERNETES_TEST_DIR/wrong-audience.token"
kubectl create token unrelated -n payments --audience=horcrux --duration=10m > "$HORCRUX_KUBERNETES_TEST_DIR/other-account.token"
npm --prefix sdk/viem run build
cargo test --locked --features kubernetes-integration kubernetes_auth_over_http -- --ignored --nocaptureThis checks valid identity, token-file replacement, wrong audience, tampered
signature, other-account wallet denial, missing/wrong CA rejection, exact signed
bytes, and a lost-response retry making only one call to mock Privy. Regenerate
tokens before rerunning after ten minutes. Remove the test resources with
kubectl delete -f examples/kubernetes-auth.yaml, stop the disposable cluster,
and delete the private token directory afterward. Never commit tokens or kubeconfigs.
Sign a pathUSD transfer on Moderato
Use the authentication option appropriate to the configured server listener. The example below uses a bearer provider; in a pod, prefer the file-reading provider above instead of copying a short-lived token into an environment variable.
import { createWalletClient, encodeFunctionData, http, parseAbi, parseUnits } from 'viem'
import { tempoModerato } from 'viem/chains'
import { horcruxAccount } from '@tempoxyz/horcrux-viem'
const account = await horcruxAccount({
url: 'https://horcrux.example.com',
walletId: process.env.HORCRUX_WALLET_ID!,
auth: {
type: 'kubernetes',
// Replace with the service's token provider; called for each operation.
getToken: () => process.env.HORCRUX_ACCESS_TOKEN!,
},
})
const client = createWalletClient({
account,
chain: tempoModerato,
transport: http('https://rpc.moderato.tempo.xyz'),
})
const pathUSD = '0x20c0000000000000000000000000000000000000'
const prepared = await client.prepareTransactionRequest({
type: 'tempo',
feeToken: pathUSD,
calls: [{
to: pathUSD,
value: 0n,
data: encodeFunctionData({
abi: parseAbi(['function transfer(address recipient, uint256 amount) returns (bool)']),
functionName: 'transfer',
args: ['0x91000F2d39017A0E4403D75f2CC977d2A9B77B61', parseUnits('1', 6)],
}),
}],
})
const signed = await client.signTransaction(prepared)
// Signed bytes only. Nothing has been broadcast.
// client.sendTransaction(...) would also broadcast via the configured chain RPC.Transaction account operations and failures
The following applies to the original asynchronous horcruxAccount, not the
raw-digest horcruxTempoAccount above.
- EIP-1559 transactions, with an empty access list and a destination address.
- Native Tempo on mainnet or Moderato: one or two calls, zero native value, an explicit
fee-token address, nonce lane, and optional validity bounds in Unix seconds.
viem's
gasbecomes Horcrux'sgasLimit; bigint quantities become hex strings. - Call
prepareTransactionRequestbeforesignTransactionif nonce, gas, or fees are missing.sendTransactionperforms preparation itself. - Message signing, typed-data signing, arbitrary hash signing, authorization signing, sponsorship, batches larger than two calls, and contract creation are not supported. Unsupported transaction fields are rejected before a signing HTTP request.
- HTTP and HTTPS URLs are supported on any host. HTTP sends credentials
unencrypted; use HTTPS in production. Redirects are not followed; a custom
fetchimplementation must preserve that behavior.
Each signing invocation creates a fresh idempotency key. A lost connection or
response body gets one retry with the same key and serialized body. Each HTTP
attempt has a 20-second timeout. HTTP errors are not retried, including 409
(SIGNING_IN_PROGRESS) and 502 (SIGNING_UNRESOLVED or PROVIDER_REJECTED).
Access may return HTML or an empty rejection before Horcrux sees a request. These
failures retain the HTTP status and recovery key as HTTP_ERROR, without copying
the response body into the error. They are not retried.
HorcruxError exposes status, code, requestId, and idempotencyKey when
available. Retain these for reconciliation. Do not blindly call signTransaction
again after an uncertain outcome: that creates a new operation and key. There is
no polling or cross-process retry journal in this first version. viem may wrap
this error as the cause of its own error.
The adapter trusts Horcrux to validate the signature and transaction; it checks the response structure and hex encoding. Horcrux rechecks wallet access on each request, including cached results.
