accounts
Version:
Tempo Accounts SDK
535 lines • 25.2 kB
JavaScript
import { Address as core_Address, Hex, Provider as ox_Provider, Secp256k1, Signature } from 'ox';
import { SignatureEnvelope } from 'ox/tempo';
import { hashMessage, hashTypedData, isAddressEqual, keccak256 } from 'viem';
import { prepareTransactionRequest } from 'viem/actions';
import { Actions, Transaction as TempoTransaction } from 'viem/tempo';
import * as AccessKey from '../AccessKey.js';
import * as Adapter from '../Adapter.js';
import * as AccessKeyTransaction from '../internal/AccessKeyTransaction.js';
import * as Store from '../Store.js';
const privySessionErrorCodes = new Set([
'attempted_rpc_call_before_logged_in',
'attempted_to_read_storage_before_client_initialized',
'embedded_wallet_before_logged_in',
'embedded_wallet_does_not_exist',
'embedded_wallet_request_error',
'missing_auth_token',
'missing_privy_token',
'oauth_session_failed',
'oauth_session_timeout',
'session_expired',
'unauthenticated',
'unauthorized',
]);
/**
* Creates a Privy adapter backed by `@privy-io/js-sdk-core` Privy sessions and embedded
* Ethereum wallets.
*
* The adapter owns silent reconnect, session-expiry cleanup, and signing. Apps supply
* the UI-bearing login flow via `loadAccounts` (and optionally a distinct `createAccount`
* for registration). Callbacks fire only on user-initiated `wallet_connect`/registration —
* never during silent restore on page reload.
*
* Silent restore on page reload pulls wallets directly from the Privy SDK
* (`client.user.get` + `client.embeddedWallet.getEthereumProvider`), so apps don't
* need to re-run the login UI when the user returns with a still-valid Privy session.
*
* Callbacks only run the Privy auth UI. They may optionally return a subset of
* embedded wallet addresses to expose; if omitted, the adapter exposes every
* embedded wallet on the resulting Privy user.
*
* @example
* ```ts
* import Privy from '@privy-io/js-sdk-core'
*
* const client = new Privy({ appId: import.meta.env.VITE_PRIVY_APP_ID })
*
* const provider = Provider.create({
* adapter: privy({
* client,
* // Optional: omit to route registration through `loadAccounts`.
* createAccount: async ({ client }) => {
* await myPrivyRegisterUI(client)
* },
* loadAccounts: async ({ client }) => {
* await myPrivyLoginUI(client)
* },
* }),
* })
* ```
*/
export function privy(options) {
const { icon, name = 'Privy', rdns = 'io.privy' } = options;
return Adapter.define({ icon, name, rdns }, ({ getClient, store }) => {
let privyClient_promise;
let restore_promise;
let walletAccounts;
async function getPrivyClient() {
privyClient_promise ??= (async () => {
await options.client.initialize?.();
return options.client;
})();
return await privyClient_promise;
}
function toStoreAccount(account, label) {
return {
address: core_Address.from(account.address),
...(label ? { label } : {}),
};
}
function toTempoAccount(account) {
const address = core_Address.from(account.address);
async function sign(parameters) {
return await signPayload({
payload: parameters.hash,
walletAccount: account,
});
}
return {
address,
sign,
signTransaction: async (transaction) => await privySignTransaction({ sign, transaction }),
source: 'privy',
type: 'local',
};
}
function clear() {
restore_promise = undefined;
walletAccounts = undefined;
store.setState({ accessKeys: [], accounts: [], activeAccount: 0 });
}
async function hasValidSession() {
const token = await (await getPrivyClient()).getAccessToken().catch((error) => {
if (isSessionError(error))
return null;
throw error;
});
return !!token;
}
/**
* Loads the user's Privy embedded Ethereum wallets and constructs their
* EIP-1193 providers. Mirrors `getAllUserEmbeddedEthereumWallets` +
* `getEntropyDetailsFromUser` from `@privy-io/js-sdk-core`: per the SDK,
* `entropyId` is the **primary** embedded wallet's address (wallet_index === 0)
* shared across all wallets of the same user, and `entropyIdVerifier` is
* hardcoded to `'ethereum-address-verifier'` for Ethereum wallets.
*/
async function loadEthereumWallets(privyClient) {
const { user } = await privyClient.user.get();
const wallets = (user?.linked_accounts ?? [])
.filter((account) => account.type === 'wallet' &&
account.wallet_client_type === 'privy' &&
account.connector_type === 'embedded' &&
account.chain_type === 'ethereum' &&
typeof account.address === 'string')
.slice()
.sort((a, b) => {
// Wallets without a `wallet_index` are sorted to the end so they
// never accidentally become primary when a sibling has an index.
const a_index = a.wallet_index ?? Number.POSITIVE_INFINITY;
const b_index = b.wallet_index ?? Number.POSITIVE_INFINITY;
return a_index - b_index;
});
// Primary is the wallet with `wallet_index === 0`. Fall back to the
// lowest-indexed wallet only when no wallet declares index 0.
const primary = wallets.find((wallet) => wallet.wallet_index === 0) ?? wallets[0];
if (!primary)
return [];
const entropyId = primary.address;
return await Promise.all(wallets.map(async (wallet) => ({
address: core_Address.from(wallet.address),
provider: await privyClient.embeddedWallet.getEthereumProvider({
wallet,
entropyId,
entropyIdVerifier: 'ethereum-address-verifier',
}),
})));
}
function selectWalletAccounts(accounts, addresses) {
if (!addresses)
return accounts;
return addresses.map((address) => {
const address_ = core_Address.from(address);
const account = accounts.find((account) => isAddressEqual(core_Address.from(account.address), address_));
if (account)
return account;
throw new ox_Provider.UnauthorizedError({
message: `Privy callback returned address "${address_}" that was not found in the user's embedded wallets.`,
});
});
}
async function restore() {
await Store.waitForHydration(store);
if (walletAccounts)
return;
if (restore_promise)
return await restore_promise;
restore_promise = (async () => {
const state = store.getState();
const persisted = state.accounts;
if (persisted.length === 0)
return;
if (!(await hasValidSession())) {
clear();
throw new ox_Provider.DisconnectedError({ message: 'Privy session expired.' });
}
const restored = await loadEthereumWallets(await getPrivyClient()).catch((error) => {
if (!isSessionError(error))
throw error;
clear();
throw new ox_Provider.DisconnectedError({ message: 'Privy session expired.' });
});
walletAccounts = restored;
const accounts = persisted
.map((account) => restored.find((walletAccount) => isAddressEqual(core_Address.from(walletAccount.address), account.address)))
.filter((account) => !!account);
// If the persisted accounts no longer exist in Privy (different user
// signed in, wallets removed), wipe the stale state so callers see a
// clean disconnected state instead of ghost accounts without providers.
if (accounts.length === 0) {
clear();
throw new ox_Provider.DisconnectedError({
message: 'Privy session no longer matches persisted accounts.',
});
}
store.setState({
accounts: accounts.map((account) => toStoreAccount(account)),
activeAccount: Math.min(state.activeAccount, accounts.length - 1),
});
})();
try {
await restore_promise;
}
finally {
restore_promise = undefined;
}
}
async function requireSession() {
if (await hasValidSession())
return;
clear();
throw new ox_Provider.DisconnectedError({ message: 'Privy session expired.' });
}
async function getTempoAccount(address) {
await restore();
await requireSession();
const state = store.getState();
const address_ = address ?? state.accounts[state.activeAccount]?.address;
if (!address_)
throw new ox_Provider.DisconnectedError({ message: 'No accounts connected.' });
if (state.accounts.length === 0)
throw new ox_Provider.DisconnectedError({
message: 'No Privy account connected.',
});
const connected = state.accounts.some((account) => isAddressEqual(account.address, address_));
if (!connected)
throw new ox_Provider.UnauthorizedError({ message: `Account "${address_}" not found.` });
const account = (walletAccounts ?? []).find((account) => isAddressEqual(core_Address.from(account.address), address_));
if (account)
return toTempoAccount(account);
throw new ox_Provider.DisconnectedError({
message: 'Privy session no longer matches persisted accounts.',
});
}
async function signPayload(parameters) {
const { payload, walletAccount } = parameters;
const result = await walletAccount.provider
.request({ method: 'secp256k1_sign', params: [payload] })
.catch((error) => {
const code = getPrivyErrorCode(error);
const message = getPrivyErrorMessage(error).toLowerCase();
const unsupported = (typeof code === 'number' && (code === 4200 || code === -32601)) ||
(typeof code === 'string' && code.toLowerCase().includes('unsupported')) ||
message.includes('unsupported') ||
message.includes('method not found');
if (unsupported)
throw new ox_Provider.UnsupportedMethodError({
message: 'Privy adapter requires raw secp256k1 hash signing via `secp256k1_sign` for Tempo transactions and access keys.',
});
if (isSessionError(error)) {
clear();
throw new ox_Provider.DisconnectedError({ message: 'Privy session expired.' });
}
throw error;
});
if (typeof result !== 'string' || !Hex.validate(result))
throw new ox_Provider.ProviderRpcError(-32603, 'Privy provider returned a non-hex secp256k1_sign result.');
const signature = result;
// Verify Privy returned a signature for the wallet we asked.
const expected = core_Address.from(walletAccount.address);
const recovered = (() => {
try {
return Secp256k1.recoverAddress({ payload, signature: Signature.fromHex(signature) });
}
catch {
return undefined;
}
})();
if (!recovered || !isAddressEqual(recovered, expected))
throw new ox_Provider.UnauthorizedError({
message: `Privy provider returned a signature for "${recovered ?? 'unknown'}" that does not match the requested wallet "${expected}".`,
});
return signature;
}
async function signTransaction(parameters) {
const account = await getTempoAccount(parameters.from);
const { feePayer, ...rest } = parameters;
const viemClient = getClient({
chainId: parameters.chainId,
feePayer: feePayer === true ? undefined : feePayer,
});
const prepared = await prepareTransactionRequest(viemClient, {
account: account.address,
...rest,
...(feePayer ? { feePayer: true } : {}),
type: 'tempo',
});
return await account.signTransaction(prepared);
}
async function privySignTransaction(parameters) {
const { sign, transaction } = parameters;
const presign = (() => {
if (transaction &&
typeof transaction === 'object' &&
'feePayerSignature' in transaction &&
transaction.feePayerSignature)
return { ...transaction, feePayerSignature: null };
return transaction;
})();
const unsignedTransaction = await TempoTransaction.serialize(presign);
const signature = await sign({ hash: keccak256(unsignedTransaction) });
return await TempoTransaction.serialize(transaction, SignatureEnvelope.from(Signature.fromHex(signature)));
}
async function connectAccounts(parameters) {
const { addresses, authorizeAccessKey, label, personalSign, privyClient } = parameters;
await requireSession();
const wallets = await loadEthereumWallets(privyClient);
const selected = selectWalletAccounts(wallets, addresses);
const account = selected[0] ? toTempoAccount(selected[0]) : undefined;
if (!account && parameters.noAccountsMessage)
throw new ox_Provider.DisconnectedError({
message: parameters.noAccountsMessage,
});
const digest = personalSign ? hashMessage(personalSign.message) : parameters.digest;
const keyAuthorization = authorizeAccessKey && account
? await AccessKey.authorize({
account,
chainId: getClient().chain.id,
parameters: authorizeAccessKey,
store,
})
: undefined;
const signature = digest && account ? await account.sign({ hash: digest }) : undefined;
walletAccounts = wallets;
restore_promise = undefined;
return {
accounts: selected.map((account, index) => toStoreAccount(account, index === 0 ? label : undefined)),
...(personalSign ? { personalSign: { message: personalSign.message } } : {}),
...(keyAuthorization ? { keyAuthorization } : {}),
signature,
};
}
async function prepareTransaction(parameters) {
const viemClient = getClient({
chainId: parameters.chainId,
feePayer: parameters.feePayer === true ? undefined : parameters.feePayer,
});
const state = store.getState();
const address = parameters.from ?? state.accounts[state.activeAccount]?.address;
const transaction = address
? await AccessKeyTransaction.create({
address,
calls: parameters.calls,
chainId: parameters.chainId ?? state.chainId,
client: viemClient,
store,
})
: undefined;
if (transaction) {
const { feePayer, ...rest } = parameters;
try {
return await transaction.prepare({
...rest,
...(feePayer ? { feePayer: true } : {}),
});
}
catch { }
}
async function sign() {
return await signTransaction(parameters);
}
return {
request: undefined,
sign,
async send() {
const signed = await sign();
return await viemClient.request({
method: 'eth_sendRawTransaction',
params: [signed],
});
},
async sendSync() {
const signed = await sign();
return await viemClient.request({
method: 'eth_sendRawTransactionSync',
params: [signed],
});
},
};
}
function isSessionError(error) {
const code = getPrivyErrorCode(error);
if (typeof code === 'string') {
const normalized = code.toLowerCase();
if (privySessionErrorCodes.has(normalized))
return true;
if (normalized.includes('session'))
return true;
if (normalized.includes('before_logged_in'))
return true;
}
const message = getPrivyErrorMessage(error).toLowerCase();
return (message.includes('missing privy token') ||
message.includes('must be logged in') ||
message.includes('not authenticated') ||
message.includes('not logged in') ||
message.includes('session expired'));
}
function getPrivyErrorCode(error) {
if (!isObject(error))
return undefined;
if (typeof error.code === 'string' || typeof error.code === 'number')
return error.code;
if (typeof error.error_code === 'string' || typeof error.error_code === 'number')
return error.error_code;
if (typeof error.errorCode === 'string' || typeof error.errorCode === 'number')
return error.errorCode;
return getPrivyErrorCode(error.cause);
}
function getPrivyErrorMessage(error) {
if (error instanceof Error) {
const caused = getPrivyErrorMessage(error.cause);
return caused ? `${error.message} ${caused}` : error.message;
}
if (!isObject(error))
return '';
const own = (typeof error.message === 'string' && error.message) ||
(typeof error.error === 'string' && error.error) ||
'';
const caused = getPrivyErrorMessage(error.cause);
if (own && caused)
return `${own} ${caused}`;
return own || caused;
}
function isObject(value) {
return typeof value === 'object' && value !== null;
}
return {
cleanup() { },
actions: {
async createAccount(parameters) {
const { authorizeAccessKey, personalSign } = parameters;
if (personalSign && parameters.digest)
throw new ox_Provider.ProviderRpcError(-32602, '`digest` and `personalSign` cannot both be set on `wallet_connect`.');
const privyClient = await getPrivyClient();
const addresses = options.createAccount
? await options.createAccount({ client: privyClient, parameters })
: await options.loadAccounts({
client: privyClient,
parameters: {
...(authorizeAccessKey ? { authorizeAccessKey } : {}),
...(parameters.digest ? { digest: parameters.digest } : {}),
...(personalSign ? { personalSign } : {}),
},
});
return await connectAccounts({
addresses,
...(authorizeAccessKey ? { authorizeAccessKey } : {}),
...(parameters.digest ? { digest: parameters.digest } : {}),
label: parameters.name,
...(personalSign ? { personalSign } : {}),
privyClient,
noAccountsMessage: 'Privy returned no wallet.',
});
},
async loadAccounts(parameters) {
const { authorizeAccessKey, personalSign } = parameters ?? {};
if (personalSign && parameters?.digest)
throw new ox_Provider.ProviderRpcError(-32602, '`digest` and `personalSign` cannot both be set on `wallet_connect`.');
const privyClient = await getPrivyClient();
const addresses = await options.loadAccounts({ client: privyClient, parameters });
return await connectAccounts({
addresses,
...(authorizeAccessKey ? { authorizeAccessKey } : {}),
...(parameters?.digest ? { digest: parameters.digest } : {}),
...(personalSign ? { personalSign } : {}),
privyClient,
});
},
async authorizeAccessKey(parameters) {
const account = await getTempoAccount(undefined);
const keyAuthorization = await AccessKey.authorize({
account,
chainId: getClient().chain.id,
parameters,
store,
});
return { keyAuthorization, rootAddress: account.address };
},
async revokeAccessKey(parameters) {
const account = await getTempoAccount(parameters.address);
try {
await Actions.accessKey.revoke(getClient(), {
account: account,
accessKey: parameters.accessKeyAddress,
});
}
catch (error) {
if (!AccessKey.isUnavailableError(error))
throw error;
}
AccessKey.remove({
accessKey: parameters.accessKeyAddress,
account: account.address,
chainId: store.getState().chainId,
store,
});
},
async signPersonalMessage(parameters) {
return await (await getTempoAccount(parameters.address)).sign({
hash: hashMessage({ raw: parameters.data }),
});
},
async signTransaction(parameters) {
return await (await prepareTransaction(parameters)).sign();
},
async signTypedData(parameters) {
const typedData = JSON.parse(parameters.data);
return await (await getTempoAccount(parameters.address)).sign({
hash: hashTypedData(typedData),
});
},
async sendTransaction(parameters) {
return await (await prepareTransaction(parameters)).send();
},
async sendTransactionSync(parameters) {
return await (await prepareTransaction(parameters)).sendSync();
},
async disconnect() {
try {
const privyClient = await getPrivyClient();
const userId = await privyClient.user
.get()
.then(({ user }) => user.id)
.catch(() => undefined);
await privyClient.auth.logout(userId ? { userId } : undefined);
}
finally {
clear();
}
},
},
};
});
}
//# sourceMappingURL=privy.js.map