Register account
Call ensureRegistered() once before the first mutating operation. The SDK
caches the registration attempt per client instance.
await client.ensureRegistered();
- Browser: posts the user’s public registration payload to your backend.
The default route is
/api/unlink/register.
- Server or custodial: pass a register callback when constructing the user
client. The callback usually calls
admin.users.register(payload).
For direct backend code, call admin.users.register(payload) when you receive a
wire payload from a browser register route:
import { createUnlinkAdmin } from "@unlink-xyz/sdk/admin";
const admin = createUnlinkAdmin({
environment: "base-sepolia",
apiKey,
});
await admin.users.register(req.body);
Get address
Get the Bech32m Unlink address for this account.
const address = await client.getAddress();
// "unlink1..."
Account constructors
Every high-level client is bound to a signing-capable account. The constructor
you use decides which operations the account can perform.
| Constructor | Source | Transfer / withdraw | Execute |
|---|
account.fromMnemonic | BIP-39 mnemonic | Yes | Yes |
account.fromSeed | 64-byte seed | Yes | Yes |
account.fromEthereumSignature | EOA personal_sign signature | Yes | Yes |
account.fromMetaMask | Wallet signature (browser) | Yes | Yes |
account.fromKeys | Raw keys | Yes | No |
execute() needs a seed-backed account, so derive with fromMnemonic,
fromSeed, fromEthereumSignature, or fromMetaMask. fromKeys can transfer
and withdraw but cannot execute.
Derive account from a wallet signature
The SDK can derive an Unlink account from an EOA personal_sign signature
instead of a mnemonic. The Quickstart shows the one-shot
account.fromMetaMask wrapper. This section is the lower-level reference.
The buildDeriveSeedMessage helper returns the exact string a wallet must sign:
import { buildDeriveSeedMessage } from "@unlink-xyz/sdk/crypto";
const message = buildDeriveSeedMessage({
appId: "your-app-id",
chainId: 84532,
});
// "Unlink: derive identity\nTenant: your-app-id\nChain: 84532\nVersion: 1"
Rules:
- The message is the single source of truth. Bumping its format is a breaking change for every existing account.
appId is embedded verbatim. The SDK rejects empty strings, LF/CR, or anything over 64 bytes UTF-8 but does not canonicalise casing or whitespace. Use a stable app identifier.
chainId must be a positive integer.
- The message uses the literal label
Tenant: for address compatibility, even though the parameter is named appId. Do not change the message text, or existing accounts derive differently.
Sign and derive
If you control the signing path yourself, build the message, sign it, and pass
the signature into account.fromEthereumSignature with the same appId and
chainId.
import { account, buildDeriveSeedMessage } from "@unlink-xyz/sdk/crypto";
const message = buildDeriveSeedMessage({
appId: "your-app-id",
chainId: 84532,
});
const signature = await walletClient.signMessage({
account: evmAddress,
message,
});
const unlinkAccount = account.fromEthereumSignature({
signature,
appId: "your-app-id",
chainId: 84532,
});
The signature is canonicalised internally and expanded with HKDF-SHA256 into the
64-byte seed consumed by account.fromSeed. appId and chainId are bound
into the HKDF salt. Any mismatch derives a fresh, empty account instead of a
silent account swap.
Stability across wallets relies on RFC-6979 deterministic ECDSA, which every mainstream wallet uses today. For long-term recovery, prefer the keystore export (account.export(keys)) over re-deriving from a wallet signature each session.
Get public key
Most integrations do not need the raw account public key. Use this only for
custom account storage.
const [x, y] = await unlinkAccount.getPublicKey();
User storage
userStorage stores up to two opaque base64 payloads for a generic application
userId. Engine does not interpret the payload. If mode is "encrypted",
encrypt and decrypt client-side before calling Engine.
Create the client with the application user id. Dynamic, your own auth, or any
other session provider should sit behind your app’s requireUser() boundary and
only supply this generic id to Engine:
const client = createUnlinkClient({
environment: "base-sepolia",
account,
userId: session.userId,
authorizationToken: {
body: ({ subjectType, unlinkAddress, userId }) =>
subjectType === "user_storage"
? {
subject_type: "user_storage",
user_id: userId,
}
: {
subject_type: "unlink_address",
unlink_address: unlinkAddress,
},
},
});
For encrypted objects, store a base64-encoded envelope. The wrapping key is derived
locally by an unlock adapter, such as a passphrase adapter today or a WebAuthn
PRF adapter when available:
import type { EncryptedStorageEnvelope } from "@unlink-xyz/sdk/browser";
const envelope: EncryptedStorageEnvelope = {
version: 1,
method: "passphrase:v1", // or "webauthn-prf:v1"
alg: "AES-256-GCM",
kdf: "PBKDF2-SHA256",
salt,
iv,
ciphertext,
};
await client.userStorage.put("recovery", {
mode: "encrypted",
data: btoa(JSON.stringify(envelope)),
});
const { objects } = await client.userStorage.list();
const recovery = await client.userStorage.get("recovery");
await client.userStorage.delete("recovery");
Object keys and userId values may contain letters, numbers, ., _, and -,
but cannot be exactly . or ... Each payload is capped at 16,384 base64
characters.
Never derive encryption keys from Dynamic JWTs, Dynamic user ids, email,
wallet addresses, Dynamic signatures, or any other identity-provider value.
For WebAuthn PRF, derive a client-side KEK from the authenticator PRF output
and decrypt locally; Engine must never receive PRF output, KEKs, plaintext
seeds, or decrypted object contents. Use passphrase unlock as the fallback
when PRF support is unavailable.