From 7ee5d42094412123b993f5c2aef4a9ccbd8f13fb Mon Sep 17 00:00:00 2001 From: Cedric Fung Date: Sat, 5 Sep 2026 13:21:03 +0000 Subject: [PATCH 1/6] improve some utils function and a bunch of tests --- .gitignore | 1 + package-lock.json | 215 +++++++++++++++++++++++++++++++- package.json | 3 + src/client/circle.ts | 9 +- src/client/http.ts | 6 +- src/client/utils/amount.ts | 4 +- src/client/utils/computer.ts | 14 ++- src/client/utils/decoder.ts | 31 +++-- src/client/utils/encoder.ts | 22 ++-- src/client/utils/invoice.ts | 3 +- src/client/utils/multisigs.ts | 2 +- src/client/utils/nfo.ts | 5 +- src/client/utils/safe.ts | 18 ++- src/client/utils/uniq.ts | 2 +- src/client/utxo.ts | 2 +- test/address.test.ts | 3 +- test/blaze.test.ts | 53 ++++++++ test/integration.ts | 3 + test/mixin/auth.test.ts | 90 +++++++++++++ test/mixin/client-utils.test.ts | 45 +++++++ test/mixin/clients.test.ts | 19 +++ test/mixin/code.test.ts | 3 +- test/mixin/codec.test.ts | 135 ++++++++++++++++++++ test/mixin/computer.test.ts | 49 ++++++++ test/mixin/http.test.ts | 83 ++++++++++++ test/mixin/invoice.test.ts | 65 ++++++++++ test/mixin/message.test.ts | 71 +++++++++++ test/mixin/network.test.ts | 3 +- test/mixin/nfo.test.ts | 9 ++ test/mixin/safe-utils.test.ts | 116 +++++++++++++++++ test/mixin/user.test.ts | 3 +- test/mixin/utils.test.ts | 2 + test/mixin/utxo.test.ts | 66 ++++++++++ test/nft.test.ts | 3 +- test/user.test.ts | 3 +- test/webview.test.ts | 146 ++++++++++++++++++++++ vitest.config.js | 12 ++ 37 files changed, 1268 insertions(+), 51 deletions(-) create mode 100644 test/blaze.test.ts create mode 100644 test/integration.ts create mode 100644 test/mixin/auth.test.ts create mode 100644 test/mixin/client-utils.test.ts create mode 100644 test/mixin/clients.test.ts create mode 100644 test/mixin/codec.test.ts create mode 100644 test/mixin/computer.test.ts create mode 100644 test/mixin/http.test.ts create mode 100644 test/mixin/invoice.test.ts create mode 100644 test/mixin/message.test.ts create mode 100644 test/mixin/safe-utils.test.ts create mode 100644 test/mixin/utxo.test.ts diff --git a/.gitignore b/.gitignore index d34b0fbd..11a68173 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store node_modules dist +coverage .idea yarn.lock diff --git a/package-lock.json b/package-lock.json index ada5ec84..92f13092 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@types/ws": "^8.5.3", "@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/parser": "^8.57.0", + "@vitest/coverage-v8": "^4.1.9", "buffer": "^6.0.3", "bunchee": "^6.9.4", "eslint": "^10.0.3", @@ -113,17 +114,66 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3139,6 +3189,37 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -3350,6 +3431,35 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4550,6 +4660,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4602,6 +4722,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4827,6 +4954,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -5253,6 +5419,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5994,6 +6188,19 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", diff --git a/package.json b/package.json index 215098f1..1f1d67f9 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "build": "rm -rf dist && bunchee", "build:umd": "bunchee --runtime browser --format umd --output dist/mixin-node-sdk.umd.js", "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:integration": "MIXIN_INTEGRATION_TESTS=1 vitest run", "test:watch": "vitest", "lint": "eslint 'src/**' && prettier --check .", "format": "prettier --write .", @@ -82,6 +84,7 @@ "@types/ws": "^8.5.3", "@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/parser": "^8.57.0", + "@vitest/coverage-v8": "^4.1.9", "buffer": "^6.0.3", "bunchee": "^6.9.4", "eslint": "^10.0.3", diff --git a/src/client/circle.ts b/src/client/circle.ts index 7203b505..5092cf65 100644 --- a/src/client/circle.ts +++ b/src/client/circle.ts @@ -28,19 +28,20 @@ export const CircleKeystoreClient = (axiosInstance: AxiosInstance) => ({ delete: (circleID: string): Promise => axiosInstance.post(`/circles/${circleID}/delete`), /** Add the user to a circle */ - addUser: (userID: string, circleID: string): Promise => axiosInstance.post(`/users/${userID}/circles`, { circleID, action: 'ADD' }), + addUser: (userID: string, circleID: string): Promise => + axiosInstance.post(`/users/${userID}/circles`, { circle_id: circleID, action: 'ADD' }), /** Remove the user from a circle */ removeUser: (userID: string, circleID: string): Promise => - axiosInstance.post(`/users/${userID}/circles`, { circleID, action: 'REMOVE' }), + axiosInstance.post(`/users/${userID}/circles`, { circle_id: circleID, action: 'REMOVE' }), /** Add the group from a certain circle */ addConversation: (conversationID: string, circleID: string): Promise => - axiosInstance.post(`/conversations/${conversationID}/circles`, { circleID, action: 'ADD' }), + axiosInstance.post(`/conversations/${conversationID}/circles`, { circle_id: circleID, action: 'ADD' }), /** Remove the group from a certain circle */ removeConversation: (conversation_id: string, circleID: string): Promise => - axiosInstance.post(`/conversations/${conversation_id}/circles`, { circleID, action: 'REMOVE' }), + axiosInstance.post(`/conversations/${conversation_id}/circles`, { circle_id: circleID, action: 'REMOVE' }), }); export const CircleClient = buildClient(CircleKeystoreClient); diff --git a/src/client/http.ts b/src/client/http.ts index 78429237..ef967266 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -9,8 +9,8 @@ import { ResponseError } from './error'; import { signAccessToken } from './utils/auth'; export function http(keystore?: Keystore, config?: RequestConfig): AxiosInstance { - const timeout = config?.timeout || 3000; - const retries = config?.retry || 5; + const timeout = config?.timeout ?? 3000; + const retries = config?.retry ?? 5; const ins = axios.create({ baseURL: 'https://api.mixin.one', @@ -32,7 +32,7 @@ export function http(keystore?: Keystore, config?: RequestConfig): AxiosInstance const requestID = uuid(); config.headers['X-Request-Id'] = requestID; const jwtToken = signAccessToken(method, url, data, requestID, keystore); - config.headers.Authorization = `Bearer ${jwtToken}`; + if (jwtToken) config.headers.Authorization = `Bearer ${jwtToken}`; } return config; diff --git a/src/client/utils/amount.ts b/src/client/utils/amount.ts index aaa44ca7..fce58372 100644 --- a/src/client/utils/amount.ts +++ b/src/client/utils/amount.ts @@ -7,11 +7,11 @@ while (zeros.length < 256) { const getMultiplier = (n: number) => BigNumber(`1${zeros.substring(0, n)}`); -export const formatUnits = (amount: string | number, unit: number) => { +export const formatUnits = (amount: BigNumber.Value, unit: number) => { const m = getMultiplier(unit); return BigNumber(amount).dividedBy(m); }; -export const parseUnits = (amount: string | number, unit: number) => { +export const parseUnits = (amount: BigNumber.Value, unit: number) => { const m = getMultiplier(unit); return BigNumber(amount).times(m).integerValue(BigNumber.ROUND_FLOOR); }; diff --git a/src/client/utils/computer.ts b/src/client/utils/computer.ts index 8798c81b..de9e8fe6 100644 --- a/src/client/utils/computer.ts +++ b/src/client/utils/computer.ts @@ -9,16 +9,24 @@ export const OperationTypeSystemCall = 2; export const OperationTypeUserDeposit = 3; export const userIdToBytes = (uid: string) => { - const x = BigNumber(uid); + let x: BigNumber; + try { + x = BigNumber(uid); + } catch { + throw new Error(`invalid user id: ${uid}`); + } + if (!x.isInteger() || x.isNegative() || x.isGreaterThan(BigNumber(2).pow(64).minus(1))) { + throw new Error(`invalid user id: ${uid}`); + } const bytes = []; let i = x; do { bytes.unshift(i.mod(256).toNumber()); i = i.dividedToIntegerBy(256); } while (!i.isZero()); - do { + while (bytes.length < 8) { bytes.unshift(0); - } while (bytes.length < 8); + } return Buffer.from(bytes); }; diff --git a/src/client/utils/decoder.ts b/src/client/utils/decoder.ts index ce637d44..23275cb5 100644 --- a/src/client/utils/decoder.ts +++ b/src/client/utils/decoder.ts @@ -1,4 +1,5 @@ import { stringify } from 'uuid'; +import BigNumber from 'bignumber.js'; import type { Input, Output } from '../types'; import { magic } from './encoder'; import { formatUnits } from './amount'; @@ -13,6 +14,8 @@ export const bytesToInterger = (b: Buffer) => { return x; }; +const bytesToBigNumber = (b: Buffer) => BigNumber(b.length ? b.toString('hex') : '0', 16); + export class Decoder { buf: Buffer; @@ -85,6 +88,13 @@ export class Decoder { return bytesToInterger(value); } + readBigInteger() { + const len = this.readInt(); + const value = this.buf.subarray(0, len); + this.read(len); + return bytesToBigNumber(value); + } + decodeInput() { const hash = this.subarray(0, 32).toString('hex'); this.read(32); @@ -105,10 +115,12 @@ export class Decoder { if (depositPrefix.equals(magic)) { const chain = this.subarray(0, 32).toString('hex'); this.read(32); - const asset = this.readBytes(); - const transaction = this.readBytes(); + const assetLength = this.readInt(); + const asset = this.readSubarray(assetLength).toString(); + const transactionLength = this.readInt(); + const transaction = this.readSubarray(transactionLength).toString(); const index = this.readUInt64(); - const amount = this.readInteger(); + const amount = formatUnits(this.readBigInteger(), 8).toNumber(); input.deposit = { chain, @@ -122,9 +134,10 @@ export class Decoder { const mintPrefix = this.subarray(0, 2); this.read(2); if (mintPrefix.equals(magic)) { - const group = this.readBytes(); + const groupLength = this.readInt(); + const group = this.readSubarray(groupLength).toString(); const batch = this.readUInt64(); - const amount = this.readInteger(); + const amount = formatUnits(this.readBigInteger(), 8).toNumber(); input.mint = { group, @@ -141,7 +154,7 @@ export class Decoder { this.read(2); if (t.at(0) !== 0) throw new Error(`invalid output type ${t.at(0)}`); const type = t.at(1); - const amount = this.readInteger(); + const amount = this.readBigInteger(); const lenKey = this.readInt(); const keys = []; @@ -167,8 +180,10 @@ export class Decoder { const prefix = this.subarray(0, 2); this.read(2); if (prefix.equals(magic)) { - const address = this.readBytes(); - const tag = this.readBytes(); + const addressLength = this.readInt(); + const address = this.readSubarray(addressLength).toString(); + const tagLength = this.readInt(); + const tag = this.readSubarray(tagLength).toString(); output.withdrawal = { address, tag, diff --git a/src/client/utils/encoder.ts b/src/client/utils/encoder.ts index 7e089d38..d3362316 100644 --- a/src/client/utils/encoder.ts +++ b/src/client/utils/encoder.ts @@ -11,12 +11,13 @@ export const magic = Buffer.from([0x77, 0x77]); const empty = Buffer.from([0x00, 0x00]); export const integerToBytes = (x: number) => { + if (!Number.isSafeInteger(x) || x < 0) throw new Error(`invalid integer ${x}`); const bytes: number[] = []; if (x === 0) return bytes; let i = x; do { - bytes.unshift(i & 255); - i = (i / 2 ** 8) | 0; + bytes.unshift(i % 256); + i = Math.floor(i / 256); } while (i !== 0); return bytes; }; @@ -32,11 +33,12 @@ export const bigNumberToBytes = (x: BigNumber) => { }; export const putUvarInt = (x: number) => { + if (!Number.isSafeInteger(x) || x < 0) throw new Error(`invalid integer ${x}`); const buf = []; let i = 0; while (x >= 0x80) { - buf[i] = x | 0x80; - x >>= 7; + buf[i] = (x % 0x80) | 0x80; + x = Math.floor(x / 0x80); i++; } buf[i] = x; @@ -124,9 +126,9 @@ export class Encoder { this.write(Buffer.from(i.hash, 'hex')); this.writeInt(i.index); - if (!i.genesis) i.genesis = ''; - this.writeInt(i.genesis.length); - this.write(Buffer.from(i.genesis)); + const genesis = i.genesis ?? ''; + this.writeInt(genesis.length); + this.write(Buffer.from(genesis)); const d = i.deposit; if (typeof d === 'undefined') { this.write(empty); @@ -151,9 +153,9 @@ export class Encoder { this.write(empty); } else { this.write(magic); - if (!m.group) m.group = ''; - this.writeInt(m.group.length); - this.write(Buffer.from(m.group)); + const group = m.group ?? ''; + this.writeInt(group.length); + this.write(Buffer.from(group)); this.writeUint64(m.batch); this.writeInteger(parseUnits(m.amount, 8)); diff --git a/src/client/utils/invoice.ts b/src/client/utils/invoice.ts index 35241a9a..ef2d0212 100644 --- a/src/client/utils/invoice.ts +++ b/src/client/utils/invoice.ts @@ -52,6 +52,7 @@ export const parseMixinInvoice = (s: string) => { const flag = dec.readByte(); if (flag === 1) { const ref = dec.readByte(); + if (ref >= mi.entries.length) return undefined; entry.index_references.push(ref); } else if (flag === 0) { const hash = dec.readSubarray(32).toString('hex'); @@ -85,7 +86,7 @@ export const attachInvoiceEntry = (invoice: MixinInvoice, entry: InvoiceEntry) = throw new Error('too many references'); } entry.index_references.forEach(ref => { - if (ref > invoice.entries.length) { + if (!Number.isInteger(ref) || ref < 0 || ref >= invoice.entries.length) { throw new Error(`invalid entry index reference: ${ref}`); } }); diff --git a/src/client/utils/multisigs.ts b/src/client/utils/multisigs.ts index b7f8048c..d1c1113c 100644 --- a/src/client/utils/multisigs.ts +++ b/src/client/utils/multisigs.ts @@ -7,9 +7,9 @@ export const TxVersion = 0x02; export const getTotalBalanceFromOutputs = (outputs: UtxoOutput[]) => outputs.reduce((prev, cur) => prev.plus(BigNumber(cur.amount)), BigNumber('0')); export const encodeScript = (threshold: number) => { + if (!Number.isInteger(threshold) || threshold < 0 || threshold > 255) throw new Error(`INVALID THRESHOLD ${threshold}`); let s = threshold.toString(16); if (s.length === 1) s = `0${s}`; - if (s.length > 2) throw new Error(`INVALID THRESHOLD ${threshold}`); return `fffe${s}`; }; diff --git a/src/client/utils/nfo.ts b/src/client/utils/nfo.ts index a5963b73..8e294086 100644 --- a/src/client/utils/nfo.ts +++ b/src/client/utils/nfo.ts @@ -30,7 +30,7 @@ export function buildCollectibleMemo(content: string, collection_id?: string, to const encoder = new Encoder(Buffer.from(Prefix, 'utf8')); encoder.write(Buffer.from([Version])); - if (collection_id && token_id) { + if (collection_id && token_id !== undefined) { encoder.write(Buffer.from([1])); encoder.writeUint64(BigInt(1)); encoder.writeUUID(DefaultChain); @@ -75,7 +75,8 @@ export const decodeNfoMemo = (hexMemo: string) => { const collection = Buffer.from(decoder.readBytes(), 'hex'); nm.collection = stringify(collection); - nm.token = parseInt(decoder.readBytes(), 16); + const token = decoder.readBytes(); + nm.token = token ? parseInt(token, 16) : 0; } nm.extra = Buffer.from(decoder.readBytes(), 'hex').toString(); diff --git a/src/client/utils/safe.ts b/src/client/utils/safe.ts index 6150cb4b..ac884341 100644 --- a/src/client/utils/safe.ts +++ b/src/client/utils/safe.ts @@ -95,14 +95,15 @@ export const getUnspentOutputsForRecipients = (outputs: SafeUtxoOutput[], rs: Sa const totalOutput = rs.reduce((prev, cur) => prev.plus(BigNumber(cur.amount)), BigNumber('0')); let totalInput = BigNumber('0'); - for (let i = 0; i < outputs.length; i++) { - const o = outputs[i]; + const utxos: SafeUtxoOutput[] = []; + for (const o of outputs) { if (o.state !== 'unspent') continue; + utxos.push(o); totalInput = totalInput.plus(BigNumber(o.amount)); if (totalInput.minus(totalOutput).isNegative()) continue; return { - utxos: outputs.slice(0, i + 1), + utxos, change: totalInput.minus(totalOutput), }; } @@ -110,6 +111,9 @@ export const getUnspentOutputsForRecipients = (outputs: SafeUtxoOutput[], rs: Sa }; export const buildSafeTransactionRecipient = (members: string[], threshold: number, amount: string): SafeTransactionRecipient => { + if (members.length === 0) throw new Error('empty members to build safe transaction recipient'); + if (!Number.isInteger(threshold) || threshold < 0 || threshold > 255) throw new Error(`invalid threshold: ${threshold}`); + const mixAddress = { version: 2, threshold, @@ -117,8 +121,11 @@ export const buildSafeTransactionRecipient = (members: string[], threshold: numb uuidMembers: [], } as MixAddress; - if (members.every(m => m.startsWith(MainAddressPrefix))) mixAddress.xinMembers = members; - if (members.every(m => validate(m))) mixAddress.uuidMembers = members; + if (members.every(m => m.startsWith(MainAddressPrefix) && getPublicFromMainnetAddress(m))) mixAddress.xinMembers = members; + if (members.every(m => validate(m))) { + if (threshold < 1 || threshold > members.length) throw new Error(`invalid threshold: ${threshold}`); + mixAddress.uuidMembers = members; + } if (mixAddress.uuidMembers.length === 0 && mixAddress.xinMembers.length === 0) throw new Error('empty members to build safe transaction recipient'); return { @@ -223,6 +230,7 @@ export const buildSafeTransaction = ( references: string[] = [], ): SafeTransaction => { if (utxos.length === 0) throw new Error('empty inputs'); + if (rs.length === 0) throw new Error('empty recipients'); if (extra.byteLength > ExtraSizeGeneralLimit) { const r = rs[0]; const amount = estimateStorageCost(extra); diff --git a/src/client/utils/uniq.ts b/src/client/utils/uniq.ts index bc486fea..01dc02e8 100644 --- a/src/client/utils/uniq.ts +++ b/src/client/utils/uniq.ts @@ -8,7 +8,7 @@ const toBytes = (data: Buffer) => new Uint8Array(data); /** Supporting multisig for tokens & collectibles */ export const hashMembers = (ids: string[]): string => { - const key = ids.sort().join(''); + const key = [...ids].sort().join(''); return newHash(Buffer.from(key)).toString('hex'); }; diff --git a/src/client/utxo.ts b/src/client/utxo.ts index 97cd503b..81979144 100644 --- a/src/client/utxo.ts +++ b/src/client/utxo.ts @@ -90,7 +90,7 @@ export const UtxoKeystoreClient = (axiosInstance: AxiosInstance) => ({ } else { const hint = uniqueConversationID(traceHash.toString('hex'), seedHash.toString('hex')); uuidRequests.push({ - receivers: ma.uuidMembers.sort(), + receivers: [...ma.uuidMembers].sort(), index: i, hint, }); diff --git a/test/address.test.ts b/test/address.test.ts index 6a366570..3ec1f4bc 100644 --- a/test/address.test.ts +++ b/test/address.test.ts @@ -1,6 +1,7 @@ import { client, app_pin } from './common'; +import { describeIntegration } from './integration'; -describe('address', () => { +describeIntegration('address', () => { const asset_id = '43d61dcd-e413-450d-80b8-101d5e903357'; const destination = '0xF2e6D6BB9E6D31B873bC23649A25A76f8852e3f5'; let tmpAddressID = ''; diff --git a/test/blaze.test.ts b/test/blaze.test.ts new file mode 100644 index 00000000..312d14e6 --- /dev/null +++ b/test/blaze.test.ts @@ -0,0 +1,53 @@ +import WebSocket from 'ws'; +import { gzip, ungzip } from 'pako'; +import { describe, expect, it, vi } from 'vitest'; +import { decodeMessage, sendRaw } from '../src/blaze/utils'; + +const encodedEnvelope = (data: Record) => gzip(Buffer.from(JSON.stringify({ data }))); + +describe('Blaze utilities', () => { + it('decodes an envelope without changing message data by default', () => { + const message = { message_id: 'message-id', data: Buffer.from('hello').toString('base64') }; + + expect(decodeMessage(encodedEnvelope(message), { parse: false })).toEqual(message); + }); + + it('decodes base64 text and JSON message data when requested', () => { + const json = encodedEnvelope({ data: Buffer.from(JSON.stringify({ value: 1 })).toString('base64') }); + const text = encodedEnvelope({ data: Buffer.from('plain text').toString('base64') }); + + expect(decodeMessage(json, { parse: true }).data).toEqual({ value: 1 }); + expect(decodeMessage(text, { parse: true }).data).toBe('plain text'); + }); + + it('sends a compressed JSON message on an open socket', async () => { + const message = { id: 'request-id', action: 'LIST_PENDING_MESSAGES' }; + const send = vi.fn((data: Uint8Array, callback: () => void) => { + expect(JSON.parse(ungzip(data, { to: 'string' }))).toEqual(message); + callback(); + }); + const socket = { readyState: WebSocket.OPEN, send } as unknown as WebSocket; + + await expect(sendRaw(socket, message)).resolves.toBe(true); + expect(send).toHaveBeenCalledOnce(); + }); + + it('does not send on a socket that is not open', async () => { + const send = vi.fn(); + const socket = { readyState: WebSocket.CLOSED, send } as unknown as WebSocket; + + await expect(sendRaw(socket, { id: 'request-id', action: 'action' })).resolves.toBe(false); + expect(send).not.toHaveBeenCalled(); + }); + + it('resolves false when an open socket never invokes its callback', async () => { + vi.useFakeTimers(); + const socket = { readyState: WebSocket.OPEN, send: vi.fn() } as unknown as WebSocket; + const result = sendRaw(socket, { id: 'request-id', action: 'action' }); + + await vi.advanceTimersByTimeAsync(5000); + + await expect(result).resolves.toBe(false); + vi.useRealTimers(); + }); +}); diff --git a/test/integration.ts b/test/integration.ts new file mode 100644 index 00000000..a695a277 --- /dev/null +++ b/test/integration.ts @@ -0,0 +1,3 @@ +import { describe } from 'vitest'; + +export const describeIntegration = process.env.MIXIN_INTEGRATION_TESTS === '1' ? describe : describe.skip; diff --git a/test/mixin/auth.test.ts b/test/mixin/auth.test.ts new file mode 100644 index 00000000..a326951c --- /dev/null +++ b/test/mixin/auth.test.ts @@ -0,0 +1,90 @@ +import { ed25519 } from '@noble/curves/ed25519.js'; +import serialize from 'serialize-javascript'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AppKeystore, Keystore, OAuthKeystore } from '../../src/client/types'; +import { getChallenge, signAccessToken, signToken } from '../../src/client/utils/auth'; +import { base64RawURLDecode } from '../../src/client/utils/base64'; +import { sha256Hash } from '../../src/client/utils/uniq'; + +const privateKey = '01'.repeat(32); +const appID = '4b79fe76-0d9d-49e6-85fd-0f6be01147da'; +const sessionID = 'a03a4496-3e59-4abd-bd1e-1ab8a2acb550'; +const requestID = '772e6bef-3bff-4fcc-987d-29bafca74d63'; + +const decodePart = (value: string) => JSON.parse(base64RawURLDecode(value).toString()); + +describe('authentication utilities', () => { + afterEach(() => vi.useRealTimers()); + + it('creates a verifiable EdDSA token', () => { + const token = signToken({ subject: 'test' }, privateKey); + const [header, payload, signature] = token.split('.'); + + expect(decodePart(header)).toEqual({ alg: 'EdDSA', typ: 'JWT' }); + expect(decodePart(payload)).toEqual({ subject: 'test' }); + expect(ed25519.verify(base64RawURLDecode(signature), Buffer.from(`${header}.${payload}`), ed25519.getPublicKey(Buffer.from(privateKey, 'hex')))).toBe(true); + }); + + it('signs application requests with normalized method, body hash, and timestamps', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-02T03:04:05Z')); + const keystore: AppKeystore = { + app_id: appID, + session_id: sessionID, + session_private_key: privateKey, + server_public_key: '02'.repeat(32), + }; + const body = { value: '