@alephium/ledger-app 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  /// <reference types="node" />
2
2
  import { Account, KeyType } from '@alephium/web3';
3
3
  import Transport from '@ledgerhq/hw-transport';
4
+ import * as serde from './serde';
4
5
  export declare const CLA = 128;
5
6
  export declare enum INS {
6
7
  GET_VERSION = 0,
@@ -17,5 +18,5 @@ export default class AlephiumApp {
17
18
  getVersion(): Promise<string>;
18
19
  getAccount(startPath: string, targetGroup?: number, keyType?: KeyType, display?: boolean): Promise<readonly [Account, number]>;
19
20
  signHash(path: string, hash: Buffer): Promise<string>;
20
- signUnsignedTx(path: string, unsignedTx: Buffer): Promise<string>;
21
+ signUnsignedTx(path: string, unsignedTx: Buffer, tokenMetadata?: serde.TokenMetadata[]): Promise<string>;
21
22
  }
package/dist/src/index.js CHANGED
@@ -39,6 +39,8 @@ var INS;
39
39
  })(INS = exports.INS || (exports.INS = {}));
40
40
  exports.GROUP_NUM = 4;
41
41
  exports.HASH_LEN = 32;
42
+ // The maximum payload size is 255: https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/hw-transport/src/Transport.ts#L261
43
+ const MAX_PAYLOAD_SIZE = 255;
42
44
  class AlephiumApp {
43
45
  constructor(transport) {
44
46
  this.transport = transport;
@@ -78,17 +80,18 @@ class AlephiumApp {
78
80
  console.log(`response ${response.length} - ${response.toString('hex')}`);
79
81
  return decodeSignature(response);
80
82
  }
81
- async signUnsignedTx(path, unsignedTx) {
83
+ async signUnsignedTx(path, unsignedTx, tokenMetadata = []) {
82
84
  console.log(`unsigned tx size: ${unsignedTx.length}`);
83
85
  const encodedPath = serde.serializePath(path);
84
- const firstFrameTxLength = 256 - 25;
86
+ const encodedTokenMetadata = serde.serializeTokenMetadata(tokenMetadata);
87
+ const firstFrameTxLength = MAX_PAYLOAD_SIZE - 20 - encodedTokenMetadata.length;
85
88
  const txData = unsignedTx.slice(0, unsignedTx.length > firstFrameTxLength ? firstFrameTxLength : unsignedTx.length);
86
- const data = Buffer.concat([encodedPath, txData]);
89
+ const data = Buffer.concat([encodedPath, encodedTokenMetadata, txData]);
87
90
  let response = await this.transport.send(exports.CLA, INS.SIGN_TX, 0x00, 0x00, data, [hw_transport_1.StatusCodes.OK]);
88
91
  if (unsignedTx.length <= firstFrameTxLength) {
89
92
  return decodeSignature(response);
90
93
  }
91
- const frameLength = 256 - 5;
94
+ const frameLength = MAX_PAYLOAD_SIZE;
92
95
  let fromIndex = firstFrameTxLength;
93
96
  while (fromIndex < unsignedTx.length) {
94
97
  const remain = unsignedTx.length - fromIndex;
@@ -3,3 +3,13 @@ export declare const TRUE = 16;
3
3
  export declare const FALSE = 0;
4
4
  export declare function splitPath(path: string): number[];
5
5
  export declare function serializePath(path: string): Buffer;
6
+ export declare const MAX_TOKEN_SIZE = 5;
7
+ export declare const MAX_TOKEN_SYMBOL_LENGTH = 12;
8
+ export declare const TOKEN_METADATA_SIZE = 46;
9
+ export interface TokenMetadata {
10
+ version: number;
11
+ tokenId: string;
12
+ symbol: string;
13
+ decimals: number;
14
+ }
15
+ export declare function serializeTokenMetadata(tokens: TokenMetadata[]): Buffer;
package/dist/src/serde.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.serializePath = exports.splitPath = exports.FALSE = exports.TRUE = void 0;
3
+ exports.serializeTokenMetadata = exports.TOKEN_METADATA_SIZE = exports.MAX_TOKEN_SYMBOL_LENGTH = exports.MAX_TOKEN_SIZE = exports.serializePath = exports.splitPath = exports.FALSE = exports.TRUE = void 0;
4
+ const web3_1 = require("@alephium/web3");
4
5
  exports.TRUE = 0x10;
5
6
  exports.FALSE = 0x00;
6
7
  function splitPath(path) {
@@ -30,3 +31,49 @@ function serializePath(path) {
30
31
  return buffer;
31
32
  }
32
33
  exports.serializePath = serializePath;
34
+ exports.MAX_TOKEN_SIZE = 5;
35
+ exports.MAX_TOKEN_SYMBOL_LENGTH = 12;
36
+ exports.TOKEN_METADATA_SIZE = 46;
37
+ function symbolToBytes(symbol) {
38
+ const buffer = Buffer.alloc(exports.MAX_TOKEN_SYMBOL_LENGTH, 0);
39
+ for (let i = 0; i < symbol.length; i++) {
40
+ buffer[i] = symbol.charCodeAt(i) & 0xFF;
41
+ }
42
+ return buffer;
43
+ }
44
+ function check(tokens) {
45
+ const hasDuplicate = tokens.some((token, index) => index !== tokens.findIndex((t) => t.tokenId === token.tokenId));
46
+ if (hasDuplicate) {
47
+ throw new Error(`There are duplicate tokens`);
48
+ }
49
+ tokens.forEach((token) => {
50
+ if (!((0, web3_1.isHexString)(token.tokenId) && token.tokenId.length === 64)) {
51
+ throw new Error(`Invalid token id: ${token.tokenId}`);
52
+ }
53
+ if (token.symbol.length > exports.MAX_TOKEN_SYMBOL_LENGTH) {
54
+ throw new Error(`The token symbol is too long: ${token.symbol}`);
55
+ }
56
+ });
57
+ if (tokens.length > exports.MAX_TOKEN_SIZE) {
58
+ throw new Error(`The token size exceeds maximum size`);
59
+ }
60
+ }
61
+ function serializeTokenMetadata(tokens) {
62
+ check(tokens);
63
+ const array = tokens
64
+ .map((metadata) => {
65
+ const symbolBytes = symbolToBytes(metadata.symbol);
66
+ const buffer = Buffer.concat([
67
+ Buffer.from([metadata.version]),
68
+ Buffer.from(metadata.tokenId, 'hex'),
69
+ symbolBytes,
70
+ Buffer.from([metadata.decimals])
71
+ ]);
72
+ if (buffer.length !== exports.TOKEN_METADATA_SIZE) {
73
+ throw new Error(`Invalid token metadata: ${metadata}`);
74
+ }
75
+ return buffer;
76
+ });
77
+ return Buffer.concat([Buffer.from([array.length]), ...array]);
78
+ }
79
+ exports.serializeTokenMetadata = serializeTokenMetadata;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const web3_1 = require("@alephium/web3");
3
4
  const serde_1 = require("./serde");
5
+ const crypto_1 = require("crypto");
4
6
  describe('serde', () => {
5
7
  it('should split path', () => {
6
8
  expect((0, serde_1.splitPath)(`m/44'/1234'/0'/0/0`)).toStrictEqual([44 + 0x80000000, 1234 + 0x80000000, 0 + 0x80000000, 0, 0]);
@@ -10,4 +12,68 @@ describe('serde', () => {
10
12
  expect((0, serde_1.serializePath)(`m/1'/2'/0'/0/0`)).toStrictEqual(Buffer.from([0x80, 0, 0, 1, 0x80, 0, 0, 2, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]));
11
13
  expect((0, serde_1.serializePath)(`m/1'/2'/0'/0/0`)).toStrictEqual(Buffer.from([0x80, 0, 0, 1, 0x80, 0, 0, 2, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]));
12
14
  });
15
+ it('should encode token metadata', () => {
16
+ const token0 = {
17
+ version: 0,
18
+ tokenId: (0, web3_1.binToHex)((0, crypto_1.randomBytes)(32)),
19
+ symbol: 'Token0',
20
+ decimals: 8
21
+ };
22
+ const token1 = {
23
+ version: 1,
24
+ tokenId: (0, web3_1.binToHex)((0, crypto_1.randomBytes)(32)),
25
+ symbol: 'Token1',
26
+ decimals: 18
27
+ };
28
+ const token2 = {
29
+ version: 2,
30
+ tokenId: (0, web3_1.binToHex)((0, crypto_1.randomBytes)(32)),
31
+ symbol: 'Token2',
32
+ decimals: 6
33
+ };
34
+ const token3 = {
35
+ version: 3,
36
+ tokenId: (0, web3_1.binToHex)((0, crypto_1.randomBytes)(32)),
37
+ symbol: 'Token3',
38
+ decimals: 0
39
+ };
40
+ const token4 = {
41
+ version: 4,
42
+ tokenId: (0, web3_1.binToHex)((0, crypto_1.randomBytes)(32)),
43
+ symbol: 'Token4',
44
+ decimals: 12
45
+ };
46
+ const encodeSymbol = (symbol) => {
47
+ return (0, web3_1.binToHex)(Buffer.from(symbol, 'ascii')).padEnd(serde_1.MAX_TOKEN_SYMBOL_LENGTH * 2, '0');
48
+ };
49
+ expect((0, web3_1.binToHex)((0, serde_1.serializeTokenMetadata)([]))).toEqual('00');
50
+ expect((0, web3_1.binToHex)((0, serde_1.serializeTokenMetadata)([token0]))).toEqual('01' + '00' + token0.tokenId + encodeSymbol(token0.symbol) + '08');
51
+ expect((0, web3_1.binToHex)((0, serde_1.serializeTokenMetadata)([token1]))).toEqual('01' + '01' + token1.tokenId + encodeSymbol(token1.symbol) + '12');
52
+ expect((0, web3_1.binToHex)((0, serde_1.serializeTokenMetadata)([token0, token1]))).toEqual('02' + '00' + token0.tokenId + encodeSymbol(token0.symbol) + '08' +
53
+ '01' + token1.tokenId + encodeSymbol(token1.symbol) + '12');
54
+ expect((0, web3_1.binToHex)((0, serde_1.serializeTokenMetadata)([token0, token1, token2, token3, token4]))).toEqual('05' + '00' + token0.tokenId + encodeSymbol(token0.symbol) + '08' +
55
+ '01' + token1.tokenId + encodeSymbol(token1.symbol) + '12' +
56
+ '02' + token2.tokenId + encodeSymbol(token2.symbol) + '06' +
57
+ '03' + token3.tokenId + encodeSymbol(token3.symbol) + '00' +
58
+ '04' + token4.tokenId + encodeSymbol(token4.symbol) + '0c');
59
+ expect(() => (0, serde_1.serializeTokenMetadata)([token0, token1, token0])).toThrow('There are duplicate tokens');
60
+ const token5 = {
61
+ version: 5,
62
+ tokenId: (0, web3_1.binToHex)((0, crypto_1.randomBytes)(32)),
63
+ symbol: 'Token5',
64
+ decimals: 18
65
+ };
66
+ expect(() => (0, serde_1.serializeTokenMetadata)([token0, token1, token2, token3, token4, token5])).toThrow('The token size exceeds maximum size');
67
+ const invalidToken = {
68
+ ...token0,
69
+ tokenId: (0, web3_1.binToHex)((0, crypto_1.randomBytes)(33))
70
+ };
71
+ expect(() => (0, serde_1.serializeTokenMetadata)([token0, invalidToken])).toThrow('Invalid token id');
72
+ const longSymbolToken = {
73
+ ...token0,
74
+ tokenId: (0, web3_1.binToHex)((0, crypto_1.randomBytes)(32)),
75
+ symbol: 'LongSymbolToken'
76
+ };
77
+ expect(() => (0, serde_1.serializeTokenMetadata)([token0, longSymbolToken, token1])).toThrow('The token symbol is too long');
78
+ });
13
79
  });
@@ -3,7 +3,8 @@ export declare enum OutputType {
3
3
  Base = 0,
4
4
  Multisig = 1,
5
5
  Token = 2,
6
- MultisigAndToken = 3
6
+ BaseAndToken = 3,
7
+ MultisigAndToken = 4
7
8
  }
8
9
  export declare function staxFlexApproveOnce(): Promise<void>;
9
10
  export declare function approveTx(outputs: OutputType[], hasExternalInputs?: boolean): Promise<void>;
@@ -21,33 +21,41 @@ async function clickAndApprove(times) {
21
21
  }
22
22
  await pressButton('both');
23
23
  }
24
+ function getModel() {
25
+ const model = process.env.MODEL;
26
+ return model ? model : 'nanos';
27
+ }
24
28
  var OutputType;
25
29
  (function (OutputType) {
26
30
  OutputType[OutputType["Base"] = 0] = "Base";
27
31
  OutputType[OutputType["Multisig"] = 1] = "Multisig";
28
32
  OutputType[OutputType["Token"] = 2] = "Token";
29
- OutputType[OutputType["MultisigAndToken"] = 3] = "MultisigAndToken";
33
+ OutputType[OutputType["BaseAndToken"] = 3] = "BaseAndToken";
34
+ OutputType[OutputType["MultisigAndToken"] = 4] = "MultisigAndToken";
30
35
  })(OutputType = exports.OutputType || (exports.OutputType = {}));
31
36
  const NanosClickTable = new Map([
32
37
  [OutputType.Base, 5],
33
38
  [OutputType.Multisig, 10],
34
39
  [OutputType.Token, 11],
40
+ [OutputType.BaseAndToken, 12],
35
41
  [OutputType.MultisigAndToken, 16],
36
42
  ]);
37
43
  const NanospClickTable = new Map([
38
44
  [OutputType.Base, 3],
39
45
  [OutputType.Multisig, 5],
40
46
  [OutputType.Token, 6],
47
+ [OutputType.BaseAndToken, 6],
41
48
  [OutputType.MultisigAndToken, 8],
42
49
  ]);
43
50
  const StaxClickTable = new Map([
44
51
  [OutputType.Base, 2],
45
52
  [OutputType.Multisig, 3],
46
53
  [OutputType.Token, 3],
54
+ [OutputType.BaseAndToken, 3],
47
55
  [OutputType.MultisigAndToken, 4],
48
56
  ]);
49
57
  function getOutputClickSize(outputType) {
50
- const model = process.env.MODEL;
58
+ const model = getModel();
51
59
  switch (model) {
52
60
  case 'nanos': return NanosClickTable.get(outputType);
53
61
  case 'nanosp':
@@ -85,15 +93,16 @@ async function touchPosition(pos) {
85
93
  });
86
94
  }
87
95
  async function _touch(times) {
88
- let continuePos = process.env.MODEL === 'stax' ? STAX_CONTINUE_POSITION : FLEX_CONTINUE_POSITION;
96
+ const model = getModel();
97
+ const continuePos = model === 'stax' ? STAX_CONTINUE_POSITION : FLEX_CONTINUE_POSITION;
89
98
  for (let i = 0; i < times; i += 1) {
90
99
  await touchPosition(continuePos);
91
100
  }
92
- let approvePos = process.env.MODEL === 'stax' ? STAX_APPROVE_POSITION : FLEX_APPROVE_POSITION;
101
+ const approvePos = model === 'stax' ? STAX_APPROVE_POSITION : FLEX_APPROVE_POSITION;
93
102
  await touchPosition(approvePos);
94
103
  }
95
104
  async function staxFlexApproveOnce() {
96
- if (process.env.MODEL === 'stax') {
105
+ if (getModel() === 'stax') {
97
106
  await touchPosition(STAX_APPROVE_POSITION);
98
107
  }
99
108
  else {
@@ -138,7 +147,7 @@ async function approveHash() {
138
147
  if (isStaxOrFlex()) {
139
148
  return await _touch(3);
140
149
  }
141
- if (process.env.MODEL === 'nanos') {
150
+ if (getModel() === 'nanos') {
142
151
  await clickAndApprove(5);
143
152
  }
144
153
  else {
@@ -152,7 +161,7 @@ async function approveAddress() {
152
161
  if (isStaxOrFlex()) {
153
162
  return await _touch(2);
154
163
  }
155
- if (process.env.MODEL === 'nanos') {
164
+ if (getModel() === 'nanos') {
156
165
  await clickAndApprove(4);
157
166
  }
158
167
  else {
@@ -161,13 +170,13 @@ async function approveAddress() {
161
170
  }
162
171
  exports.approveAddress = approveAddress;
163
172
  function isStaxOrFlex() {
164
- return !process.env.MODEL.startsWith('nano');
173
+ return !getModel().startsWith('nano');
165
174
  }
166
175
  function skipBlindSigningWarning() {
167
176
  if (!needToAutoApprove())
168
177
  return;
169
178
  if (isStaxOrFlex()) {
170
- const rejectPos = process.env.MODEL === 'stax' ? STAX_REJECT_POSITION : FLEX_REJECT_POSITION;
179
+ const rejectPos = getModel() === 'stax' ? STAX_REJECT_POSITION : FLEX_REJECT_POSITION;
171
180
  touchPosition(rejectPos);
172
181
  }
173
182
  else {
@@ -179,8 +188,9 @@ async function enableBlindSigning() {
179
188
  if (!needToAutoApprove())
180
189
  return;
181
190
  if (isStaxOrFlex()) {
182
- const settingsPos = process.env.MODEL === 'stax' ? STAX_SETTINGS_POSITION : FLEX_SETTINGS_POSITION;
183
- const blindSettingPos = process.env.MODEL === 'stax' ? STAX_BLIND_SETTING_POSITION : FLEX_BLIND_SETTING_POSITION;
191
+ const model = getModel();
192
+ const settingsPos = model === 'stax' ? STAX_SETTINGS_POSITION : FLEX_SETTINGS_POSITION;
193
+ const blindSettingPos = model === 'stax' ? STAX_BLIND_SETTING_POSITION : FLEX_BLIND_SETTING_POSITION;
184
194
  await touchPosition(settingsPos);
185
195
  await touchPosition(blindSettingPos);
186
196
  await touchPosition(settingsPos);
@@ -32,6 +32,7 @@ const web3_test_1 = require("@alephium/web3-test");
32
32
  const web3_wallet_1 = require("@alephium/web3-wallet");
33
33
  const blakejs_1 = __importDefault(require("blakejs"));
34
34
  const utils_1 = require("./utils");
35
+ const crypto_1 = require("crypto");
35
36
  describe('ledger wallet', () => {
36
37
  const nodeProvider = new web3_1.NodeProvider("http://127.0.0.1:22973");
37
38
  web3_1.web3.setCurrentNodeProvider(nodeProvider);
@@ -108,7 +109,7 @@ describe('ledger wallet', () => {
108
109
  await app.close();
109
110
  expect((0, web3_1.transactionVerifySignature)(hash.toString('hex'), account.publicKey, signature)).toBe(true);
110
111
  }, 10000);
111
- it('shoudl transfer alph to one address', async () => {
112
+ it('should transfer alph to one address', async () => {
112
113
  const transport = await (0, utils_1.createTransport)();
113
114
  const app = new src_1.default(transport);
114
115
  const [testAccount] = await app.getAccount(path);
@@ -230,6 +231,79 @@ describe('ledger wallet', () => {
230
231
  expect(token.amount).toEqual('1111111111111111111111111');
231
232
  await app.close();
232
233
  }, 120000);
234
+ async function genTokensAndDestinations(fromAddress, toAddress, mintAmount, transferAmount) {
235
+ const tokens = [];
236
+ const tokenSymbol = 'TestTokenABC';
237
+ const destinations = [];
238
+ for (let i = 0; i < 5; i += 1) {
239
+ const tokenInfo = await (0, web3_test_1.mintToken)(fromAddress, mintAmount);
240
+ const tokenMetadata = {
241
+ version: 0,
242
+ tokenId: tokenInfo.contractId,
243
+ symbol: tokenSymbol.slice(0, tokenSymbol.length - i),
244
+ decimals: 18 - i
245
+ };
246
+ tokens.push(tokenMetadata);
247
+ destinations.push({
248
+ address: toAddress,
249
+ attoAlphAmount: web3_1.DUST_AMOUNT.toString(),
250
+ tokens: [
251
+ {
252
+ id: tokenMetadata.tokenId,
253
+ amount: transferAmount.toString()
254
+ }
255
+ ]
256
+ });
257
+ }
258
+ return { tokens, destinations };
259
+ }
260
+ it('should transfer token with metadata', async () => {
261
+ const transport = await (0, utils_1.createTransport)();
262
+ const app = new src_1.default(transport);
263
+ const [testAccount] = await app.getAccount(path);
264
+ await transferToAddress(testAccount.address);
265
+ const toAddress = '1BmVCLrjttchZMW7i6df7mTdCKzHpy38bgDbVL1GqV6P7';
266
+ const transferAmount = 1234567890123456789012345n;
267
+ const mintAmount = 2222222222222222222222222n;
268
+ const { tokens, destinations } = await genTokensAndDestinations(testAccount.address, toAddress, mintAmount, transferAmount);
269
+ const randomOrderTokens = tokens.sort((a, b) => b.tokenId.localeCompare(a.tokenId));
270
+ const buildTxResult = await nodeProvider.transactions.postTransactionsBuild({
271
+ fromPublicKey: testAccount.publicKey,
272
+ destinations: destinations
273
+ });
274
+ (0, utils_1.approveTx)(Array(5).fill(utils_1.OutputType.BaseAndToken));
275
+ const signature = await app.signUnsignedTx(path, Buffer.from(buildTxResult.unsignedTx, 'hex'), randomOrderTokens);
276
+ expect((0, web3_1.transactionVerifySignature)(buildTxResult.txId, testAccount.publicKey, signature)).toBe(true);
277
+ const submitResult = await nodeProvider.transactions.postTransactionsSubmit({
278
+ unsignedTx: buildTxResult.unsignedTx,
279
+ signature: signature
280
+ });
281
+ await (0, web3_1.waitForTxConfirmation)(submitResult.txId, 1, 1000);
282
+ const balances = await nodeProvider.addresses.getAddressesAddressBalance(toAddress);
283
+ tokens.forEach((metadata) => {
284
+ const tokenBalance = balances.tokenBalances.find((t) => t.id === metadata.tokenId);
285
+ expect(BigInt(tokenBalance.amount)).toEqual(transferAmount);
286
+ });
287
+ await app.close();
288
+ }, 120000);
289
+ it('should reject tx if the metadata version is invalid', async () => {
290
+ const transport = await (0, utils_1.createTransport)();
291
+ const app = new src_1.default(transport);
292
+ const [testAccount] = await app.getAccount(path);
293
+ await transferToAddress(testAccount.address);
294
+ const toAddress = '1BmVCLrjttchZMW7i6df7mTdCKzHpy38bgDbVL1GqV6P7';
295
+ const transferAmount = 1234567890123456789012345n;
296
+ const mintAmount = 2222222222222222222222222n;
297
+ const { tokens, destinations } = await genTokensAndDestinations(testAccount.address, toAddress, mintAmount, transferAmount);
298
+ const invalidTokenIndex = (0, crypto_1.randomInt)(5);
299
+ tokens[invalidTokenIndex] = { ...tokens[invalidTokenIndex], version: 1 };
300
+ const buildTxResult = await nodeProvider.transactions.postTransactionsBuild({
301
+ fromPublicKey: testAccount.publicKey,
302
+ destinations: destinations
303
+ });
304
+ await expect(app.signUnsignedTx(path, Buffer.from(buildTxResult.unsignedTx, 'hex'), tokens)).rejects.toThrow();
305
+ await app.close();
306
+ }, 120000);
233
307
  it('should transfer from multiple inputs', async () => {
234
308
  const transport = await (0, utils_1.createTransport)();
235
309
  const app = new src_1.default(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alephium/ledger-app",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "GPL",
5
5
  "types": "dist/src/index.d.ts",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -16,6 +16,9 @@ export enum INS {
16
16
  export const GROUP_NUM = 4
17
17
  export const HASH_LEN = 32
18
18
 
19
+ // The maximum payload size is 255: https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/hw-transport/src/Transport.ts#L261
20
+ const MAX_PAYLOAD_SIZE = 255
21
+
19
22
  export default class AlephiumApp {
20
23
  readonly transport: Transport
21
24
 
@@ -67,18 +70,23 @@ export default class AlephiumApp {
67
70
  return decodeSignature(response)
68
71
  }
69
72
 
70
- async signUnsignedTx(path: string, unsignedTx: Buffer): Promise<string> {
73
+ async signUnsignedTx(
74
+ path: string,
75
+ unsignedTx: Buffer,
76
+ tokenMetadata: serde.TokenMetadata[] = []
77
+ ): Promise<string> {
71
78
  console.log(`unsigned tx size: ${unsignedTx.length}`)
72
79
  const encodedPath = serde.serializePath(path)
73
- const firstFrameTxLength = 256 - 25;
80
+ const encodedTokenMetadata = serde.serializeTokenMetadata(tokenMetadata)
81
+ const firstFrameTxLength = MAX_PAYLOAD_SIZE - 20 - encodedTokenMetadata.length;
74
82
  const txData = unsignedTx.slice(0, unsignedTx.length > firstFrameTxLength ? firstFrameTxLength : unsignedTx.length)
75
- const data = Buffer.concat([encodedPath, txData])
83
+ const data = Buffer.concat([encodedPath, encodedTokenMetadata, txData])
76
84
  let response = await this.transport.send(CLA, INS.SIGN_TX, 0x00, 0x00, data, [StatusCodes.OK])
77
85
  if (unsignedTx.length <= firstFrameTxLength) {
78
86
  return decodeSignature(response)
79
87
  }
80
88
 
81
- const frameLength = 256 - 5
89
+ const frameLength = MAX_PAYLOAD_SIZE
82
90
  let fromIndex = firstFrameTxLength
83
91
  while (fromIndex < unsignedTx.length) {
84
92
  const remain = unsignedTx.length - fromIndex
package/src/serde.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { isHexString } from "@alephium/web3"
2
+
1
3
  export const TRUE = 0x10
2
4
  export const FALSE = 0x00
3
5
 
@@ -28,3 +30,60 @@ export function serializePath(path: string): Buffer {
28
30
  nodes.forEach((element, index) => buffer.writeUInt32BE(element, 4 * index))
29
31
  return buffer
30
32
  }
33
+
34
+ export const MAX_TOKEN_SIZE = 5
35
+ export const MAX_TOKEN_SYMBOL_LENGTH = 12
36
+ export const TOKEN_METADATA_SIZE = 46
37
+ export interface TokenMetadata {
38
+ version: number,
39
+ tokenId: string,
40
+ symbol: string,
41
+ decimals: number
42
+ }
43
+
44
+ function symbolToBytes(symbol: string): Buffer {
45
+ const buffer = Buffer.alloc(MAX_TOKEN_SYMBOL_LENGTH, 0)
46
+ for (let i = 0; i < symbol.length; i++) {
47
+ buffer[i] = symbol.charCodeAt(i) & 0xFF
48
+ }
49
+ return buffer
50
+ }
51
+
52
+ function check(tokens: TokenMetadata[]) {
53
+ const hasDuplicate = tokens.some((token, index) => index !== tokens.findIndex((t) => t.tokenId === token.tokenId))
54
+ if (hasDuplicate) {
55
+ throw new Error(`There are duplicate tokens`)
56
+ }
57
+
58
+ tokens.forEach((token) => {
59
+ if (!(isHexString(token.tokenId) && token.tokenId.length === 64)) {
60
+ throw new Error(`Invalid token id: ${token.tokenId}`)
61
+ }
62
+ if (token.symbol.length > MAX_TOKEN_SYMBOL_LENGTH) {
63
+ throw new Error(`The token symbol is too long: ${token.symbol}`)
64
+ }
65
+ })
66
+
67
+ if (tokens.length > MAX_TOKEN_SIZE) {
68
+ throw new Error(`The token size exceeds maximum size`)
69
+ }
70
+ }
71
+
72
+ export function serializeTokenMetadata(tokens: TokenMetadata[]): Buffer {
73
+ check(tokens)
74
+ const array = tokens
75
+ .map((metadata) => {
76
+ const symbolBytes = symbolToBytes(metadata.symbol)
77
+ const buffer = Buffer.concat([
78
+ Buffer.from([metadata.version]),
79
+ Buffer.from(metadata.tokenId, 'hex'),
80
+ symbolBytes,
81
+ Buffer.from([metadata.decimals])
82
+ ])
83
+ if (buffer.length !== TOKEN_METADATA_SIZE) {
84
+ throw new Error(`Invalid token metadata: ${metadata}`)
85
+ }
86
+ return buffer
87
+ })
88
+ return Buffer.concat([Buffer.from([array.length]), ...array])
89
+ }
package/test/utils.ts CHANGED
@@ -19,10 +19,16 @@ async function clickAndApprove(times: number) {
19
19
  await pressButton('both')
20
20
  }
21
21
 
22
+ function getModel(): string {
23
+ const model = process.env.MODEL
24
+ return model ? model as string : 'nanos'
25
+ }
26
+
22
27
  export enum OutputType {
23
28
  Base,
24
29
  Multisig,
25
30
  Token,
31
+ BaseAndToken,
26
32
  MultisigAndToken
27
33
  }
28
34
 
@@ -30,6 +36,7 @@ const NanosClickTable = new Map([
30
36
  [OutputType.Base, 5],
31
37
  [OutputType.Multisig, 10],
32
38
  [OutputType.Token, 11],
39
+ [OutputType.BaseAndToken, 12],
33
40
  [OutputType.MultisigAndToken, 16],
34
41
  ])
35
42
 
@@ -37,6 +44,7 @@ const NanospClickTable = new Map([
37
44
  [OutputType.Base, 3],
38
45
  [OutputType.Multisig, 5],
39
46
  [OutputType.Token, 6],
47
+ [OutputType.BaseAndToken, 6],
40
48
  [OutputType.MultisigAndToken, 8],
41
49
  ])
42
50
 
@@ -44,11 +52,12 @@ const StaxClickTable = new Map([
44
52
  [OutputType.Base, 2],
45
53
  [OutputType.Multisig, 3],
46
54
  [OutputType.Token, 3],
55
+ [OutputType.BaseAndToken, 3],
47
56
  [OutputType.MultisigAndToken, 4],
48
57
  ])
49
58
 
50
59
  function getOutputClickSize(outputType: OutputType) {
51
- const model = process.env.MODEL
60
+ const model = getModel()
52
61
  switch (model) {
53
62
  case 'nanos': return NanosClickTable.get(outputType)!
54
63
  case 'nanosp':
@@ -98,16 +107,17 @@ async function touchPosition(pos: Position) {
98
107
  }
99
108
 
100
109
  async function _touch(times: number) {
101
- let continuePos = process.env.MODEL === 'stax' ? STAX_CONTINUE_POSITION : FLEX_CONTINUE_POSITION
110
+ const model = getModel()
111
+ const continuePos = model === 'stax' ? STAX_CONTINUE_POSITION : FLEX_CONTINUE_POSITION
102
112
  for (let i = 0; i < times; i += 1) {
103
113
  await touchPosition(continuePos)
104
114
  }
105
- let approvePos = process.env.MODEL === 'stax' ? STAX_APPROVE_POSITION : FLEX_APPROVE_POSITION
115
+ const approvePos = model === 'stax' ? STAX_APPROVE_POSITION : FLEX_APPROVE_POSITION
106
116
  await touchPosition(approvePos)
107
117
  }
108
118
 
109
119
  export async function staxFlexApproveOnce() {
110
- if (process.env.MODEL === 'stax') {
120
+ if (getModel() === 'stax') {
111
121
  await touchPosition(STAX_APPROVE_POSITION)
112
122
  } else {
113
123
  await touchPosition(FLEX_APPROVE_POSITION)
@@ -151,7 +161,7 @@ export async function approveHash() {
151
161
  if (isStaxOrFlex()) {
152
162
  return await _touch(3)
153
163
  }
154
- if (process.env.MODEL === 'nanos') {
164
+ if (getModel() === 'nanos') {
155
165
  await clickAndApprove(5)
156
166
  } else {
157
167
  await clickAndApprove(3)
@@ -163,7 +173,7 @@ export async function approveAddress() {
163
173
  if (isStaxOrFlex()) {
164
174
  return await _touch(2)
165
175
  }
166
- if (process.env.MODEL === 'nanos') {
176
+ if (getModel() === 'nanos') {
167
177
  await clickAndApprove(4)
168
178
  } else {
169
179
  await clickAndApprove(2)
@@ -171,13 +181,13 @@ export async function approveAddress() {
171
181
  }
172
182
 
173
183
  function isStaxOrFlex(): boolean {
174
- return !process.env.MODEL!.startsWith('nano')
184
+ return !getModel().startsWith('nano')
175
185
  }
176
186
 
177
187
  export function skipBlindSigningWarning() {
178
188
  if (!needToAutoApprove()) return
179
189
  if (isStaxOrFlex()) {
180
- const rejectPos = process.env.MODEL === 'stax' ? STAX_REJECT_POSITION : FLEX_REJECT_POSITION
190
+ const rejectPos = getModel() === 'stax' ? STAX_REJECT_POSITION : FLEX_REJECT_POSITION
181
191
  touchPosition(rejectPos)
182
192
  } else {
183
193
  clickAndApprove(3)
@@ -187,8 +197,9 @@ export function skipBlindSigningWarning() {
187
197
  export async function enableBlindSigning() {
188
198
  if (!needToAutoApprove()) return
189
199
  if (isStaxOrFlex()) {
190
- const settingsPos = process.env.MODEL === 'stax' ? STAX_SETTINGS_POSITION : FLEX_SETTINGS_POSITION
191
- const blindSettingPos = process.env.MODEL === 'stax' ? STAX_BLIND_SETTING_POSITION : FLEX_BLIND_SETTING_POSITION
200
+ const model = getModel()
201
+ const settingsPos = model === 'stax' ? STAX_SETTINGS_POSITION : FLEX_SETTINGS_POSITION
202
+ const blindSettingPos = model === 'stax' ? STAX_BLIND_SETTING_POSITION : FLEX_BLIND_SETTING_POSITION
192
203
  await touchPosition(settingsPos)
193
204
  await touchPosition(blindSettingPos)
194
205
  await touchPosition(settingsPos)
@@ -1,10 +1,11 @@
1
- import SpeculosTransport from '@ledgerhq/hw-transport-node-speculos'
2
1
  import AlephiumApp, { GROUP_NUM } from '../src'
3
- import { ALPH_TOKEN_ID, Address, NodeProvider, ONE_ALPH, binToHex, codec, groupOfAddress, node, sleep, transactionVerifySignature, waitForTxConfirmation, web3 } from '@alephium/web3'
2
+ import { ALPH_TOKEN_ID, Address, DUST_AMOUNT, NodeProvider, ONE_ALPH, binToHex, codec, groupOfAddress, node, sleep, transactionVerifySignature, waitForTxConfirmation, web3 } from '@alephium/web3'
4
3
  import { getSigner, mintToken, transfer } from '@alephium/web3-test'
5
4
  import { PrivateKeyWallet } from '@alephium/web3-wallet'
6
5
  import blake from 'blakejs'
7
6
  import { approveAddress, approveHash, approveTx, createTransport, enableBlindSigning, getRandomInt, needToAutoApprove, OutputType, skipBlindSigningWarning, staxFlexApproveOnce } from './utils'
7
+ import { TokenMetadata } from '../src/serde'
8
+ import { randomInt } from 'crypto'
8
9
 
9
10
  describe('ledger wallet', () => {
10
11
  const nodeProvider = new NodeProvider("http://127.0.0.1:22973")
@@ -95,7 +96,7 @@ describe('ledger wallet', () => {
95
96
  expect(transactionVerifySignature(hash.toString('hex'), account.publicKey, signature)).toBe(true)
96
97
  }, 10000)
97
98
 
98
- it('shoudl transfer alph to one address', async () => {
99
+ it('should transfer alph to one address', async () => {
99
100
  const transport = await createTransport()
100
101
  const app = new AlephiumApp(transport)
101
102
  const [testAccount] = await app.getAccount(path)
@@ -239,6 +240,96 @@ describe('ledger wallet', () => {
239
240
  await app.close()
240
241
  }, 120000)
241
242
 
243
+ async function genTokensAndDestinations(
244
+ fromAddress: string,
245
+ toAddress: string,
246
+ mintAmount: bigint,
247
+ transferAmount: bigint
248
+ ) {
249
+ const tokens: TokenMetadata[] = []
250
+ const tokenSymbol = 'TestTokenABC'
251
+ const destinations: node.Destination[] = []
252
+ for (let i = 0; i < 5; i += 1) {
253
+ const tokenInfo = await mintToken(fromAddress, mintAmount);
254
+ const tokenMetadata: TokenMetadata = {
255
+ version: 0,
256
+ tokenId: tokenInfo.contractId,
257
+ symbol: tokenSymbol.slice(0, tokenSymbol.length - i),
258
+ decimals: 18 - i
259
+ }
260
+ tokens.push(tokenMetadata)
261
+ destinations.push({
262
+ address: toAddress,
263
+ attoAlphAmount: DUST_AMOUNT.toString(),
264
+ tokens: [
265
+ {
266
+ id: tokenMetadata.tokenId,
267
+ amount: transferAmount.toString()
268
+ }
269
+ ]
270
+ })
271
+ }
272
+ return { tokens, destinations }
273
+ }
274
+
275
+ it('should transfer token with metadata', async () => {
276
+ const transport = await createTransport()
277
+ const app = new AlephiumApp(transport)
278
+ const [testAccount] = await app.getAccount(path)
279
+ await transferToAddress(testAccount.address)
280
+
281
+ const toAddress = '1BmVCLrjttchZMW7i6df7mTdCKzHpy38bgDbVL1GqV6P7';
282
+ const transferAmount = 1234567890123456789012345n
283
+ const mintAmount = 2222222222222222222222222n
284
+ const { tokens, destinations } = await genTokensAndDestinations(testAccount.address, toAddress, mintAmount, transferAmount)
285
+
286
+ const randomOrderTokens = tokens.sort((a, b) => b.tokenId.localeCompare(a.tokenId))
287
+ const buildTxResult = await nodeProvider.transactions.postTransactionsBuild({
288
+ fromPublicKey: testAccount.publicKey,
289
+ destinations: destinations
290
+ })
291
+
292
+ approveTx(Array(5).fill(OutputType.BaseAndToken))
293
+ const signature = await app.signUnsignedTx(path, Buffer.from(buildTxResult.unsignedTx, 'hex'), randomOrderTokens)
294
+ expect(transactionVerifySignature(buildTxResult.txId, testAccount.publicKey, signature)).toBe(true)
295
+
296
+ const submitResult = await nodeProvider.transactions.postTransactionsSubmit({
297
+ unsignedTx: buildTxResult.unsignedTx,
298
+ signature: signature
299
+ })
300
+ await waitForTxConfirmation(submitResult.txId, 1, 1000)
301
+ const balances = await nodeProvider.addresses.getAddressesAddressBalance(toAddress)
302
+ tokens.forEach((metadata) => {
303
+ const tokenBalance = balances.tokenBalances!.find((t) => t.id === metadata.tokenId)!
304
+ expect(BigInt(tokenBalance.amount)).toEqual(transferAmount)
305
+ })
306
+
307
+ await app.close()
308
+ }, 120000)
309
+
310
+ it('should reject tx if the metadata version is invalid', async () => {
311
+ const transport = await createTransport()
312
+ const app = new AlephiumApp(transport)
313
+ const [testAccount] = await app.getAccount(path)
314
+ await transferToAddress(testAccount.address)
315
+
316
+ const toAddress = '1BmVCLrjttchZMW7i6df7mTdCKzHpy38bgDbVL1GqV6P7';
317
+ const transferAmount = 1234567890123456789012345n
318
+ const mintAmount = 2222222222222222222222222n
319
+ const { tokens, destinations } = await genTokensAndDestinations(testAccount.address, toAddress, mintAmount, transferAmount)
320
+
321
+ const invalidTokenIndex = randomInt(5)
322
+ tokens[invalidTokenIndex] = { ...tokens[invalidTokenIndex], version: 1 }
323
+ const buildTxResult = await nodeProvider.transactions.postTransactionsBuild({
324
+ fromPublicKey: testAccount.publicKey,
325
+ destinations: destinations
326
+ })
327
+
328
+ await expect(app.signUnsignedTx(path, Buffer.from(buildTxResult.unsignedTx, 'hex'), tokens)).rejects.toThrow()
329
+
330
+ await app.close()
331
+ }, 120000)
332
+
242
333
  it('should transfer from multiple inputs', async () => {
243
334
  const transport = await createTransport()
244
335
  const app = new AlephiumApp(transport)