5 - Add accounts: full sync
Account Bridge
AccountBridge offers a generic abstraction to synchronize accounts and perform transactions.
It is designed for the end user frontend interface and is agnostic of the way it runs, has multiple implementations and does not know how the data is even stored: in fact it's just a set of stateless functions.
If any of your operations takes longer than the times stated below, get in touch with us on Discord.
Receive
The receive
method allows to derivatives address of an account with a Ledger device but also display it on the device if verify is passed in.
As you may see in libs/coin-modules/coin-mycoin/src/bridge/index.ts
, CoinFramework provides a helper to implement it easily with makeAccountBridgeReceive()
, and there are a very few reasons to implement your own.
Synchronization
We usually group the scanAccounts
and sync
into the same file synchronisation.ts
as they both use similar logic as a getAccountShape
function passed to helpers.
libs/coin-modules/coin-mycoin/src/bridge/synchronisation.ts
:
import { encodeAccountId } from "@ledgerhq/coin-framework/account/index";
import { makeSync, makeScanAccounts, mergeOps, type GetAccountShape } from "@ledgerhq/coin-framework/bridge/jsHelpers";
import type { Account } from "@ledgerhq/types-live";
import { getAccount, getOperations } from "../network";
const getAccountShape: GetAccountShape = async (info) => {
const { id, address, initialAccount } = info;
const oldOperations = initialAccount?.operations || [];
// Needed for incremental synchronisation
const startAt = oldOperations.length
? (oldOperations[0].blockHeight || 0) + 1
: 0;
const accountId = encodeAccountId({
type: "js",
version: "2",
currencyId: currency.id,
xpubOrAddress: address,
derivationMode,
});
// get the current account balance state depending your api implementation
const { blockHeight, balance, additionalBalance, nonce } = await getAccount(
address
);
// Merge new operations with the previously synced ones
const newOperations = await getOperations(id, address, startAt);
const operations = mergeOps(oldOperations, newOperations);
const shape = {
id,
balance,
spendableBalance: balance,
operationsCount: operations.length,
blockHeight,
myCoinResources: {
nonce,
additionalBalance,
},
};
return { ...shape, operations };
};
const postSync = (initial: Account, parent: Account) => parent;
export const scanAccounts = makeScanAccounts({ getAccountShape });
export const sync = makeSync({ getAccountShape, postSync });
The scanAccounts
function performs the derivation of addresses for a given currency
and deviceId
, and returns an Observable that will notify every Account
that it discovered.
With the makeScanAccounts
helper, you only have to pass a getAccountShape
function to execute the generic scan that Ledger Live use with the correct derivation modes for MyCoin, and it will determine when to stop (generally as soon as an empty account was found).
The sync
function performs one "Account synchronisation" which consists of refreshing all fields of a (previously created) Account from your api.
It is executed every 2 minutes if everything works as expected, but if it fails, a retry strategy will be executed with an increasing delay (to avoid burdening a failing API).
Under the hood of the makeSync
helper, the returned value is an Observable of an updater function (Account=>Account), which is a pattern having some advantages:
- it avoids race conditions
- the updater is called in a reducer, and allows to produce an immutable state by applying the update to the latest account instance (with reconciliation on Ledger Live Desktop)
- it's an observable, so we can interrupt it when/if multiple updates occurs
In some cases, you might need to write a postSync
patch to add update logic after sync (before the reconciliation that occurs on Ledger Live Desktop).
If this postSync
function is complex (eg, more than 200 lines of code), you should split it in a libs/coin-modules/coin-mycoin/src/bridge/postSyncPatch.ts
file.
Currency Bridge
Scanning accounts
As we have seen Synchronization, the scanAccounts
, which is part of the CurrencyBridge, share common logic with the sync function, that's why we preferably put them in a synchronisation.ts
file.
The makeScanAccounts
helper will automatically execute the default address derivation logic, but for some reason if you need to have a completely new way to scan account, you could then implement your own strategy.
Preload currency data (optional)
Before creating or using a currency bridge (e.g. scanning accounts, or every 2 minutes for synchronization), Ledger Live will try to preload some currency data (e.g. tokens, delegators, etc) required for ledger-live-common feature to correctly work.
This preloaded data will be stored in a persisted cache for future use, for Ledger Live to still work if temporarily offline, and speed up startup, depending on getPreloadStrategy
(preloadMaxAge
determines the data expiration that will trigger a refresh).
This cache contains the JSON serialized response from preload
which is then hydrated through hydrate
(which needs deserialization), directly after preload, or after a boot.
Live-Common features will then be able to reuse those data anywhere (e.g. validating transactions) with getCurrentMyCoinPreloadData
, or by subscribing to getMyCoinPreloadDataUpdates
observable.
libs/coin-modules/coin-mycoin/src/bridge/preload.ts
:
import { Observable, Subject } from "rxjs";
import { log } from "@ledgerhq/logs";
import type { MyCoinPreloadData } from "../types";
import { getPreloadedData } from "../network";
const PRELOAD_MAX_AGE = 30 * 60 * 1000; // 30 minutes
let currentPreloadedData: MyCoinPreloadData = {
somePreloadedData: {},
};
function fromHydratePreloadData(data: any): MyCoinPreloadData {
let foo = null;
if (typeof data === "object" && data) {
if (typeof data.somePreloadedData === "object" && data.somePreloadedData) {
foo = data.somePreloadedData.foo || "bar";
}
}
return {
somePreloadedData: { foo },
};
}
const updates = new Subject<MyCoinPreloadData>();
export function getCurrentMyCoinPreloadData(): MyCoinPreloadData {
return currentPreloadedData;
}
export function setMyCoinPreloadData(data: MyCoinPreloadData) {
if (data === currentPreloadedData) return;
currentPreloadedData = data;
updates.next(data);
}
export function getMyCoinPreloadDataUpdates(): Observable<MyCoinPreloadData> {
return updates.asObservable();
}
export const getPreloadStrategy = () => ({
preloadMaxAge: PRELOAD_MAX_AGE,
});
export const preload = async (): Promise<MyCoinPreloadData> => {
log("mycoin/preload", "preloading mycoin data...");
const somePreloadedData = await getPreloadedData();
return { somePreloadedData };
};
export const hydrate = (data: any) => {
const hydrated = fromHydratePreloadData(data);
log("mycoin/preload", `hydrated foo with ${hydrated.somePreloadedData.foo}`);
setMyCoinPreloadData(hydrated);
};
Read more on Currency Bridge documentation (opens in a new tab).
Starting with a mock
A mock will help you test different UI flows on Desktop and Mobile. It's connected to any indexer / explorer and gives you a rough idea on how it will look like when connected to the UI.
For example you can use it by doing MOCK=1 pnpm dev:lld
on ledger-live-desktop
import { BigNumber } from "bignumber.js";
import {
NotEnoughBalance,
RecipientRequired,
InvalidAddress,
FeeTooHigh,
} from "@ledgerhq/errors";
import type { Transaction } from "../types";
import type { AccountBridge, CurrencyBridge } from "@ledgerhq/types-live";
import {
scanAccounts,
signOperation,
broadcast,
sync,
isInvalidRecipient,
} from "../../../bridge/mockHelpers";
import { getMainAccount } from "@ledgerhq/coin-framework/account/index";
import { makeAccountBridgeReceive } from "../../../bridge/mockHelpers";
const receive = makeAccountBridgeReceive();
const createTransaction = (): Transaction => ({
family: "mycoin",
mode: "send",
amount: BigNumber(0),
recipient: "",
useAllAmount: false,
fees: null,
});
const updateTransaction = (t, patch) => ({ ...t, ...patch });
const prepareTransaction = async (a, t) => t;
const estimateMaxSpendable = ({ account, parentAccount, transaction }) => {
const mainAccount = getMainAccount(account, parentAccount);
const estimatedFees = transaction?.fees || BigNumber(5000);
return Promise.resolve(
BigNumber.max(0, mainAccount.balance.minus(estimatedFees))
);
};
const getTransactionStatus = (account, t) => {
const errors = {};
const warnings = {};
const useAllAmount = !!t.useAllAmount;
const estimatedFees = BigNumber(5000);
const totalSpent = useAllAmount
? account.balance
: BigNumber(t.amount).plus(estimatedFees);
const amount = useAllAmount
? account.balance.minus(estimatedFees)
: BigNumber(t.amount);
if (amount.gt(0) && estimatedFees.times(10).gt(amount)) {
warnings.amount = new FeeTooHigh();
}
if (totalSpent.gt(account.balance)) {
errors.amount = new NotEnoughBalance();
}
if (!t.recipient) {
errors.recipient = new RecipientRequired();
} else if (isInvalidRecipient(t.recipient)) {
errors.recipient = new InvalidAddress();
}
return Promise.resolve({
errors,
warnings,
estimatedFees,
amount,
totalSpent,
});
};
const accountBridge: AccountBridge<Transaction> = {
estimateMaxSpendable,
createTransaction,
updateTransaction,
getTransactionStatus,
prepareTransaction,
sync,
receive,
signOperation,
broadcast,
};
const currencyBridge: CurrencyBridge = {
scanAccounts,
preload: async () => {},
hydrate: () => {},
};
export default { currencyBridge, accountBridge };
Split your code
You can now start to implement the bridge for MyCoin. It may need some changes back and forth between the types, your api wrapper, and the different files.
The skeleton of libs/coin-modules/coin-mycoin/src/bridge/index.ts
should look something like this:
import getAddressWrapper from "@ledgerhq/coin-framework/bridge/getAddressWrapper";
import {
defaultUpdateTransaction,
makeAccountBridgeReceive,
makeScanAccounts,
} from "@ledgerhq/coin-framework/bridge/jsHelpers";
import { CoinConfig } from "@ledgerhq/coin-framework/config";
import { SignerContext } from "@ledgerhq/coin-framework/signer";
import type { AccountBridge, CurrencyBridge } from "@ledgerhq/types-live";
import myCoinConfig, { type MyCoinConfig } from "../config";
import signerGetAddress from "../signer";
import { MyCoinAccount, MyCoinSigner, TransactionStatus, type Transaction } from "../types";
import { broadcast } from "./broadcast";
import { createTransaction } from "./createTransaction";
import { estimateMaxSpendable } from "./estimateMaxSpendable";
import { getTransactionStatus } from "./getTransactionStatus";
import { getPreloadStrategy, hydrate, preload } from "./preload";
import { prepareTransaction } from "./prepareTransaction";
import {
assignFromAccountRaw,
assignToAccountRaw,
fromOperationExtraRaw,
toOperationExtraRaw,
} from "./serialization";
import { buildSignOperation } from "./signOperation";
import { getAccountShape, sync } from "./synchronization";
function buildCurrencyBridge(signerContext: SignerContext<MyCoinSigner>): CurrencyBridge {
const getAddress = signerGetAddress(signerContext);
const scanAccounts = makeScanAccounts({
getAccountShape,
getAddressFn: getAddressWrapper(getAddress),
});
return {
getPreloadStrategy,
preload,
hydrate,
scanAccounts,
};
}
function buildAccountBridge(
signerContext: SignerContext<MyCoinSigner>,
): AccountBridge<Transaction, MyCoinAccount, TransactionStatus> {
const getAddress = signerGetAddress(signerContext);
const receive = makeAccountBridgeReceive(getAddressWrapper(getAddress));
const signOperation = buildSignOperation(signerContext);
return {
estimateMaxSpendable,
createTransaction,
updateTransaction: defaultUpdateTransaction,
getTransactionStatus,
prepareTransaction,
sync,
receive,
signOperation,
broadcast,
assignFromAccountRaw,
assignToAccountRaw,
fromOperationExtraRaw,
toOperationExtraRaw,
};
}
export function createBridges(
signerContext: SignerContext<MyCoinSigner>,
coinConfig: CoinConfig<MyCoinConfig>,
) {
myCoinConfig.setCoinConfig(coinConfig);
return {
currencyBridge: buildCurrencyBridge(signerContext),
accountBridge: buildAccountBridge(signerContext),
};
}
For better readability and maintainability, split your code into multiple files.