Skip to content

Commit 92b90a6

Browse files
committed
feat(concordium): build PLT token sub-accounts during account sync
1 parent 0f0627f commit 92b90a6

18 files changed

Lines changed: 1488 additions & 37 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ledgerhq/coin-concordium": minor
3+
"@ledgerhq/live-common": minor
4+
---
5+
6+
Build PLT token sub-accounts during Concordium account sync, behind the new `enableTokens` coin config flag (off by default). Tokens are resolved from the CAL by on-chain address, per-token pause and allow/deny state is cached on the account, and PLT balances are reported on the `api/` surface.

apps/ledger-live-desktop/src/renderer/families/concordium/OnboardModal/__tests__/OnboardModal.integ.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ describe("OnboardModal Integration", () => {
162162
grpcPort: 443,
163163
proxyUrl: "https://ccd-wallet-proxy-mainnet.coin.ledger.com",
164164
minReserve: 0,
165+
enableTokens: false,
165166
}));
166167
});
167168

libs/coin-modules/coin-concordium/src/api/craftTransaction.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,24 @@ describe("api/craftTransaction", () => {
101101
expect(result).toHaveProperty("transaction");
102102
expect(typeof result.transaction).toBe("string");
103103
});
104+
105+
it("should reject a non-native asset instead of crafting a CCD transfer", async () => {
106+
const api = createApi("concordium_testnet");
107+
const transactionIntent = {
108+
intentType: "transaction" as const,
109+
type: "send",
110+
sender: VALID_ADDRESS,
111+
recipient: VALID_ADDRESS_2,
112+
amount: BigInt(1000000),
113+
asset: { type: "plt", assetReference: "t-USDT" },
114+
} as any;
115+
116+
await expect(api.craftTransaction(context, transactionIntent)).rejects.toThrow(
117+
/asset type plt is not supported/,
118+
);
119+
// Crafting ignores `asset`, so without the guard this would sign a CCD
120+
// transfer of the same integer amount. PLT crafting is LIVE-28337.
121+
expect(getNextValidSequenceMock).not.toHaveBeenCalled();
122+
expect(craftTransactionMock).not.toHaveBeenCalled();
123+
});
104124
});

libs/coin-modules/coin-concordium/src/api/estimateFees.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,21 @@ describe("api/estimateFees", () => {
8484

8585
expect(result).toEqual({ value: BigInt(1000) });
8686
});
87+
88+
it("should reject a non-native asset rather than return a plausible native fee", async () => {
89+
const api = createApi("concordium_testnet");
90+
const transactionIntent = {
91+
intentType: "transaction" as const,
92+
type: "send",
93+
sender: VALID_ADDRESS,
94+
recipient: VALID_ADDRESS_2,
95+
amount: BigInt(1000000),
96+
asset: { type: "plt", assetReference: "t-USDT" },
97+
} as any;
98+
99+
await expect(api.estimateFees(context, transactionIntent)).rejects.toThrow(
100+
/asset type plt is not supported/,
101+
);
102+
expect(estimateFeesMock).not.toHaveBeenCalled();
103+
});
87104
});

libs/coin-modules/coin-concordium/src/api/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
TransactionType,
2121
} from "@ledgerhq/concordium-core";
2222
import BigNumber from "bignumber.js";
23+
import invariant from "invariant";
2324
import { validateAddress } from "../bridge/validateAddress";
2425
import { rejectBalanceOptions } from "@ledgerhq/coin-module-framework/api/getBalance/rejectBalanceOptions";
2526
import {
@@ -105,11 +106,27 @@ export function createApi(currencyId: string) {
105106
} satisfies CoinModuleImpl<ConcordiumCoinConfig, ConcordiumMemo>;
106107
}
107108

109+
/**
110+
* Crafting ignores `asset` and always emits a native transfer, so without this
111+
* a PLT intent would be signed as a CCD transfer of the same integer amount.
112+
* `getBalance` reports PLT balances, which makes such an intent constructible;
113+
* PLT crafting itself lands in LIVE-28337.
114+
*/
115+
function assertNativeAsset(transactionIntent: TransactionIntent<ConcordiumMemo>): void {
116+
invariant(
117+
transactionIntent.asset.type === "native",
118+
"concordium: asset type %s is not supported",
119+
transactionIntent.asset.type,
120+
);
121+
}
122+
108123
async function craftTransaction(
109124
config: ConcordiumCoinConfig,
110125
transactionIntent: TransactionIntent<ConcordiumMemo>,
111126
currencyId: string,
112127
): Promise<CraftedTransaction> {
128+
assertNativeAsset(transactionIntent);
129+
113130
const nextSequenceNumber = await getNextValidSequence(
114131
config,
115132
transactionIntent.sender,
@@ -139,6 +156,10 @@ async function estimateFees(
139156
transactionIntent: TransactionIntent<ConcordiumMemo>,
140157
currencyId: string,
141158
): Promise<FeeEstimation> {
159+
// Rejected here too, or a caller gets a plausible native fee for a send that
160+
// cannot be crafted.
161+
assertNativeAsset(transactionIntent);
162+
142163
const memo =
143164
"memo" in transactionIntent && transactionIntent.memo?.type === "string"
144165
? transactionIntent.memo.value

libs/coin-modules/coin-concordium/src/bridge/index.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
1+
import type { ConcordiumAccount } from "../types";
12
import { createBridges } from ".";
23

4+
jest.mock("@ledgerhq/ledger-wallet-framework/bridge/jsHelpers", () => ({
5+
getSerializedAddressParameters: jest.fn(),
6+
makeSync: jest.fn(() => jest.fn()),
7+
makeScanAccounts: jest.fn(() => jest.fn()),
8+
mergeOps: jest.fn(),
9+
}));
10+
11+
jest.mock("../config", () => ({
12+
__esModule: true,
13+
default: { setCoinConfig: jest.fn(), getCoinConfig: jest.fn() },
14+
}));
15+
16+
const { makeSync, makeScanAccounts } = jest.requireMock(
17+
"@ledgerhq/ledger-wallet-framework/bridge/jsHelpers",
18+
);
19+
const coinConfig = jest.requireMock("../config").default;
20+
21+
type PostSync = (initial: ConcordiumAccount, synced: ConcordiumAccount) => ConcordiumAccount;
22+
23+
const account = (subAccounts?: unknown[]): ConcordiumAccount =>
24+
({
25+
id: "acc",
26+
currency: { id: "concordium_testnet" },
27+
...(subAccounts === undefined ? {} : { subAccounts }),
28+
}) as unknown as ConcordiumAccount;
29+
330
describe("createBridges", () => {
431
it("has a currency bridge and an account bridge with required methods", () => {
532
expect(createBridges(undefined as any, {} as any)).toEqual({
@@ -28,3 +55,52 @@ describe("createBridges", () => {
2855
});
2956
});
3057
});
58+
59+
/**
60+
* Token visibility is cleared in `postSync` rather than in the account shape, so
61+
* omitting it from either builder silently reintroduces the empty token section.
62+
*/
63+
describe("createBridges token visibility wiring", () => {
64+
const build = (enableTokens: boolean): { sync: PostSync; scan: PostSync } => {
65+
jest.clearAllMocks();
66+
coinConfig.getCoinConfig.mockReturnValue({ enableTokens });
67+
createBridges(undefined as never, {} as never);
68+
69+
return {
70+
sync: makeSync.mock.calls[0][0].postSync,
71+
scan: makeScanAccounts.mock.calls[0][0].postSync,
72+
};
73+
};
74+
75+
it("passes a postSync to both makeSync and makeScanAccounts", () => {
76+
const { sync, scan } = build(false);
77+
78+
expect(sync).toEqual(expect.any(Function));
79+
expect(scan).toEqual(expect.any(Function));
80+
});
81+
82+
it.each([
83+
["sync", (b: { sync: PostSync; scan: PostSync }) => b.sync],
84+
["scan", (b: { sync: PostSync; scan: PostSync }) => b.scan],
85+
])("removes subAccounts on the %s path when tokens are off", (_name, pick) => {
86+
const result = pick(build(false))(account(), account([{ id: "sub" }]));
87+
88+
expect("subAccounts" in result).toBe(false);
89+
});
90+
91+
it.each([
92+
["sync", (b: { sync: PostSync; scan: PostSync }) => b.sync],
93+
["scan", (b: { sync: PostSync; scan: PostSync }) => b.scan],
94+
])("keeps subAccounts on the %s path when tokens are on", (_name, pick) => {
95+
const built = build(true);
96+
const synced = account([{ id: "sub" }]);
97+
98+
expect(pick(built)(account(), synced)).toBe(synced);
99+
});
100+
101+
it("reads the flag per currency, so one network can differ from the other", () => {
102+
build(false).sync(account(), account([]));
103+
104+
expect(coinConfig.getCoinConfig).toHaveBeenCalledWith("concordium_testnet");
105+
});
106+
});

libs/coin-modules/coin-concordium/src/bridge/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,20 @@ import { buildReceive } from "./receive";
2626
import { assignFromAccountRaw, assignToAccountRaw } from "./serialization";
2727
import { buildSignOperation } from "./signOperation";
2828
import { getAccountShape } from "./sync";
29+
import { stripSubAccounts } from "./tokens";
2930
import { updateTransaction } from "./updateTransaction";
3031
import { validateAddress } from "./validateAddress";
3132

33+
/**
34+
* See `stripSubAccounts` for why the shape cannot clear tokens itself. Wired
35+
* into scanning as well as syncing: a freshly discovered account never reaches
36+
* `makeSync`, and would keep an empty array until its first sync.
37+
*/
38+
const postSync = (_initial: ConcordiumAccount, synced: ConcordiumAccount): ConcordiumAccount =>
39+
concordiumCoinConfig.getCoinConfig(synced.currency.id).enableTokens
40+
? synced
41+
: stripSubAccounts(synced);
42+
3243
export function createBridges(
3344
signerContext: SignerContext<ConcordiumSigner>,
3445
coinConfig: CoinConfig<ConcordiumCoinConfig>,
@@ -37,7 +48,7 @@ export function createBridges(
3748

3849
const getAddress = resolver(signerContext);
3950
const receive = buildReceive(signerContext);
40-
const scanAccounts = makeScanAccounts({ getAccountShape, getAddressFn: getAddress });
51+
const scanAccounts = makeScanAccounts({ getAccountShape, getAddressFn: getAddress, postSync });
4152
const onboardAccount = buildOnboardAccount(signerContext);
4253
const pairWalletConnect = buildPairWalletConnect();
4354

@@ -48,7 +59,7 @@ export function createBridges(
4859
};
4960

5061
const signOperation = buildSignOperation(signerContext);
51-
const sync = makeSync({ getAccountShape });
62+
const sync = makeSync({ getAccountShape, postSync });
5263

5364
const accountBridge: AccountBridge<Transaction, ConcordiumAccount> = {
5465
broadcast,

libs/coin-modules/coin-concordium/src/bridge/serialization.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import BigNumber from "bignumber.js";
2+
import type { Account, AccountRaw } from "@ledgerhq/types-live";
23
import type {
34
ConcordiumAccount,
45
ConcordiumAccountRaw,
@@ -18,6 +19,19 @@ import {
1819
assignFromAccountRaw,
1920
} from "./serialization";
2021

22+
jest.mock("../config", () => ({
23+
__esModule: true,
24+
default: { getCoinConfig: jest.fn() },
25+
}));
26+
27+
const coinConfig = jest.requireMock("../config").default;
28+
29+
// Token state only survives deserialization while the feature is on. The suites
30+
// below assume it is; "assignFromAccountRaw and the token flag" varies it.
31+
beforeEach(() => {
32+
coinConfig.getCoinConfig.mockReturnValue({ enableTokens: true });
33+
});
34+
2135
const ACCOUNT_ID = "js:2:concordium_testnet:someaddr:";
2236

2337
function createRawOperation(overrides?: Partial<RawOperation>): RawOperation {
@@ -353,3 +367,74 @@ describe("mapRawOperationToBridgeOperation", () => {
353367
expect(result.blockHash).toBeNull();
354368
});
355369
});
370+
371+
/**
372+
* Deserialization is the one account-producing path no `postSync` covers, so
373+
* the flag has to be enforced here too.
374+
*/
375+
describe("assignFromAccountRaw and the token flag", () => {
376+
const storedTokens = { "t-USDT": { transferStatus: "allowed" } };
377+
378+
const raw = () =>
379+
({
380+
concordiumResources: {
381+
isOnboarded: true,
382+
credId: "",
383+
publicKey: "",
384+
identityIndex: 0,
385+
credNumber: 0,
386+
ipIdentity: 0,
387+
tokens: { ...storedTokens },
388+
},
389+
}) as unknown as AccountRaw;
390+
391+
const account = () =>
392+
({
393+
currency: { id: "concordium_testnet", family: "concordium" },
394+
subAccounts: [{ id: "sub", type: "TokenAccount" }],
395+
}) as unknown as Account;
396+
397+
beforeEach(() => jest.clearAllMocks());
398+
399+
it("drops sub-accounts and stored token state when the flag is off", () => {
400+
coinConfig.getCoinConfig.mockReturnValue({ enableTokens: false });
401+
const target = account();
402+
403+
assignFromAccountRaw(raw(), target);
404+
405+
expect(target.subAccounts).toBeUndefined();
406+
expect(
407+
"tokens" in (target as never as { concordiumResources: object }).concordiumResources,
408+
).toBe(false);
409+
});
410+
411+
it("keeps both when the flag is on", () => {
412+
coinConfig.getCoinConfig.mockReturnValue({ enableTokens: true });
413+
const target = account();
414+
415+
assignFromAccountRaw(raw(), target);
416+
417+
expect(target.subAccounts).toHaveLength(1);
418+
expect(
419+
(target as never as { concordiumResources: { tokens?: object } }).concordiumResources.tokens,
420+
).toEqual(storedTokens);
421+
});
422+
423+
it.each([
424+
[
425+
"the config cannot be resolved",
426+
() => {
427+
throw new Error("MissingCoinConfig");
428+
},
429+
],
430+
["the flag is absent from the config", () => ({})],
431+
])("fails closed and strips tokens when %s", (_case, impl) => {
432+
// An unreadable flag must not expose token UI for a feature that is off by
433+
// default. Sub-accounts are rebuilt from chain on the next sync.
434+
coinConfig.getCoinConfig.mockImplementation(impl);
435+
const target = account();
436+
437+
expect(() => assignFromAccountRaw(raw(), target)).not.toThrow();
438+
expect(target.subAccounts).toBeUndefined();
439+
});
440+
});

libs/coin-modules/coin-concordium/src/bridge/serialization.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
ConcordiumResources,
88
RawOperation,
99
} from "../types";
10+
import coinConfig from "../config";
11+
import { applyTokensToResources } from "./tokens";
1012

1113
export function isConcordiumAccount(account: Account): account is ConcordiumAccount {
1214
return account.currency?.family === "concordium" && "concordiumResources" in account;
@@ -54,14 +56,43 @@ export function assignToAccountRaw(account: Account, accountRaw: AccountRaw): vo
5456
);
5557
}
5658

59+
/**
60+
* Reads the token flag, treating an unreadable config as off.
61+
*
62+
* `getCoinConfig` throws when unset, but that does not mean "too early":
63+
* `fromAccountRaw` awaits `getAccountBridgeByFamily`, which loads the family
64+
* setup, and concordium's setup seeds the config at module initialization. A
65+
* throw therefore signals abnormal config resolution, where failing closed is
66+
* right — exposing token UI for a feature that is off by default is worse than
67+
* rebuilding sub-accounts from chain on the next sync.
68+
*/
69+
function tokensEnabled(currencyId: string): boolean {
70+
try {
71+
return coinConfig.getCoinConfig(currencyId).enableTokens === true;
72+
} catch {
73+
return false;
74+
}
75+
}
76+
5777
export function assignFromAccountRaw(accountRaw: AccountRaw, account: Account): void {
5878
if (!isConcordiumAccountRaw(accountRaw) || !accountRaw.concordiumResources) {
5979
return;
6080
}
6181

62-
(account as ConcordiumAccount).concordiumResources = copyResources(
63-
accountRaw.concordiumResources,
64-
);
82+
const resources = copyResources(accountRaw.concordiumResources);
83+
84+
// The only account-producing path no `postSync` covers: the framework assigns
85+
// the raw token sub-accounts just before calling this hook, so without a strip
86+
// here disabling the flag would hold only until the next app start.
87+
if (!tokensEnabled(account.currency.id)) {
88+
(account as ConcordiumAccount).concordiumResources = applyTokensToResources(resources, {
89+
kind: "cleared",
90+
});
91+
delete account.subAccounts;
92+
return;
93+
}
94+
95+
(account as ConcordiumAccount).concordiumResources = resources;
6596
}
6697

6798
export function mapRawOperationToBridgeOperation(op: RawOperation, accountId: string): Operation {

0 commit comments

Comments
 (0)