Skip to main content

Transfer protection

Transfers out of SafeBank — to an external address or a fiat off-ramp — pass through two controls before they can be proposed:

  1. a cooling-off hold on payees you have not had saved for long, and
  2. 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

SituationResult
Protection disabled for the tenantAllowed
Destination is one of your own SafeBank walletsAllowed
Destination is not a saved contactBlockedNEW_PAYEE_NOT_SAVED
Contact is younger than the cooling-off windowBlockedPAYEE_COOLING_OFF
First payment to this payeeChallenged
Amount over the tenant thresholdChallenged
OtherwiseAllowed

Defaults: a 24-hour cooling-off window (configurable, but never below 24h while protection is on) and a $100 threshold.

Saving a contact does not unblock it immediately

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 message is the control

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.

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({ /* … */ })

Factor types

TypeNotes
EMAIL6-digit code by email.
SMS6-digit code by SMS. Cannot be your only factor.
TOTPAuthenticator app. Works offline, so it survives an email/SMS provider outage.
PRIVY_MFAA 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 (TOTP or PRIVY_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

MethodPathScopePurpose
GET/v1/2fa/factorstwofactor:readList your factors and the N-of-M policy
POST/v1/2fa/factorstwofactor:manageBegin enrolling a factor
POST/v1/2fa/factors/{factorId}/verifytwofactor:manageActivate it
DELETE/v1/2fa/factors/{factorId}twofactor:manageRevoke it
GET/v1/2fa/challenges/{challengeId}twofactor:readRead a pending challenge
POST/v1/2fa/challenges/{challengeId}/sendtwofactor:readDeliver or resend a code
POST/v1/2fa/challenges/{challengeId}/verifytwofactor:readVerify one factor
GET/v1/2fa/settingstwofactor:readRead tenant settings
PATCH/v1/2fa/settingstwofactor:manageUpdate 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.