Skip to main content

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.
ConstructorSourceTransfer / withdrawExecute
account.fromMnemonicBIP-39 mnemonicYesYes
account.fromSeed64-byte seedYesYes
account.fromEthereumSignatureEOA personal_sign signatureYesYes
account.fromMetaMaskWallet signature (browser)YesYes
account.fromKeysRaw keysYesNo
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.

Message format

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.