@alephium/ledger-app 0.5.2 → 0.6.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.
- package/dist/merkle-tree/proofs.json +1 -0
- package/dist/merkle-tree/token.json +1107 -0
- package/dist/src/ledger-app.d.ts +1 -2
- package/dist/src/ledger-app.js +33 -20
- package/dist/src/merkle.d.ts +9 -0
- package/dist/src/merkle.js +59 -0
- package/dist/src/serde.d.ts +3 -1
- package/dist/src/serde.js +19 -17
- package/dist/src/tx-encoder.d.ts +11 -0
- package/dist/src/tx-encoder.js +82 -0
- package/dist/src/types.d.ts +1 -0
- package/dist/src/types.js +3 -1
- package/dist/test/merkle.test.d.ts +1 -0
- package/dist/test/merkle.test.js +18 -0
- package/dist/test/tx-encoder.test.d.ts +1 -0
- package/dist/test/tx-encoder.test.js +73 -0
- package/dist/test/utils.d.ts +6 -3
- package/dist/test/utils.js +13 -4
- package/dist/test/wallet.test.js +96 -32
- package/merkle-tree/proofs.json +1 -0
- package/merkle-tree/token.json +1107 -0
- package/package.json +8 -6
- package/src/index.ts +1 -1
- package/src/ledger-app.ts +38 -30
- package/src/merkle.ts +63 -0
- package/src/serde.ts +19 -18
- package/src/tx-encoder.ts +90 -0
- package/src/types.ts +3 -1
- package/test/merkle.test.ts +20 -0
- package/test/tx-encoder.test.ts +80 -0
- package/test/utils.ts +9 -0
- package/test/wallet.test.ts +101 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alephium/ledger-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"license": "GPL",
|
|
5
5
|
"types": "dist/src/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"clean:windows": "node -e \"if (process.platform === 'win32') process.exit(1)\" || , if exist dist rmdir /Q /S dist",
|
|
13
13
|
"lint": "eslint . --ext ts",
|
|
14
14
|
"lint:fix": "eslint . --fix --ext ts",
|
|
15
|
+
"test": "BACKEND=speculos jest -i --config ./jest-config.json",
|
|
15
16
|
"speculos-test": "BACKEND=speculos jest -i --config ./jest-config.json",
|
|
16
17
|
"device-test": "BACKEND=device jest -i --config ./jest-config.json",
|
|
17
18
|
"pub": "npm run build && npm publish --access public"
|
|
@@ -26,13 +27,14 @@
|
|
|
26
27
|
"trailingComma": "none"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@alephium/web3": "^1.
|
|
30
|
-
"@ledgerhq/hw-transport": "6.31.0"
|
|
30
|
+
"@alephium/web3": "^1.5.0",
|
|
31
|
+
"@ledgerhq/hw-transport": "6.31.0",
|
|
32
|
+
"blakejs": "^1.2.1"
|
|
31
33
|
},
|
|
32
34
|
"devDependencies": {
|
|
33
|
-
"@alephium/
|
|
34
|
-
"@alephium/web3-
|
|
35
|
-
"@alephium/
|
|
35
|
+
"@alephium/cli": "^1.5.0",
|
|
36
|
+
"@alephium/web3-test": "^1.5.0",
|
|
37
|
+
"@alephium/web3-wallet": "^1.5.0",
|
|
36
38
|
"@ledgerhq/hw-transport-node-hid": "6.29.1",
|
|
37
39
|
"@ledgerhq/hw-transport-node-speculos": "6.29.0",
|
|
38
40
|
"@types/elliptic": "^6.4.13",
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export * from './types'
|
|
2
|
-
export * from './ledger-app'
|
|
2
|
+
export * from './ledger-app'
|
package/src/ledger-app.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { Account, KeyType, addressFromPublicKey, encodeHexSignature, groupOfAddress } from '@alephium/web3'
|
|
1
|
+
import { Account, KeyType, addressFromPublicKey, binToHex, codec, encodeHexSignature, groupOfAddress } from '@alephium/web3'
|
|
2
2
|
import Transport, { StatusCodes } from '@ledgerhq/hw-transport'
|
|
3
3
|
import * as serde from './serde'
|
|
4
4
|
import { ec as EC } from 'elliptic'
|
|
5
|
-
import { TokenMetadata } from './types'
|
|
5
|
+
import { MAX_TOKEN_SIZE, MAX_TOKEN_SYMBOL_LENGTH, TokenMetadata } from './types'
|
|
6
|
+
import { encodeTokenMetadata, encodeUnsignedTx } from './tx-encoder'
|
|
7
|
+
import { merkleTokens } from './merkle'
|
|
6
8
|
|
|
7
9
|
const ec = new EC('secp256k1')
|
|
8
10
|
|
|
@@ -17,9 +19,6 @@ export enum INS {
|
|
|
17
19
|
export const GROUP_NUM = 4
|
|
18
20
|
export const HASH_LEN = 32
|
|
19
21
|
|
|
20
|
-
// The maximum payload size is 255: https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/hw-transport/src/Transport.ts#L261
|
|
21
|
-
const MAX_PAYLOAD_SIZE = 255
|
|
22
|
-
|
|
23
22
|
export class AlephiumApp {
|
|
24
23
|
readonly transport: Transport
|
|
25
24
|
|
|
@@ -71,34 +70,43 @@ export class AlephiumApp {
|
|
|
71
70
|
return decodeSignature(response)
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
async signUnsignedTx(
|
|
75
|
-
path: string,
|
|
76
|
-
unsignedTx: Buffer,
|
|
77
|
-
tokenMetadata: TokenMetadata[] = []
|
|
78
|
-
): Promise<string> {
|
|
73
|
+
async signUnsignedTx(path: string, unsignedTx: Buffer): Promise<string> {
|
|
79
74
|
console.log(`unsigned tx size: ${unsignedTx.length}`)
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
75
|
+
const tokenMetadata = getTokenMetadata(unsignedTx)
|
|
76
|
+
serde.checkTokenMetadata(tokenMetadata)
|
|
77
|
+
const tokenMetadataFrames = encodeTokenMetadata(tokenMetadata)
|
|
78
|
+
const txFrames = encodeUnsignedTx(path, unsignedTx)
|
|
79
|
+
const allFrames = [...tokenMetadataFrames, ...txFrames]
|
|
80
|
+
|
|
81
|
+
let response: Buffer | undefined = undefined
|
|
82
|
+
for (const frame of allFrames) {
|
|
83
|
+
response = await this.transport.send(CLA, INS.SIGN_TX, frame.p1, frame.p2, frame.data, [StatusCodes.OK])
|
|
88
84
|
}
|
|
85
|
+
return decodeSignature(response!)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
89
88
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
89
|
+
function getTokenMetadata(unsignedTx: Buffer): TokenMetadata[] {
|
|
90
|
+
const result: TokenMetadata[] = []
|
|
91
|
+
const outputs = codec.unsignedTxCodec.decode(unsignedTx).fixedOutputs
|
|
92
|
+
outputs.forEach((output) => {
|
|
93
|
+
output.tokens.forEach((t) => {
|
|
94
|
+
const tokenIdHex = binToHex(t.tokenId)
|
|
95
|
+
if (result.find((t) => isTokenIdEqual(t.tokenId, tokenIdHex)) !== undefined) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const metadata = merkleTokens.find((t) => isTokenIdEqual(t.tokenId, tokenIdHex))
|
|
99
|
+
if (metadata !== undefined && metadata.symbol.length <= MAX_TOKEN_SYMBOL_LENGTH) {
|
|
100
|
+
result.push(metadata)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
const size = Math.min(result.length, MAX_TOKEN_SIZE)
|
|
105
|
+
return result.slice(0, size)
|
|
106
|
+
}
|
|
99
107
|
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
function isTokenIdEqual(a: string, b: string): boolean {
|
|
109
|
+
return a.toLowerCase() === b.toLowerCase()
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
function decodeSignature(response: Buffer): string {
|
|
@@ -109,4 +117,4 @@ function decodeSignature(response: Buffer): string {
|
|
|
109
117
|
const s = response.slice(6 + rLen, 6 + rLen + sLen)
|
|
110
118
|
console.log(`${rLen} - ${r.toString('hex')}\n${sLen} - ${s.toString('hex')}`)
|
|
111
119
|
return encodeHexSignature(r.toString('hex'), s.toString('hex'))
|
|
112
|
-
}
|
|
120
|
+
}
|
package/src/merkle.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { binToHex, hexToBinUnsafe } from '@alephium/web3'
|
|
2
|
+
import mainnetTokenListJson from '../merkle-tree/token.json'
|
|
3
|
+
import proofsJson from '../merkle-tree/proofs.json'
|
|
4
|
+
import { serializeSingleTokenMetadata } from './serde'
|
|
5
|
+
import { TokenMetadata } from './types'
|
|
6
|
+
import { blake2b } from 'blakejs'
|
|
7
|
+
|
|
8
|
+
export const merkleTokens: TokenMetadata[] = mainnetTokenListJson.tokens.map((token) => {
|
|
9
|
+
return {
|
|
10
|
+
version: 0,
|
|
11
|
+
tokenId: token.id,
|
|
12
|
+
symbol: token.symbol,
|
|
13
|
+
decimals: token.decimals
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export function hashPair(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
18
|
+
return blake2b(Buffer.concat([a, b].sort(Buffer.compare)), undefined, 32)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function generateMerkleTree(tokens: TokenMetadata[]): Uint8Array[][] {
|
|
22
|
+
let level: Uint8Array[] = tokens.map((token) => blake2b(serializeSingleTokenMetadata(token), undefined, 32))
|
|
23
|
+
|
|
24
|
+
const tree: Uint8Array[][] = []
|
|
25
|
+
while (level.length > 1) {
|
|
26
|
+
tree.push(level)
|
|
27
|
+
level = level.reduce<Uint8Array[]>((acc, _, i, arr) => {
|
|
28
|
+
if (i % 2 === 0) {
|
|
29
|
+
acc.push(i + 1 < arr.length ? hashPair(arr[i], arr[i + 1]) : arr[i])
|
|
30
|
+
}
|
|
31
|
+
return acc
|
|
32
|
+
}, [])
|
|
33
|
+
}
|
|
34
|
+
tree.push(level) // Root
|
|
35
|
+
|
|
36
|
+
return tree
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function generateProofs(): { proofs: Record<string, string>; root: string } {
|
|
40
|
+
const tree = generateMerkleTree(merkleTokens)
|
|
41
|
+
const proofs = merkleTokens.reduce<Record<string, string>>((acc, token, tokenIndex) => {
|
|
42
|
+
const proof = tree.slice(0, -1).reduce<Uint8Array[]>((proofAcc, level, levelIndex) => {
|
|
43
|
+
const index = Math.floor(tokenIndex / 2 ** levelIndex)
|
|
44
|
+
const pairIndex = index % 2 === 0 ? index + 1 : index - 1
|
|
45
|
+
const siblingOrUncle = level[pairIndex]
|
|
46
|
+
|
|
47
|
+
if (siblingOrUncle) {
|
|
48
|
+
proofAcc.push(siblingOrUncle)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return proofAcc
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
acc[token.tokenId] = proof.map((hash) => binToHex(hash)).join('')
|
|
55
|
+
return acc
|
|
56
|
+
}, {})
|
|
57
|
+
|
|
58
|
+
console.log('root', tree[tree.length - 1].map((hash) => binToHex(hash)).join(''))
|
|
59
|
+
return { proofs, root: binToHex(tree[tree.length - 1][0]) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const tokenMerkleRoot = hexToBinUnsafe('b3380866c595544781e9da0ccd79399de8878abfb0bf40545b57a287387d419d')
|
|
63
|
+
export const tokenMerkleProofs = proofsJson as Record<string, string>
|
package/src/serde.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isHexString } from
|
|
2
|
-
import { MAX_TOKEN_SIZE, MAX_TOKEN_SYMBOL_LENGTH, TOKEN_METADATA_SIZE, TokenMetadata } from
|
|
1
|
+
import { isHexString } from '@alephium/web3'
|
|
2
|
+
import { MAX_TOKEN_SIZE, MAX_TOKEN_SYMBOL_LENGTH, TOKEN_METADATA_SIZE, TokenMetadata } from './types'
|
|
3
3
|
|
|
4
4
|
export const TRUE = 0x10
|
|
5
5
|
export const FALSE = 0x00
|
|
@@ -40,7 +40,7 @@ function symbolToBytes(symbol: string): Buffer {
|
|
|
40
40
|
return buffer
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function
|
|
43
|
+
export function checkTokenMetadata(tokens: TokenMetadata[]) {
|
|
44
44
|
const hasDuplicate = tokens.some((token, index) => index !== tokens.findIndex((t) => t.tokenId === token.tokenId))
|
|
45
45
|
if (hasDuplicate) {
|
|
46
46
|
throw new Error(`There are duplicate tokens`)
|
|
@@ -60,21 +60,22 @@ function check(tokens: TokenMetadata[]) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export function serializeSingleTokenMetadata(metadata: TokenMetadata): Buffer {
|
|
64
|
+
const symbolBytes = symbolToBytes(metadata.symbol)
|
|
65
|
+
const buffer = Buffer.concat([
|
|
66
|
+
Buffer.from([metadata.version]),
|
|
67
|
+
Buffer.from(metadata.tokenId, 'hex'),
|
|
68
|
+
symbolBytes,
|
|
69
|
+
Buffer.from([metadata.decimals]),
|
|
70
|
+
])
|
|
71
|
+
if (buffer.length !== TOKEN_METADATA_SIZE) {
|
|
72
|
+
throw new Error(`Invalid token metadata: ${metadata}`)
|
|
73
|
+
}
|
|
74
|
+
return buffer
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
export function serializeTokenMetadata(tokens: TokenMetadata[]): Buffer {
|
|
64
|
-
|
|
65
|
-
const array = tokens
|
|
66
|
-
.map((metadata) => {
|
|
67
|
-
const symbolBytes = symbolToBytes(metadata.symbol)
|
|
68
|
-
const buffer = Buffer.concat([
|
|
69
|
-
Buffer.from([metadata.version]),
|
|
70
|
-
Buffer.from(metadata.tokenId, 'hex'),
|
|
71
|
-
symbolBytes,
|
|
72
|
-
Buffer.from([metadata.decimals])
|
|
73
|
-
])
|
|
74
|
-
if (buffer.length !== TOKEN_METADATA_SIZE) {
|
|
75
|
-
throw new Error(`Invalid token metadata: ${metadata}`)
|
|
76
|
-
}
|
|
77
|
-
return buffer
|
|
78
|
-
})
|
|
78
|
+
checkTokenMetadata(tokens)
|
|
79
|
+
const array = tokens.map((metadata) => serializeSingleTokenMetadata(metadata))
|
|
79
80
|
return Buffer.concat([Buffer.from([array.length]), ...array])
|
|
80
81
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { tokenMerkleProofs } from "./merkle"
|
|
2
|
+
import { checkTokenMetadata, serializePath, serializeSingleTokenMetadata } from "./serde"
|
|
3
|
+
import { MAX_PAYLOAD_SIZE, TokenMetadata } from "./types"
|
|
4
|
+
|
|
5
|
+
export interface Frame {
|
|
6
|
+
p1: number
|
|
7
|
+
p2: number
|
|
8
|
+
data: Buffer
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function encodeTokenMetadata(tokenMetadata: TokenMetadata[]): Frame[] {
|
|
12
|
+
const frames = tokenMetadata.flatMap((metadata, index) => {
|
|
13
|
+
const isFirstToken = index === 0
|
|
14
|
+
const firstFramePrefix = isFirstToken ? Buffer.from([tokenMetadata.length]) : Buffer.alloc(0)
|
|
15
|
+
const buffers = encodeTokenAndProof(metadata, firstFramePrefix)
|
|
16
|
+
if (buffers.length === 0) return []
|
|
17
|
+
|
|
18
|
+
assert(buffers.every((buffer) => buffer.length <= MAX_PAYLOAD_SIZE), 'Invalid token frame size')
|
|
19
|
+
const frames: Frame[] = []
|
|
20
|
+
const firstFrameP2 = isFirstToken ? 0 : 1
|
|
21
|
+
frames.push({ p1: 0, p2: firstFrameP2, data: buffers[0] })
|
|
22
|
+
buffers.slice(1).forEach((data) => frames.push({ p1: 0, p2: 2, data }))
|
|
23
|
+
return frames
|
|
24
|
+
})
|
|
25
|
+
if (frames.length === 0) {
|
|
26
|
+
return [{ p1: 0, p2: 0, data: Buffer.from([0]) }]
|
|
27
|
+
} else {
|
|
28
|
+
return frames
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function encodeTokenAndProof(
|
|
33
|
+
tokenMetadata: TokenMetadata,
|
|
34
|
+
firstFramePrefix: Buffer
|
|
35
|
+
): Buffer[] {
|
|
36
|
+
const proof = tokenMerkleProofs[tokenMetadata.tokenId]
|
|
37
|
+
if (proof === undefined) return []
|
|
38
|
+
const proofBytes = Buffer.from(proof, 'hex')
|
|
39
|
+
const encodedProofLength = encodeProofLength(proofBytes.length)
|
|
40
|
+
const encodedTokenMetadata = serializeSingleTokenMetadata(tokenMetadata)
|
|
41
|
+
|
|
42
|
+
const firstFrameRemainSize =
|
|
43
|
+
MAX_PAYLOAD_SIZE - encodedTokenMetadata.length - encodedProofLength.length - firstFramePrefix.length
|
|
44
|
+
const firstFrameProofSize = Math.floor(firstFrameRemainSize / 32) * 32
|
|
45
|
+
if (firstFrameProofSize >= proofBytes.length) {
|
|
46
|
+
return [Buffer.concat([firstFramePrefix, encodedTokenMetadata, encodedProofLength, proofBytes])]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const firstFrameProof = proofBytes.slice(0, firstFrameProofSize)
|
|
50
|
+
const result: Buffer[] = [Buffer.concat([firstFramePrefix, encodedTokenMetadata, encodedProofLength, firstFrameProof])]
|
|
51
|
+
let from_index = firstFrameProofSize
|
|
52
|
+
while (from_index < proofBytes.length) {
|
|
53
|
+
const remainProofLength = proofBytes.length - from_index
|
|
54
|
+
const frameProofSize = Math.min(Math.floor(MAX_PAYLOAD_SIZE / 32) * 32, remainProofLength)
|
|
55
|
+
const frameProof = proofBytes.slice(from_index, from_index + frameProofSize)
|
|
56
|
+
from_index += frameProofSize
|
|
57
|
+
result.push(frameProof)
|
|
58
|
+
}
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function encodeProofLength(length: number): Uint8Array {
|
|
63
|
+
assert((length % 32 === 0) && (length > 0 && length < 0xffff), 'Invalid token proof size')
|
|
64
|
+
const buffer = Buffer.alloc(2);
|
|
65
|
+
buffer.writeUint16BE(length);
|
|
66
|
+
return buffer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function encodeUnsignedTx(path: string, unsignedTx: Buffer): Frame[] {
|
|
70
|
+
const encodedPath = serializePath(path)
|
|
71
|
+
const firstFrameTxLength = MAX_PAYLOAD_SIZE - 20;
|
|
72
|
+
if (firstFrameTxLength >= unsignedTx.length) {
|
|
73
|
+
return [{ p1: 1, p2: 0, data: Buffer.concat([encodedPath, unsignedTx]) }]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const firstFrameTxData = unsignedTx.slice(0, firstFrameTxLength)
|
|
77
|
+
const frames: Frame[] = [{ p1: 1, p2: 0, data: Buffer.concat([encodedPath, firstFrameTxData]) }]
|
|
78
|
+
let fromIndex = firstFrameTxLength
|
|
79
|
+
while (fromIndex < unsignedTx.length) {
|
|
80
|
+
const remain = unsignedTx.length - fromIndex
|
|
81
|
+
const frameTxLength = Math.min(MAX_PAYLOAD_SIZE, remain)
|
|
82
|
+
frames.push({ p1: 1, p2: 1, data: unsignedTx.slice(fromIndex, fromIndex + frameTxLength) })
|
|
83
|
+
fromIndex += frameTxLength
|
|
84
|
+
}
|
|
85
|
+
return frames
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function assert(condition: boolean, msg: string) {
|
|
89
|
+
if (!condition) throw Error(msg)
|
|
90
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export const MAX_TOKEN_SIZE = 5
|
|
2
2
|
export const MAX_TOKEN_SYMBOL_LENGTH = 12
|
|
3
3
|
export const TOKEN_METADATA_SIZE = 46
|
|
4
|
+
// The maximum payload size is 255: https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/hw-transport/src/Transport.ts#L261
|
|
5
|
+
export const MAX_PAYLOAD_SIZE = 255
|
|
4
6
|
|
|
5
7
|
export interface TokenMetadata {
|
|
6
8
|
version: number,
|
|
7
9
|
tokenId: string,
|
|
8
10
|
symbol: string,
|
|
9
11
|
decimals: number
|
|
10
|
-
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { hashPair, merkleTokens, tokenMerkleProofs, tokenMerkleRoot } from '../src/merkle'
|
|
2
|
+
import { serializeSingleTokenMetadata } from '../src/serde'
|
|
3
|
+
import { blake2b } from 'blakejs'
|
|
4
|
+
import { binToHex } from '@alephium/web3'
|
|
5
|
+
|
|
6
|
+
describe('Merkle', () => {
|
|
7
|
+
it('should verify proofs', () => {
|
|
8
|
+
for (const token of merkleTokens) {
|
|
9
|
+
const proof = tokenMerkleProofs[token.tokenId]
|
|
10
|
+
|
|
11
|
+
let currentHash = blake2b(serializeSingleTokenMetadata(token), undefined, 32)
|
|
12
|
+
for (let i = 0; i < proof.length; i += 64) {
|
|
13
|
+
const sibling = proof.slice(i, i + 64)
|
|
14
|
+
currentHash = hashPair(currentHash, Buffer.from(sibling, 'hex'))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
expect(JSON.stringify(currentHash)).toBe(JSON.stringify(tokenMerkleRoot))
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { merkleTokens, tokenMerkleProofs } from '../src/merkle'
|
|
2
|
+
import { assert, encodeProofLength, encodeTokenMetadata, encodeUnsignedTx } from '../src/tx-encoder'
|
|
3
|
+
import { MAX_PAYLOAD_SIZE, MAX_TOKEN_SIZE, TOKEN_METADATA_SIZE } from '../src'
|
|
4
|
+
import { serializePath, serializeSingleTokenMetadata } from '../src/serde';
|
|
5
|
+
import { randomBytes } from 'crypto';
|
|
6
|
+
|
|
7
|
+
describe('TxEncoder', () => {
|
|
8
|
+
|
|
9
|
+
function shuffle<T>(array: T[]): T[] {
|
|
10
|
+
for (let i = array.length - 1; i > 0; i--) {
|
|
11
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
12
|
+
[array[i], array[j]] = [array[j], array[i]]
|
|
13
|
+
}
|
|
14
|
+
return array
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getFrameSize(proofLength: number): number {
|
|
18
|
+
if (proofLength <= 192) return 1
|
|
19
|
+
if (proofLength <= 416) return 2
|
|
20
|
+
if (proofLength <= 640) return 3
|
|
21
|
+
throw Error(`Invalid proof length: ${proofLength}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it('should encode token metadata and proof', () => {
|
|
25
|
+
const frames0 = encodeTokenMetadata([])
|
|
26
|
+
expect(frames0).toEqual([{ p1: 0, p2: 0, data: Buffer.from([0]) }])
|
|
27
|
+
|
|
28
|
+
const tokenSize = Math.floor(Math.random() * MAX_TOKEN_SIZE) + 1
|
|
29
|
+
assert(tokenSize >= 1 && tokenSize <= 5, 'Invalid token size')
|
|
30
|
+
const tokens = shuffle(Object.entries(tokenMerkleProofs))
|
|
31
|
+
const selectedTokens = tokens.slice(0, tokenSize)
|
|
32
|
+
const tokenMetadatas = selectedTokens.map(([tokenId]) => merkleTokens.find((t) => t.tokenId === tokenId)!)
|
|
33
|
+
const frames = encodeTokenMetadata(tokenMetadatas)
|
|
34
|
+
const tokenAndProofs = Buffer.concat(frames.map((frame, index) => index === 0 ? frame.data.slice(1) : frame.data))
|
|
35
|
+
|
|
36
|
+
const expected = Buffer.concat(tokenMetadatas.map((metadata, index) => {
|
|
37
|
+
const proof = Buffer.from(selectedTokens[index][1], 'hex')
|
|
38
|
+
const encodedProofLength = encodeProofLength(proof.length)
|
|
39
|
+
const encodedTokenMetadata = serializeSingleTokenMetadata(metadata)
|
|
40
|
+
return Buffer.concat([encodedTokenMetadata, encodedProofLength, proof])
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
expect(tokenAndProofs).toEqual(expected)
|
|
44
|
+
|
|
45
|
+
let frameIndex = 0
|
|
46
|
+
tokenMetadatas.forEach((_, index) => {
|
|
47
|
+
const proof = Buffer.from(selectedTokens[index][1], 'hex')
|
|
48
|
+
const isFirstToken = index === 0
|
|
49
|
+
const prefixLength = isFirstToken ? 1 + TOKEN_METADATA_SIZE + 2 : TOKEN_METADATA_SIZE + 2
|
|
50
|
+
const tokenFrames = frames.slice(frameIndex, frameIndex + getFrameSize(proof.length))
|
|
51
|
+
const firstFrameP2 = isFirstToken ? 0 : 1
|
|
52
|
+
expect(tokenFrames[0].p1).toEqual(0)
|
|
53
|
+
expect(tokenFrames[0].p2).toEqual(firstFrameP2)
|
|
54
|
+
tokenFrames.slice(1).forEach((frame) => {
|
|
55
|
+
expect(frame.p1).toEqual(0)
|
|
56
|
+
expect(frame.p2).toEqual(2)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const expectedProof = Buffer.concat([tokenFrames[0].data.slice(prefixLength), ...tokenFrames.slice(1).map((f) => f.data)])
|
|
60
|
+
expect(proof).toEqual(expectedProof)
|
|
61
|
+
|
|
62
|
+
frameIndex += tokenFrames.length
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should encode tx', () => {
|
|
67
|
+
const path = `m/44'/1234'/0'/0/0`
|
|
68
|
+
const encodedPath = serializePath(path)
|
|
69
|
+
const unsignedTx0 = randomBytes(200)
|
|
70
|
+
const frames0 = encodeUnsignedTx(path, unsignedTx0)
|
|
71
|
+
expect(frames0).toEqual([{ p1: 1, p2: 0, data: Buffer.concat([encodedPath, unsignedTx0]) }])
|
|
72
|
+
|
|
73
|
+
const unsignedTx1 = randomBytes(250)
|
|
74
|
+
const frames1 = encodeUnsignedTx(path, unsignedTx1)
|
|
75
|
+
expect(frames1).toEqual([
|
|
76
|
+
{ p1: 1, p2: 0, data: Buffer.concat([encodedPath, unsignedTx1.slice(0, MAX_PAYLOAD_SIZE - 20)]) },
|
|
77
|
+
{ p1: 1, p2: 1, data: unsignedTx1.slice( MAX_PAYLOAD_SIZE - 20) },
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
})
|
package/test/utils.ts
CHANGED
|
@@ -27,6 +27,8 @@ function getModel(): string {
|
|
|
27
27
|
export enum OutputType {
|
|
28
28
|
Base,
|
|
29
29
|
Multisig,
|
|
30
|
+
Nanos10,
|
|
31
|
+
Nanos11,
|
|
30
32
|
Token,
|
|
31
33
|
BaseAndToken,
|
|
32
34
|
MultisigAndToken
|
|
@@ -35,6 +37,8 @@ export enum OutputType {
|
|
|
35
37
|
const NanosClickTable = new Map([
|
|
36
38
|
[OutputType.Base, 5],
|
|
37
39
|
[OutputType.Multisig, 10],
|
|
40
|
+
[OutputType.Nanos10, 10],
|
|
41
|
+
[OutputType.Nanos11, 11],
|
|
38
42
|
[OutputType.Token, 11],
|
|
39
43
|
[OutputType.BaseAndToken, 12],
|
|
40
44
|
[OutputType.MultisigAndToken, 16],
|
|
@@ -139,6 +143,7 @@ async function touch(outputs: OutputType[], hasExternalInputs: boolean) {
|
|
|
139
143
|
|
|
140
144
|
export async function approveTx(outputs: OutputType[], hasExternalInputs: boolean = false) {
|
|
141
145
|
if (!needToAutoApprove()) return
|
|
146
|
+
await sleep(2000)
|
|
142
147
|
const isSelfTransfer = outputs.length === 0 && !hasExternalInputs
|
|
143
148
|
if (isSelfTransfer) {
|
|
144
149
|
if (isStaxOrFlex()) {
|
|
@@ -184,6 +189,10 @@ function isStaxOrFlex(): boolean {
|
|
|
184
189
|
return !getModel().startsWith('nano')
|
|
185
190
|
}
|
|
186
191
|
|
|
192
|
+
export function isNanos(): boolean {
|
|
193
|
+
return getModel() === 'nanos'
|
|
194
|
+
}
|
|
195
|
+
|
|
187
196
|
export function skipBlindSigningWarning() {
|
|
188
197
|
if (!needToAutoApprove()) return
|
|
189
198
|
if (isStaxOrFlex()) {
|
package/test/wallet.test.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { ALPH_TOKEN_ID, Address, DUST_AMOUNT, NodeProvider, ONE_ALPH, binToHex,
|
|
|
3
3
|
import { getSigner, mintToken, transfer } from '@alephium/web3-test'
|
|
4
4
|
import { PrivateKeyWallet } from '@alephium/web3-wallet'
|
|
5
5
|
import blake from 'blakejs'
|
|
6
|
-
import { approveAddress, approveHash, approveTx, createTransport, enableBlindSigning, getRandomInt, needToAutoApprove, OutputType, skipBlindSigningWarning, staxFlexApproveOnce } from './utils'
|
|
6
|
+
import { approveAddress, approveHash, approveTx, createTransport, enableBlindSigning, getRandomInt, isNanos, needToAutoApprove, OutputType, skipBlindSigningWarning, staxFlexApproveOnce } from './utils'
|
|
7
7
|
import { TokenMetadata } from '../src/types'
|
|
8
|
-
import {
|
|
8
|
+
import { randomBytes } from 'crypto'
|
|
9
|
+
import { merkleTokens, tokenMerkleProofs } from '../src/merkle'
|
|
9
10
|
|
|
10
11
|
describe('ledger wallet', () => {
|
|
11
12
|
const nodeProvider = new NodeProvider("http://127.0.0.1:22973")
|
|
@@ -272,37 +273,87 @@ describe('ledger wallet', () => {
|
|
|
272
273
|
return { tokens, destinations }
|
|
273
274
|
}
|
|
274
275
|
|
|
275
|
-
it('should transfer
|
|
276
|
+
it('should transfer tokens with proof', async () => {
|
|
276
277
|
const transport = await createTransport()
|
|
277
278
|
const app = new AlephiumApp(transport)
|
|
278
279
|
const [testAccount] = await app.getAccount(path)
|
|
279
280
|
await transferToAddress(testAccount.address)
|
|
281
|
+
const newAccount = await getSigner()
|
|
282
|
+
|
|
283
|
+
const selectedTokens = [
|
|
284
|
+
merkleTokens[5], // decimals is 0
|
|
285
|
+
merkleTokens[6], // decimals is 18
|
|
286
|
+
merkleTokens[8], // decimals is 9
|
|
287
|
+
merkleTokens[11], // decimals is 8
|
|
288
|
+
merkleTokens[13], // decimals is 6
|
|
289
|
+
]
|
|
290
|
+
const outputs: node.FixedAssetOutput[] = selectedTokens.map((token, index) => {
|
|
291
|
+
return {
|
|
292
|
+
hint:0,
|
|
293
|
+
key: '',
|
|
294
|
+
attoAlphAmount: DUST_AMOUNT.toString(),
|
|
295
|
+
address: newAccount.address,
|
|
296
|
+
tokens: [{ id: token.tokenId, amount: (BigInt(index + 1) * ONE_ALPH).toString() }],
|
|
297
|
+
lockTime: 0,
|
|
298
|
+
message: ''
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
const unsignedTx: node.UnsignedTx = {
|
|
302
|
+
txId: '',
|
|
303
|
+
version: 0,
|
|
304
|
+
networkId: 4,
|
|
305
|
+
gasAmount: 100000,
|
|
306
|
+
gasPrice: (ONE_ALPH / 10000000n).toString(),
|
|
307
|
+
inputs: [{ outputRef: { hint: 0, key: binToHex(randomBytes(32)) }, unlockScript: '00' + testAccount.publicKey }],
|
|
308
|
+
fixedOutputs: outputs
|
|
309
|
+
}
|
|
310
|
+
const encodedUnsignedTx = codec.unsignedTxCodec.encodeApiUnsignedTx(unsignedTx)
|
|
280
311
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
312
|
+
if (isNanos()) {
|
|
313
|
+
approveTx([OutputType.Nanos11, OutputType.Nanos10, OutputType.Nanos10, OutputType.Nanos10, OutputType.Nanos11])
|
|
314
|
+
} else {
|
|
315
|
+
approveTx(Array(5).fill(OutputType.BaseAndToken))
|
|
316
|
+
}
|
|
317
|
+
const signature = await app.signUnsignedTx(path, Buffer.from(encodedUnsignedTx))
|
|
318
|
+
const txId = blake.blake2b(encodedUnsignedTx, undefined, 32)
|
|
319
|
+
expect(transactionVerifySignature(binToHex(txId), testAccount.publicKey, signature)).toBe(true)
|
|
285
320
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
fromPublicKey: testAccount.publicKey,
|
|
289
|
-
destinations: destinations
|
|
290
|
-
})
|
|
321
|
+
await app.close()
|
|
322
|
+
}, 120000)
|
|
291
323
|
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
324
|
+
it('should reject tx if the token proof is invalid', async () => {
|
|
325
|
+
const transport = await createTransport()
|
|
326
|
+
const app = new AlephiumApp(transport)
|
|
327
|
+
const [testAccount] = await app.getAccount(path)
|
|
328
|
+
await transferToAddress(testAccount.address)
|
|
295
329
|
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
330
|
+
const toAddress = '1BmVCLrjttchZMW7i6df7mTdCKzHpy38bgDbVL1GqV6P7'
|
|
331
|
+
const selectedToken = merkleTokens[6] // the decimals is 18
|
|
332
|
+
const output: node.FixedAssetOutput = {
|
|
333
|
+
hint: 0,
|
|
334
|
+
key: '',
|
|
335
|
+
attoAlphAmount: DUST_AMOUNT.toString(),
|
|
336
|
+
address: toAddress,
|
|
337
|
+
tokens: [{ id: selectedToken.tokenId, amount: ONE_ALPH.toString() }],
|
|
338
|
+
lockTime: 0,
|
|
339
|
+
message: ''
|
|
340
|
+
}
|
|
341
|
+
const unsignedTx: node.UnsignedTx = {
|
|
342
|
+
txId: '',
|
|
343
|
+
version: 0,
|
|
344
|
+
networkId: 4,
|
|
345
|
+
gasAmount: 100000,
|
|
346
|
+
gasPrice: (ONE_ALPH / 10000000n).toString(),
|
|
347
|
+
inputs: [{ outputRef: { hint: 0, key: binToHex(randomBytes(32)) }, unlockScript: '00' + testAccount.publicKey }],
|
|
348
|
+
fixedOutputs: [output]
|
|
349
|
+
}
|
|
350
|
+
const encodedUnsignedTx = codec.unsignedTxCodec.encodeApiUnsignedTx(unsignedTx)
|
|
351
|
+
|
|
352
|
+
const originalProof = tokenMerkleProofs[selectedToken.tokenId]
|
|
353
|
+
const invalidProof = originalProof.slice(0, originalProof.length - 64)
|
|
354
|
+
tokenMerkleProofs[selectedToken.tokenId] = invalidProof
|
|
355
|
+
await expect(app.signUnsignedTx(path, Buffer.from(encodedUnsignedTx))).rejects.toThrow()
|
|
356
|
+
tokenMerkleProofs[selectedToken.tokenId] = originalProof
|
|
306
357
|
|
|
307
358
|
await app.close()
|
|
308
359
|
}, 120000)
|
|
@@ -313,19 +364,31 @@ describe('ledger wallet', () => {
|
|
|
313
364
|
const [testAccount] = await app.getAccount(path)
|
|
314
365
|
await transferToAddress(testAccount.address)
|
|
315
366
|
|
|
316
|
-
const toAddress = '1BmVCLrjttchZMW7i6df7mTdCKzHpy38bgDbVL1GqV6P7'
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
367
|
+
const toAddress = '1BmVCLrjttchZMW7i6df7mTdCKzHpy38bgDbVL1GqV6P7'
|
|
368
|
+
const tokenIndex = 6
|
|
369
|
+
const selectedToken = merkleTokens[tokenIndex]
|
|
370
|
+
const output: node.FixedAssetOutput = {
|
|
371
|
+
hint: 0,
|
|
372
|
+
key: '',
|
|
373
|
+
attoAlphAmount: DUST_AMOUNT.toString(),
|
|
374
|
+
address: toAddress,
|
|
375
|
+
tokens: [{ id: selectedToken.tokenId, amount: ONE_ALPH.toString() }],
|
|
376
|
+
lockTime: 0,
|
|
377
|
+
message: ''
|
|
378
|
+
}
|
|
379
|
+
const unsignedTx: node.UnsignedTx = {
|
|
380
|
+
txId: '',
|
|
381
|
+
version: 0,
|
|
382
|
+
networkId: 4,
|
|
383
|
+
gasAmount: 100000,
|
|
384
|
+
gasPrice: (ONE_ALPH / 10000000n).toString(),
|
|
385
|
+
inputs: [{ outputRef: { hint: 0, key: binToHex(randomBytes(32)) }, unlockScript: '00' + testAccount.publicKey }],
|
|
386
|
+
fixedOutputs: [output]
|
|
387
|
+
}
|
|
388
|
+
const encodedUnsignedTx = codec.unsignedTxCodec.encodeApiUnsignedTx(unsignedTx)
|
|
389
|
+
merkleTokens[tokenIndex] = { ...selectedToken, version: 1 }
|
|
390
|
+
await expect(app.signUnsignedTx(path, Buffer.from(encodedUnsignedTx))).rejects.toThrow()
|
|
391
|
+
merkleTokens[tokenIndex] = selectedToken
|
|
329
392
|
|
|
330
393
|
await app.close()
|
|
331
394
|
}, 120000)
|