How a Salt transaction gets signed
An organisation holds your accounts and the collaborators who sign for them. Each organisation has one set of Robo Guardians, the automated co-signers. Policies attach to accounts, one set per chain.
The check runs on the exact transaction the signers will sign. During the ceremony each Robo Guardian hashes the payload it's asked to sign and compares it with the transaction record the policy check ran on. If they differ, it refuses to sign.
Start with salt-fi
salt-fi is a terminal app that walks through Salt end to end: organisations, Robo Guardians, accounts, transfers, swaps and policies. Each menu item is a short, readable use of the SDK, so the source doubles as example code. It's open source under Apache-2.0 and maintained independently by one of Salt's founders; it isn't an official Salt product.
github.com/salt-space/salt-fi · the README documents every menu item.
Set up
You need Node.js 24 or newer, Git, and a throwaway EVM private key for testnet. Never use a key that holds real funds.
git clone https://github.com/salt-space/salt-fi.git
cd salt-fi
npm install
cp .env.example .env # then set PRIVATE_KEY=0x…
npm run dev:testnetPick Getting started the first time. It walks through the five setup steps in order, explains each one, and resumes where you left off:
- Create an organisation. You're its owner.
- Invite a collaborator. Accounts need at least two collaborators, the owner plus one more.
- Set up Robo Guardians.
- Create an account. This runs an MPC key-generation ceremony between the signers and the Robo Guardians.
- Add a first policy.
Where the SDK calls live
| What you're doing | salt-fi source |
|---|---|
| Signing in and caching the session | src/session.ts |
| Organisations and Robo Guardian setup | src/commands/create-organisation.tssrc/commands/robos.ts |
| Creating accounts (key generation) | src/commands/accounts.ts |
| Sending, with live signing progress | src/commands/send.ts |
| Policy pre-checks before a swap or bridge | src/commands/tx-preflight.ts |
| Managing policies | src/commands/policy-management.tssrc/policies.ts |
Or build your own front end
Salt supports a tenant model, so you can build your own front end on the SDK instead. That gives you more control, and an experience built around how your team works. Ask Salt to approve your tenant domain; localhost works for development. Pass that domain as domain when you create the Salt client. It goes into every sign-in message your users sign, and Salt resolves your tenant from it. You still set up Robo Guardians through salt-fi or Salt's web app.
const salt = new Salt({
environment: "TESTNET",
domain: "app.example.com", // your approved tenant domain
siweStatement: "Sign in to Example Treasury", // shown in the wallet prompt
});Salt's own web app, due for release in November 2026, is built entirely on the Salt SDK.
Signing in: your wallet is your identity
There are no Salt usernames or passwords. You sign in with Sign-In with Ethereum (SIWE): the SDK has your wallet sign a standard login message, and Salt verifies the signature and issues a session token. The key stays on your machine. Nothing goes on-chain and there's no gas to pay.
That wallet's address is who you are in Salt:
- Organisations invite you by address, and you accept with the same wallet. Your access level (owner, member or agent) is attached to that address, and only owners can change policies.
- Accounts you co-sign use this wallet in their key-generation and signing ceremonies.
- Backups are encrypted to this wallet's public key, which Salt recovers from your sign-in signature. Your key-share backups are written to Salt's shard registry contract on Arbitrum, and Salt pays the gas. If you set up the organisation's Robo Guardians, their seed backup is encrypted to your key too. Back the key up like any other.
Give every person and every automated signer on your team its own key. Sessions are issued per environment, and each has its own Salt sign-in domain: testnet.salt.space for testnet and app.salt.space for mainnet. Both are for SDK access only, not browser access. salt-fi asks which environment to use at launch.
Robo Guardians, installed by Claude Code over SSH
Robo Guardians are your organisation's automated co-signers. They hold key shares for every account, enforce policies by refusing to sign anything the check rejected, and run on a server you control. Each host runs three signers in one Docker container. Their seed is generated on that server and encrypted to the owner's wallet key before an encrypted backup goes to Salt.
salt-fi generates a one-shot install script for any Ubuntu or Debian server, or an AWS CloudFormation link that does the same with no server access. Below, Claude Code installs the script over SSH. These are replays of a real session on testnet, for a demo organisation called Nara Capital.
1 of 3Create the organisation and its setup script
In salt-fi, choose Organisation → Create organisation, accept the offer to set up Robo Guardians, and pick the self-hosted install script. salt-fi writes robo-setup-<org>-<id>.sh to the project folder. It contains the host's one-time setup code, so treat it like a password. It's gitignored.
│ Nara Capital Robos│◇ Robo host registered│◇ How do you want to set up this robo host?│ Esc: go back│ Self-hosted install script│◆ Wrote robo-setup-nara-capital-6abd680b63af1d0023ab2f1a.sh (contains a secret OTP / one-time password — already gitignored, treat it like a credential).││ This is a one-shot, fully automated script — you don't need to install│ Docker or anything else yourself first. Just get it onto whatever│ machine will run your Robo Guardians (a VPS, a spare box, a cloud│ instance — anything running Ubuntu or Debian) and run it as root:│ scp robo-setup-nara-capital-6abd680b63af1d0023ab2f1a.sh root@<your-server>:~ && ssh root@<your-server> "chmod +x ~/robo-setup-nara-capital-6abd680b63af1d0023ab2f1a.sh && ~/robo-setup-nara-capital-6abd680b63af1d0023ab2f1a.sh"││ See docs/robo-hosting/ in this repo for platform-specific walkthroughs│ (e.g. how to do this with only a browser-based terminal, no scp).││ Once it's running, use "Check robo status" here to confirm it connected.│◆ Organisation│ Esc: go back│ ● Create organisation│ ○ Invite collaborators│ ○ Manage collaborators│ ○ Manage your invitations│ ○ List your organisations│ ↑/↓ to navigate • Enter: confirm└
2 of 3Let Claude Code install it over SSH
Open Claude Code in the salt-fi folder, give it the server, and say what's already running there. For example:
Prompt to Claude CodeSet up Nara Capital's Robo Guardians on my server: ssh -i ~/.ssh/<key> root@<server-ip>. It already runs robos for other organisations. Look first without changing anything, adapt the setup script so it can't collide with them, and show me the changes before you run it.
Claude looked at the server first and found three robos already running there. The stock script expects to be the only robo on a server: it refuses to run next to an existing one, and its Docker install step could upgrade Docker, restarting the daemon and briefly stopping every robo on the box. So Claude gave the new robo its own container name, directory, update job and cron entry, skipped the Docker reinstall, showed the diff, and ran it once approved.
latest: Pulling from saltrobo/bootstrapDigest: sha256:348d33d7c1aafa8ba0cb083bf915ef749601289d5f4d7460d5e76a29057cea3aStatus: Image is up to date for saltrobo/bootstrap:latestdocker.io/saltrobo/bootstrap:latestWrote seed.txt, encrypted_seed.txt, signers.txt (3 signers)Sending progress update for step 2 to https://testnet.salt.space/api/robos/setupProgress update 2: HTTP 204Response:latest: Pulling from saltrobo/testnet38f37bf5cf40: Pulling fs layerdba05f76c4b5: Download complete38f37bf5cf40: Download complete38f37bf5cf40: Pull completeDigest: sha256:a887279a4719e0db2cffdaf3769f3853187a946c86ef71806cb9fe7e029d6ad4Status: Downloaded newer image for saltrobo/testnet:latestdocker.io/saltrobo/testnet:latestSending progress update for step 3 to https://testnet.salt.space/api/robos/setupProgress update 3: HTTP 204Response:0a5918ea9d4b4650867c79663a373b674a72503a616e1a5474fc1bdb14cd14beSending progress update for step 4 to https://testnet.salt.space/api/robos/setupProgress update 4: HTTP 204Response:Cron job added. tail logs at /var/log/update_docker-nara-capital.log~/salt-fi $ # 3. Check the new robo is up and connected~/salt-fi $ ssh -i ~/.ssh/salt_misc $VPS 'docker ps --filter name=robos-nara-capital --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"; docker logs --tail 15 robos-nara-capital'NAMES IMAGE STATUSrobos-nara-capital saltrobo/testnet:latest Up 30 secondsApp is running on port 4300Signer 2 0x8cc435C40Fb8E1CA5d1F20F4fa7E8A5e3EDADb01Signer 3 0x2baEcc0D64B5ac84f128a687eB9a03112a648477Signer 1 0x8AC6038854Bee5F565279B30Cc8B8231F2c8a5DD~/salt-fi $
$VPS stands in for root@<server-ip>. Pauses longer than 1.4 seconds are shortened.3 of 3Check they're online, then activate them
Back in salt-fi, Robo Guardians → Check robo guardians should list three signers online. They can't sign until the owner activates them. Activate Robo Guardians (2FA) signs a message with the owner's wallet that includes the setup code, and activation consumes that code.
│ Check robo guardians│◇ Check robo status for which organisation?│ Esc: go back│ Nara Capital│◇ At least one robo guardian is online││ 3/3 robo guardian(s) online:│ ✓ online 0x8AC6038854Bee5F565279B30Cc8B8231F2c8a5DD│ ✓ online 0x8cc435C40Fb8E1CA5d1F20F4fa7E8A5e3EDADb01│ ✓ online 0x2baEcc0D64B5ac84f128a687eB9a03112a648477│◆ 2FA activation: complete — these robos can sign.│■ Robo Guardians│ Esc: go back│ Set up Robo Guardians││◆ What would you like to do?│ Esc: exit│ ● Getting started (guided first-time setup)│ ○ Organisation ▸│ ○ Robo Guardians ▸│ ○ Accounts ▸│ ○ Assets ▸│ ○ Policies ▸│ ○ Privacy (Hinkal) ▸│ ○ Hyperliquid ▸│ ○ Exit│ ↑/↓ to navigate • Enter: confirm└
Hosting several organisations' robos on one server
The install script uses a fixed container name (robos) and directory (/salt/robos). For each additional organisation, give its robo its own name and directory, point its update_docker.sh and cron entry at them, and don't reinstall Docker on a server that's already running robos. That's the whole of Claude's change in step 2. The Hostinger guide in salt-fi covers doing it by hand.
The policy layer
Policies are granular rules attached to an account, per chain. Salt evaluates every applicable policy together against each transaction the account proposes, before any co-signing. If a policy is breached, the Robo Guardians won't sign and the SDK throws a PolicyBreachError listing the policies that blocked it.
The six policy types
| Type | What it constrains | Params |
|---|---|---|
allowed_recipients | Only these addresses may receive funds. | { recipients: [{ address, nickname? }] } |
denied_recipients | These addresses may not receive funds. | { recipients: [...] } |
denied_proposers | These signers may not propose transactions. The addresses sit under recipients for legacy reasons. | { recipients: [...] } |
transaction_limit_token_denominated | The most one transaction may move, per token. The zero address means the native asset. | { limits: [{ address, amount }] } |
contract_param_restriction | Arguments of specific contract calls. | { restrictions: [{ contractAddress, functionSignature, paramIndex, operator, value }] } |
nominated_approversComing soon | Requires distinct approval for each transaction. | { approvers: [{ address }] } |
How they combine
- There's one policy per type per chain. The chain is a chain ID string, or
"*"for every chain, and a"*"policy and a chain-specific one of the same type both apply. - An allowlist and a blocklist both apply, so an address on both is blocked.
- Updating a policy replaces its params, so send the full list.
- Limits are per transaction, not cumulative. An amount of
"0"freezes that asset. - A contract restriction applies only to calls of its function on its contract, and every restriction a call matches must pass. Two
eqrestrictions on the same argument can never both hold, so together they block that function entirely. - Restriction values aren't validated when saved, and a malformed one blocks every call it matches. Only top-level arguments can be restricted (numbers, addresses, bools, bytes and strings), not struct fields or array elements.
What policies see, and what they don't
Policies check each transaction your account sends: where it goes, its native value and the arguments of the function it calls. Four things fall outside that:
- Tokens pulled under an approval. Limits count native value and the amount in a token's
transfer()call. When a router or bridge pulls tokens you've approved, no limit applies, so cap the approval itself. - Who you approve. For a contract call, an allowlist checks the contract, so a swap needs both the token (for the approval) and the router on the list. The list never sees who the approval goes to, so restrict the spender too.
- Signed messages. A token permit or an off-chain order is a signature, not a transaction. The funds move later, when someone else submits it.
- Relayed transfers. Your deposit into a privacy pool is checked, but after that the pool's relayer moves the funds, not your account.
The example under Create policies from code caps a USDC approval and restricts its spender.
Check before you submit
runPoliciesCheck evaluates a proposed transaction without signing anything. Fill it the way submitTx will, or the answer won't match what the Robo Guardians see.
const fees = await salt.getGasPrice(421614);
const check = await salt.runPoliciesCheck(accountId, {
nonce: await salt.getAccountNonce(accountId, 421614),
from: accountAddress, // the Salt account's address, not your signer's
to: router,
amount: value.toString(), // native value in wei: native limits read it
data: calldata,
network: "421614",
gas: "500000",
maxFeePerGas: fees.maxFeePerGas.toString(),
maxPriorityFeePerGas: fees.maxPriorityFeePerGas.toString(),
});
if (check.policyBreach) {
for (const policy of check.rejectedPolicies) console.log("blocked by", policy.type, policy.chain);
}check.networkPolicies lists every policy that was evaluated. A policy on this chain that's missing from it didn't apply to this transaction, which usually means it doesn't constrain what you're doing.
Create policies from code
await salt.createAccountPolicy({
accountId,
organisationId,
type: "contract_param_restriction",
chain: "421614",
params: {
restrictions: [
// USDC approvals may only go to the router, and never above 250 USDC (6 decimals)
{ contractAddress: USDC, functionSignature: "approve(address,uint256)", paramIndex: 0, operator: "eq", value: ROUTER },
{ contractAddress: USDC, functionSignature: "approve(address,uint256)", paramIndex: 1, operator: "lte", value: "250000000" },
],
},
});In salt-fi
- Manage policies is a guided builder for every type you can create, with presets for common contract restrictions.
- Policy chat takes plain English, such as "cap USDC approvals at 250 on Arbitrum", proposes the exact policy, and asks before applying it. It needs an API key for the language model that powers it, set in
.env. - Swap, Bridge and the Hinkal deposit check their transactions before submitting and show which policies pass or fail.
Not expressible today
- Cumulative or time-window limits, such as a daily cap.
- Time-based access.
- Enforced human approvals:
nominated_approversisn't enforced yet. - "Either A or B" for one contract argument.
- Separate allowlists for payees and for contracts. One allowlist covers both.
Be a Design Partner
Salt is currently working with multiple funds and fintechs to evolve the policy layer. To join the workshops, reach out to your contact at Salt.
Using the SDK directly
salt-sdk is an ES module that uses top-level await, so your package.json needs "type": "module". It declares Node.js 24 or newer.
npm install salt-sdk viemimport { Salt, PolicyBreachError } from "salt-sdk";
import { createPublicClient, createWalletClient, http, parseEther } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arbitrumSepolia, sepolia } from "viem/chains";
// Your signer. On testnet, Salt's key-share registry lives on Arbitrum Sepolia;
// the signer only signs there, and Salt pays the registry gas.
const walletClient = createWalletClient({
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
chain: arbitrumSepolia,
transport: http(),
});
const salt = new Salt({ environment: "TESTNET", domain: "testnet.salt.space" });
await salt.authenticate(walletClient); // SIWE
const [org] = await salt.getOrganisations();
const [account] = await salt.getAccounts(org.id);
// Send 0.001 ETH on Sepolia. Policies are checked first, and the
// Robo Guardians co-sign only if every one passes.
try {
const ceremony = await salt.submitTx({
accountId: account.id,
to: "0x…",
value: parseEther("0.001"),
data: "0x",
chainId: 11155111,
userAddress: walletClient.account.address,
walletClient,
publicClient: createPublicClient({ chain: sepolia, transport: http() }),
});
const { transaction } = await ceremony.wait();
console.log("broadcast", transaction.broadcastReceipt?.transactionHash);
} catch (err) {
if (err instanceof PolicyBreachError) console.error("blocked by", err.rejectedPolicies.map((p) => p.type));
else throw err;
}The account needs gas on the chain it sends from. On testnet, salt-fi's Assets → Faucet for Salt accounts points you at faucets that work for new accounts.
Where to go next
- Salt documentation
- SDK API reference
- salt-fi README, which covers setup and every menu item
- Robo Guardian hosting guides for Hostinger and AWS
- What each transaction stage means