Transfer protection
Transfers out of SafeBank — to an external address or a fiat off-ramp — pass through two controls before they can be proposed:
- a cooling-off hold on payees you have not had saved for long, and
- N-of-M second-factor approval, bound to the exact amount and destination.
Transfers between your own SafeBank wallets are unaffected, and card authorizations are out of scope entirely.
When a transfer is challenged or blocked
| Situation | Result |
|---|---|
| Protection disabled for the tenant | Allowed |
| Destination is one of your own SafeBank wallets | Allowed |
| Destination is not a saved contact | Blocked — NEW_PAYEE_NOT_SAVED |
| Contact is younger than the cooling-off window | Blocked — PAYEE_COOLING_OFF |
| First payment to this payee | Challenged |
| Amount over the tenant threshold | Challenged |
| Otherwise | Allowed |
Defaults: a 24-hour cooling-off window (configurable, but never below 24h while protection is on) and a $100 threshold.
Saving starts the clock; it does not skip it. That is the point — the hold defeats an attacker who has your session and your phone right now, because they cannot wait a day.
The cooling-off clock runs on the destination, not the contact
Editing a contact's destination restarts the window, and so does a @handle
being repointed by the tenant that owns it. Otherwise a long-saved contact
could be moved to a new address and inherit its aged trust.
The challenge handshake
POST /v1/payments → 403 TWOFA_REQUIRED (details.challengeId)
POST /v1/2fa/challenges/{id}/send → code delivered, stating amount + destination
POST /v1/2fa/challenges/{id}/verify → repeat until satisfied === required
POST /v1/payments + X-SafeBank-2FA-Challenge + the SAME Idempotency-Key → 202
The delivered email or SMS states the amount and the destination. That is what
makes this resistant to phishing: someone talked into approving "a login" sees
$2,400 to 0xAb…3f21 and can refuse.
Never enter a code whose message does not match what you are sending, and if you build your own UI, render the amount and destination verbatim — a paraphrase destroys the property.
A satisfied approval is cryptographically bound to one transaction. A code
obtained for "$50 to bob" cannot authorize "$2,400 to someone else": the replay
is rejected with TWOFA_BINDING_MISMATCH. Approvals are single-use.
- TypeScript SDK
- CLI
const sb = new SafeBank({
apiKey: process.env.SAFEBANK_API_KEY!,
onTwoFactorRequired: async challenge => {
// Show challenge.transaction to the user verbatim, then collect a code.
const factor = challenge.factors[0]
if (factor.type === 'EMAIL' || factor.type === 'SMS') {
await sb.twoFactor.sendChallengeCode(challenge.challengeId, factor.id)
}
const code = await promptUser()
await sb.twoFactor.verifyChallenge(challenge.challengeId, factor.id, { code })
return challenge.challengeId // SDK replays with the same idempotency key
},
})
await sb.payments.create({ /* … */ })
# Read the tenant policy (works with an API key):
sb 2fa settings
# A payment an agent may make: saved contact, past its cooling-off window,
# at or under the threshold.
sb pay send --from <wallet> --to acme --amount 50
The CLI authenticates with an API key, and a key cannot receive a code — so
it cannot hold or use a second factor. Enrolling, revoking, and changing the
tenant settings all require a signed-in person, under Settings → Security
in the dashboard. sb 2fa enroll and friends will tell you this rather than
failing at the API.
It also means an API key never sees a challenge: anything that would be
challenged is refused outright with TWOFA_AGENT_NOT_PERMITTED.
Factor types
| Type | Notes |
|---|---|
EMAIL | 6-digit code by email. |
SMS | 6-digit code by SMS. Cannot be your only factor. |
TOTP | Authenticator app. Works offline, so it survives an email/SMS provider outage. |
PRIVY_MFA | A Privy embedded-wallet signature, verified against your registered signer address. Browser only. |
Two rules on factor management are deliberate and worth knowing:
- A new factor is held for 24 hours before it can approve a transfer, so someone who compromises your session cannot add their own device and use it immediately. Revocation, by contrast, takes effect immediately.
- You must keep at least one offline factor (
TOTPorPRIVY_MFA). Approval fails closed, so a member whose only factors are email and SMS would be locked out of their own funds during a provider outage.
Key endpoints
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /v1/2fa/factors | twofactor:read | List your factors and the N-of-M policy |
POST | /v1/2fa/factors | twofactor:manage | Begin enrolling a factor |
POST | /v1/2fa/factors/{factorId}/verify | twofactor:manage | Activate it |
DELETE | /v1/2fa/factors/{factorId} | twofactor:manage | Revoke it |
GET | /v1/2fa/challenges/{challengeId} | twofactor:read | Read a pending challenge |
POST | /v1/2fa/challenges/{challengeId}/send | twofactor:read | Deliver or resend a code |
POST | /v1/2fa/challenges/{challengeId}/verify | twofactor:read | Verify one factor |
GET | /v1/2fa/settings | twofactor:read | Read tenant settings |
PATCH | /v1/2fa/settings | twofactor:manage | Update them (owner/admin only) |
Agents and API keys
An API key cannot receive a code, so it cannot complete a challenge. An agent
may pay only a saved contact that is past its cooling-off window, at or
under the threshold. Anything else is refused with
TWOFA_AGENT_NOT_PERMITTED, and no challenge is created.
Turning it off
Protection is tenant-wide and on by default. Only an owner or admin user session can change it — never an API key, whatever scopes it holds — and the change is itself second-factor gated and audit-logged.
sb 2fa settings
sb 2fa settings:set --threshold 500 --cooling-off 48
See also: Errors for the full code list.