├── .nvmrc ├── docs ├── static │ ├── .nojekyll │ └── img │ │ ├── logo.jpeg │ │ ├── logo.png │ │ ├── favicon.ico │ │ ├── docusaurus.png │ │ ├── tutorial │ │ ├── localeDropdown.png │ │ └── docsVersionDropdown.png │ │ └── logo.svg ├── babel.config.js ├── .nojekyll ├── src │ ├── components │ │ ├── HomepageFeatures.module.css │ │ └── HomepageFeatures.tsx │ ├── pages │ │ ├── index.module.css │ │ └── _index.tsx │ └── css │ │ └── custom.css ├── tsconfig.json ├── .gitignore ├── README.md ├── package.json ├── sidebars.js ├── docs │ └── tutorials │ │ └── addressTags.md └── docusaurus.config.js ├── example ├── src │ ├── App.css │ ├── vite-env.d.ts │ ├── main.tsx │ ├── Button.tsx │ ├── index.css │ ├── App.tsx │ └── Lattice.tsx ├── vite.config.ts ├── .gitignore ├── index.html ├── tsconfig.json ├── package.json └── public │ └── vite.svg ├── src ├── schemas │ └── index.ts ├── types │ ├── tiny-secp256k1.d.ts │ ├── pair.ts │ ├── vitest.d.ts │ ├── connect.ts │ ├── fetchActiveWallet.ts │ ├── removeKvRecords.ts │ ├── getAddresses.ts │ ├── addKvRecords.ts │ ├── shared.ts │ ├── getKvRecords.ts │ ├── messages.ts │ ├── fetchEncData.ts │ ├── declarations.d.ts │ ├── utils.ts │ ├── secureMessages.ts │ ├── client.ts │ ├── firmware.ts │ ├── index.ts │ └── sign.ts ├── __test__ │ ├── vitest-env.d.ts │ ├── utils │ │ ├── constants.ts │ │ ├── testEnvironment.ts │ │ ├── vitest.d.ts │ │ ├── getters.ts │ │ ├── testConstants.ts │ │ ├── serializers.ts │ │ ├── testRequest.ts │ │ ├── runners.ts │ │ ├── __test__ │ │ │ ├── builders.test.ts │ │ │ ├── serializers.test.ts │ │ │ └── __snapshots__ │ │ │ │ └── builders.test.ts.snap │ │ ├── setup.ts │ │ ├── determinism.ts │ │ └── ethers.ts │ ├── integration │ │ ├── __mocks__ │ │ │ ├── server.ts │ │ │ ├── setup.ts │ │ │ ├── connect.json │ │ │ ├── 4byte.ts │ │ │ └── handlers.ts │ │ ├── client.interop.test.ts │ │ └── connect.test.ts │ ├── unit │ │ ├── __snapshots__ │ │ │ └── selectDefFrom4byteABI.test.ts.snap │ │ ├── api.test.ts │ │ ├── decoders.test.ts │ │ ├── selectDefFrom4byteABI.test.ts │ │ ├── ethereum.validate.test.ts │ │ ├── module.interop.test.ts │ │ ├── encoders.test.ts │ │ └── personalSignValidation.test.ts │ └── e2e │ │ ├── ethereum │ │ └── addresses.test.ts │ │ ├── signing │ │ ├── eip712-msg.test.ts │ │ ├── evm-abi.test.ts │ │ ├── solana │ │ │ ├── solana.programs.test.ts │ │ │ └── solana.test.ts │ │ ├── evm-tx.test.ts │ │ └── unformatted.test.ts │ │ ├── xpub.test.ts │ │ └── solana │ │ └── addresses.test.ts ├── protocol │ └── index.ts ├── index.ts ├── api │ ├── wallets.ts │ ├── index.ts │ ├── state.ts │ ├── addressTags.ts │ ├── setup.ts │ └── utilities.ts ├── functions │ ├── index.ts │ ├── fetchDecoder.ts │ ├── pair.ts │ ├── removeKvRecords.ts │ ├── fetchActiveWallet.ts │ ├── addKvRecords.ts │ ├── getKvRecords.ts │ └── connect.ts ├── calldata │ └── index.ts └── shared │ ├── predicates.ts │ ├── errors.ts │ └── utilities.ts ├── .env.template ├── .prettierignore ├── .gitignore ├── tsconfig.build.json ├── .prettierrc ├── .gitmodules ├── vite.config.ts ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── publish.yml │ ├── docs-build-deploy.yml │ └── build-test.yml └── dependabot.yml ├── tsup.config.ts ├── README.md ├── LICENSE ├── tsconfig.json ├── scripts ├── README.md └── pair-device.ts ├── eslint.config.mjs ├── package.json └── patches └── vitest@2.1.3.patch /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transaction'; 2 | -------------------------------------------------------------------------------- /example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/types/tiny-secp256k1.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'tiny-secp256k1'; 2 | -------------------------------------------------------------------------------- /src/__test__/vitest-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/protocol/index.ts: -------------------------------------------------------------------------------- 1 | export * from './latticeConstants'; 2 | export * from './secureMessages'; 3 | -------------------------------------------------------------------------------- /docs/static/img/logo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridPlus/gridplus-sdk/HEAD/docs/static/img/logo.jpeg -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridPlus/gridplus-sdk/HEAD/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridPlus/gridplus-sdk/HEAD/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridPlus/gridplus-sdk/HEAD/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /src/__test__/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const MSG_PAYLOAD_METADATA_SZ = 28; // Metadata that must go in ETH_MSG requests 2 | -------------------------------------------------------------------------------- /docs/static/img/tutorial/localeDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridPlus/gridplus-sdk/HEAD/docs/static/img/tutorial/localeDropdown.png -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | APP_NAME="SDK Test" 2 | DEVICE_ID="" 3 | PASSWORD="" 4 | 5 | ENC_PW= 6 | ETHERSCAN_KEY="" 7 | baseUrl="https://signing.gridpl.us" 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # JSONC files with comments - parser incompatibility 2 | src/__test__/vectors.jsonc 3 | 4 | # Generated lockfiles 5 | pnpm-lock.yaml 6 | -------------------------------------------------------------------------------- /docs/static/img/tutorial/docsVersionDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridPlus/gridplus-sdk/HEAD/docs/static/img/tutorial/docsVersionDropdown.png -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build 2 | docs/docs/reference 3 | .DS_Store 4 | dist 5 | node_modules 6 | coverage 7 | .env 8 | *.temp 9 | ~/node_modules 10 | cache 11 | -------------------------------------------------------------------------------- /src/types/pair.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | 3 | export interface PairRequestParams { 4 | pairingSecret: string; 5 | client: Client; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/vitest.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | declare global { 3 | namespace Vi { 4 | interface JestAssertion { 5 | toEqualElseLog(a: any, msg: string): any; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node"] 5 | }, 6 | "exclude": ["src/__test__", "**/*.test.ts", "**/*.spec.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { CALLDATA as Calldata } from './calldata/index'; 2 | export { Client } from './client'; 3 | export { EXTERNAL as Constants } from './constants'; 4 | export { EXTERNAL as Utils } from './util'; 5 | export * from './api'; 6 | -------------------------------------------------------------------------------- /src/types/connect.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | 3 | export interface ConnectRequestParams { 4 | id: string; 5 | } 6 | 7 | export interface ConnectRequestFunctionParams extends ConnectRequestParams { 8 | client: Client; 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": "**/src/*.ts", 5 | "options": { 6 | "parser": "typescript" 7 | } 8 | } 9 | ], 10 | "singleQuote": true, 11 | "tabWidth": 2, 12 | "trailingComma": "all" 13 | } 14 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/docusaurus/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "skipLibCheck": true 6 | }, 7 | "include": ["../src"], 8 | "types": ["node", "jest", "vitest", "vitest/globals"] 9 | } 10 | -------------------------------------------------------------------------------- /src/__test__/integration/__mocks__/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | import { handlers } from './handlers'; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /src/types/fetchActiveWallet.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | 3 | export interface FetchActiveWalletRequestFunctionParams { 4 | client: Client; 5 | } 6 | 7 | export interface ValidatedFetchActiveWalletRequest { 8 | sharedSecret: Buffer; 9 | } 10 | -------------------------------------------------------------------------------- /src/__test__/integration/__mocks__/setup.ts: -------------------------------------------------------------------------------- 1 | import { server } from './server'; 2 | 3 | export const setup = () => { 4 | beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); 5 | afterAll(() => server.close()); 6 | afterEach(() => server.resetHandlers()); 7 | }; 8 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "forge/lib/forge-std"] 2 | path = forge/lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "forge/lib/openzeppelin-contracts"] 5 | path = forge/lib/openzeppelin-contracts 6 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 7 | -------------------------------------------------------------------------------- /src/api/wallets.ts: -------------------------------------------------------------------------------- 1 | import { ActiveWallets } from '../types'; 2 | import { queue } from './utilities'; 3 | 4 | /** 5 | * Fetches the active wallets 6 | */ 7 | export const fetchActiveWallets = async (): Promise => { 8 | return queue((client) => client.fetchActiveWallet()); 9 | }; 10 | -------------------------------------------------------------------------------- /example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/types/removeKvRecords.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | 3 | export interface RemoveKvRecordsRequestParams { 4 | type?: number; 5 | ids?: string[]; 6 | } 7 | 8 | export interface RemoveKvRecordsRequestFunctionParams 9 | extends RemoveKvRecordsRequestParams { 10 | client: Client; 11 | } 12 | -------------------------------------------------------------------------------- /src/functions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './addKvRecords'; 2 | export * from './connect'; 3 | export * from './fetchEncData'; 4 | export * from './fetchActiveWallet'; 5 | export * from './getAddresses'; 6 | export * from './getKvRecords'; 7 | export * from './pair'; 8 | export * from './removeKvRecords'; 9 | export * from './sign'; 10 | -------------------------------------------------------------------------------- /src/types/getAddresses.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | 3 | export interface GetAddressesRequestParams { 4 | startPath: number[]; 5 | n: number; 6 | flag?: number; 7 | iterIdx?: number; 8 | } 9 | 10 | export interface GetAddressesRequestFunctionParams 11 | extends GetAddressesRequestParams { 12 | client: Client; 13 | } 14 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | /docs/api -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/types/addKvRecords.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | import { KVRecords } from './shared'; 3 | 4 | export interface AddKvRecordsRequestParams { 5 | records: KVRecords; 6 | type?: number; 7 | caseSensitive?: boolean; 8 | } 9 | 10 | export interface AddKvRecordsRequestFunctionParams 11 | extends AddKvRecordsRequestParams { 12 | client: Client; 13 | } 14 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export { getClient, parseDerivationPath } from './utilities'; 2 | 3 | export * from './addresses'; 4 | export * from './addressTags'; 5 | export * from './signing'; 6 | export * from './wallets'; 7 | export * from './setup'; 8 | 9 | export { 10 | BTC_LEGACY_XPUB_PATH, 11 | BTC_WRAPPED_SEGWIT_YPUB_PATH, 12 | BTC_SEGWIT_ZPUB_PATH, 13 | } from '../constants'; 14 | -------------------------------------------------------------------------------- /src/__test__/utils/testEnvironment.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | expect.extend({ 6 | toEqualElseLog(received: unknown, expected: unknown, message?: string) { 7 | return { 8 | pass: received === expected, 9 | message: () => 10 | message ?? `Expected ${String(received)} to equal ${String(expected)}`, 11 | }; 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /example/src/Button.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export const Button = ({ onClick, children }) => { 4 | const [isLoading, setIsLoading] = useState(false); 5 | 6 | const handleOnClick = () => { 7 | setIsLoading(true); 8 | onClick().finally(() => setIsLoading(false)); 9 | }; 10 | return ( 11 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/types/shared.ts: -------------------------------------------------------------------------------- 1 | import type { ec } from 'elliptic'; 2 | 3 | export interface KVRecords { 4 | [key: string]: string; 5 | } 6 | 7 | export interface EncrypterParams { 8 | payload: Buffer; 9 | sharedSecret: Buffer; 10 | } 11 | 12 | export type KeyPair = ec.KeyPair; 13 | 14 | export type WalletPath = [number, number, number, number, number]; 15 | 16 | export interface DecryptedResponse { 17 | decryptedData: Buffer; 18 | newEphemeralPub: KeyPair; 19 | } 20 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 966px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | -------------------------------------------------------------------------------- /src/__test__/utils/vitest.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface CustomMatchers { 4 | toEqualElseLog(expected: unknown, message?: string): R; 5 | } 6 | 7 | declare module 'vitest' { 8 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 9 | interface Assertion extends CustomMatchers {} 10 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 11 | interface AsymmetricMatchersContaining extends CustomMatchers {} 12 | } 13 | -------------------------------------------------------------------------------- /src/__test__/integration/__mocks__/connect.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": 200, 3 | "message": "0100c7d11ffc00d70001048f065cf1beafadb8d15d31adc8d1f029e25b24dc9cda017cfd054a2a86e2b53eaabf9437cf69fa68f20fff3b80b2b5806e8fc6651890bdae7ffad2c4a8a43151000f00009d00f3d077fabeac89f0f0b2fba9c642630e3a1c1aa845f9e3c9e2c0234d6d05634b0d7bbf4c0de40a69336e5126392aa86976d2378cefd835659f22b1b7dc47c9f7731696ae1ba92c9d3011ad9c9cb12973b0e788a4d87d72f4a757e1a7e4fb41616d0790e90fcd0fe138b4e15170b77f76e2974e47bd275010fc70f03ed71692da4780422fba345bb787abaa91f8e9b7075819" 4 | } 5 | -------------------------------------------------------------------------------- /src/types/getKvRecords.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | 3 | export interface GetKvRecordsRequestParams { 4 | type?: number; 5 | n?: number; 6 | start?: number; 7 | } 8 | 9 | export interface GetKvRecordsRequestFunctionParams 10 | extends GetKvRecordsRequestParams { 11 | client: Client; 12 | } 13 | 14 | export type AddressTag = { 15 | caseSensitive: boolean; 16 | id: number; 17 | key: string; 18 | type: number; 19 | val: string; 20 | }; 21 | 22 | export interface GetKvRecordsData { 23 | records: AddressTag[]; 24 | fetched: number; 25 | total: number; 26 | } 27 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 | 11 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": false, 6 | "forceConsistentCasingInFileNames": true, 7 | "isolatedModules": true, 8 | "jsx": "react-jsx", 9 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 10 | "module": "ESNext", 11 | "moduleResolution": "Node", 12 | "noEmit": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "target": "ESNext", 17 | "useDefineForClassFields": true 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /src/__test__/unit/__snapshots__/selectDefFrom4byteABI.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`selectDefFrom4byteAbi > select correct result 1`] = ` 4 | [ 5 | "swapExactTokensForTokens", 6 | [ 7 | "#1", 8 | 3, 9 | 32, 10 | [], 11 | ], 12 | [ 13 | "#2", 14 | 3, 15 | 32, 16 | [], 17 | ], 18 | [ 19 | "#3", 20 | 1, 21 | 0, 22 | [ 23 | 0, 24 | ], 25 | ], 26 | [ 27 | "#4", 28 | 1, 29 | 0, 30 | [], 31 | ], 32 | [ 33 | "#5", 34 | 3, 35 | 32, 36 | [], 37 | ], 38 | ] 39 | `; 40 | -------------------------------------------------------------------------------- /src/types/messages.ts: -------------------------------------------------------------------------------- 1 | import { LatticeMsgType } from '../protocol'; 2 | 3 | export interface LatticeMessageHeader { 4 | // Protocol version. Should always be 0x01 5 | // [uint8] 6 | version: number; 7 | // Protocol request type. Should always be 0x02 8 | // for "secure" message type. 9 | // [uint8] 10 | type: LatticeMsgType; 11 | // Random message ID for internal tracking in firmware 12 | // [4 bytes] 13 | id: Buffer; 14 | // Length of payload data being used 15 | // For an encrypted request, this indicates the 16 | // size of the non-zero decrypted data. 17 | // [uint16] 18 | len: number; 19 | } 20 | -------------------------------------------------------------------------------- /src/api/state.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | 3 | export let saveClient: (clientData: string | null) => Promise; 4 | 5 | export const setSaveClient = ( 6 | fn: (clientData: string | null) => Promise, 7 | ) => { 8 | saveClient = fn; 9 | }; 10 | 11 | export let loadClient: () => Promise; 12 | 13 | export const setLoadClient = (fn: () => Promise) => { 14 | loadClient = fn; 15 | }; 16 | 17 | let functionQueue: Promise; 18 | 19 | export const getFunctionQueue = () => functionQueue; 20 | 21 | export const setFunctionQueue = (queue: Promise) => { 22 | functionQueue = queue; 23 | }; 24 | -------------------------------------------------------------------------------- /src/calldata/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Exports containing utils that allow inclusion of calldata decoder info in signing requests. If 3 | * calldata decoder info is packed into the request, it is used to decode the calldata in the 4 | * request. It is optional. 5 | */ 6 | import { 7 | getNestedCalldata, 8 | parseCanonicalName, 9 | parseSolidityJSONABI, 10 | replaceNestedDefs, 11 | } from './evm'; 12 | 13 | export const CALLDATA = { 14 | EVM: { 15 | type: 1, 16 | parsers: { 17 | parseSolidityJSONABI, 18 | parseCanonicalName, 19 | }, 20 | processors: { 21 | getNestedCalldata, 22 | replaceNestedDefs, 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | include: ['**/*.{test,spec}.{js,mjs,ts,mts,jsx,tsx}'], 8 | /** connect.test.ts is excluded because it is still a WIP (https://github.com/GridPlus/gridplus-sdk/issues/420) */ 9 | exclude: ['./src/__test__/integration/connect.test.ts'], 10 | testTimeout: 120000, 11 | maxConcurrency: 1, 12 | fileParallelism: false, 13 | setupFiles: ['./src/__test__/utils/testEnvironment.ts'], 14 | coverage: { 15 | provider: 'istanbul', 16 | reporter: ['lcov'], 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/types/fetchEncData.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | 3 | export interface EIP2335KeyExportReq { 4 | path: number[]; 5 | c?: number; 6 | kdf?: number; 7 | walletUID?: Buffer; 8 | } 9 | 10 | export interface FetchEncDataRequest { 11 | schema: number; 12 | params: EIP2335KeyExportReq; // NOTE: This is a union, but only one type of request exists currently 13 | } 14 | 15 | export interface FetchEncDataRequestFunctionParams extends FetchEncDataRequest { 16 | client: Client; 17 | } 18 | 19 | export interface EIP2335KeyExportData { 20 | iterations: number; 21 | cipherText: Buffer; 22 | salt: Buffer; 23 | checksum: Buffer; 24 | iv: Buffer; 25 | pubkey: Buffer; 26 | } 27 | -------------------------------------------------------------------------------- /src/__test__/utils/getters.ts: -------------------------------------------------------------------------------- 1 | import seedrandom from 'seedrandom'; 2 | 3 | export const getEnv = () => { 4 | if (!process.env) throw new Error('env cannot be found'); 5 | return process.env; 6 | }; 7 | export const getDeviceId = (): string => getEnv()['DEVICE_ID'] ?? ''; 8 | export const getN = (): number => parseInt(getEnv()['N'] ?? '5'); 9 | export const getSeed = (): string => getEnv()['SEED'] ?? 'myrandomseed'; 10 | export const getTestnet = (): string => getEnv()['TESTNET'] ?? ''; 11 | export const getEtherscanKey = (): string => getEnv()['ETHERSCAN_KEY'] ?? ''; 12 | export const getEncPw = (): string => getEnv()['ENC_PW'] ?? null; 13 | 14 | export const getPrng = (seed?: string) => { 15 | return seedrandom(seed ? seed : getSeed()); 16 | }; 17 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@esbuild-plugins/node-globals-polyfill": "^0.1.1", 13 | "@ethereumjs/common": "^3.0.2", 14 | "@ethereumjs/tx": "^4.0.2", 15 | "buffer": "^6.0.3", 16 | "gridplus-sdk": "^2.4.3", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^18.0.26", 22 | "@types/react-dom": "^18.0.9", 23 | "@vitejs/plugin-react": "^3.0.0", 24 | "typescript": "^4.9.3", 25 | "vite": "^4.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/__test__/e2e/ethereum/addresses.test.ts: -------------------------------------------------------------------------------- 1 | import { question } from 'readline-sync'; 2 | import { pair } from '../../../api'; 3 | import { fetchAddresses } from '../../../api/addresses'; 4 | import { setupClient } from '../../utils/setup'; 5 | 6 | describe('Ethereum Addresses', () => { 7 | test('pair', async () => { 8 | const isPaired = await setupClient(); 9 | if (!isPaired) { 10 | const secret = question('Please enter the pairing secret: '); 11 | await pair(secret.toUpperCase()); 12 | } 13 | }); 14 | 15 | test('Should fetch addressess', async () => { 16 | const addresses = await fetchAddresses(); 17 | expect(addresses.length).toBe(10); 18 | expect(addresses.every((addr) => !addr.startsWith('11111'))).toBe(true); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 📝 Summary 2 | 3 | ## 4 | 5 | ## 🔧 Context / Implementation 6 | 7 | ## 8 | 9 | ## 🧪 Test Plan 10 | 11 | 12 | 13 | 1. First step 14 | 2. Second step 15 | 3. Expected result 16 | 17 | ## 🖼️ Screenshots (if applicable) 18 | 19 | | Before | After | 20 | | ------ | ----- | 21 | | | | 22 | 23 | 30 | -------------------------------------------------------------------------------- /src/types/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'aes-js'; 2 | declare module 'hash.js/lib/hash/sha'; 3 | declare module 'hash.js/lib/hash/ripemd.js' { 4 | export function ripemd160(): { 5 | update: (data: any) => any; 6 | digest: (encoding?: any) => any; 7 | }; 8 | } 9 | declare module 'lodash/inRange.js' { 10 | const fn: (number: any, start?: any, end?: any) => boolean; 11 | export default fn; 12 | } 13 | declare module 'lodash/isInteger.js' { 14 | const fn: (value: any) => boolean; 15 | export default fn; 16 | } 17 | declare module 'lodash/isEmpty.js' { 18 | const fn: (value: any) => boolean; 19 | export default fn; 20 | } 21 | 22 | // Add more flexible typing to reduce strict type checking for complex modules 23 | declare global { 24 | interface NodeRequire { 25 | (id: string): any; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/__test__/utils/testConstants.ts: -------------------------------------------------------------------------------- 1 | import { mnemonicToSeedSync } from 'bip39'; 2 | /** 3 | * Common test constants used across the GridPlus SDK test suite 4 | * 5 | * These constants are shared across multiple test files to ensure consistency 6 | * and avoid duplication of test data. 7 | */ 8 | 9 | /** 10 | * Standard test mnemonic used for deterministic testing 11 | * 12 | * This mnemonic is used across multiple test files to ensure consistent 13 | * test behavior and deterministic results. 14 | */ 15 | export const TEST_MNEMONIC = 16 | 'test test test test test test test test test test test junk'; 17 | 18 | /** 19 | * Shared seed derived from TEST_MNEMONIC 20 | * 21 | * Consumers can reuse this to avoid re-deriving the seed in each test. 22 | */ 23 | export const TEST_SEED = mnemonicToSeedSync(TEST_MNEMONIC); 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | name: Publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v5 14 | 15 | - name: Install Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 20.x 19 | registry-url: 'https://registry.npmjs.org' 20 | 21 | - name: Install pnpm 22 | uses: pnpm/action-setup@v2 23 | with: 24 | version: 8 25 | 26 | - name: Install dependencies 27 | run: pnpm install 28 | 29 | - name: Build project 30 | run: pnpm run build 31 | 32 | - name: Publish to NPM 33 | run: pnpm publish --no-git-checks 34 | 35 | env: 36 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | -------------------------------------------------------------------------------- /src/__test__/integration/client.interop.test.ts: -------------------------------------------------------------------------------- 1 | import { fetchActiveWallets, setup } from '../../api'; 2 | import { getDeviceId } from '../utils/getters'; 3 | import { setupTestClient } from '../utils/helpers'; 4 | import { getStoredClient, setStoredClient } from '../utils/setup'; 5 | 6 | /** 7 | * This test is used to test the interoperability between the Class-based API and the Functional API. 8 | */ 9 | describe.skip('client interop', () => { 10 | it('should setup the Client, then use that client data to', async () => { 11 | const client = setupTestClient(); 12 | const isPaired = await client.connect(getDeviceId()); 13 | expect(isPaired).toBe(true); 14 | 15 | await setup({ 16 | getStoredClient, 17 | setStoredClient, 18 | }); 19 | 20 | const activeWallets = await fetchActiveWallets(); 21 | expect(activeWallets).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import { dirname, resolve } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import { defineConfig } from 'tsup'; 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)); 7 | const pkg = JSON.parse( 8 | readFileSync(resolve(__dirname, 'package.json'), 'utf-8'), 9 | ); 10 | 11 | const external = Object.keys({ 12 | ...(pkg.dependencies ?? {}), 13 | ...(pkg.peerDependencies ?? {}), 14 | }); 15 | 16 | export default defineConfig({ 17 | entry: ['src/index.ts'], 18 | outDir: './dist', 19 | format: ['esm', 'cjs'], 20 | target: 'node20', 21 | sourcemap: true, 22 | clean: true, 23 | bundle: true, 24 | dts: true, 25 | silent: true, 26 | outExtension: ({ format }) => ({ 27 | js: format === 'esm' ? '.mjs' : '.cjs', 28 | }), 29 | external, 30 | tsconfig: './tsconfig.build.json', 31 | }); 32 | -------------------------------------------------------------------------------- /src/__test__/utils/serializers.ts: -------------------------------------------------------------------------------- 1 | export const serializeObjectWithBuffers = (obj: any) => { 2 | return Object.entries(obj).reduce((acc: any, [key, value]) => { 3 | if (value instanceof Buffer) { 4 | acc[key] = { isBuffer: true, value: value.toString('hex') }; 5 | } else if (typeof value === 'object') { 6 | acc[key] = serializeObjectWithBuffers(value); 7 | } else { 8 | acc[key] = value; 9 | } 10 | return acc; 11 | }, {}); 12 | }; 13 | 14 | export const deserializeObjectWithBuffers = (obj: any) => { 15 | return Object.entries(obj).reduce((acc: any, [key, value]: any) => { 16 | if (value?.isBuffer) { 17 | acc[key] = Buffer.from(value.value, 'hex'); 18 | } else if (typeof value === 'object') { 19 | acc[key] = deserializeObjectWithBuffers(value); 20 | } else { 21 | acc[key] = value; 22 | } 23 | return acc; 24 | }, {}); 25 | }; 26 | -------------------------------------------------------------------------------- /src/__test__/e2e/signing/eip712-msg.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * EIP-712 Typed Data Message Signing Test Suite 3 | * 4 | * Tests EIP-712 message signing compatibility between Lattice and viem. 5 | * Replaces the forge-based contract test with a pure signature comparison approach. 6 | */ 7 | import { describe, it, beforeAll } from 'vitest'; 8 | import { signAndCompareEIP712Message } from '../../utils/viemComparison'; 9 | import { setupClient } from '../../utils/setup'; 10 | import { EIP712_MESSAGE_VECTORS } from './eip712-vectors'; 11 | 12 | describe('EIP-712 Message Signing - Viem Compatibility', () => { 13 | beforeAll(async () => { 14 | await setupClient(); 15 | }); 16 | 17 | EIP712_MESSAGE_VECTORS.forEach((vector, index) => { 18 | it(`${vector.name} (${index + 1}/${EIP712_MESSAGE_VECTORS.length})`, async () => { 19 | await signAndCompareEIP712Message(vector.message, vector.name); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # npm dependencies - daily checks, grouped security updates, individual PRs for others 4 | - package-ecosystem: 'npm' 5 | directory: '/' 6 | schedule: 7 | interval: 'daily' 8 | versioning-strategy: 'increase-if-necessary' 9 | open-pull-requests-limit: 10 10 | commit-message: 11 | prefix: 'deps' 12 | prefix-development: 'deps' 13 | include: 'scope' 14 | labels: 15 | - 'dependencies' 16 | groups: 17 | security-patches: 18 | applies-to: security-updates 19 | patterns: 20 | - '*' 21 | 22 | # GitHub Actions - monthly 23 | - package-ecosystem: 'github-actions' 24 | directory: '/' 25 | schedule: 26 | interval: 'monthly' 27 | open-pull-requests-limit: 3 28 | commit-message: 29 | prefix: 'ci' 30 | include: 'scope' 31 | labels: 32 | - 'github-actions' 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://user-images.githubusercontent.com/7378490/156425132-232af539-63d9-4dc5-8a6c-63c7bda20125.png) 2 | 3 | # GridPlus Lattice1 SDK 4 | 5 | - **For help with this SDK, see the [GridPlus SDK Documentation](https://gridplus.github.io/gridplus-sdk)** 6 | - **For help with your Lattice1 hardware, see the [Lattice1 Documentation](https://docs.gridplus.io)** 7 | 8 | This SDK is designed to facilitate communication with a user's [Lattice1 hardware wallet](https://gridplus.io/lattice). Once paired to a given Lattice, an instance of this SDK is used to make encrypted requests for things like getting addresses/public keys and making signatures. 9 | 10 | The Lattice1 is an internet connected device which listens for requests and fills them in firmware. Web requests originate from this SDK and responses are returned asynchronously. Some requests require user authorization and may time out if the user does not approve them. 11 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /src/shared/predicates.ts: -------------------------------------------------------------------------------- 1 | import { LatticeResponseCode } from '../protocol'; 2 | import { FirmwareVersion, FirmwareConstants } from '../types'; 3 | import { isFWSupported } from './utilities'; 4 | 5 | export const isDeviceBusy = (responseCode: number) => 6 | responseCode === LatticeResponseCode.deviceBusy || 7 | responseCode === LatticeResponseCode.gceTimeout; 8 | 9 | export const isWrongWallet = (responseCode: number) => 10 | responseCode === LatticeResponseCode.wrongWallet; 11 | 12 | export const isInvalidEphemeralId = (responseCode: number) => 13 | responseCode === LatticeResponseCode.invalidEphemId; 14 | 15 | export const doesFetchWalletsOnLoad = (fwVersion: FirmwareVersion) => 16 | isFWSupported(fwVersion, { major: 0, minor: 14, fix: 1 }); 17 | 18 | export const shouldUseEVMLegacyConverter = (fwConstants: FirmwareConstants) => 19 | fwConstants.genericSigning && 20 | fwConstants.genericSigning.encodingTypes && 21 | fwConstants.genericSigning.encodingTypes.EVM; 22 | -------------------------------------------------------------------------------- /src/__test__/e2e/xpub.test.ts: -------------------------------------------------------------------------------- 1 | import { fetchBtcXpub, fetchBtcYpub, fetchBtcZpub } from '../../api'; 2 | import { setupClient } from '../utils/setup'; 3 | 4 | describe('XPUB', () => { 5 | beforeAll(async () => { 6 | await setupClient(); 7 | }); 8 | 9 | test('fetchBtcXpub returns xpub', async () => { 10 | const xpub = await fetchBtcXpub(); 11 | expect(xpub).toBeTruthy(); 12 | expect(xpub.startsWith('xpub')).toBe(true); 13 | expect(xpub.length).toBeGreaterThan(100); 14 | }); 15 | 16 | test('fetchBtcYpub returns ypub', async () => { 17 | const ypub = await fetchBtcYpub(); 18 | expect(ypub).toBeTruthy(); 19 | expect(ypub.startsWith('ypub')).toBe(true); 20 | expect(ypub.length).toBeGreaterThan(100); 21 | }); 22 | 23 | test('fetchBtcZpub returns zpub', async () => { 24 | const zpub = await fetchBtcZpub(); 25 | expect(zpub).toBeTruthy(); 26 | expect(zpub.startsWith('zpub')).toBe(true); 27 | expect(zpub.length).toBeGreaterThan(100); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #53b7e8; 10 | --ifm-color-primary-dark: rgb(33, 175, 144); 11 | --ifm-color-primary-darker: rgb(31, 165, 136); 12 | --ifm-color-primary-darkest: rgb(26, 136, 112); 13 | --ifm-color-primary-light: rgb(70, 203, 174); 14 | --ifm-color-primary-lighter: rgb(102, 212, 189); 15 | --ifm-color-primary-lightest: rgb(146, 224, 208); 16 | --ifm-code-font-size: 95%; 17 | } 18 | 19 | .docusaurus-highlight-code-line { 20 | background-color: rgba(0, 0, 0, 0.1); 21 | display: block; 22 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 23 | padding: 0 var(--ifm-pre-padding); 24 | } 25 | 26 | html[data-theme='dark'] .docusaurus-highlight-code-line { 27 | background-color: rgba(0, 0, 0, 0.3); 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/errors.ts: -------------------------------------------------------------------------------- 1 | import { LatticeResponseCode, ProtocolConstants } from '../protocol'; 2 | 3 | const buildLatticeResponseErrorMessage = ({ 4 | responseCode, 5 | errorMessage, 6 | }: { 7 | responseCode?: LatticeResponseCode; 8 | errorMessage?: string; 9 | }) => { 10 | const msg: string[] = []; 11 | if (responseCode) { 12 | msg.push(`${ProtocolConstants.responseMsg[responseCode]}`); 13 | } 14 | if (errorMessage) { 15 | msg.push('Error Message: '); 16 | msg.push(errorMessage); 17 | } 18 | return msg.join('\n'); 19 | }; 20 | 21 | export class LatticeResponseError extends Error { 22 | constructor( 23 | public responseCode?: LatticeResponseCode, 24 | public errorMessage?: string, 25 | ) { 26 | const message = buildLatticeResponseErrorMessage({ 27 | responseCode, 28 | errorMessage, 29 | }); 30 | super(message); 31 | this.name = 'LatticeResponseError'; 32 | this.responseCode = responseCode; 33 | this.errorMessage = errorMessage; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/__test__/e2e/signing/evm-abi.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test ABI decoding of various EVM smart contract function calls. 3 | * These transactions use contract addresses so the device can fetch ABI data dynamically. 4 | */ 5 | 6 | import { sign } from '../../../api'; 7 | import { setupClient } from '../../utils/setup'; 8 | import { ABI_TEST_VECTORS } from '../../vectors/abi-vectors'; 9 | 10 | describe('[EVM ABI] ABI Decoding Tests', () => { 11 | beforeAll(async () => { 12 | await setupClient(); 13 | }); 14 | 15 | describe('ABI Patterns with Complex Structures', () => { 16 | ABI_TEST_VECTORS.forEach((testCase, index) => { 17 | it(`Should test ${testCase.name} (${index + 1}/${ABI_TEST_VECTORS.length})`, async () => { 18 | const result = await sign(testCase.tx); 19 | expect(result).toBeDefined(); 20 | expect(result.sig).toBeDefined(); 21 | expect(result.sig.r).toBeDefined(); 22 | expect(result.sig.s).toBeDefined(); 23 | expect(result.sig.v).toBeDefined(); 24 | }); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/types/utils.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | 3 | export interface TestRequestPayload { 4 | payload: Buffer; 5 | testID: number; 6 | client: Client; 7 | } 8 | 9 | export interface EthDepositInfo { 10 | networkName: string; 11 | forkVersion: Buffer; 12 | validatorsRoot: Buffer; 13 | } 14 | 15 | export interface EthDepositDataReq { 16 | // (optional) BLS withdrawal key or ETH1 withdrawal address 17 | withdrawalKey?: Buffer | string; 18 | // Amount to be deposited in GWei (10**9 wei) 19 | amountGwei: number; 20 | // Info about the chain we are using. 21 | // You probably shouldn't change this unless you know what you're doing. 22 | info: EthDepositInfo; 23 | // In order to be compatible with Ethereum's online launchpad, you need 24 | // to set the CLI version. Obviously we are not using the CLI here but 25 | // we are following the protocol outlined in v2.3.0. 26 | depositCliVersion: string; 27 | } 28 | 29 | export interface EthDepositDataResp { 30 | // Validator's pubkey as a hex string 31 | pubkey: string; 32 | // JSON encoded deposit data 33 | depositData: string; 34 | } 35 | -------------------------------------------------------------------------------- /src/functions/fetchDecoder.ts: -------------------------------------------------------------------------------- 1 | import { validateConnectedClient } from '../shared/validators'; 2 | 3 | import { getClient } from '../api'; 4 | import { fetchCalldataDecoder } from '../util'; 5 | import { TransactionRequest } from '../types'; 6 | 7 | /** 8 | * `fetchDecoder` fetches the ABI for a given contract address and chain ID. 9 | * @category Lattice 10 | * @returns An object containing the ABI and encoded definition of the contract. 11 | */ 12 | export async function fetchDecoder({ 13 | data, 14 | to, 15 | chainId, 16 | }: TransactionRequest): Promise { 17 | try { 18 | const client = await getClient(); 19 | validateConnectedClient(client); 20 | 21 | const fwVersion = client.getFwVersion(); 22 | const supportsDecoderRecursion = 23 | fwVersion.major > 0 || fwVersion.minor >= 16; 24 | 25 | const { def } = await fetchCalldataDecoder( 26 | data, 27 | to, 28 | chainId, 29 | supportsDecoderRecursion, 30 | ); 31 | 32 | return def; 33 | } catch (error) { 34 | console.warn('Failed to fetch ABI:', error); 35 | return undefined; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 GridPlus, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/__test__/utils/testRequest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LatticeSecureEncryptedRequestType, 3 | encryptedSecureRequest, 4 | } from '../../protocol'; 5 | import type { TestRequestPayload } from '../../types'; 6 | 7 | /** 8 | * `test` takes a data object with a testID and a payload, and sends them to the device. 9 | * @category Lattice 10 | */ 11 | export const testRequest = async ({ 12 | payload, 13 | testID, 14 | client, 15 | }: TestRequestPayload) => { 16 | if (!payload) { 17 | throw new Error( 18 | 'First argument must contain `testID` and `payload` fields.', 19 | ); 20 | } 21 | const sharedSecret = client.sharedSecret; 22 | const ephemeralPub = client.ephemeralPub; 23 | const url = client.url; 24 | 25 | const TEST_DATA_SZ = 500; 26 | const data = Buffer.alloc(TEST_DATA_SZ + 6); 27 | data.writeUInt32BE(testID, 0); 28 | data.writeUInt16BE(payload.length, 4); 29 | payload.copy(data, 6); 30 | 31 | const { decryptedData } = await encryptedSecureRequest({ 32 | data, 33 | requestType: LatticeSecureEncryptedRequestType.test, 34 | sharedSecret, 35 | ephemeralPub, 36 | url, 37 | }); 38 | return decryptedData; 39 | }; 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": true, 9 | "jsx": "react-jsx", 10 | "lib": ["ES2022", "DOM"], 11 | "module": "ES2022", 12 | "moduleResolution": "Bundler", 13 | "outDir": "./dist", 14 | // "strictNullChecks": true, 15 | // "strictFunctionTypes": true, 16 | // "strictBindCallApply": true, 17 | // "strictPropertyInitialization": true, 18 | // "noImplicitAny": true, 19 | // "noImplicitThis": true, 20 | // "alwaysStrict": true, 21 | // "noUnusedLocals": true, 22 | // "noUnusedParameters": true, 23 | // "noImplicitReturns": true, 24 | // "noFallthroughCasesInSwitch": true, 25 | "resolveJsonModule": true, 26 | "rootDir": "./src", 27 | "skipDefaultLibCheck": true, 28 | "skipLibCheck": true, 29 | "sourceMap": true, 30 | "strict": false, 31 | "target": "ES2022", 32 | "types": ["node", "vitest", "vitest/globals"] 33 | }, 34 | "exclude": ["node_modules"], 35 | "include": ["src"] 36 | } 37 | -------------------------------------------------------------------------------- /docs/src/pages/_index.tsx: -------------------------------------------------------------------------------- 1 | import Link from '@docusaurus/Link'; 2 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 3 | import Layout from '@theme/Layout'; 4 | import clsx from 'clsx'; 5 | import React from 'react'; 6 | import styles from './index.module.css'; 7 | 8 | function HomepageHeader() { 9 | const { siteConfig } = useDocusaurusContext(); 10 | return ( 11 |
12 |
13 |

{siteConfig.title}

14 |

{siteConfig.tagline}

15 |
16 | 20 | Getting Started 21 | 22 |
23 |
24 |
25 | ); 26 | } 27 | 28 | export default function Home(): JSX.Element { 29 | const { siteConfig } = useDocusaurusContext(); 30 | return ( 31 | 35 | 36 |
{/* */}
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /example/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gridplus-sdk-docs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "TYPEDOC_WATCH=true docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "^3.4.0", 19 | "@docusaurus/preset-classic": "^3.4.0", 20 | "@mdx-js/react": "^3.0.1", 21 | "clsx": "^2.1.1", 22 | "mdx-mermaid": "^2.0.0", 23 | "mermaid": "^10.9.0", 24 | "prism-react-renderer": "^2.3.1", 25 | "react": "^18.3.1", 26 | "react-dom": "^18.3.1" 27 | }, 28 | "devDependencies": { 29 | "@docusaurus/module-type-aliases": "^3.4.0", 30 | "@tsconfig/docusaurus": "^2.0.3", 31 | "docusaurus-plugin-typedoc": "^1.0.1", 32 | "typedoc": "^0.25.13", 33 | "typedoc-plugin-markdown": "^4.0.1", 34 | "typescript": "^5.4.5" 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.5%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/docs-build-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build & deploy docs 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v5 13 | 14 | - name: Install Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 20.x 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v4 21 | with: 22 | version: 8 23 | 24 | - name: Install dependencies for SDK 25 | run: pnpm install 26 | 27 | - name: Install dependencies for Docs 28 | run: npm ci 29 | working-directory: ./docs 30 | 31 | - name: Build project 32 | run: pnpm run build 33 | working-directory: ./docs 34 | 35 | - name: Upload production-ready build files 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: production-files 39 | path: docs/build 40 | 41 | deploy: 42 | name: Deploy 43 | needs: build 44 | runs-on: ubuntu-latest 45 | if: github.ref == 'refs/heads/main' 46 | 47 | steps: 48 | - name: Download artifact 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: production-files 52 | path: docs/build 53 | 54 | - name: Deploy to gh-pages 55 | uses: peaceiris/actions-gh-pages@v4 56 | with: 57 | github_token: ${{ secrets.GITHUB_TOKEN }} 58 | publish_dir: docs/build 59 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # GridPlus SDK Scripts 2 | 3 | ## Device Pairing Script 4 | 5 | The `pair-device.ts` script provides a simple CLI interface for pairing your GridPlus Lattice device with the SDK. 6 | 7 | ### Usage 8 | 9 | #### Option 1: Using npm script (recommended) 10 | 11 | ```bash 12 | npm run pair-device 13 | ``` 14 | 15 | #### Option 2: Direct execution 16 | 17 | ```bash 18 | npx tsx scripts/pair-device.ts 19 | ``` 20 | 21 | ### Configuration 22 | 23 | The script can be configured using environment variables or interactive prompts: 24 | 25 | #### Environment Variables 26 | 27 | Create a `.env` file in the project root with: 28 | 29 | ```env 30 | DEVICE_ID=your_device_id 31 | PASSWORD=your_password 32 | APP_NAME=your_app_name 33 | ``` 34 | 35 | #### Interactive Mode 36 | 37 | If environment variables are not set, the script will prompt you for: 38 | 39 | - Device ID 40 | - Password (defaults to "password") 41 | - App Name (defaults to "CLI Pairing Tool") 42 | 43 | ### Pairing Process 44 | 45 | 1. The script attempts to connect to your device 46 | 2. If already paired, it confirms the connection 47 | 3. If not paired, it prompts for the pairing secret displayed on your Lattice device 48 | 4. Upon successful pairing, client state is saved to `./client.temp` 49 | 50 | ### Notes 51 | 52 | - The client state is saved locally in `./client.temp` for future use 53 | - Make sure your Lattice device is connected and accessible 54 | - The pairing secret is case-insensitive (automatically converted to uppercase) 55 | - If pairing fails, check your device connection and try again 56 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | line-height: 24px; 5 | font-weight: 400; 6 | 7 | color-scheme: light dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | background-color: #242424; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | -webkit-text-size-adjust: 100%; 16 | } 17 | 18 | #root { 19 | max-width: 1280px; 20 | margin: 0 auto; 21 | padding: 2rem; 22 | text-align: center; 23 | } 24 | 25 | a { 26 | font-weight: 500; 27 | color: #646cff; 28 | text-decoration: inherit; 29 | } 30 | a:hover { 31 | color: #535bf2; 32 | } 33 | 34 | body { 35 | margin: 0; 36 | display: flex; 37 | place-items: center; 38 | min-width: 320px; 39 | min-height: 100vh; 40 | } 41 | 42 | h1 { 43 | font-size: 3.2em; 44 | line-height: 1.1; 45 | } 46 | 47 | button { 48 | border-radius: 8px; 49 | border: 1px solid transparent; 50 | padding: 0.6em 1.2em; 51 | font-size: 1em; 52 | font-weight: 500; 53 | font-family: inherit; 54 | background-color: #1a1a1a; 55 | cursor: pointer; 56 | transition: border-color 0.25s; 57 | margin-top: 10px; 58 | } 59 | button:hover { 60 | border-color: #646cff; 61 | } 62 | button:focus, 63 | button:focus-visible { 64 | outline: 4px auto -webkit-focus-ring-color; 65 | } 66 | 67 | input { 68 | border-radius: 8px; 69 | border: 1px solid transparent; 70 | margin-top: 15px; 71 | font-size: 16px; 72 | padding: 0.6em; 73 | } 74 | -------------------------------------------------------------------------------- /src/__test__/e2e/solana/addresses.test.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey } from '@solana/web3.js'; 2 | import { question } from 'readline-sync'; 3 | import { pair } from '../../../api'; 4 | import { fetchSolanaAddresses } from '../../../api/addresses'; 5 | import { setupClient } from '../../utils/setup'; 6 | 7 | describe('Solana Addresses', () => { 8 | test('pair', async () => { 9 | const isPaired = await setupClient(); 10 | if (!isPaired) { 11 | const secret = question('Please enter the pairing secret: '); 12 | await pair(secret.toUpperCase()); 13 | } 14 | }); 15 | 16 | test('Should fetch a single Solana Ed25519 public key using fetchSolanaAddresses', async () => { 17 | const addresses = await fetchSolanaAddresses({ 18 | n: 10, 19 | }); 20 | 21 | const addrs = addresses 22 | .filter((addr) => { 23 | try { 24 | // Check if the key is a valid Ed25519 public key 25 | const pk = new PublicKey(addr); 26 | const isOnCurve = PublicKey.isOnCurve(pk.toBytes()); 27 | expect(isOnCurve).toBe(true); 28 | return true; 29 | } catch (e) { 30 | console.error('Invalid Solana public key:', e); 31 | return false; 32 | } 33 | }) 34 | .map((addr) => { 35 | const pk = new PublicKey(addr); 36 | return pk.toBase58(); 37 | }); 38 | 39 | // Ensure we got at least one valid address 40 | expect(addrs.length).toBeGreaterThan(0); 41 | // Ensure none of the addresses start with '11111' 42 | expect(addrs.every((addr) => !addr.startsWith('11111'))).toBe(true); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/__test__/utils/runners.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../../client'; 2 | import type { TestRequestPayload, SignRequestParams } from '../../types'; 3 | import { getEncodedPayload } from '../../genericSigning'; 4 | import { parseWalletJobResp, validateGenericSig } from './helpers'; 5 | import { testRequest } from './testRequest'; 6 | import { TEST_SEED } from './testConstants'; 7 | 8 | export async function runTestCase( 9 | payload: TestRequestPayload, 10 | expectedCode: number, 11 | ) { 12 | const res = await testRequest(payload); 13 | //@ts-expect-error - Accessing private property 14 | const fwVersion = payload.client.fwVersion; 15 | const parsedRes = parseWalletJobResp(res, fwVersion); 16 | expect(parsedRes.resultStatus).toEqual(expectedCode); 17 | return parsedRes; 18 | } 19 | 20 | export async function runGeneric(request: SignRequestParams, client: Client) { 21 | const response = await client.sign(request); 22 | // If no encoding type is specified we encode in hex or ascii 23 | const encodingType = request.data.encodingType || null; 24 | const allowedEncodings = client.getFwConstants().genericSigning.encodingTypes; 25 | const { payloadBuf } = getEncodedPayload( 26 | request.data.payload, 27 | encodingType, 28 | allowedEncodings, 29 | ); 30 | const seed = TEST_SEED; 31 | validateGenericSig( 32 | seed, 33 | response.sig, 34 | payloadBuf, 35 | request.data, 36 | response.pubkey, 37 | ); 38 | return response; 39 | } 40 | 41 | export const runEthMsg = async (req: SignRequestParams, client: Client) => { 42 | const sig = await client.sign(req); 43 | expect(sig.sig).not.toEqual(null); 44 | }; 45 | -------------------------------------------------------------------------------- /src/__test__/e2e/signing/solana/solana.programs.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * REQUIRED TEST MNEMONIC: 3 | * These tests require a SafeCard loaded with the standard test mnemonic: 4 | * "test test test test test test test test test test test junk" 5 | * 6 | * Running with a different mnemonic will cause test failures due to 7 | * incorrect key derivations and signature mismatches. 8 | */ 9 | import { Constants } from '../../../..'; 10 | import { setupClient } from '../../../utils/setup'; 11 | import { dexlabProgram, raydiumProgram } from './__mocks__/programs'; 12 | 13 | describe('Solana Programs', () => { 14 | let client; 15 | 16 | beforeAll(async () => { 17 | client = await setupClient(); 18 | }); 19 | 20 | it('should sign Dexlab program', async () => { 21 | const payload = dexlabProgram; 22 | const signedMessage = await client.sign({ 23 | data: { 24 | signerPath: [0x80000000 + 44, 0x80000000 + 501, 0x80000000], 25 | curveType: Constants.SIGNING.CURVES.ED25519, 26 | hashType: Constants.SIGNING.HASHES.NONE, 27 | encodingType: Constants.SIGNING.ENCODINGS.SOLANA, 28 | payload, 29 | }, 30 | }); 31 | expect(signedMessage).toBeTruthy(); 32 | }); 33 | 34 | it('should sign Raydium program', async () => { 35 | const payload = raydiumProgram; 36 | const signedMessage = await client.sign({ 37 | data: { 38 | signerPath: [0x80000000 + 44, 0x80000000 + 501, 0x80000000], 39 | curveType: Constants.SIGNING.CURVES.ED25519, 40 | hashType: Constants.SIGNING.HASHES.NONE, 41 | encodingType: Constants.SIGNING.ENCODINGS.SOLANA, 42 | payload, 43 | }, 44 | }); 45 | expect(signedMessage).toBeTruthy(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/__test__/utils/__test__/builders.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildEvmReq, 3 | buildRandomVectors, 4 | getFwVersionsList, 5 | } from '../builders'; 6 | 7 | describe('building', () => { 8 | test('should test client', () => { 9 | expect(getFwVersionsList()).toMatchSnapshot(); 10 | }); 11 | 12 | test('RANDOM_VEC', () => { 13 | const RANDOM_VEC = buildRandomVectors(10); 14 | expect(RANDOM_VEC).toMatchInlineSnapshot(` 15 | [ 16 | "9f2c1f8", 17 | "334e3bf5", 18 | "3748e38b", 19 | "3b2f82b", 20 | "1eecc588", 21 | "36b9be74", 22 | "332c1296", 23 | "2afaf74c", 24 | "1121991", 25 | "2851e10c", 26 | ] 27 | `); 28 | }); 29 | 30 | test('buildEvmReq', () => { 31 | const testObj = buildEvmReq({ 32 | common: 'test', 33 | data: { payload: 'test' }, 34 | txData: { data: 'test', type: undefined }, 35 | }); 36 | expect(testObj).toMatchInlineSnapshot(` 37 | { 38 | "common": "test", 39 | "data": { 40 | "curveType": 0, 41 | "encodingType": 4, 42 | "hashType": 1, 43 | "payload": "test", 44 | "signerPath": [ 45 | 2147483692, 46 | 2147483708, 47 | 2147483648, 48 | 0, 49 | 0, 50 | ], 51 | }, 52 | "txData": { 53 | "data": "test", 54 | "gasLimit": 50000, 55 | "maxFeePerGas": 1200000000, 56 | "maxPriorityFeePerGas": 1200000000, 57 | "nonce": 0, 58 | "to": "0xe242e54155b1abc71fc118065270cecaaf8b7768", 59 | "type": undefined, 60 | "value": 100, 61 | }, 62 | } 63 | `); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/types/secureMessages.ts: -------------------------------------------------------------------------------- 1 | import { LatticeSecureMsgType, LatticeResponseCode } from '../protocol'; 2 | import { LatticeMessageHeader } from './messages'; 3 | 4 | export interface LatticeSecureRequest { 5 | // Message header 6 | header: LatticeMessageHeader; 7 | // Request data 8 | payload: LatticeSecureRequestPayload; 9 | } 10 | 11 | export interface LatticeSecureRequestPayload { 12 | // Indicates whether this is a connect (0x01) or 13 | // encrypted (0x02) secure request 14 | // [uint8] 15 | requestType: LatticeSecureMsgType; 16 | // Request data 17 | // [connect = 65 bytes, encrypted = 1732] 18 | data: Buffer; 19 | } 20 | 21 | export interface LatticeSecureConnectResponsePayload { 22 | // [214 bytes] 23 | data: Buffer; 24 | } 25 | 26 | export interface LatticeSecureEncryptedResponsePayload { 27 | // Error code 28 | responseCode: LatticeResponseCode; 29 | // Response data 30 | // [3392 bytes] 31 | data: Buffer; 32 | } 33 | 34 | export interface LatticeSecureConnectRequestPayloadData { 35 | // Public key corresponding to the static Client keypair 36 | // [65 bytes] 37 | pubkey: Buffer; 38 | } 39 | 40 | export interface LatticeSecureEncryptedRequestPayloadData { 41 | // SHA256(sharedSecret).slice(0, 4) 42 | // [uint32] 43 | ephemeralId: number; 44 | // Encrypted data envelope 45 | // [1728 bytes] 46 | encryptedData: Buffer; 47 | } 48 | 49 | export interface LatticeSecureDecryptedResponse { 50 | // ECDSA public key that should replace the client's ephemeral key 51 | // [65 bytes] 52 | ephemeralPub: Buffer; 53 | // Decrypted response data 54 | // [Variable size] 55 | data: Buffer; 56 | // Checksum on response data (ephemeralKey | data) 57 | // [uint32] 58 | checksum: number; 59 | } 60 | -------------------------------------------------------------------------------- /src/api/addressTags.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | import { MAX_ADDR } from '../constants'; 3 | import { AddressTag } from '../types'; 4 | import { queue } from './utilities'; 5 | 6 | /** 7 | * Sends request to the Lattice to add Address Tags. 8 | */ 9 | export const addAddressTags = async ( 10 | tags: [{ [key: string]: string }], 11 | ): Promise => { 12 | // convert an array of objects to an object 13 | const records = tags.reduce((acc, tag) => { 14 | const key = Object.keys(tag)[0]; 15 | acc[key] = tag[key]; 16 | return acc; 17 | }, {}); 18 | 19 | return queue((client) => client.addKvRecords({ records })); 20 | }; 21 | 22 | /** 23 | * Fetches Address Tags from the Lattice. 24 | */ 25 | export const fetchAddressTags = async ({ 26 | n = MAX_ADDR, 27 | start = 0, 28 | }: { n?: number; start?: number } = {}) => { 29 | const addressTags: AddressTag[] = []; 30 | let remainingToFetch = n; 31 | let fetched = start; 32 | 33 | while (remainingToFetch > 0) { 34 | await queue((client) => 35 | client 36 | .getKvRecords({ 37 | start: fetched, 38 | n: remainingToFetch > MAX_ADDR ? MAX_ADDR : remainingToFetch, 39 | }) 40 | .then(async (res) => { 41 | addressTags.push(...res.records); 42 | fetched = res.fetched + fetched; 43 | remainingToFetch = res.total - fetched; 44 | }), 45 | ); 46 | } 47 | return addressTags; 48 | }; 49 | 50 | /** 51 | * Removes Address Tags from the Lattice. 52 | */ 53 | export const removeAddressTags = async ( 54 | tags: AddressTag[], 55 | ): Promise => { 56 | const ids = tags.map((tag) => `${tag.id}`); 57 | return queue((client: Client) => client.removeKvRecords({ ids })); 58 | }; 59 | -------------------------------------------------------------------------------- /src/__test__/utils/setup.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | import readlineSync from 'readline-sync'; 3 | import { getClient, pair, setup } from '../../api'; 4 | 5 | const question = readlineSync.question; 6 | 7 | const TEMP_CLIENT_FILE = './client.temp'; 8 | 9 | export async function setStoredClient(data: string) { 10 | try { 11 | fs.writeFileSync(TEMP_CLIENT_FILE, data); 12 | } catch (err) { 13 | console.error('Failed to store client data:', err); 14 | return; 15 | } 16 | } 17 | 18 | export async function getStoredClient() { 19 | try { 20 | return fs.readFileSync(TEMP_CLIENT_FILE, 'utf8'); 21 | } catch (err) { 22 | console.error('Failed to read stored client data:', err); 23 | return ''; 24 | } 25 | } 26 | 27 | export async function setupClient() { 28 | const deviceId = process.env.DEVICE_ID; 29 | const baseUrl = process.env.baseUrl || 'https://signing.gridpl.us'; 30 | const password = process.env.PASSWORD || 'password'; 31 | const name = process.env.APP_NAME || 'SDK Test'; 32 | let pairingSecret = process.env.PAIRING_SECRET; 33 | const isPaired = await setup({ 34 | deviceId, 35 | password, 36 | name, 37 | baseUrl, 38 | getStoredClient, 39 | setStoredClient, 40 | }); 41 | if (!isPaired) { 42 | if (!pairingSecret) { 43 | if (process.env.CI) { 44 | throw new Error( 45 | 'Pairing secret is required. If simulator is running, set PAIRING_SECRET environment variable.', 46 | ); 47 | } 48 | pairingSecret = question('Enter pairing secret:'); 49 | if (!pairingSecret) { 50 | throw new Error('Pairing secret is required.'); 51 | } 52 | } 53 | await pair(pairingSecret.toUpperCase()); 54 | } 55 | return getClient(); 56 | } 57 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | import sidebar from './docs/reference/typedoc-sidebar.cjs'; 13 | // @ts-check 14 | 15 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 16 | const sidebars = { 17 | // By default, Docusaurus generates a sidebar from the docs folder structure 18 | sidebar: [ 19 | { 20 | type: 'doc', 21 | id: 'index', 22 | }, 23 | { 24 | type: 'doc', 25 | id: 'migration-v3-to-v4', 26 | }, 27 | { 28 | type: 'category', 29 | label: 'Basic Functionality', 30 | collapsible: false, 31 | items: [ 32 | { 33 | type: 'doc', 34 | id: 'addresses', 35 | }, 36 | { 37 | type: 'doc', 38 | id: 'signing', 39 | }, 40 | ], 41 | }, 42 | { 43 | type: 'category', 44 | label: 'Tutorials', 45 | collapsible: false, 46 | items: [ 47 | { 48 | type: 'doc', 49 | id: 'tutorials/calldataDecoding', 50 | }, 51 | { 52 | type: 'doc', 53 | id: 'tutorials/addressTags', 54 | }, 55 | ], 56 | }, 57 | { 58 | type: 'doc', 59 | id: 'testing', 60 | }, 61 | { 62 | type: 'category', 63 | label: 'Reference', 64 | items: [sidebar], 65 | }, 66 | // { 67 | // type: 'autogenerated', 68 | // dirName: '.', // '.' means the current docs folder 69 | // }, 70 | ], 71 | }; 72 | 73 | module.exports = sidebars; 74 | -------------------------------------------------------------------------------- /src/__test__/utils/__test__/serializers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deserializeObjectWithBuffers, 3 | serializeObjectWithBuffers, 4 | } from '../serializers'; 5 | 6 | describe('serializers', () => { 7 | test('serialize obj', () => { 8 | const obj = { 9 | a: 1, 10 | b: Buffer.from('test'), 11 | c: { 12 | d: 2, 13 | e: Buffer.from('test'), 14 | }, 15 | }; 16 | const serialized = serializeObjectWithBuffers(obj); 17 | expect(serialized).toMatchInlineSnapshot(` 18 | { 19 | "a": 1, 20 | "b": { 21 | "isBuffer": true, 22 | "value": "74657374", 23 | }, 24 | "c": { 25 | "d": 2, 26 | "e": { 27 | "isBuffer": true, 28 | "value": "74657374", 29 | }, 30 | }, 31 | } 32 | `); 33 | }); 34 | 35 | test('deserialize obj', () => { 36 | const obj = { 37 | a: 1, 38 | b: { 39 | isBuffer: true, 40 | value: '74657374', 41 | }, 42 | c: { 43 | d: 2, 44 | e: { 45 | isBuffer: true, 46 | value: '74657374', 47 | }, 48 | }, 49 | }; 50 | 51 | const serialized = deserializeObjectWithBuffers(obj); 52 | expect(serialized).toMatchInlineSnapshot(` 53 | { 54 | "a": 1, 55 | "b": { 56 | "data": [ 57 | 116, 58 | 101, 59 | 115, 60 | 116, 61 | ], 62 | "type": "Buffer", 63 | }, 64 | "c": { 65 | "d": 2, 66 | "e": { 67 | "data": [ 68 | 116, 69 | 101, 70 | 115, 71 | 116, 72 | ], 73 | "type": "Buffer", 74 | }, 75 | }, 76 | } 77 | `); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/types/client.ts: -------------------------------------------------------------------------------- 1 | import { CURRENCIES } from '../constants'; 2 | import { KeyPair } from './shared'; 3 | import type { Address, Hash, Signature } from 'viem'; 4 | 5 | export type Currency = keyof typeof CURRENCIES; 6 | 7 | export type SigningPath = number[]; 8 | 9 | export interface SignData { 10 | tx?: string; 11 | txHash?: Hash; 12 | changeRecipient?: string; 13 | sig?: Signature; 14 | sigs?: Buffer[]; 15 | signer?: Address; 16 | err?: string; 17 | } 18 | 19 | export type SigningRequestResponse = SignData | { pubkey: null; sig: null }; 20 | 21 | /** 22 | * @deprecated This type uses legacy field names and number types instead of viem-compatible bigint. 23 | * Use viem's TransactionSerializable types directly, or create viem-aligned request types. 24 | * This will be removed in a future version. 25 | */ 26 | export interface TransactionPayload { 27 | type: number; 28 | gasPrice: number; 29 | nonce: number; 30 | gasLimit: number; 31 | to: string; 32 | value: number; 33 | data: string; 34 | maxFeePerGas: number; 35 | maxPriorityFeePerGas: number; 36 | } 37 | 38 | export interface Wallet { 39 | /** 32 byte id */ 40 | uid: Buffer; 41 | /** 20 char (max) string */ 42 | name: Buffer | null; 43 | /** 4 byte flag */ 44 | capabilities: number; 45 | /** External or internal wallet */ 46 | external: boolean; 47 | } 48 | 49 | export interface ActiveWallets { 50 | internal: Wallet; 51 | external: Wallet; 52 | } 53 | 54 | export interface RequestParams { 55 | url: string; 56 | payload: any; //TODO Fix this any 57 | timeout?: number; 58 | retries?: number; 59 | } 60 | 61 | export interface ClientStateData { 62 | activeWallets: ActiveWallets; 63 | ephemeralPub: KeyPair; 64 | fwVersion: Buffer; 65 | deviceId: string; 66 | name: string; 67 | baseUrl: string; 68 | privKey: Buffer; 69 | key: Buffer; 70 | retryCount: number; 71 | timeout: number; 72 | } 73 | -------------------------------------------------------------------------------- /src/types/firmware.ts: -------------------------------------------------------------------------------- 1 | import { EXTERNAL } from '../constants'; 2 | 3 | export type FirmwareArr = [number, number, number]; 4 | 5 | export interface FirmwareVersion { 6 | major: number; 7 | minor: number; 8 | fix: number; 9 | } 10 | 11 | export interface GenericSigningData { 12 | calldataDecoding: { 13 | reserved: number; 14 | maxSz: number; 15 | }; 16 | baseReqSz: number; 17 | // See `GENERIC_SIGNING_BASE_MSG_SZ` in firmware 18 | baseDataSz: number; 19 | hashTypes: typeof EXTERNAL.SIGNING.HASHES; 20 | curveTypes: typeof EXTERNAL.SIGNING.CURVES; 21 | encodingTypes: { 22 | NONE: typeof EXTERNAL.SIGNING.ENCODINGS.NONE; 23 | SOLANA: typeof EXTERNAL.SIGNING.ENCODINGS.SOLANA; 24 | EVM?: typeof EXTERNAL.SIGNING.ENCODINGS.EVM; 25 | }; 26 | } 27 | 28 | export interface FirmwareConstants { 29 | abiCategorySz: number; 30 | abiMaxRmv: number; 31 | addrFlagsAllowed: boolean; 32 | allowBtcLegacyAndSegwitAddrs: boolean; 33 | allowedEthTxTypes: number[]; 34 | contractDeployKey: string; 35 | eip712MaxTypeParams: number; 36 | eip712Supported: boolean; 37 | ethMaxDataSz: number; 38 | ethMaxGasPrice: number; 39 | ethMaxMsgSz: number; 40 | ethMsgPreHashAllowed: boolean; 41 | extraDataFrameSz: number; 42 | extraDataMaxFrames: number; 43 | genericSigning: GenericSigningData; 44 | getAddressFlags: [ 45 | typeof EXTERNAL.GET_ADDR_FLAGS.ED25519_PUB, 46 | typeof EXTERNAL.GET_ADDR_FLAGS.SECP256K1_PUB, 47 | ]; 48 | kvActionMaxNum: number; 49 | kvActionsAllowed: boolean; 50 | kvKeyMaxStrSz: number; 51 | kvRemoveMaxNum: number; 52 | kvValMaxStrSz: number; 53 | maxDecoderBufSz: number; 54 | personalSignHeaderSz: number; 55 | prehashAllowed: boolean; 56 | reqMaxDataSz: number; 57 | varAddrPathSzAllowed: boolean; 58 | flexibleAddrPaths?: boolean; 59 | } 60 | 61 | export interface LatticeError { 62 | code: string; 63 | errno: string; 64 | message: string; 65 | } 66 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import styles from './HomepageFeatures.module.css'; 4 | 5 | type FeatureItem = { 6 | title: string; 7 | image: string; 8 | description: JSX.Element; 9 | }; 10 | 11 | const FeatureList: FeatureItem[] = [ 12 | { 13 | title: 'Easy to Use', 14 | image: '/img/undraw_docusaurus_mountain.svg', 15 | description: ( 16 | <> 17 | Docusaurus was designed from the ground up to be easily installed and 18 | used to get your website up and running quickly. 19 | 20 | ), 21 | }, 22 | { 23 | title: 'Focus on What Matters', 24 | image: '/img/undraw_docusaurus_tree.svg', 25 | description: ( 26 | <> 27 | Docusaurus lets you focus on your docs, and we'll do the chores. Go 28 | ahead and move your docs into the docs directory. 29 | 30 | ), 31 | }, 32 | { 33 | title: 'Powered by React', 34 | image: '/img/undraw_docusaurus_react.svg', 35 | description: ( 36 | <> 37 | Extend or customize your website layout by reusing React. Docusaurus can 38 | be extended while reusing the same header and footer. 39 | 40 | ), 41 | }, 42 | ]; 43 | 44 | function Feature({ title, image, description }: FeatureItem) { 45 | return ( 46 |
47 |
48 | {title} 49 |
50 |
51 |

{title}

52 |

{description}

53 |
54 |
55 | ); 56 | } 57 | 58 | export default function HomepageFeatures(): JSX.Element { 59 | return ( 60 |
61 |
62 |
63 | {FeatureList.map((props, idx) => ( 64 | 65 | ))} 66 |
67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/__test__/unit/api.test.ts: -------------------------------------------------------------------------------- 1 | import { parseDerivationPath } from '../../api/utilities'; 2 | 3 | describe('parseDerivationPath', () => { 4 | it('parses a simple derivation path correctly', () => { 5 | const result = parseDerivationPath('44/0/0/0'); 6 | expect(result).toEqual([44, 0, 0, 0]); 7 | }); 8 | 9 | it('parses a derivation path with hardened indices correctly', () => { 10 | const result = parseDerivationPath("44'/0'/0'/0"); 11 | expect(result).toEqual([0x8000002c, 0x80000000, 0x80000000, 0]); 12 | }); 13 | 14 | it('handles mixed hardened and non-hardened indices', () => { 15 | const result = parseDerivationPath("44'/60/0'/0/0"); 16 | expect(result).toEqual([0x8000002c, 60, 0x80000000, 0, 0]); 17 | }); 18 | 19 | it('interprets lowercase x as 0', () => { 20 | const result = parseDerivationPath('44/x/0/0'); 21 | expect(result).toEqual([44, 0, 0, 0]); 22 | }); 23 | 24 | it('interprets uppercase X as 0', () => { 25 | const result = parseDerivationPath('44/X/0/0'); 26 | expect(result).toEqual([44, 0, 0, 0]); 27 | }); 28 | 29 | it("interprets X' as hardened zero", () => { 30 | const result = parseDerivationPath("44'/X'/0/0"); 31 | expect(result).toEqual([0x8000002c, 0x80000000, 0, 0]); 32 | }); 33 | 34 | it("interprets x' as hardened zero", () => { 35 | const result = parseDerivationPath("44'/x'/0/0"); 36 | expect(result).toEqual([0x8000002c, 0x80000000, 0, 0]); 37 | }); 38 | 39 | it('handles a complex path with all features', () => { 40 | const result = parseDerivationPath("44'/501'/X'/0'"); 41 | expect(result).toEqual([0x8000002c, 0x800001f5, 0x80000000, 0x80000000]); 42 | }); 43 | 44 | it('returns an empty array for an empty path', () => { 45 | const result = parseDerivationPath(''); 46 | expect(result).toEqual([]); 47 | }); 48 | 49 | it('handles leading slash correctly', () => { 50 | const result = parseDerivationPath('/44/0/0/0'); 51 | expect(result).toEqual([44, 0, 0, 0]); 52 | }); 53 | 54 | it('throws error for invalid input', () => { 55 | expect(() => parseDerivationPath('invalid/path')).toThrow( 56 | 'Invalid part in derivation path: invalid', 57 | ); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/__test__/e2e/signing/evm-tx.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unified EVM Transaction Test Suite 3 | * 4 | * This single test file contains ALL EVM transaction tests organized by transaction type. 5 | * Each transaction type has its own describe block with comprehensive test vectors. 6 | * This replaces all individual EVM test files to avoid duplication and provide unified testing. 7 | */ 8 | 9 | import { setupClient } from '../../utils/setup'; 10 | import { signAndCompareTransaction } from '../../utils/viemComparison'; 11 | import { 12 | EDGE_CASE_TEST_VECTORS, 13 | EIP1559_TEST_VECTORS, 14 | EIP2930_TEST_VECTORS, 15 | EIP7702_TEST_VECTORS, 16 | LEGACY_VECTORS, 17 | } from './vectors'; 18 | 19 | describe('EVM Transaction Signing - Unified Test Suite', () => { 20 | beforeAll(async () => { 21 | await setupClient(); 22 | }); 23 | 24 | describe('Legacy Transactions', () => { 25 | LEGACY_VECTORS.forEach((vector, index) => { 26 | it(`${vector.name} (${index + 1}/${LEGACY_VECTORS.length})`, async () => { 27 | await signAndCompareTransaction(vector.tx, vector.name); 28 | }); 29 | }); 30 | }); 31 | 32 | describe('EIP-1559 Transactions (Fee Market)', () => { 33 | EIP1559_TEST_VECTORS.forEach((vector, index) => { 34 | it(`${vector.name} (${index + 1}/${EIP1559_TEST_VECTORS.length})`, async () => { 35 | await signAndCompareTransaction(vector.tx, vector.name); 36 | }); 37 | }); 38 | }); 39 | 40 | describe('EIP-2930 Transactions (Access Lists)', () => { 41 | EIP2930_TEST_VECTORS.forEach((vector, index) => { 42 | it(`${vector.name} (${index + 1}/${EIP2930_TEST_VECTORS.length})`, async () => { 43 | await signAndCompareTransaction(vector.tx, vector.name); 44 | }); 45 | }); 46 | }); 47 | 48 | describe('EIP-7702 Transactions (Account Abstraction)', () => { 49 | EIP7702_TEST_VECTORS.forEach((vector, index) => { 50 | it(`${vector.name} (${index + 1}/${EIP7702_TEST_VECTORS.length})`, async () => { 51 | await signAndCompareTransaction(vector.tx, vector.name); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('Edge Cases & Boundary Conditions', () => { 57 | EDGE_CASE_TEST_VECTORS.forEach((vector, index) => { 58 | it(`${vector.name} (${index + 1}/${EDGE_CASE_TEST_VECTORS.length})`, async () => { 59 | await signAndCompareTransaction(vector.tx, vector.name); 60 | }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/functions/pair.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LatticeSecureEncryptedRequestType, 3 | encryptedSecureRequest, 4 | } from '../protocol'; 5 | import { getPubKeyBytes } from '../shared/utilities'; 6 | import { validateConnectedClient } from '../shared/validators'; 7 | import { PairRequestParams, KeyPair } from '../types'; 8 | import { generateAppSecret, toPaddedDER } from '../util'; 9 | 10 | /** 11 | * If a pairing secret is provided, `pair` uses it to sign a hash of the public key, name, and 12 | * pairing secret. It then sends the name and signature to the device. If no pairing secret is 13 | * provided, `pair` sends a zero-length name buffer to the device. 14 | * @category Lattice 15 | * @returns The active wallet object. 16 | */ 17 | export async function pair({ 18 | client, 19 | pairingSecret, 20 | }: PairRequestParams): Promise { 21 | const { url, sharedSecret, ephemeralPub, appName, key } = 22 | validateConnectedClient(client); 23 | const data = encodePairRequest({ pairingSecret, key, appName }); 24 | 25 | const { newEphemeralPub } = await encryptedSecureRequest({ 26 | data, 27 | requestType: LatticeSecureEncryptedRequestType.finalizePairing, 28 | sharedSecret, 29 | ephemeralPub, 30 | url, 31 | }); 32 | 33 | client.mutate({ 34 | ephemeralPub: newEphemeralPub, 35 | isPaired: true, 36 | }); 37 | 38 | await client.fetchActiveWallet(); 39 | return client.hasActiveWallet(); 40 | } 41 | 42 | export const encodePairRequest = ({ 43 | key, 44 | pairingSecret, 45 | appName, 46 | }: { 47 | key: KeyPair; 48 | pairingSecret: string; 49 | appName: string; 50 | }) => { 51 | // Build the payload data 52 | const pubKeyBytes = getPubKeyBytes(key); 53 | const nameBuf = Buffer.alloc(25); 54 | if (pairingSecret.length > 0) { 55 | // If a pairing secret of zero length is passed in, it usually indicates we want to cancel 56 | // the pairing attempt. In this case we pass a zero-length name buffer so the firmware can 57 | // know not to draw the error screen. Note that we still expect an error to come back 58 | // (RESP_ERR_PAIR_FAIL) 59 | nameBuf.write(appName); 60 | } 61 | const hash = generateAppSecret( 62 | pubKeyBytes, 63 | nameBuf, 64 | Buffer.from(pairingSecret), 65 | ); 66 | const sig = key.sign(hash); 67 | const derSig = toPaddedDER(sig); 68 | const payload = Buffer.concat([nameBuf, derSig]); 69 | return payload; 70 | }; 71 | -------------------------------------------------------------------------------- /src/__test__/unit/decoders.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | decodeConnectResponse, 3 | decodeFetchEncData, 4 | decodeGetAddressesResponse, 5 | decodeGetKvRecordsResponse, 6 | decodeSignResponse, 7 | } from '../../functions'; 8 | import { DecodeSignResponseParams } from '../../types'; 9 | import { 10 | clientKeyPair, 11 | connectDecoderData, 12 | decoderTestsFwConstants, 13 | fetchEncryptedDataDecoderData, 14 | fetchEncryptedDataRequest, 15 | getAddressesDecoderData, 16 | getAddressesFlag, 17 | getKvRecordsDecoderData, 18 | signBitcoinDecoderData, 19 | signBitcoinRequest, 20 | signGenericDecoderData, 21 | signGenericRequest, 22 | } from './__mocks__/decoderData'; 23 | 24 | describe('decoders', () => { 25 | test('connect', () => { 26 | expect( 27 | decodeConnectResponse(connectDecoderData, clientKeyPair), 28 | ).toMatchSnapshot(); 29 | }); 30 | 31 | test('getAddresses', () => { 32 | expect( 33 | decodeGetAddressesResponse(getAddressesDecoderData, getAddressesFlag), 34 | ).toMatchSnapshot(); 35 | }); 36 | 37 | test('sign - bitcoin', () => { 38 | const params: DecodeSignResponseParams = { 39 | data: signBitcoinDecoderData, 40 | request: signBitcoinRequest, 41 | isGeneric: false, 42 | currency: 'BTC', 43 | }; 44 | expect(decodeSignResponse(params)).toMatchSnapshot(); 45 | }); 46 | 47 | test('sign - generic', () => { 48 | const params: DecodeSignResponseParams = { 49 | data: signGenericDecoderData, 50 | request: signGenericRequest, 51 | isGeneric: true, 52 | }; 53 | expect(decodeSignResponse(params)).toMatchSnapshot(); 54 | }); 55 | 56 | test('getKvRecords', () => { 57 | expect( 58 | decodeGetKvRecordsResponse( 59 | getKvRecordsDecoderData, 60 | decoderTestsFwConstants, 61 | ), 62 | ).toMatchSnapshot(); 63 | }); 64 | 65 | test('fetchEncryptedData', () => { 66 | // This test is different than the others because one part of the data is 67 | // randomly generated (UUID) before the response is returned. 68 | // We will just zero it out for testing purposes. 69 | const decoded = decodeFetchEncData({ 70 | data: fetchEncryptedDataDecoderData, 71 | ...fetchEncryptedDataRequest, 72 | }); 73 | const decodedDerp = JSON.parse(decoded.toString()); 74 | decodedDerp.uuid = '00000000-0000-0000-0000-000000000000'; 75 | expect(Buffer.from(JSON.stringify(decodedDerp))).toMatchSnapshot(); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/__test__/unit/selectDefFrom4byteABI.test.ts: -------------------------------------------------------------------------------- 1 | import { selectDefFrom4byteABI } from '../../util'; 2 | 3 | describe('selectDefFrom4byteAbi', () => { 4 | beforeAll(() => { 5 | // Disable this mock to restore console logs when testing 6 | console.warn = vi.fn(); 7 | }); 8 | 9 | afterAll(() => { 10 | vi.clearAllMocks(); 11 | }); 12 | 13 | test('select correct result', () => { 14 | const result = [ 15 | { 16 | bytes_signature: '8í9', 17 | created_at: '2020-08-09T08:56:14.110995Z', 18 | hex_signature: '0x38ed1739', 19 | id: 171801, 20 | text_signature: 21 | 'swapExactTokensForTokens(uint256,uint256,address[],address,uint256)', 22 | }, 23 | { 24 | bytes_signature: '8í9', 25 | created_at: '2020-01-09T08:56:14.110995Z', 26 | hex_signature: '0x38ed1739', 27 | id: 171806, 28 | text_signature: 29 | 'swapExactTokensForTokens(uint256,uint256,address[],address,uint256)', 30 | }, 31 | { 32 | bytes_signature: '', 33 | created_at: '2020-01-09T08:56:14.110995Z', 34 | hex_signature: '0x38ed9', 35 | id: 171806, 36 | text_signature: 'swapToken', 37 | }, 38 | ]; 39 | const selector = '0x38ed1739'; 40 | expect(selectDefFrom4byteABI(result, selector)).toMatchSnapshot(); 41 | }); 42 | 43 | test('handle no match', () => { 44 | const result = [ 45 | { 46 | bytes_signature: '', 47 | created_at: '2020-01-09T08:56:14.110995Z', 48 | hex_signature: '0x3ed9', 49 | id: 171806, 50 | text_signature: 'swapToken', 51 | }, 52 | ]; 53 | const selector = '0x38ed1739'; 54 | expect(() => selectDefFrom4byteABI(result, selector)).toThrowError(); 55 | }); 56 | 57 | test('handle no selector', () => { 58 | const result = [ 59 | { 60 | bytes_signature: '', 61 | created_at: '2020-01-09T08:56:14.110995Z', 62 | hex_signature: '0x3ed9', 63 | id: 171806, 64 | text_signature: 'swapToken', 65 | }, 66 | ]; 67 | const selector = undefined; 68 | expect(() => selectDefFrom4byteABI(result, selector)).toThrowError(); 69 | }); 70 | 71 | test('handle no result', () => { 72 | const result = undefined; 73 | const selector = '0x38ed1739'; 74 | expect(() => selectDefFrom4byteABI(result, selector)).toThrowError(); 75 | }); 76 | 77 | test('handle bad data', () => { 78 | const result = []; 79 | const selector = ''; 80 | expect(() => selectDefFrom4byteABI(result, selector)).toThrowError(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/functions/removeKvRecords.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LatticeSecureEncryptedRequestType, 3 | encryptedSecureRequest, 4 | } from '../protocol'; 5 | import { validateConnectedClient } from '../shared/validators'; 6 | import { 7 | RemoveKvRecordsRequestFunctionParams, 8 | FirmwareConstants, 9 | } from '../types'; 10 | 11 | /** 12 | * `removeKvRecords` takes in an array of ids and sends a request to remove them from the Lattice. 13 | * @category Lattice 14 | * @returns A callback with an error or null. 15 | */ 16 | export async function removeKvRecords({ 17 | client, 18 | type: _type, 19 | ids: _ids, 20 | }: RemoveKvRecordsRequestFunctionParams): Promise { 21 | const { url, sharedSecret, ephemeralPub, fwConstants } = 22 | validateConnectedClient(client); 23 | 24 | const { type, ids } = validateRemoveKvRequest({ 25 | fwConstants, 26 | type: _type, 27 | ids: _ids, 28 | }); 29 | 30 | const data = encodeRemoveKvRecordsRequest({ 31 | type, 32 | ids, 33 | fwConstants, 34 | }); 35 | 36 | const { decryptedData, newEphemeralPub } = await encryptedSecureRequest({ 37 | data, 38 | requestType: LatticeSecureEncryptedRequestType.removeKvRecords, 39 | sharedSecret, 40 | ephemeralPub, 41 | url, 42 | }); 43 | 44 | client.mutate({ 45 | ephemeralPub: newEphemeralPub, 46 | }); 47 | 48 | return decryptedData; 49 | } 50 | 51 | export const validateRemoveKvRequest = ({ 52 | fwConstants, 53 | type, 54 | ids, 55 | }: { 56 | fwConstants: FirmwareConstants; 57 | type?: number; 58 | ids?: string[]; 59 | }) => { 60 | if (!fwConstants.kvActionsAllowed) { 61 | throw new Error('Unsupported. Please update firmware.'); 62 | } 63 | if (!Array.isArray(ids) || ids.length < 1) { 64 | throw new Error('You must include one or more `ids` to removed.'); 65 | } 66 | if (ids.length > fwConstants.kvRemoveMaxNum) { 67 | throw new Error( 68 | `Only up to ${fwConstants.kvRemoveMaxNum} records may be removed at once.`, 69 | ); 70 | } 71 | if (type !== 0 && !type) { 72 | throw new Error('You must specify a type.'); 73 | } 74 | return { type, ids }; 75 | }; 76 | 77 | export const encodeRemoveKvRecordsRequest = ({ 78 | fwConstants, 79 | type, 80 | ids, 81 | }: { 82 | fwConstants: FirmwareConstants; 83 | type: number; 84 | ids: string[]; 85 | }) => { 86 | const payload = Buffer.alloc(5 + 4 * fwConstants.kvRemoveMaxNum); 87 | payload.writeUInt32LE(type, 0); 88 | payload.writeUInt8(ids.length, 4); 89 | for (let i = 0; i < ids.length; i++) { 90 | const id = parseInt(ids[i] as string); 91 | payload.writeUInt32LE(id, 5 + 4 * i); 92 | } 93 | return payload; 94 | }; 95 | -------------------------------------------------------------------------------- /src/__test__/integration/connect.test.ts: -------------------------------------------------------------------------------- 1 | import { HARDENED_OFFSET } from '../../constants'; 2 | import { buildEthSignRequest } from '../utils/builders'; 3 | import { getDeviceId } from '../utils/getters'; 4 | import { BTC_PURPOSE_P2PKH, ETH_COIN, setupTestClient } from '../utils/helpers'; 5 | 6 | describe('connect', () => { 7 | it('should test connect', async () => { 8 | const client = setupTestClient(); 9 | const isPaired = await client.connect(getDeviceId()); 10 | expect(isPaired).toMatchSnapshot(); 11 | }); 12 | 13 | it('should test fetchActiveWallet', async () => { 14 | const client = setupTestClient(); 15 | await client.connect(getDeviceId()); 16 | await client.fetchActiveWallet(); 17 | }); 18 | 19 | it('should test getAddresses', async () => { 20 | const client = setupTestClient(); 21 | await client.connect(getDeviceId()); 22 | 23 | const startPath = [BTC_PURPOSE_P2PKH, ETH_COIN, HARDENED_OFFSET, 0, 0]; 24 | 25 | const addrs = await client.getAddresses({ startPath, n: 1 }); 26 | expect(addrs).toMatchSnapshot(); 27 | }); 28 | 29 | it('should test sign', async () => { 30 | const client = setupTestClient(); 31 | await client.connect(getDeviceId()); 32 | 33 | const { req } = await buildEthSignRequest(client); 34 | const signData = await client.sign(req); 35 | expect(signData).toMatchSnapshot(); 36 | }); 37 | 38 | it('should test fetchActiveWallet', async () => { 39 | const client = setupTestClient(); 40 | await client.connect(getDeviceId()); 41 | 42 | const activeWallet = await client.fetchActiveWallet(); 43 | expect(activeWallet).toMatchSnapshot(); 44 | }); 45 | 46 | it('should test getKvRecords', async () => { 47 | const client = setupTestClient(); 48 | await client.connect(getDeviceId()); 49 | 50 | const activeWallet = await client.getKvRecords({ start: 0 }); 51 | expect(activeWallet).toMatchSnapshot(); 52 | }); 53 | it('should test addKvRecords', async () => { 54 | const client = setupTestClient(); 55 | await client.connect(getDeviceId()); 56 | 57 | const activeWallet = await client.addKvRecords({ 58 | records: { test2: 'test2' }, 59 | }); 60 | expect(activeWallet).toMatchSnapshot(); 61 | }); 62 | it('should test removeKvRecords', async () => { 63 | const client = setupTestClient(); 64 | await client.connect(getDeviceId()); 65 | await client.addKvRecords({ records: { test: `${Math.random()}` } }); 66 | const { records } = await client.getKvRecords({ start: 0 }); 67 | const activeWallet = await client.removeKvRecords({ 68 | ids: records.map((r) => `${r.id}`), 69 | }); 70 | expect(activeWallet).toMatchSnapshot(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/__test__/integration/__mocks__/4byte.ts: -------------------------------------------------------------------------------- 1 | export const fourbyteResponse0xa9059cbb = { 2 | results: [ 3 | { 4 | bytes_signature: '©œ»', 5 | created_at: '2016-07-09T03:58:28.234977Z', 6 | hex_signature: '0xa9059cbb', 7 | id: 145, 8 | text_signature: 'transfer(address,uint256)', 9 | }, 10 | { 11 | bytes_signature: '©œ»', 12 | created_at: '2018-05-11T08:39:29.708250Z', 13 | hex_signature: '0xa9059cbb', 14 | id: 31780, 15 | text_signature: 'many_msg_babbage(bytes1)', 16 | }, 17 | { 18 | bytes_signature: '©œ»', 19 | created_at: '2019-03-22T19:13:17.314877Z', 20 | hex_signature: '0xa9059cbb', 21 | id: 161159, 22 | text_signature: 'transfer(bytes4[9],bytes5[6],int48[11])', 23 | }, 24 | { 25 | bytes_signature: '©œ»', 26 | created_at: '2021-10-20T05:29:13.555535Z', 27 | hex_signature: '0xa9059cbb', 28 | id: 313067, 29 | text_signature: 'func_2093253501(bytes)', 30 | }, 31 | ], 32 | }; 33 | 34 | export const fourbyteResponse0x38ed1739 = { 35 | results: [ 36 | { 37 | bytes_signature: '8í9', 38 | created_at: '2020-08-09T08:56:14.110995Z', 39 | hex_signature: '0x38ed1739', 40 | id: 171806, 41 | text_signature: 42 | 'swapExactTokensForTokens(uint256,uint256,address[],address,uint256)', 43 | }, 44 | ], 45 | }; 46 | 47 | export const fourbyteResponseac9650d8 = { 48 | results: [ 49 | { 50 | id: 134730, 51 | created_at: '2018-10-13T08:40:29.456228Z', 52 | text_signature: 'multicall(bytes[])', 53 | hex_signature: '0xac9650d8', 54 | bytes_signature: '¬\x96PØ', 55 | }, 56 | ], 57 | }; 58 | 59 | export const fourbyteResponse0c49ccbe = { 60 | results: [ 61 | { 62 | id: 186682, 63 | created_at: '2021-05-09T03:48:17.627742Z', 64 | text_signature: 65 | 'decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))', 66 | hex_signature: '0x0c49ccbe', 67 | bytes_signature: '\\fI̾', 68 | }, 69 | ], 70 | }; 71 | 72 | export const fourbyteResponsefc6f7865 = { 73 | results: [ 74 | { 75 | id: 186681, 76 | created_at: '2021-05-09T03:48:17.621683Z', 77 | text_signature: 'collect((uint256,address,uint128,uint128))', 78 | hex_signature: '0xfc6f7865', 79 | bytes_signature: 'üoxe', 80 | }, 81 | ], 82 | }; 83 | 84 | export const fourbyteResponse0x6a761202 = { 85 | results: [ 86 | { 87 | id: 169422, 88 | created_at: '2020-01-28T10:40:07.614936Z', 89 | text_signature: 90 | 'execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)', 91 | hex_signature: '0x6a761202', 92 | bytes_signature: 'jv\\u0012\\u0002', 93 | }, 94 | ], 95 | }; 96 | -------------------------------------------------------------------------------- /src/functions/fetchActiveWallet.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY_WALLET_UID } from '../constants'; 2 | import { 3 | LatticeSecureEncryptedRequestType, 4 | encryptedSecureRequest, 5 | } from '../protocol'; 6 | import { 7 | validateActiveWallets, 8 | validateConnectedClient, 9 | } from '../shared/validators'; 10 | import { 11 | FetchActiveWalletRequestFunctionParams, 12 | ActiveWallets, 13 | } from '../types'; 14 | 15 | /** 16 | * Fetch the active wallet in the device. 17 | * 18 | * The Lattice has two wallet interfaces: internal and external. If a SafeCard is inserted and 19 | * unlocked, the external interface is considered "active" and this will return its {@link Wallet} 20 | * data. Otherwise it will return the info for the internal Lattice wallet. 21 | */ 22 | export async function fetchActiveWallet({ 23 | client, 24 | }: FetchActiveWalletRequestFunctionParams): Promise { 25 | const { url, sharedSecret, ephemeralPub } = validateConnectedClient(client); 26 | 27 | const { decryptedData, newEphemeralPub } = await encryptedSecureRequest({ 28 | data: Buffer.alloc(0), 29 | requestType: LatticeSecureEncryptedRequestType.getWallets, 30 | sharedSecret, 31 | ephemeralPub, 32 | url, 33 | }); 34 | 35 | const activeWallets = decodeFetchActiveWalletResponse(decryptedData); 36 | const validActiveWallets = validateActiveWallets(activeWallets); 37 | 38 | client.mutate({ 39 | ephemeralPub: newEphemeralPub, 40 | activeWallets: validActiveWallets, 41 | }); 42 | 43 | return validActiveWallets; 44 | } 45 | 46 | export const decodeFetchActiveWalletResponse = (data: Buffer) => { 47 | // Read the external wallet data first. If it is non-null, the external wallet will be the 48 | // active wallet of the device and we should save it. If the external wallet is blank, it means 49 | // there is no card present and we should save and use the interal wallet. If both wallets are 50 | // empty, it means the device still needs to be set up. 51 | const walletDescriptorLen = 71; 52 | // Internal first 53 | const activeWallets: ActiveWallets = { 54 | internal: { 55 | uid: EMPTY_WALLET_UID, 56 | external: false, 57 | name: Buffer.alloc(0), 58 | capabilities: 0, 59 | }, 60 | external: { 61 | uid: EMPTY_WALLET_UID, 62 | external: true, 63 | name: Buffer.alloc(0), 64 | capabilities: 0, 65 | }, 66 | }; 67 | let off = 0; 68 | activeWallets.internal.uid = data.slice(off, off + 32); 69 | activeWallets.internal.capabilities = data.readUInt32BE(off + 32); 70 | activeWallets.internal.name = data.slice(off + 36, off + walletDescriptorLen); 71 | // Offset the first item 72 | off += walletDescriptorLen; 73 | // External 74 | activeWallets.external.uid = data.slice(off, off + 32); 75 | activeWallets.external.capabilities = data.readUInt32BE(off + 32); 76 | activeWallets.external.name = data.slice(off + 36, off + walletDescriptorLen); 77 | return activeWallets; 78 | }; 79 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | // Re-export everything from client.ts 2 | export * from './client'; 3 | 4 | // Re-export everything from addKvRecords.ts 5 | export * from './addKvRecords'; 6 | 7 | // Re-export everything from connect.ts 8 | export * from './connect'; 9 | 10 | // Re-export everything from fetchActiveWallet.ts 11 | export * from './fetchActiveWallet'; 12 | 13 | // Re-export everything from fetchEncData.ts 14 | export * from './fetchEncData'; 15 | 16 | // Re-export everything from firmware.ts 17 | export * from './firmware'; 18 | 19 | // Re-export everything from getAddresses.ts 20 | export * from './getAddresses'; 21 | 22 | // Re-export everything from getKvRecords.ts 23 | export * from './getKvRecords'; 24 | 25 | // Re-export everything from messages.ts 26 | export * from './messages'; 27 | 28 | // Re-export everything from pair.ts 29 | export * from './pair'; 30 | 31 | // Re-export everything from removeKvRecords.ts 32 | export * from './removeKvRecords'; 33 | 34 | // Re-export everything from secureMessages.ts 35 | export * from './secureMessages'; 36 | 37 | // Re-export everything from shared.ts 38 | export * from './shared'; 39 | 40 | // Re-export everything from sign.ts 41 | export * from './sign'; 42 | 43 | // Re-export everything from utils.ts 44 | export * from './utils'; 45 | 46 | // We don't need to export from vitest.d.ts as it's a declaration file for Vitest 47 | 48 | // Exports from client.ts 49 | export type { 50 | Currency, 51 | SigningPath, 52 | SignData, 53 | SigningRequestResponse, 54 | TransactionPayload, 55 | Wallet, 56 | ActiveWallets, 57 | RequestParams, 58 | ClientStateData, 59 | } from './client'; 60 | 61 | // Exports from addKvRecords.ts 62 | export type { 63 | AddKvRecordsRequestParams, 64 | AddKvRecordsRequestFunctionParams, 65 | } from './addKvRecords'; 66 | 67 | // Exports from fetchEncData.ts 68 | export type { 69 | EIP2335KeyExportReq, 70 | FetchEncDataRequest, 71 | FetchEncDataRequestFunctionParams, 72 | EIP2335KeyExportData, 73 | } from './fetchEncData'; 74 | 75 | // Exports from getKvRecords.ts 76 | export type { 77 | GetKvRecordsRequestParams, 78 | GetKvRecordsRequestFunctionParams, 79 | AddressTag, 80 | GetKvRecordsData, 81 | } from './getKvRecords'; 82 | 83 | // Exports from removeKvRecords.ts 84 | export type { 85 | RemoveKvRecordsRequestParams, 86 | RemoveKvRecordsRequestFunctionParams, 87 | } from './removeKvRecords'; 88 | 89 | // Exports from shared.ts 90 | export type { 91 | KVRecords, 92 | EncrypterParams, 93 | KeyPair, 94 | WalletPath, 95 | DecryptedResponse, 96 | } from './shared'; 97 | 98 | // Note: We don't export from vitest.d.ts as it's a declaration file for Vitest 99 | 100 | // Note: fetchEncData.d.ts, utils.d.ts, and addKvRecords.d.ts are declaration files, 101 | // so we don't need to export from them directly. Their types should be available 102 | // through their respective .ts files. 103 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { getClient, pair, setup } from '../../src/api/index'; 3 | import './App.css'; 4 | import { Lattice } from './Lattice'; 5 | 6 | function App() { 7 | const [label, setLabel] = useState('No Device'); 8 | 9 | const getStoredClient = () => 10 | window.localStorage.getItem('storedClient') || ''; 11 | 12 | const setStoredClient = (storedClient: string | null) => { 13 | if (!storedClient) return; 14 | window.localStorage.setItem('storedClient', storedClient); 15 | 16 | const client = getClient(); 17 | setLabel(client?.getDeviceId() || 'No Device'); 18 | }; 19 | 20 | useEffect(() => { 21 | if (getStoredClient()) { 22 | setup({ getStoredClient, setStoredClient }); 23 | } 24 | }, []); 25 | 26 | const submitInit = (e: any) => { 27 | e.preventDefault(); 28 | const deviceId = e.currentTarget[0].value; 29 | const password = e.currentTarget[1].value; 30 | const name = e.currentTarget[2].value; 31 | setup({ 32 | deviceId, 33 | password, 34 | name, 35 | getStoredClient, 36 | setStoredClient, 37 | }); 38 | }; 39 | 40 | const submitPair = (e: React.FormEvent) => { 41 | e.preventDefault(); 42 | // @ts-expect-error - bad html types 43 | const pairingCode = e.currentTarget[0].value.toUpperCase(); 44 | pair(pairingCode); 45 | }; 46 | 47 | return ( 48 |
49 |

EXAMPLE APP

50 |
51 |
60 |
64 | 65 | 66 | 71 | 72 |
73 |
74 |
83 |
87 | 88 | 89 |
90 |
91 | 92 |
93 |
94 | ); 95 | } 96 | 97 | export default App; 98 | -------------------------------------------------------------------------------- /src/functions/addKvRecords.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LatticeSecureEncryptedRequestType, 3 | encryptedSecureRequest, 4 | } from '../protocol'; 5 | import { 6 | validateConnectedClient, 7 | validateKvRecord, 8 | validateKvRecords, 9 | } from '../shared/validators'; 10 | import { 11 | AddKvRecordsRequestFunctionParams, 12 | KVRecords, 13 | FirmwareConstants, 14 | } from '../types'; 15 | 16 | /** 17 | * `addKvRecords` takes in a set of key-value records and sends a request to add them to the 18 | * Lattice. 19 | * @category Lattice 20 | * @returns A callback with an error or null. 21 | */ 22 | export async function addKvRecords({ 23 | client, 24 | records, 25 | type, 26 | caseSensitive, 27 | }: AddKvRecordsRequestFunctionParams): Promise { 28 | const { url, sharedSecret, ephemeralPub, fwConstants } = 29 | validateConnectedClient(client); 30 | validateAddKvRequest({ records, fwConstants }); 31 | 32 | // Build the data for this request 33 | const data = encodeAddKvRecordsRequest({ 34 | records, 35 | type, 36 | caseSensitive, 37 | fwConstants, 38 | }); 39 | 40 | const { decryptedData, newEphemeralPub } = await encryptedSecureRequest({ 41 | data, 42 | requestType: LatticeSecureEncryptedRequestType.addKvRecords, 43 | sharedSecret, 44 | ephemeralPub, 45 | url, 46 | }); 47 | 48 | client.mutate({ 49 | ephemeralPub: newEphemeralPub, 50 | }); 51 | 52 | return decryptedData; 53 | } 54 | 55 | export const validateAddKvRequest = ({ 56 | records, 57 | fwConstants, 58 | }: { 59 | records: KVRecords; 60 | fwConstants: FirmwareConstants; 61 | }) => { 62 | validateKvRecords(records, fwConstants); 63 | }; 64 | 65 | export const encodeAddKvRecordsRequest = ({ 66 | records, 67 | type, 68 | caseSensitive, 69 | fwConstants, 70 | }: { 71 | records: KVRecords; 72 | type: number; 73 | caseSensitive: boolean; 74 | fwConstants: FirmwareConstants; 75 | }) => { 76 | const payload = Buffer.alloc(1 + 139 * fwConstants.kvActionMaxNum); 77 | payload.writeUInt8(Object.keys(records).length, 0); 78 | let off = 1; 79 | Object.entries(records).forEach(([_key, _val]) => { 80 | const { key, val } = validateKvRecord( 81 | { key: _key, val: _val }, 82 | fwConstants, 83 | ); 84 | // Skip the ID portion. This will get added by firmware. 85 | payload.writeUInt32LE(0, off); 86 | off += 4; 87 | payload.writeUInt32LE(type, off); 88 | off += 4; 89 | payload.writeUInt8(caseSensitive ? 1 : 0, off); 90 | off += 1; 91 | payload.writeUInt8(String(key).length + 1, off); 92 | off += 1; 93 | Buffer.from(String(key)).copy(payload, off); 94 | off += fwConstants.kvKeyMaxStrSz + 1; 95 | payload.writeUInt8(String(val).length + 1, off); 96 | off += 1; 97 | Buffer.from(String(val)).copy(payload, off); 98 | off += fwConstants.kvValMaxStrSz + 1; 99 | }); 100 | return payload; 101 | }; 102 | -------------------------------------------------------------------------------- /scripts/pair-device.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | 3 | import * as fs from 'node:fs'; 4 | import { question } from 'readline-sync'; 5 | import { setup, pair, getClient } from '../src/api'; 6 | import * as dotenv from 'dotenv'; 7 | 8 | // Load environment variables 9 | dotenv.config(); 10 | 11 | // Storage functions for client state 12 | const getStoredClient = async (): Promise => { 13 | try { 14 | return fs.readFileSync('./client.temp', 'utf8'); 15 | } catch (err) { 16 | return ''; 17 | } 18 | }; 19 | 20 | const setStoredClient = async (data: string | null): Promise => { 21 | try { 22 | if (data) { 23 | fs.writeFileSync('./client.temp', data); 24 | } 25 | } catch (err) { 26 | console.error('Failed to store client data:', err); 27 | } 28 | }; 29 | 30 | async function main() { 31 | console.log('GridPlus SDK Device Pairing Tool\n'); 32 | 33 | try { 34 | // Get device configuration 35 | const deviceId = process.env.DEVICE_ID || question('Enter Device ID: '); 36 | const password = 37 | process.env.PASSWORD || 38 | question('Enter Password (default: password): ', { 39 | defaultInput: 'password', 40 | }); 41 | const name = 42 | process.env.APP_NAME || 43 | question('Enter App Name (default: CLI Pairing Tool): ', { 44 | defaultInput: 'CLI Pairing Tool', 45 | }); 46 | 47 | console.log('\nAttempting to connect to device...'); 48 | 49 | // Setup the client 50 | const isPaired = await setup({ 51 | deviceId, 52 | password, 53 | name, 54 | getStoredClient, 55 | setStoredClient, 56 | }); 57 | 58 | if (isPaired) { 59 | console.log('✅ Device is already paired!'); 60 | const client = await getClient(); 61 | console.log(`Connected to device: ${client?.getDeviceId()}`); 62 | } else { 63 | console.log('⚠️ Device is not paired. Starting pairing process...'); 64 | console.log('Please check your Lattice device for the pairing secret.'); 65 | 66 | const secret = question('Enter the pairing secret from your device: '); 67 | 68 | console.log('Pairing with device...'); 69 | const pairResult = await pair(secret.toUpperCase()); 70 | 71 | if (pairResult) { 72 | console.log('✅ Device paired successfully!'); 73 | const client = await getClient(); 74 | console.log(`Connected to device: ${client?.getDeviceId()}`); 75 | } else { 76 | console.log('❌ Pairing failed. Please try again.'); 77 | process.exit(1); 78 | } 79 | } 80 | 81 | console.log('\n🎉 Pairing process completed successfully!'); 82 | console.log('Client state has been saved to ./client.temp'); 83 | console.log('You can now use the SDK with this device.'); 84 | } catch (error) { 85 | console.error('❌ Error during pairing process:', error); 86 | process.exit(1); 87 | } 88 | } 89 | 90 | // Run the main function 91 | main().catch((error) => { 92 | console.error('❌ Unexpected error:', error); 93 | process.exit(1); 94 | }); 95 | -------------------------------------------------------------------------------- /src/__test__/unit/ethereum.validate.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SignTypedDataVersion, 3 | TypedDataUtils, 4 | type MessageTypes, 5 | type TypedMessage, 6 | } from '@metamask/eth-sig-util'; 7 | import { ecsign, privateToAddress } from 'ethereumjs-util'; 8 | import { mnemonicToAccount } from 'viem/accounts'; 9 | import { HARDENED_OFFSET } from '../../constants'; 10 | import ethereum from '../../ethereum'; 11 | import { buildFirmwareConstants, DEFAULT_SIGNER } from '../utils/builders'; 12 | import { TEST_MNEMONIC } from '../utils/testConstants'; 13 | 14 | const typedData: TypedMessage = { 15 | types: { 16 | EIP712Domain: [{ name: 'chainId', type: 'uint256' }], 17 | Greeting: [ 18 | { name: 'salutation', type: 'string' }, 19 | { name: 'target', type: 'string' }, 20 | { name: 'born', type: 'int32' }, 21 | ], 22 | }, 23 | primaryType: 'Greeting', 24 | domain: { chainId: 1 }, 25 | message: { 26 | salutation: 'Hello', 27 | target: 'Ethereum', 28 | born: 2015, 29 | }, 30 | }; 31 | 32 | describe('validateEthereumMsgResponse', () => { 33 | it('recovers expected signature for EIP712 payload', () => { 34 | const account = mnemonicToAccount(TEST_MNEMONIC); 35 | const priv = Buffer.from(account.getHdKey().privateKey!); 36 | const signer = privateToAddress(priv); 37 | const digest = TypedDataUtils.eip712Hash( 38 | typedData, 39 | SignTypedDataVersion.V4, 40 | ); 41 | const sig = ecsign(Buffer.from(digest), priv); 42 | const fwConstants = buildFirmwareConstants(); 43 | const request = ethereum.buildEthereumMsgRequest({ 44 | signerPath: DEFAULT_SIGNER, 45 | protocol: 'eip712', 46 | payload: JSON.parse(JSON.stringify(typedData)), 47 | fwConstants, 48 | }); 49 | const result = ethereum.validateEthereumMsgResponse( 50 | { 51 | signer: `0x${signer.toString('hex')}`, 52 | sig: { r: Buffer.from(sig.r), s: Buffer.from(sig.s) }, 53 | }, 54 | request, 55 | ); 56 | 57 | expect(result.v.toString('hex')).toBe('1c'); 58 | }); 59 | 60 | it('validates response using buildEthereumMsgRequest request context', () => { 61 | const fwConstants = buildFirmwareConstants(); 62 | const signerPath = [...DEFAULT_SIGNER]; 63 | signerPath[2] = HARDENED_OFFSET; 64 | 65 | const request = ethereum.buildEthereumMsgRequest({ 66 | signerPath, 67 | protocol: 'eip712', 68 | payload: JSON.parse(JSON.stringify(typedData)), 69 | fwConstants, 70 | }); 71 | 72 | const account = mnemonicToAccount(TEST_MNEMONIC); 73 | const priv = Buffer.from(account.getHdKey().privateKey!); 74 | const signer = privateToAddress(priv); 75 | const digest = TypedDataUtils.eip712Hash( 76 | typedData, 77 | SignTypedDataVersion.V4, 78 | ); 79 | const sig = ecsign(Buffer.from(digest), priv); 80 | 81 | const result = ethereum.validateEthereumMsgResponse( 82 | { 83 | signer, 84 | sig: { r: Buffer.from(sig.r), s: Buffer.from(sig.s) }, 85 | }, 86 | request, 87 | ); 88 | 89 | expect(result.v.toString('hex')).toBe('1c'); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/__test__/utils/__test__/__snapshots__/builders.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`building > should test client 1`] = ` 4 | [ 5 | [ 6 | 0, 7 | 10, 8 | 0, 9 | ], 10 | [ 11 | 0, 12 | 10, 13 | 1, 14 | ], 15 | [ 16 | 0, 17 | 10, 18 | 2, 19 | ], 20 | [ 21 | 0, 22 | 10, 23 | 3, 24 | ], 25 | [ 26 | 0, 27 | 10, 28 | 4, 29 | ], 30 | [ 31 | 0, 32 | 11, 33 | 0, 34 | ], 35 | [ 36 | 0, 37 | 11, 38 | 1, 39 | ], 40 | [ 41 | 0, 42 | 11, 43 | 2, 44 | ], 45 | [ 46 | 0, 47 | 11, 48 | 3, 49 | ], 50 | [ 51 | 0, 52 | 11, 53 | 4, 54 | ], 55 | [ 56 | 0, 57 | 12, 58 | 0, 59 | ], 60 | [ 61 | 0, 62 | 12, 63 | 1, 64 | ], 65 | [ 66 | 0, 67 | 12, 68 | 2, 69 | ], 70 | [ 71 | 0, 72 | 12, 73 | 3, 74 | ], 75 | [ 76 | 0, 77 | 12, 78 | 4, 79 | ], 80 | [ 81 | 0, 82 | 13, 83 | 0, 84 | ], 85 | [ 86 | 0, 87 | 13, 88 | 1, 89 | ], 90 | [ 91 | 0, 92 | 13, 93 | 2, 94 | ], 95 | [ 96 | 0, 97 | 13, 98 | 3, 99 | ], 100 | [ 101 | 0, 102 | 13, 103 | 4, 104 | ], 105 | [ 106 | 0, 107 | 14, 108 | 0, 109 | ], 110 | [ 111 | 0, 112 | 14, 113 | 1, 114 | ], 115 | [ 116 | 0, 117 | 14, 118 | 2, 119 | ], 120 | [ 121 | 0, 122 | 14, 123 | 3, 124 | ], 125 | [ 126 | 0, 127 | 14, 128 | 4, 129 | ], 130 | [ 131 | 0, 132 | 15, 133 | 0, 134 | ], 135 | [ 136 | 0, 137 | 15, 138 | 1, 139 | ], 140 | [ 141 | 0, 142 | 15, 143 | 2, 144 | ], 145 | [ 146 | 0, 147 | 15, 148 | 3, 149 | ], 150 | [ 151 | 0, 152 | 15, 153 | 4, 154 | ], 155 | [ 156 | 0, 157 | 16, 158 | 0, 159 | ], 160 | [ 161 | 0, 162 | 16, 163 | 1, 164 | ], 165 | [ 166 | 0, 167 | 16, 168 | 2, 169 | ], 170 | [ 171 | 0, 172 | 16, 173 | 3, 174 | ], 175 | [ 176 | 0, 177 | 16, 178 | 4, 179 | ], 180 | [ 181 | 0, 182 | 17, 183 | 0, 184 | ], 185 | [ 186 | 0, 187 | 17, 188 | 1, 189 | ], 190 | [ 191 | 0, 192 | 17, 193 | 2, 194 | ], 195 | [ 196 | 0, 197 | 17, 198 | 3, 199 | ], 200 | [ 201 | 0, 202 | 17, 203 | 4, 204 | ], 205 | [ 206 | 0, 207 | 18, 208 | 0, 209 | ], 210 | [ 211 | 0, 212 | 18, 213 | 1, 214 | ], 215 | [ 216 | 0, 217 | 18, 218 | 2, 219 | ], 220 | [ 221 | 0, 222 | 18, 223 | 3, 224 | ], 225 | [ 226 | 0, 227 | 18, 228 | 4, 229 | ], 230 | [ 231 | 0, 232 | 19, 233 | 0, 234 | ], 235 | [ 236 | 0, 237 | 19, 238 | 1, 239 | ], 240 | [ 241 | 0, 242 | 19, 243 | 2, 244 | ], 245 | [ 246 | 0, 247 | 19, 248 | 3, 249 | ], 250 | [ 251 | 0, 252 | 19, 253 | 4, 254 | ], 255 | ] 256 | `; 257 | -------------------------------------------------------------------------------- /src/__test__/unit/module.interop.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | existsSync, 3 | mkdirSync, 4 | mkdtempSync, 5 | rmSync, 6 | symlinkSync, 7 | } from 'node:fs'; 8 | import os from 'node:os'; 9 | import path from 'node:path'; 10 | import { fileURLToPath } from 'node:url'; 11 | import { execSync, spawnSync } from 'node:child_process'; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | const packageRoot = path.resolve(__dirname, '../../..'); 16 | const cjsOutput = path.resolve(packageRoot, 'dist/index.cjs'); 17 | const esmOutput = path.resolve(packageRoot, 'dist/index.mjs'); 18 | const packageName = 'gridplus-sdk'; 19 | 20 | let built = false; 21 | let fixtureDir: string | undefined; 22 | 23 | const ensureBuildArtifacts = () => { 24 | if (built) { 25 | return; 26 | } 27 | console.log('Building package with pnpm run build ...'); 28 | execSync('pnpm run build', { 29 | cwd: packageRoot, 30 | stdio: 'inherit', 31 | }); 32 | if (!existsSync(cjsOutput) || !existsSync(esmOutput)) { 33 | throw new Error('Expected dual build outputs were not generated'); 34 | } 35 | built = true; 36 | }; 37 | 38 | const ensureLinkedFixture = () => { 39 | if (fixtureDir) { 40 | return fixtureDir; 41 | } 42 | const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'gridplus-sdk-interop-')); 43 | const nodeModulesDir = path.join(tmpDir, 'node_modules'); 44 | mkdirSync(nodeModulesDir, { recursive: true }); 45 | const linkTarget = path.join(nodeModulesDir, packageName); 46 | symlinkSync(packageRoot, linkTarget, 'junction'); 47 | fixtureDir = tmpDir; 48 | return fixtureDir; 49 | }; 50 | 51 | const runNodeCheck = (args: string[]) => { 52 | const cwd = ensureLinkedFixture(); 53 | const result = spawnSync(process.execPath, args, { 54 | cwd, 55 | env: { ...process.env }, 56 | encoding: 'utf-8', 57 | }); 58 | if (result.error) { 59 | throw result.error; 60 | } 61 | if (result.status !== 0) { 62 | throw new Error( 63 | `Node command failed (${result.status}):\n${result.stderr || result.stdout}`, 64 | ); 65 | } 66 | }; 67 | 68 | describe('package module interoperability', () => { 69 | beforeAll(() => { 70 | ensureBuildArtifacts(); 71 | }); 72 | afterAll(() => { 73 | if (fixtureDir) { 74 | rmSync(fixtureDir, { recursive: true, force: true }); 75 | fixtureDir = undefined; 76 | } 77 | }); 78 | 79 | it('exposes CommonJS entry via require()', () => { 80 | const script = ` 81 | const sdk = require('${packageName}'); 82 | if (typeof sdk.connect !== 'function') { 83 | throw new Error('connect export missing'); 84 | } 85 | if (typeof sdk.Client !== 'function') { 86 | throw new Error('Client export missing'); 87 | } 88 | `; 89 | runNodeCheck(['-e', script]); 90 | }); 91 | 92 | it('exposes ESM entry via dynamic import()', () => { 93 | const script = ` 94 | const sdk = await import('${packageName}'); 95 | if (typeof sdk.connect !== 'function') { 96 | throw new Error('connect export missing'); 97 | } 98 | if (typeof sdk.Client !== 'function') { 99 | throw new Error('Client export missing'); 100 | } 101 | `; 102 | runNodeCheck(['--input-type=module', '-e', script]); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /docs/docs/tutorials/addressTags.md: -------------------------------------------------------------------------------- 1 | # 🏷️ Addresses Tags 2 | 3 | To make signing requests even more readable, you can "tag" addresses ahead of time. After that, any transaction requests referencing the tagged address will display your human-readable name instead of the raw address string. Tagging is done using what we call the "KV" API, which stands for key-value associations. You may add any mapping where the **key** and **value** are each **up to 64 bytes**. 4 | 5 | :::info 6 | Address tags are rendered on the Lattice screen anywhere an address might be rendered, including inside EIP712 requests and decoded transaction calldata! 7 | ::: 8 | 9 | There are three methods used to manage tags: 10 | 11 | - [`addAddressTags`](../reference/api/addressTags#addAddressTags): Add a set of address tags 12 | - [`fetchAddressTags`](../reference/api/addressTags#fetchAddressTags): Fetch `n` tags, starting at index `start` 13 | - [`removeAddressTags`](../reference/api/addressTags#removeAddressTags): Remove a set of tags based on the passed `id`s 14 | 15 | ## Example 16 | 17 | The following code snippet and accompanying comments should show you how to manage address tags. We will be replacing an address tag if it exists on the Lattice already, or adding a new tag if an existing one does not exist: 18 | 19 | ```ts 20 | import { setup, pair } from 'gridplus-sdk'; 21 | import { 22 | addAddressTags, 23 | fetchAddressTags, 24 | removeAddressTags, 25 | } from 'gridplus-sdk/api/addressTags'; 26 | 27 | // Set up your client and connect to the Lattice 28 | const isPaired = await setup({ 29 | name: 'My Wallet', 30 | deviceId: 'ABC123', 31 | password: 'my-secure-password', 32 | getStoredClient: () => localStorage.getItem('lattice-client'), 33 | setStoredClient: (client) => localStorage.setItem('lattice-client', client), 34 | }); 35 | 36 | if (!isPaired) { 37 | const secret = prompt('Enter the 6-digit code from your Lattice:'); 38 | await pair(secret); 39 | } 40 | 41 | // Fetch 10 tags per request (max=10) 42 | const nPerReq = 10; 43 | 44 | // Reference to the address that will be used in this example 45 | const uniswapRouter = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D'; 46 | 47 | // The new tag we want to add 48 | // NOTE: Emoji-based tags are not currently supported, sry 😔 49 | const newTag = 'New Uniswap Router Tag'; 50 | 51 | // Find out how many tags are stored on the target Lattice by passing 52 | // an empty struct as the options. 53 | const existingTags = await fetchAddressTags({}); 54 | 55 | // Loop through all saved tags and search for a possible match to the address 56 | // we want to re-tag here. 57 | for ( 58 | let reqIdx = 0; 59 | reqIdx < Math.floor(existingTags.total / nPerReq); 60 | reqIdx++ 61 | ) { 62 | // Fetch all the tags in sets of `nPerReq` 63 | const tags = fetchAddressTags({ n: nPerReq, start: reqIdx * nPerReq }); 64 | // Determine if we have found our tag 65 | for (let i = 0; i < tags.length; i++) { 66 | if (tags[i][uniswapRouter] !== undefined) { 67 | // We have a tag saved - delete it by id 68 | await removeAddressTags({ ids: [tags[0].id] }); 69 | // This probs wouldn't work in a JS/TS script like this but you get the idea 70 | break; 71 | } 72 | } 73 | } 74 | 75 | // We can now be sure there is no tag for our address in question. 76 | // Add the new tag! 77 | const newTags = [ 78 | { 79 | [uniswapRouter]: newTag, 80 | }, 81 | ]; 82 | await addAddressTags({ records: newTags }); 83 | ``` 84 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import tsPlugin from '@typescript-eslint/eslint-plugin'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import prettierConfig from 'eslint-config-prettier'; 5 | import prettierPlugin from 'eslint-plugin-prettier'; 6 | 7 | const restrictedNodeImports = [ 8 | { name: 'crypto', message: 'Use node:crypto instead.' }, 9 | { name: 'fs', message: 'Use node:fs instead.' }, 10 | { name: 'os', message: 'Use node:os instead.' }, 11 | { name: 'path', message: 'Use node:path instead.' }, 12 | { name: 'stream', message: 'Use node:stream instead.' }, 13 | { name: 'url', message: 'Use node:url instead.' }, 14 | { name: 'util', message: 'Use node:util instead.' }, 15 | ]; 16 | 17 | export default [ 18 | js.configs.recommended, 19 | { 20 | files: ['src/**/*.ts', 'src/**/*.tsx'], 21 | plugins: { 22 | '@typescript-eslint': tsPlugin, 23 | prettier: prettierPlugin, 24 | }, 25 | languageOptions: { 26 | parser: tsParser, 27 | parserOptions: { 28 | ecmaVersion: 'latest', 29 | sourceType: 'module', 30 | }, 31 | globals: { 32 | Buffer: 'readonly', 33 | URL: 'readonly', 34 | fetch: 'readonly', 35 | Response: 'readonly', 36 | Request: 'readonly', 37 | RequestInit: 'readonly', 38 | AbortController: 'readonly', 39 | caches: 'readonly', 40 | setTimeout: 'readonly', 41 | clearTimeout: 'readonly', 42 | // Test globals 43 | vi: 'readonly', 44 | describe: 'readonly', 45 | it: 'readonly', 46 | test: 'readonly', 47 | expect: 'readonly', 48 | beforeAll: 'readonly', 49 | afterAll: 'readonly', 50 | beforeEach: 'readonly', 51 | afterEach: 'readonly', 52 | // Node.js globals 53 | process: 'readonly', 54 | // Browser globals 55 | window: 'readonly', 56 | document: 'readonly', 57 | console: 'readonly', 58 | }, 59 | }, 60 | rules: { 61 | ...tsPlugin.configs.recommended.rules, 62 | ...prettierPlugin.configs.recommended.rules, 63 | 'prettier/prettier': 'error', 64 | eqeqeq: ['error'], 65 | 'no-var': ['warn'], 66 | 'no-duplicate-imports': ['error'], 67 | 'prefer-const': ['error'], 68 | 'prefer-spread': ['error'], 69 | 'no-console': ['off'], 70 | 'react/react-in-jsx-scope': 'off', 71 | '@typescript-eslint/no-explicit-any': 'off', 72 | '@typescript-eslint/no-unused-vars': [ 73 | 'warn', 74 | { argsIgnorePattern: '^_' }, 75 | ], 76 | quotes: [ 77 | 'warn', 78 | 'single', 79 | { avoidEscape: true, allowTemplateLiterals: true }, 80 | ], 81 | 'no-restricted-imports': ['error', { paths: restrictedNodeImports }], 82 | 'no-restricted-syntax': [ 83 | 'error', 84 | { 85 | selector: "CallExpression[callee.name='require']", 86 | message: 'Use ESM imports instead of require.', 87 | }, 88 | { 89 | selector: 90 | "AssignmentExpression[left.object.name='module'][left.property.name='exports']", 91 | message: 'Use ESM exports instead of module.exports.', 92 | }, 93 | ], 94 | }, 95 | }, 96 | prettierConfig, 97 | { 98 | ignores: [ 99 | 'dist/**', 100 | 'node_modules/**', 101 | 'coverage/**', 102 | '*.js', 103 | '*.cjs', 104 | '*.mjs', 105 | 'build/**', 106 | 'docs/**', 107 | 'patches/**', 108 | ], 109 | }, 110 | ]; 111 | -------------------------------------------------------------------------------- /src/__test__/utils/determinism.ts: -------------------------------------------------------------------------------- 1 | import type { TypedTransaction } from '@ethereumjs/tx'; 2 | import { SignTypedDataVersion, TypedDataUtils } from '@metamask/eth-sig-util'; 3 | import BIP32Factory from 'bip32'; 4 | import { ecsign, privateToAddress } from 'ethereumjs-util'; 5 | import { Hash } from 'ox'; 6 | import * as ecc from 'tiny-secp256k1'; 7 | import type { Client } from '../../client'; 8 | import { getPathStr } from '../../shared/utilities'; 9 | import type { SigningPath } from '../../types'; 10 | import { ethPersonalSignMsg, getSigStr } from './helpers'; 11 | import { TEST_SEED } from './testConstants'; 12 | 13 | export async function testUniformSigs( 14 | payload: any, 15 | tx: TypedTransaction, 16 | client: Client, 17 | ) { 18 | const tx1Resp = await client.sign(payload); 19 | const tx2Resp = await client.sign(payload); 20 | const tx3Resp = await client.sign(payload); 21 | const tx4Resp = await client.sign(payload); 22 | const tx5Resp = await client.sign(payload); 23 | // Check sig 1 24 | expect(getSigStr(tx1Resp, tx)).toEqual(getSigStr(tx2Resp, tx)); 25 | expect(getSigStr(tx1Resp, tx)).toEqual(getSigStr(tx3Resp, tx)); 26 | expect(getSigStr(tx1Resp, tx)).toEqual(getSigStr(tx4Resp, tx)); 27 | expect(getSigStr(tx1Resp, tx)).toEqual(getSigStr(tx5Resp, tx)); 28 | // Check sig 2 29 | expect(getSigStr(tx2Resp, tx)).toEqual(getSigStr(tx1Resp, tx)); 30 | expect(getSigStr(tx2Resp, tx)).toEqual(getSigStr(tx3Resp, tx)); 31 | expect(getSigStr(tx2Resp, tx)).toEqual(getSigStr(tx4Resp, tx)); 32 | expect(getSigStr(tx2Resp, tx)).toEqual(getSigStr(tx5Resp, tx)); 33 | // Check sig 3 34 | expect(getSigStr(tx3Resp, tx)).toEqual(getSigStr(tx1Resp, tx)); 35 | expect(getSigStr(tx3Resp, tx)).toEqual(getSigStr(tx2Resp, tx)); 36 | expect(getSigStr(tx3Resp, tx)).toEqual(getSigStr(tx4Resp, tx)); 37 | expect(getSigStr(tx3Resp, tx)).toEqual(getSigStr(tx5Resp, tx)); 38 | // Check sig 4 39 | expect(getSigStr(tx4Resp, tx)).toEqual(getSigStr(tx1Resp, tx)); 40 | expect(getSigStr(tx4Resp, tx)).toEqual(getSigStr(tx2Resp, tx)); 41 | expect(getSigStr(tx4Resp, tx)).toEqual(getSigStr(tx3Resp, tx)); 42 | expect(getSigStr(tx4Resp, tx)).toEqual(getSigStr(tx5Resp, tx)); 43 | // Check sig 5 44 | expect(getSigStr(tx5Resp, tx)).toEqual(getSigStr(tx1Resp, tx)); 45 | expect(getSigStr(tx5Resp, tx)).toEqual(getSigStr(tx2Resp, tx)); 46 | expect(getSigStr(tx5Resp, tx)).toEqual(getSigStr(tx3Resp, tx)); 47 | expect(getSigStr(tx5Resp, tx)).toEqual(getSigStr(tx4Resp, tx)); 48 | } 49 | 50 | export function deriveAddress(seed: Buffer, path: SigningPath) { 51 | const bip32 = BIP32Factory(ecc); 52 | const wallet = bip32.fromSeed(seed); 53 | const priv = wallet.derivePath(getPathStr(path)).privateKey; 54 | return `0x${privateToAddress(priv).toString('hex')}`; 55 | } 56 | 57 | export function signPersonalJS(_msg: string, path: SigningPath) { 58 | const bip32 = BIP32Factory(ecc); 59 | const wallet = bip32.fromSeed(TEST_SEED); 60 | const priv = wallet.derivePath(getPathStr(path)).privateKey; 61 | const msg = ethPersonalSignMsg(_msg); 62 | const hash = Buffer.from(Hash.keccak256(Buffer.from(msg))); 63 | const sig = ecsign(hash, priv); 64 | const v = (sig.v - 27).toString(16).padStart(2, '0'); 65 | return `${sig.r.toString('hex')}${sig.s.toString('hex')}${v}`; 66 | } 67 | 68 | export function signEip712JS(payload: any, path: SigningPath) { 69 | const bip32 = BIP32Factory(ecc); 70 | const wallet = bip32.fromSeed(TEST_SEED); 71 | const priv = wallet.derivePath(getPathStr(path)).privateKey; 72 | // Calculate the EIP712 hash using the same method as the SDK validation 73 | const hash = TypedDataUtils.eip712Hash(payload, SignTypedDataVersion.V4); 74 | const sig = ecsign(Buffer.from(hash), priv); 75 | const v = (sig.v - 27).toString(16).padStart(2, '0'); 76 | return `${sig.r.toString('hex')}${sig.s.toString('hex')}${v}`; 77 | } 78 | -------------------------------------------------------------------------------- /src/api/setup.ts: -------------------------------------------------------------------------------- 1 | import { Utils } from '..'; 2 | import { Client } from '../client'; 3 | import { setSaveClient, setLoadClient, saveClient, loadClient } from './state'; 4 | import { buildLoadClientFn, buildSaveClientFn, queue } from './utilities'; 5 | 6 | /** 7 | * @interface {Object} SetupParameters - parameters for the setup function 8 | * @prop {string} SetupParameters.deviceId - the device id of the client 9 | * @prop {string} SetupParameters.password - the password of the client 10 | * @prop {string} SetupParameters.name - the name of the client 11 | * @prop {string} SetupParameters.appSecret - the app secret of the client 12 | * @prop {Function} SetupParameters.getStoredClient - a function that returns the stored client data 13 | * @prop {Function} SetupParameters.setStoredClient - a function that stores the client data 14 | */ 15 | type SetupParameters = 16 | | { 17 | deviceId: string; 18 | password: string; 19 | name: string; 20 | appSecret?: string; 21 | getStoredClient: () => Promise; 22 | setStoredClient: (clientData: string | null) => Promise; 23 | baseUrl?: string; 24 | } 25 | | { 26 | getStoredClient: () => Promise; 27 | setStoredClient: (clientData: string | null) => Promise; 28 | }; 29 | 30 | /** 31 | * `setup` initializes the Client and executes `connect()` if necessary. It returns a promise that 32 | * resolves to a boolean that indicates whether the Client is paired to the application to which it's 33 | * attempting to connect. 34 | * 35 | * @param {Object} SetupParameters - paramaters for the setup function 36 | * @param {string} SetupParameters.deviceId - the device id of the client 37 | * @param {string} SetupParameters.password - the password of the client 38 | * @param {string} SetupParameters.name - the name of the client 39 | * @param {string} SetupParameters.appSecret - the app secret of the client 40 | * @param {Function} SetupParameters.getStoredClient - a function that returns the stored client data 41 | * @param {Function} SetupParameters.setStoredClient - a function that stores the client data 42 | * @returns {Promise} - a promise that resolves to a boolean that indicates whether the Client is paired to the application to which it's attempting to connect 43 | * 44 | */ 45 | export const setup = async (params: SetupParameters): Promise => { 46 | if (!params.getStoredClient) throw new Error('Client data getter required'); 47 | setLoadClient(buildLoadClientFn(params.getStoredClient)); 48 | 49 | if (!params.setStoredClient) throw new Error('Client data setter required'); 50 | setSaveClient(buildSaveClientFn(params.setStoredClient)); 51 | 52 | if ('deviceId' in params && 'password' in params && 'name' in params) { 53 | const privKey = 54 | params.appSecret || 55 | Utils.generateAppSecret(params.deviceId, params.password, params.name); 56 | const client = new Client({ 57 | deviceId: params.deviceId, 58 | privKey, 59 | name: params.name, 60 | baseUrl: params.baseUrl, 61 | }); 62 | return client.connect(params.deviceId).then(async (isPaired) => { 63 | await saveClient(client.getStateData()); 64 | return isPaired; 65 | }); 66 | } else { 67 | const client = await loadClient(); 68 | if (!client) throw new Error('Client not initialized'); 69 | const deviceId = client.getDeviceId(); 70 | if (!client.ephemeralPub && deviceId) { 71 | return connect(deviceId); 72 | } else { 73 | await saveClient(client.getStateData()); 74 | return Promise.resolve(true); 75 | } 76 | } 77 | }; 78 | 79 | export const connect = async (deviceId: string): Promise => { 80 | return queue((client) => client.connect(deviceId)); 81 | }; 82 | 83 | export const pair = async (pairingCode: string): Promise => { 84 | return queue((client) => client.pair(pairingCode)); 85 | }; 86 | -------------------------------------------------------------------------------- /src/functions/getKvRecords.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LatticeSecureEncryptedRequestType, 3 | encryptedSecureRequest, 4 | } from '../protocol'; 5 | import { validateConnectedClient } from '../shared/validators'; 6 | import { 7 | GetKvRecordsRequestFunctionParams, 8 | GetKvRecordsData, 9 | FirmwareConstants, 10 | } from '../types'; 11 | 12 | export async function getKvRecords({ 13 | client, 14 | type: _type, 15 | n: _n, 16 | start: _start, 17 | }: GetKvRecordsRequestFunctionParams): Promise { 18 | const { url, sharedSecret, ephemeralPub, fwConstants } = 19 | validateConnectedClient(client); 20 | 21 | const { type, n, start } = validateGetKvRequest({ 22 | type: _type, 23 | n: _n, 24 | start: _start, 25 | fwConstants, 26 | }); 27 | 28 | const data = encodeGetKvRecordsRequest({ type, n, start }); 29 | 30 | const { decryptedData, newEphemeralPub } = await encryptedSecureRequest({ 31 | data, 32 | requestType: LatticeSecureEncryptedRequestType.getKvRecords, 33 | sharedSecret, 34 | ephemeralPub, 35 | url, 36 | }); 37 | 38 | client.mutate({ 39 | ephemeralPub: newEphemeralPub, 40 | }); 41 | 42 | return decodeGetKvRecordsResponse(decryptedData, fwConstants); 43 | } 44 | 45 | export const validateGetKvRequest = ({ 46 | fwConstants, 47 | n, 48 | type, 49 | start, 50 | }: { 51 | fwConstants: FirmwareConstants; 52 | n?: number; 53 | type?: number; 54 | start?: number; 55 | }) => { 56 | if (!fwConstants.kvActionsAllowed) { 57 | throw new Error('Unsupported. Please update firmware.'); 58 | } 59 | if (!n || n < 1) { 60 | throw new Error('You must request at least one record.'); 61 | } 62 | if (n > fwConstants.kvActionMaxNum) { 63 | throw new Error( 64 | `You may only request up to ${fwConstants.kvActionMaxNum} records at once.`, 65 | ); 66 | } 67 | if (type !== 0 && !type) { 68 | throw new Error('You must specify a type.'); 69 | } 70 | if (start !== 0 && !start) { 71 | throw new Error('You must specify a type.'); 72 | } 73 | 74 | return { fwConstants, n, type, start }; 75 | }; 76 | 77 | export const encodeGetKvRecordsRequest = ({ 78 | type, 79 | n, 80 | start, 81 | }: { 82 | type: number; 83 | n: number; 84 | start: number; 85 | }) => { 86 | const payload = Buffer.alloc(9); 87 | payload.writeUInt32LE(type, 0); 88 | payload.writeUInt8(n, 4); 89 | payload.writeUInt32LE(start, 5); 90 | return payload; 91 | }; 92 | 93 | export const decodeGetKvRecordsResponse = ( 94 | data: Buffer, 95 | fwConstants: FirmwareConstants, 96 | ) => { 97 | let off = 0; 98 | const nTotal = data.readUInt32BE(off); 99 | off += 4; 100 | const nFetched = parseInt(data.slice(off, off + 1).toString('hex'), 16); 101 | off += 1; 102 | if (nFetched > fwConstants.kvActionMaxNum) 103 | throw new Error('Too many records fetched. Firmware error.'); 104 | const records: any = []; 105 | for (let i = 0; i < nFetched; i++) { 106 | const r: any = {}; 107 | r.id = data.readUInt32BE(off); 108 | off += 4; 109 | r.type = data.readUInt32BE(off); 110 | off += 4; 111 | r.caseSensitive = 112 | parseInt(data.slice(off, off + 1).toString('hex'), 16) === 1 113 | ? true 114 | : false; 115 | off += 1; 116 | const keySz = parseInt(data.slice(off, off + 1).toString('hex'), 16); 117 | off += 1; 118 | r.key = data.slice(off, off + keySz - 1).toString(); 119 | off += fwConstants.kvKeyMaxStrSz + 1; 120 | const valSz = parseInt(data.slice(off, off + 1).toString('hex'), 16); 121 | off += 1; 122 | r.val = data.slice(off, off + valSz - 1).toString(); 123 | off += fwConstants.kvValMaxStrSz + 1; 124 | records.push(r); 125 | } 126 | return { 127 | records, 128 | total: nTotal, 129 | fetched: nFetched, 130 | }; 131 | }; 132 | -------------------------------------------------------------------------------- /example/src/Lattice.tsx: -------------------------------------------------------------------------------- 1 | import { Chain, Common, Hardfork } from '@ethereumjs/common'; 2 | import { TransactionFactory } from '@ethereumjs/tx'; 3 | import { useState } from 'react'; 4 | import { 5 | addAddressTags, 6 | fetchAddresses, 7 | fetchAddressTags, 8 | fetchLedgerLiveAddresses, 9 | removeAddressTags, 10 | sign, 11 | signMessage, 12 | } from '../../src/api'; 13 | import { Button } from './Button'; 14 | 15 | export const Lattice = ({ label }) => { 16 | const [addresses, setAddresses] = useState([]); 17 | const [addressTags, setAddressTags] = useState<{ id: string }[]>([]); 18 | const [ledgerAddresses, setLedgerAddresses] = useState([]); 19 | 20 | const getTxPayload = () => { 21 | const txData = { 22 | type: 1, 23 | maxFeePerGas: 1200000000, 24 | maxPriorityFeePerGas: 1200000000, 25 | nonce: 0, 26 | gasLimit: 50000, 27 | to: '0xe242e54155b1abc71fc118065270cecaaf8b7768', 28 | value: 1000000000000, 29 | data: '0x17e914679b7e160613be4f8c2d3203d236286d74eb9192f6d6f71b9118a42bb033ccd8e8', 30 | gasPrice: 1200000000, 31 | }; 32 | const common = new Common({ 33 | chain: Chain.Mainnet, 34 | hardfork: Hardfork.London, 35 | }); 36 | const tx = TransactionFactory.fromTxData(txData, { common }); 37 | const payload = tx.getMessageToSign(false); 38 | return payload; 39 | }; 40 | 41 | return ( 42 |
51 |

{label}

52 | 53 | 54 | 55 |
56 |

Addresses

57 |
    58 | {addresses?.map((address) => ( 59 |
  • {address}
  • 60 | ))} 61 |
62 |
63 | 71 | 80 | 88 | 97 |
98 |

Address Tags

99 |
    100 | {addressTags?.map((tag: any) => ( 101 |
  • 102 | {tag.key}: {tag.val} 103 |
  • 104 | ))} 105 |
106 |
107 | 108 |
109 |

Ledger Addresses

110 |
    111 | {ledgerAddresses?.map((ledgerAddress: any) => ( 112 |
  • {ledgerAddress}
  • 113 | ))} 114 |
115 |
116 | 124 |
125 | ); 126 | }; 127 | -------------------------------------------------------------------------------- /src/__test__/integration/__mocks__/handlers.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw'; 2 | import connectResponse from './connect.json'; 3 | import getAddressesResponse from './getAddresses.json'; 4 | import signResponse from './sign.json'; 5 | import fetchActiveWalletResponse from './fetchActiveWallet.json'; 6 | import addKvRecordsResponse from './addKvRecords.json'; 7 | import getKvRecordsResponse from './getKvRecords.json'; 8 | import removeKvRecordsResponse from './removeKvRecords.json'; 9 | import { 10 | etherscanResponse0x06412d7e, 11 | etherscanResponse0x7a250d56, 12 | etherscanResponse0xa0b86991, 13 | etherscanResponse0xc36442b6, 14 | } from './etherscan'; 15 | import { 16 | fourbyteResponse0c49ccbe, 17 | fourbyteResponse0x38ed1739, 18 | fourbyteResponse0x6a761202, 19 | fourbyteResponse0xa9059cbb, 20 | fourbyteResponseac9650d8, 21 | fourbyteResponsefc6f7865, 22 | } from './4byte'; 23 | 24 | export const handlers = [ 25 | http.post('https://signing.gridpl.us/test/connect', () => { 26 | return HttpResponse.json(connectResponse); 27 | }), 28 | http.post('https://signing.gridpl.us/test/getAddresses', () => { 29 | return HttpResponse.json(getAddressesResponse); 30 | }), 31 | http.post('https://signing.gridpl.us/test/sign', () => { 32 | return HttpResponse.json(signResponse); 33 | }), 34 | http.post('https://signing.gridpl.us/test/fetchActiveWallet', () => { 35 | return HttpResponse.json(fetchActiveWalletResponse); 36 | }), 37 | http.post('https://signing.gridpl.us/test/addKvRecords', () => { 38 | return HttpResponse.json(addKvRecordsResponse); 39 | }), 40 | http.post('https://signing.gridpl.us/test/getKvRecords', () => { 41 | return HttpResponse.json(getKvRecordsResponse); 42 | }), 43 | http.post('https://signing.gridpl.us/test/removeKvRecords', () => { 44 | return HttpResponse.json(removeKvRecordsResponse); 45 | }), 46 | http.get('https://api.etherscan.io/api', ({ request }) => { 47 | const url = new URL(request.url); 48 | const module = url.searchParams.get('module'); 49 | const action = url.searchParams.get('action'); 50 | const address = url.searchParams.get('address'); 51 | 52 | if (module === 'contract' && action === 'getabi') { 53 | if (address === '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') { 54 | return HttpResponse.json({ 55 | result: JSON.stringify(etherscanResponse0xa0b86991), 56 | }); 57 | } 58 | if (address === '0x7a250d5630b4cf539739df2c5dacb4c659f2488d') { 59 | return HttpResponse.json({ 60 | result: JSON.stringify(etherscanResponse0x7a250d56), 61 | }); 62 | } 63 | if (address === '0xc36442b4a4522e871399cd717abdd847ab11fe88') { 64 | return HttpResponse.json({ 65 | result: JSON.stringify(etherscanResponse0xc36442b6), 66 | }); 67 | } 68 | if (address === '0x06412d7ebfbf66c25607e2ed24c1d207043be327') { 69 | return HttpResponse.json({ 70 | result: JSON.stringify(etherscanResponse0x06412d7e), 71 | }); 72 | } 73 | } 74 | return new HttpResponse(null, { status: 404 }); 75 | }), 76 | http.get('https://www.4byte.directory/api/v1/signatures', ({ request }) => { 77 | const url = new URL(request.url); 78 | const hexSignature = url.searchParams.get('hex_signature'); 79 | if (hexSignature === '0xa9059cbb') { 80 | return HttpResponse.json(fourbyteResponse0xa9059cbb); 81 | } 82 | if (hexSignature === '0x38ed1739') { 83 | return HttpResponse.json(fourbyteResponse0x38ed1739); 84 | } 85 | if (hexSignature === '0xac9650d8') { 86 | return HttpResponse.json(fourbyteResponseac9650d8); 87 | } 88 | if (hexSignature === '0x0c49ccbe') { 89 | return HttpResponse.json(fourbyteResponse0c49ccbe); 90 | } 91 | if (hexSignature === '0xfc6f7865') { 92 | return HttpResponse.json(fourbyteResponsefc6f7865); 93 | } 94 | if (hexSignature === '0x6a761202') { 95 | return HttpResponse.json(fourbyteResponse0x6a761202); 96 | } 97 | return new HttpResponse(null, { status: 404 }); 98 | }), 99 | ]; 100 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | - 'release/**' 9 | pull_request: 10 | branches: 11 | - main 12 | - dev 13 | - 'release/**' 14 | 15 | jobs: 16 | build: 17 | name: Lint, Build & Unit, E2E Tests 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: read 21 | packages: read 22 | env: 23 | INTERNAL_EVENT: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v5 28 | 29 | - name: Install Node.js 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: 20.x 33 | 34 | - name: Install pnpm 35 | uses: pnpm/action-setup@v2 36 | with: 37 | version: 9 38 | 39 | - name: Install dependencies 40 | run: pnpm install 41 | 42 | - name: Run linter 43 | run: pnpm run lint 44 | 45 | - name: Run tests 46 | run: pnpm run test 47 | 48 | - name: Build project 49 | run: pnpm run build 50 | 51 | - name: Release a nightly build 52 | if: env.INTERNAL_EVENT == 'true' 53 | run: pnpx pkg-pr-new publish 54 | 55 | - name: Checkout lattice-simulator 56 | if: env.INTERNAL_EVENT == 'true' 57 | uses: actions/checkout@v5 58 | with: 59 | repository: GridPlus/lattice-simulator 60 | path: lattice-simulator 61 | token: ${{ secrets.GRIDPLUS_SIM_PAT }} 62 | 63 | - name: Install simulator dependencies 64 | if: env.INTERNAL_EVENT == 'true' 65 | working-directory: lattice-simulator 66 | run: pnpm install 67 | 68 | - name: Start simulator in background 69 | if: env.INTERNAL_EVENT == 'true' 70 | working-directory: lattice-simulator 71 | env: 72 | CI: '1' 73 | DEBUG_SIGNING: '1' 74 | DEBUG: 'lattice*' 75 | LATTICE_MNEMONIC: 'test test test test test test test test test test test junk' 76 | PORT: '3000' 77 | DEVICE_ID: 'SD0001' 78 | PASSWORD: '12345678' 79 | PAIRING_SECRET: '12345678' 80 | ENC_PW: '12345678' 81 | run: | 82 | pnpm run dev > simulator.log 2>&1 & 83 | echo $! > simulator.pid 84 | echo "Simulator PID: $(cat simulator.pid)" 85 | 86 | # Wait for simulator to be ready 87 | echo "Waiting for simulator to start..." 88 | for i in {1..30}; do 89 | if curl -s http://localhost:3000 > /dev/null 2>&1; then 90 | echo "Simulator is ready!" 91 | break 92 | fi 93 | if [ $i -eq 30 ]; then 94 | echo "Simulator failed to start within 30 seconds" 95 | cat simulator.log 96 | exit 1 97 | fi 98 | sleep 1 99 | done 100 | 101 | - name: Run SDK e2e tests with simulator 102 | if: env.INTERNAL_EVENT == 'true' 103 | working-directory: ${{ github.workspace }} 104 | env: 105 | CI: '1' 106 | DEBUG_SIGNING: '1' 107 | baseUrl: 'http://127.0.0.1:3000' 108 | DEVICE_ID: 'SD0001' 109 | PASSWORD: '12345678' 110 | PAIRING_SECRET: '12345678' 111 | ENC_PW: '12345678' 112 | APP_NAME: 'lattice-manager' 113 | run: pnpm run e2e --reporter=basic 114 | 115 | - name: Show simulator logs on failure 116 | if: failure() && env.INTERNAL_EVENT == 'true' 117 | working-directory: lattice-simulator 118 | run: | 119 | echo "=== Simulator logs ===" 120 | cat simulator.log || echo "No simulator logs found" 121 | 122 | - name: Stop simulator 123 | if: always() && env.INTERNAL_EVENT == 'true' 124 | working-directory: lattice-simulator 125 | run: | 126 | if [ -f simulator.pid ]; then 127 | kill $(cat simulator.pid) || true 128 | fi 129 | -------------------------------------------------------------------------------- /src/shared/utilities.ts: -------------------------------------------------------------------------------- 1 | import { HARDENED_OFFSET } from '../constants'; 2 | import { KeyPair, ActiveWallets, FirmwareVersion } from '../types'; 3 | 4 | /** 5 | * Get 64 bytes representing the public key This is the uncompressed key without the leading 04 6 | * byte 7 | * @param KeyPair - //TODO Describe the keypair 8 | * @param LE - Whether to return the public key in little endian format. 9 | * @returns A Buffer containing the public key. 10 | */ 11 | export const getPubKeyBytes = (key: KeyPair, LE = false) => { 12 | const k = key.getPublic(); 13 | const p = k.encode('hex', false); 14 | const pb = Buffer.from(p, 'hex'); 15 | if (LE === true) { 16 | // Need to flip X and Y components to little endian 17 | const x = pb.slice(1, 33).reverse(); 18 | const y = pb.slice(33, 65).reverse(); 19 | // @ts-expect-error - TODO: Find out why Buffer won't accept pb[0] 20 | return Buffer.concat([pb[0], x, y]); 21 | } else { 22 | return pb; 23 | } 24 | }; 25 | 26 | /** 27 | * Get the shared secret, derived via ECDH from the local private key and the ephemeral public key 28 | * @internal 29 | * @returns Buffer 30 | */ 31 | export const getSharedSecret = (key: KeyPair, ephemeralPub: KeyPair) => { 32 | // Once every ~256 attempts, we will get a key that starts with a `00` byte, which can lead to 33 | // problems initializing AES if we don't force a 32 byte BE buffer. 34 | return Buffer.from(key.derive(ephemeralPub.getPublic()).toArray('be', 32)); 35 | }; 36 | 37 | // Given a set of wallet data, which contains two wallet descriptors, parse the data and save it 38 | // to memory 39 | export const parseWallets = (walletData: any): ActiveWallets => { 40 | // Read the external wallet data first. If it is non-null, the external wallet will be the 41 | // active wallet of the device and we should save it. If the external wallet is blank, it means 42 | // there is no card present and we should save and use the interal wallet. If both wallets are 43 | // empty, it means the device still needs to be set up. 44 | const walletDescriptorLen = 71; 45 | // Internal first 46 | let off = 0; 47 | const activeWallets: ActiveWallets = { 48 | internal: { 49 | uid: undefined, 50 | capabilities: undefined, 51 | name: undefined, 52 | external: false, 53 | }, 54 | external: { 55 | uid: undefined, 56 | capabilities: undefined, 57 | name: undefined, 58 | external: true, 59 | }, 60 | }; 61 | activeWallets.internal.uid = walletData.slice(off, off + 32); 62 | // NOTE: `capabilities` and `name` were deprecated in Lattice firmware. 63 | // They never provided any real information, but have been archived here 64 | // since the response size has been preserved and we may bring them back 65 | // in a different form. 66 | // activeWallets.internal.capabilities = walletData.readUInt32BE(off + 32); 67 | // activeWallets.internal.name = walletData.slice( 68 | // off + 36, 69 | // off + walletDescriptorLen, 70 | // ); 71 | // Offset the first item 72 | off += walletDescriptorLen; 73 | // External 74 | activeWallets.external.uid = walletData.slice(off, off + 32); 75 | // activeWallets.external.capabilities = walletData.readUInt32BE(off + 32); 76 | // activeWallets.external.name = walletData.slice( 77 | // off + 36, 78 | // off + walletDescriptorLen, 79 | // ); 80 | 81 | return activeWallets; 82 | }; 83 | 84 | // Determine if a provided firmware version matches or exceeds the current firmware version 85 | export const isFWSupported = ( 86 | fwVersion: FirmwareVersion, 87 | versionSupported: FirmwareVersion, 88 | ): boolean => { 89 | const { major, minor, fix } = fwVersion; 90 | const { major: _major, minor: _minor, fix: _fix } = versionSupported; 91 | return ( 92 | major > _major || 93 | (major >= _major && minor > _minor) || 94 | (major >= _major && minor >= _minor && fix >= _fix) 95 | ); 96 | }; 97 | 98 | /** 99 | * Convert a set of BIP39 path indices to a string 100 | * @param path - Set of indices 101 | */ 102 | export const getPathStr = function (path) { 103 | let pathStr = 'm'; 104 | path.forEach((idx) => { 105 | if (idx >= HARDENED_OFFSET) { 106 | pathStr += `/${idx - HARDENED_OFFSET}'`; 107 | } else { 108 | pathStr += `/${idx}`; 109 | } 110 | }); 111 | return pathStr; 112 | }; 113 | -------------------------------------------------------------------------------- /src/__test__/unit/encoders.test.ts: -------------------------------------------------------------------------------- 1 | import { EXTERNAL } from '../../constants'; 2 | import { 3 | encodeAddKvRecordsRequest, 4 | encodeGetAddressesRequest, 5 | encodeGetKvRecordsRequest, 6 | encodePairRequest, 7 | encodeRemoveKvRecordsRequest, 8 | encodeSignRequest, 9 | } from '../../functions'; 10 | import { buildTransaction } from '../../shared/functions'; 11 | import { getP256KeyPair } from '../../util'; 12 | import { 13 | buildFirmwareConstants, 14 | buildGetAddressesObject, 15 | buildSignObject, 16 | buildWallet, 17 | getFwVersionsList, 18 | } from '../utils/builders'; 19 | 20 | describe('encoders', () => { 21 | let mockRandom: any; 22 | 23 | beforeAll(() => { 24 | mockRandom = vi.spyOn(globalThis.Math, 'random').mockReturnValue(0.1); 25 | }); 26 | 27 | afterAll(() => { 28 | mockRandom.mockRestore(); 29 | }); 30 | 31 | describe('pair', () => { 32 | test('pair encoder', () => { 33 | const privKey = Buffer.alloc(32, '1'); 34 | expect(privKey.toString()).toMatchSnapshot(); 35 | const key = getP256KeyPair(privKey); 36 | const payload = encodePairRequest({ 37 | key, 38 | pairingSecret: 'testtest', 39 | appName: 'testtest', 40 | }); 41 | const payloadAsString = payload.toString('hex'); 42 | expect(payloadAsString).toMatchSnapshot(); 43 | }); 44 | }); 45 | 46 | describe('getAddresses', () => { 47 | test('encodeGetAddressesRequest with default flag', () => { 48 | const payload = encodeGetAddressesRequest(buildGetAddressesObject()); 49 | const payloadAsString = payload.toString('hex'); 50 | expect(payloadAsString).toMatchSnapshot(); 51 | }); 52 | 53 | test('encodeGetAddressesRequest with ED25519_PUB', () => { 54 | const mockObject = buildGetAddressesObject({ 55 | flag: EXTERNAL.GET_ADDR_FLAGS.ED25519_PUB, 56 | }); 57 | const payload = encodeGetAddressesRequest(mockObject); 58 | const payloadAsString = payload.toString('hex'); 59 | expect(payloadAsString).toMatchSnapshot(); 60 | }); 61 | 62 | test('encodeGetAddressesRequest with SECP256K1_PUB', () => { 63 | const mockObject = buildGetAddressesObject({ 64 | flag: EXTERNAL.GET_ADDR_FLAGS.SECP256K1_PUB, 65 | }); 66 | const payload = encodeGetAddressesRequest(mockObject); 67 | const payloadAsString = payload.toString('hex'); 68 | expect(payloadAsString).toMatchSnapshot(); 69 | }); 70 | }); 71 | 72 | describe('sign', () => { 73 | test.each(getFwVersionsList())( 74 | 'should test sign encoder with firmware v%d.%d.%d', 75 | (major, minor, patch) => { 76 | const fwVersion = Buffer.from([patch, minor, major]); 77 | const txObj = buildSignObject(fwVersion); 78 | const tx = buildTransaction(txObj); 79 | const req = { 80 | ...txObj, 81 | ...tx, 82 | wallet: buildWallet(), 83 | }; 84 | const { payload } = encodeSignRequest(req); 85 | const payloadAsString = payload.toString('hex'); 86 | expect(payloadAsString).toMatchSnapshot(); 87 | }, 88 | ); 89 | }); 90 | 91 | describe('KvRecords', () => { 92 | test('getKvRecords', () => { 93 | const mockObject = { type: 0, n: 1, start: 0 }; 94 | const payload = encodeGetKvRecordsRequest(mockObject); 95 | const payloadAsString = payload.toString('hex'); 96 | expect(payloadAsString).toMatchSnapshot(); 97 | }); 98 | 99 | test('addKvRecords', () => { 100 | const fwConstants = buildFirmwareConstants(); 101 | const mockObject = { 102 | type: 0, 103 | records: { key: 'value' }, 104 | caseSensitive: false, 105 | fwConstants, 106 | }; 107 | const payload = encodeAddKvRecordsRequest(mockObject); 108 | const payloadAsString = payload.toString('hex'); 109 | expect(payloadAsString).toMatchSnapshot(); 110 | }); 111 | 112 | test('removeKvRecords', () => { 113 | const fwConstants = buildFirmwareConstants(); 114 | const mockObject = { 115 | type: 0, 116 | ids: ['0'], 117 | caseSensitive: false, 118 | fwConstants, 119 | }; 120 | const payload = encodeRemoveKvRecordsRequest(mockObject); 121 | const payloadAsString = payload.toString('hex'); 122 | expect(payloadAsString).toMatchSnapshot(); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /src/api/utilities.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../client'; 2 | import { EXTERNAL, HARDENED_OFFSET } from '../constants'; 3 | import { 4 | getFunctionQueue, 5 | loadClient, 6 | saveClient, 7 | setFunctionQueue, 8 | } from './state'; 9 | 10 | /** 11 | * `queue` is a function that wraps all functional API calls. It limits the number of concurrent 12 | * requests to the server to 1, and ensures that the client state data is saved after each call. 13 | * This is necessary because the ephemeral public key must be updated after each successful request, 14 | * and two concurrent requests could result in the same key being used twice or the wrong key being 15 | * written to memory locally. 16 | * 17 | * @internal 18 | */ 19 | export const queue = async (fn: (client: Client) => Promise) => { 20 | const client = await loadClient(); 21 | if (!client) throw new Error('Client not initialized'); 22 | if (!getFunctionQueue()) { 23 | setFunctionQueue(Promise.resolve()); 24 | } 25 | setFunctionQueue( 26 | getFunctionQueue().then( 27 | async () => 28 | await fn(client) 29 | .catch((err) => { 30 | // Empty the queue if any function call fails 31 | setFunctionQueue(Promise.resolve()); 32 | throw err; 33 | }) 34 | .then((returnValue) => { 35 | saveClient(client.getStateData()); 36 | return returnValue; 37 | }), 38 | ), 39 | ); 40 | return getFunctionQueue(); 41 | }; 42 | 43 | export const getClient = async (): Promise => { 44 | const client = loadClient ? await loadClient() : undefined; 45 | if (!client) throw new Error('Client not initialized'); 46 | return client; 47 | }; 48 | 49 | const encodeClientData = (clientData: string) => { 50 | return Buffer.from(clientData).toString('base64'); 51 | }; 52 | 53 | const decodeClientData = (clientData: string) => { 54 | return Buffer.from(clientData, 'base64').toString(); 55 | }; 56 | 57 | export const buildSaveClientFn = ( 58 | setStoredClient: (clientData: string | null) => Promise, 59 | ) => { 60 | return async (clientData: string | null) => { 61 | if (!clientData) return; 62 | const encodedData = encodeClientData(clientData); 63 | await setStoredClient(encodedData); 64 | }; 65 | }; 66 | 67 | export const buildLoadClientFn = (getStoredClient: () => Promise) => { 68 | return async () => { 69 | const clientData = await getStoredClient(); 70 | if (!clientData) return undefined; 71 | const stateData = decodeClientData(clientData); 72 | if (!stateData) return undefined; 73 | const client = new Client({ stateData }); 74 | if (!client) throw new Error('Client not initialized'); 75 | return client; 76 | }; 77 | }; 78 | 79 | export const getStartPath = ( 80 | defaultStartPath: number[], 81 | addressIndex = 0, // The value to increment `defaultStartPath` 82 | pathIndex = 4, // Which index in `defaultStartPath` array to increment 83 | ): number[] => { 84 | const startPath = [...defaultStartPath]; 85 | if (addressIndex > 0) { 86 | startPath[pathIndex] = defaultStartPath[pathIndex] + addressIndex; 87 | } 88 | return startPath; 89 | }; 90 | 91 | export const isEIP712Payload = (payload: any) => 92 | typeof payload !== 'string' && 93 | 'types' in payload && 94 | 'domain' in payload && 95 | 'primaryType' in payload && 96 | 'message' in payload; 97 | 98 | export function parseDerivationPath(path: string): number[] { 99 | if (!path) return []; 100 | const components = path.split('/').filter(Boolean); 101 | return parseDerivationPathComponents(components); 102 | } 103 | 104 | export function parseDerivationPathComponents(components: string[]): number[] { 105 | return components.map((part) => { 106 | const lowerPart = part.toLowerCase(); 107 | if (lowerPart === 'x') return 0; // Wildcard 108 | if (lowerPart === "x'") return HARDENED_OFFSET; // Hardened wildcard 109 | if (part.endsWith("'")) 110 | return parseInt(part.slice(0, -1)) + HARDENED_OFFSET; 111 | const val = parseInt(part); 112 | if (isNaN(val)) { 113 | throw new Error(`Invalid part in derivation path: ${part}`); 114 | } 115 | return val; 116 | }); 117 | } 118 | 119 | export function getFlagFromPath(path: number[]): number | undefined { 120 | if (path.length >= 2 && path[1] === 501 + HARDENED_OFFSET) { 121 | return EXTERNAL.GET_ADDR_FLAGS.ED25519_PUB; // SOLANA 122 | } 123 | return undefined; 124 | } 125 | -------------------------------------------------------------------------------- /src/__test__/utils/ethers.ts: -------------------------------------------------------------------------------- 1 | const EVM_TYPES = [ 2 | null, 3 | 'address', 4 | 'bool', 5 | 'uint', 6 | 'int', 7 | 'bytes', 8 | 'string', 9 | 'tuple', 10 | ]; 11 | 12 | export function convertDecoderToEthers(def) { 13 | const converted = getConvertedDef(def); 14 | const types: any[] = []; 15 | const data: any[] = []; 16 | converted.forEach((i: any) => { 17 | types.push(i.type); 18 | data.push(i.data); 19 | }); 20 | return { types, data }; 21 | } 22 | 23 | // Convert an encoded def into a combination of ethers-compatable 24 | // type names and data fields. The data should be random but it 25 | // doesn't matter much for these tests, which mainly just test 26 | // structure of the definitions 27 | function getConvertedDef(def) { 28 | const converted: any[] = []; 29 | def.forEach((param) => { 30 | const arrSzs = param[3]; 31 | const evmType = EVM_TYPES[parseInt(param[1].toString('hex'), 16)]; 32 | let type = evmType; 33 | const numBytes = parseInt(param[2].toString('hex'), 16); 34 | if (numBytes > 0) { 35 | type = `${type}${numBytes * 8}`; 36 | } 37 | // Handle tuples by recursively generating data 38 | let tupleData; 39 | if (evmType === 'tuple') { 40 | tupleData = []; 41 | type = `${type}(`; 42 | const tupleDef = getConvertedDef(param[4]); 43 | tupleDef.forEach((tupleParam: any) => { 44 | type = `${type}${tupleParam.type}, `; 45 | tupleData.push(tupleParam.data); 46 | }); 47 | type = type.slice(0, type.length - 2); 48 | type = `${type})`; 49 | } 50 | // Get the data of a single function (i.e. excluding arrays) 51 | const funcData = tupleData ? tupleData : genParamData(param); 52 | // Apply the data to arrays 53 | for (let i = 0; i < arrSzs.length; i++) { 54 | const sz = parseInt(arrSzs[i].toString('hex')); 55 | if (isNaN(sz)) { 56 | // This is a 0 size, which means we need to 57 | // define a size to generate data 58 | type = `${type}[]`; 59 | } else { 60 | type = `${type}[${sz}]`; 61 | } 62 | } 63 | // If this param is a tuple we need to copy base data 64 | // across all dimensions. The individual params are already 65 | // arraified this way, but not the tuple type 66 | if (tupleData) { 67 | converted.push({ type, data: getArrayData(param, funcData) }); 68 | } else { 69 | converted.push({ type, data: funcData }); 70 | } 71 | }); 72 | return converted; 73 | } 74 | 75 | function genTupleData(tupleParam) { 76 | const nestedData: any = []; 77 | tupleParam.forEach((nestedParam) => { 78 | nestedData.push( 79 | genData( 80 | EVM_TYPES[parseInt(nestedParam[1].toString('hex'), 16)] ?? '', 81 | nestedParam, 82 | ), 83 | ); 84 | }); 85 | return nestedData; 86 | } 87 | 88 | function genParamData(param: any[]) { 89 | const evmType = EVM_TYPES[parseInt(param[1].toString('hex'), 16)] ?? ''; 90 | const baseData = genData(evmType, param); 91 | return getArrayData(param, baseData); 92 | } 93 | 94 | function getArrayData(param: any, baseData: any) { 95 | let arrayData, data; 96 | const arrSzs = param[3]; 97 | for (let i = 0; i < arrSzs.length; i++) { 98 | // let sz = parseInt(arrSzs[i].toString('hex')); TODO: fix this 99 | const dimData: any = []; 100 | let sz = parseInt(param[3][i].toString('hex')); 101 | if (isNaN(sz)) { 102 | sz = 2; //1; 103 | } 104 | if (!arrayData) { 105 | arrayData = []; 106 | } 107 | const lastDimData = JSON.parse(JSON.stringify(arrayData)); 108 | for (let j = 0; j < sz; j++) { 109 | if (i === 0) { 110 | dimData.push(baseData); 111 | } else { 112 | dimData.push(lastDimData); 113 | } 114 | } 115 | arrayData = dimData; 116 | } 117 | if (!data) { 118 | data = arrayData ? arrayData : baseData; 119 | } 120 | return data; 121 | } 122 | 123 | function genData(type: string, param: any[]) { 124 | switch (type) { 125 | case 'address': 126 | return '0xdead00000000000000000000000000000000beef'; 127 | case 'bool': 128 | return true; 129 | case 'uint': 130 | return 9; 131 | case 'int': 132 | return -9; 133 | case 'bytes': 134 | return '0xdeadbeef'; 135 | case 'string': 136 | return 'string'; 137 | case 'tuple': 138 | if (!param || param.length < 4) { 139 | throw new Error('Invalid tuple data'); 140 | } 141 | return genTupleData(param[4]); 142 | default: 143 | throw new Error('Unrecognized type'); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | import { themes } from 'prism-react-renderer'; 5 | 6 | const excludedFiles = [ 7 | 'bitcoin', 8 | 'ethereum', 9 | 'genericSigning', 10 | 'index', 11 | 'calldata/index', 12 | ].map((s) => `../src/${s}.ts`); 13 | 14 | /** @type {import('@docusaurus/types').Config} */ 15 | const config = { 16 | title: 'GridPlus SDK', 17 | tagline: 'The new standard for hardware wallets', 18 | url: 'https://gridplus.io', 19 | baseUrl: '/gridplus-sdk/', 20 | onBrokenLinks: 'throw', 21 | onBrokenMarkdownLinks: 'warn', 22 | favicon: 'img/logo.jpeg', 23 | organizationName: 'gridplus', 24 | projectName: 'gridplus-sdk', 25 | plugins: [ 26 | [ 27 | 'docusaurus-plugin-typedoc', 28 | { 29 | id: 'gridplus-sdk', 30 | tsconfig: '../tsconfig.json', 31 | entryPoints: ['../src/api', '../src/constants.ts', '../src/util.ts'], 32 | entryFileName: 'index', 33 | out: './docs/reference', 34 | outputFileStrategy: 'modules', 35 | entryPointStrategy: 'expand', 36 | exclude: ['**/node_modules', '**/tests'], 37 | excludeNotDocumented: true, 38 | excludeInternal: true, 39 | excludePrivate: true, 40 | excludeGroups: true, 41 | readme: 'none', 42 | skipErrorChecking: true, 43 | expandParameters: true, 44 | expandObjects: true, 45 | parametersFormat: 'table', 46 | propertiesFormat: 'table', 47 | enumMembersFormat: 'table', 48 | typeDeclarationFormat: 'table', 49 | sanitizeComments: true, 50 | sidebar: { 51 | autoConfiguration: true, 52 | pretty: true, 53 | }, 54 | plugin: ['typedoc-plugin-markdown'], 55 | }, 56 | ], 57 | ], 58 | presets: [ 59 | [ 60 | 'classic', 61 | /** @type {import('@docusaurus/preset-classic').Options} */ 62 | ({ 63 | docs: { 64 | sidebarPath: require.resolve('./sidebars.js'), 65 | // Please change this to your repo. 66 | editUrl: 'https://github.com/gridplus/gridplus-sdk', 67 | remarkPlugins: [require('mdx-mermaid')], 68 | routeBasePath: '/', 69 | }, 70 | blog: false, 71 | theme: { 72 | customCss: require.resolve('./src/css/custom.css'), 73 | }, 74 | }), 75 | ], 76 | ], 77 | 78 | themeConfig: 79 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 80 | ({ 81 | navbar: { 82 | title: '', 83 | logo: { 84 | alt: 'Gridplus Logo', 85 | src: 'img/logo.png', 86 | }, 87 | items: [ 88 | { 89 | type: 'doc', 90 | docId: 'index', 91 | position: 'left', 92 | label: 'Docs', 93 | }, 94 | // { 95 | // type: "docSidebar", 96 | // position: "left", 97 | // sidebarId: "api", 98 | // label: "API", 99 | // }, 100 | { 101 | href: 'https://github.com/gridplus/gridplus-sdk', 102 | label: 'GitHub', 103 | position: 'right', 104 | }, 105 | ], 106 | }, 107 | footer: { 108 | style: 'dark', 109 | links: [ 110 | { 111 | title: 'Community', 112 | items: [ 113 | { 114 | label: 'Stack Overflow', 115 | href: 'https://stackoverflow.com/questions/tagged/gridplus', 116 | }, 117 | { 118 | label: 'Discord', 119 | href: 'https://discordapp.com/invite/gridplus', 120 | }, 121 | { 122 | label: 'Twitter', 123 | href: 'https://twitter.com/gridplus', 124 | }, 125 | ], 126 | }, 127 | { 128 | title: 'More', 129 | items: [ 130 | { 131 | label: 'Blog', 132 | href: 'https://blog.gridplus.io', 133 | }, 134 | { 135 | label: 'GitHub', 136 | href: 'https://github.com/gridplus/gridplus-sdk', 137 | }, 138 | ], 139 | }, 140 | ], 141 | copyright: `Copyright © ${new Date().getFullYear()} GridPlus, Inc.`, 142 | }, 143 | prism: { 144 | theme: themes.dracula, 145 | darkTheme: themes.dracula, 146 | }, 147 | }), 148 | }; 149 | 150 | module.exports = config; 151 | -------------------------------------------------------------------------------- /src/functions/connect.ts: -------------------------------------------------------------------------------- 1 | import { ProtocolConstants, connectSecureRequest } from '../protocol'; 2 | import { doesFetchWalletsOnLoad } from '../shared/predicates'; 3 | import { getSharedSecret, parseWallets } from '../shared/utilities'; 4 | import { 5 | validateBaseUrl, 6 | validateDeviceId, 7 | validateKey, 8 | } from '../shared/validators'; 9 | import { ConnectRequestFunctionParams, KeyPair, ActiveWallets } from '../types'; 10 | import { aes256_decrypt, getP256KeyPairFromPub } from '../util'; 11 | 12 | export async function connect({ 13 | client, 14 | id, 15 | }: ConnectRequestFunctionParams): Promise { 16 | const { deviceId, key, baseUrl } = validateConnectRequest({ 17 | deviceId: id, 18 | // @ts-expect-error - private access 19 | key: client.key, 20 | baseUrl: client.baseUrl, 21 | }); 22 | 23 | const url = `${baseUrl}/${deviceId}`; 24 | 25 | const respPayloadData = await connectSecureRequest({ 26 | url, 27 | pubkey: client.publicKey, 28 | }); 29 | 30 | // Decode response data params. 31 | // Response payload data is *not* encrypted. 32 | const { isPaired, fwVersion, activeWallets, ephemeralPub } = 33 | await decodeConnectResponse(respPayloadData, key); 34 | 35 | // Update client state with response data 36 | 37 | client.mutate({ 38 | deviceId, 39 | ephemeralPub, 40 | url, 41 | isPaired, 42 | fwVersion, 43 | activeWallets, 44 | }); 45 | 46 | // If we are paired and are on older firmware (<0.14.1), we need a 47 | // follow up request to sync wallet state. 48 | if (isPaired && !doesFetchWalletsOnLoad(client.getFwVersion())) { 49 | await client.fetchActiveWallet(); 50 | } 51 | 52 | // Return flag indicating whether we are paired or not. 53 | // If we are *not* already paired, the Lattice is now in 54 | // pairing mode and expects a `finalizePairing` encrypted 55 | // request as a follow up. 56 | return isPaired; 57 | } 58 | 59 | export const validateConnectRequest = ({ 60 | deviceId, 61 | key, 62 | baseUrl, 63 | }: { 64 | deviceId?: string; 65 | key?: KeyPair; 66 | baseUrl?: string; 67 | }): { 68 | deviceId: string; 69 | key: KeyPair; 70 | baseUrl: string; 71 | } => { 72 | const validDeviceId = validateDeviceId(deviceId); 73 | const validKey = validateKey(key); 74 | const validBaseUrl = validateBaseUrl(baseUrl); 75 | 76 | return { 77 | deviceId: validDeviceId, 78 | key: validKey, 79 | baseUrl: validBaseUrl, 80 | }; 81 | }; 82 | 83 | /** 84 | * `decodeConnectResponse` will call `StartPairingMode` on the device, which gives the user 60 seconds to 85 | * finalize the pairing. This will return an ephemeral public key, which is needed for the next 86 | * request. 87 | * - If the device is already paired, this ephemPub is simply used to encrypt the next request. 88 | * - If the device is not paired, it is needed to pair the device within 60 seconds. 89 | * @category Device Response 90 | * @internal 91 | * @returns true if we are paired to the device already 92 | */ 93 | export const decodeConnectResponse = ( 94 | response: Buffer, 95 | key: KeyPair, 96 | ): { 97 | isPaired: boolean; 98 | fwVersion: Buffer; 99 | activeWallets: ActiveWallets | undefined; 100 | ephemeralPub: KeyPair; 101 | } => { 102 | let off = 0; 103 | const isPaired = 104 | response.readUInt8(off) === ProtocolConstants.pairingStatus.paired; 105 | off++; 106 | // If we are already paired, we get the next ephemeral key 107 | const pub = response.slice(off, off + 65).toString('hex'); 108 | off += 65; // Set the public key 109 | const ephemeralPub = getP256KeyPairFromPub(pub); 110 | // Grab the firmware version (will be 0-length for older fw versions) It is of format 111 | // |fix|minor|major|reserved| 112 | const fwVersion = response.slice(off, off + 4); 113 | off += 4; 114 | 115 | // If we are already paired, the response will include some encrypted data about the current 116 | // wallets This data was added in Lattice firmware v0.14.1 117 | if (isPaired) { 118 | //TODO && this._fwVersionGTE(0, 14, 1)) { 119 | // Later versions of firmware added wallet info 120 | const encWalletData = response.slice(off, off + 160); 121 | off += 160; 122 | const sharedSecret = getSharedSecret(key, ephemeralPub); 123 | const decWalletData = aes256_decrypt(encWalletData, sharedSecret); 124 | // Sanity check to make sure the last part of the decrypted data is empty. The last 2 bytes 125 | // are AES padding 126 | if ( 127 | decWalletData[decWalletData.length - 2] !== 0 || 128 | decWalletData[decWalletData.length - 1] !== 0 129 | ) { 130 | throw new Error('Failed to connect to Lattice.'); 131 | } 132 | const activeWallets = parseWallets(decWalletData); 133 | return { isPaired, fwVersion, activeWallets, ephemeralPub }; 134 | } 135 | // return the state of our pairing 136 | return { isPaired, fwVersion, activeWallets: undefined, ephemeralPub }; 137 | }; 138 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gridplus-sdk", 3 | "version": "4.0.0", 4 | "type": "module", 5 | "description": "SDK to interact with GridPlus Lattice1 device", 6 | "scripts": { 7 | "build": "tsup", 8 | "commit": "git-cz", 9 | "lint:fix": "eslint src --c .ts,.tsx --config eslint.config.mjs --fix && prettier --write .", 10 | "lint": "eslint src --c .ts,.tsx --config eslint.config.mjs", 11 | "format": "prettier --write .", 12 | "pair-device": "tsx scripts/pair-device.ts", 13 | "precommit": "npm run lint:fix && npm run test", 14 | "test": "vitest run ./src/__test__/unit ./src/__test__/integration", 15 | "test-unit": "vitest run ./src/__test__/unit --maxConcurrency 10 --fileParallelism true", 16 | "unit-api": "vitest run ./src/__test__/unit/api.test.ts", 17 | "unit-decode": "vitest run ./src/__test__/unit/decoders.test.ts", 18 | "unit-encode": "vitest run ./src/__test__/unit/encoders.test.ts", 19 | "unit-validators": "vitest run ./src/__test__/unit/validators.test.ts", 20 | "test-int": "vitest run ./src/__test__/integration/", 21 | "test-utils": "vitest run src/__test__/utils/__test__/", 22 | "e2e": "vitest run ./src/__test__/e2e/", 23 | "e2e-btc": "vitest run ./src/__test__/e2e/btc.test.ts", 24 | "e2e-eth": "vitest run ./src/__test__/e2e/eth.msg.test.ts", 25 | "e2e-gen": "vitest run ./src/__test__/e2e/general.test.ts", 26 | "e2e-kv": "vitest run ./src/__test__/e2e/kv.test.ts", 27 | "e2e-ne": "vitest run ./src/__test__/e2e/non-exportable.test.ts", 28 | "e2e-sign": "vitest run ./src/__test__/e2e/signing", 29 | "e2e-sign-bls": "vitest run ./src/__test__/e2e/signing/bls.test.ts", 30 | "e2e-sign-determinism": "vitest run ./src/__test__/e2e/signing/determinism.test.ts", 31 | "e2e-sign-evm-abi": "vitest run ./src/__test__/e2e/signing/evm-abi.test.ts", 32 | "e2e-sign-evm-tx": "vitest run ./src/__test__/e2e/signing/evm-tx.test.ts", 33 | "e2e-sign-solana": "vitest run ./src/__test__/e2e/signing/solana*", 34 | "e2e-sign-unformatted": "vitest run ./src/__test__/e2e/signing/unformatted.test.ts", 35 | "e2e-api": "vitest run ./src/__test__/e2e/api.test.ts", 36 | "e2e-sign-eip712": "vitest run ./src/__test__/e2e/signing/eip712-msg.test.ts" 37 | }, 38 | "files": [ 39 | "dist" 40 | ], 41 | "main": "./dist/index.cjs", 42 | "module": "./dist/index.mjs", 43 | "exports": { 44 | ".": { 45 | "types": "./dist/index.d.ts", 46 | "import": "./dist/index.mjs", 47 | "require": "./dist/index.cjs" 48 | }, 49 | "./package.json": "./package.json" 50 | }, 51 | "types": "./dist/index.d.ts", 52 | "repository": { 53 | "type": "git", 54 | "url": "https://github.com/GridPlus/gridplus-sdk.git" 55 | }, 56 | "dependencies": { 57 | "@ethereumjs/common": "^10.0.0", 58 | "@ethereumjs/rlp": "^10.0.0", 59 | "@ethereumjs/tx": "^10.0.0", 60 | "@metamask/eth-sig-util": "^8.2.0", 61 | "@types/uuid": "^11.0.0", 62 | "aes-js": "^3.1.2", 63 | "bech32": "^2.0.0", 64 | "bignumber.js": "^9.3.1", 65 | "bitwise": "^2.2.1", 66 | "bn.js": "^5.2.2", 67 | "bs58check": "^4.0.0", 68 | "buffer": "^6.0.3", 69 | "cbor": "^10.0.11", 70 | "cbor-bigdecimal": "^10.0.11", 71 | "crc-32": "^1.2.2", 72 | "elliptic": "6.6.1", 73 | "hash.js": "^1.1.7", 74 | "lodash": "^4.17.21", 75 | "ox": "^0.9.7", 76 | "secp256k1": "5.0.1", 77 | "uuid": "^13.0.0", 78 | "viem": "^2.37.8", 79 | "zod": "^4.1.11" 80 | }, 81 | "devDependencies": { 82 | "@chainsafe/bls-keystore": "^3.1.0", 83 | "@eslint/js": "^9.36.0", 84 | "@noble/bls12-381": "^1.4.0", 85 | "@solana/web3.js": "^1.98.4", 86 | "@types/bn.js": "^5.2.0", 87 | "@types/elliptic": "^6.4.18", 88 | "@types/jest": "^30.0.0", 89 | "@types/lodash": "^4.17.20", 90 | "@types/node": "^24.5.2", 91 | "@types/readline-sync": "^1.4.8", 92 | "@types/secp256k1": "^4.0.6", 93 | "@types/seedrandom": "^3.0.8", 94 | "@typescript-eslint/eslint-plugin": "^8.44.1", 95 | "@typescript-eslint/parser": "^8.44.1", 96 | "@vitest/coverage-istanbul": "^2.1.3", 97 | "bip32": "^4.0.0", 98 | "bip39": "^3.1.0", 99 | "bitcoinjs-lib": "6.1.7", 100 | "bls12-381-keygen": "^0.2.4", 101 | "dotenv": "^17.2.2", 102 | "ecpair": "^3.0.0", 103 | "ed25519-hd-key": "^1.3.0", 104 | "eslint": "^9.36.0", 105 | "eslint-config-prettier": "^10.1.8", 106 | "eslint-plugin-prettier": "^5.5.4", 107 | "ethereumjs-util": "^7.1.5", 108 | "jsonc": "^2.0.0", 109 | "msw": "^2.11.3", 110 | "prettier": "^3.6.2", 111 | "prettier-eslint": "^16.4.2", 112 | "random-words": "^2.0.1", 113 | "readline-sync": "^1.4.10", 114 | "seedrandom": "^3.0.5", 115 | "tsup": "^8.5.0", 116 | "tsx": "^4.20.6", 117 | "tweetnacl": "^1.0.3", 118 | "tiny-secp256k1": "^2.2.4", 119 | "typescript": "^5.9.2", 120 | "vite": "^7.1.7", 121 | "vite-plugin-dts": "^4.5.4", 122 | "vitest": "3.2.4" 123 | }, 124 | "license": "MIT", 125 | "engines": { 126 | "node": ">=20" 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /patches/vitest@2.1.3.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/chunks/utils.CY6Spixo.js b/dist/chunks/utils.CY6Spixo.js 2 | index ee001766fb2f3a98b39bd78aded8e5798ec68c28..a69caf50b0a8d21d9352e4eeabff47f548219263 100644 3 | --- a/dist/chunks/utils.CY6Spixo.js 4 | +++ b/dist/chunks/utils.CY6Spixo.js 5 | @@ -1,7 +1,7 @@ 6 | -import { stripVTControlCharacters } from 'node:util'; 7 | -import { isAbsolute, relative, dirname, basename } from 'pathe'; 8 | -import c from 'tinyrainbow'; 9 | -import { a as slash } from './base.DwXGwWst.js'; 10 | +import { stripVTControlCharacters } from "node:util"; 11 | +import { isAbsolute, relative, dirname, basename } from "pathe"; 12 | +import c from "tinyrainbow"; 13 | +import { a as slash } from "./base.DwXGwWst.js"; 14 | 15 | const F_RIGHT = "\u2192"; 16 | const F_DOWN = "\u2193"; 17 | @@ -93,8 +93,8 @@ function renderSnapshotSummary(rootDir, snapshots) { 18 | uncheckedFile.filePath 19 | )}` 20 | ); 21 | - uncheckedFile.keys.forEach( 22 | - (key) => summary.push(` ${c.gray(F_DOT)} ${key}`) 23 | + uncheckedFile.keys.forEach((key) => 24 | + summary.push(` ${c.gray(F_DOT)} ${key}`) 25 | ); 26 | }); 27 | } 28 | @@ -111,12 +111,16 @@ function getStateString(tasks, name = "tests", showTotal = true) { 29 | const failed = tasks.filter((i) => i.result?.state === "fail"); 30 | const skipped2 = tasks.filter((i) => i.mode === "skip"); 31 | const todo = tasks.filter((i) => i.mode === "todo"); 32 | - return [ 33 | - failed.length ? c.bold(c.red(`${failed.length} failed`)) : null, 34 | - passed.length ? c.bold(c.green(`${passed.length} passed`)) : null, 35 | - skipped2.length ? c.yellow(`${skipped2.length} skipped`) : null, 36 | - todo.length ? c.gray(`${todo.length} todo`) : null 37 | - ].filter(Boolean).join(c.dim(" | ")) + (showTotal ? c.gray(` (${tasks.length})`) : ""); 38 | + return ( 39 | + [ 40 | + failed.length ? c.bold(c.red(`${failed.length} failed`)) : null, 41 | + passed.length ? c.bold(c.green(`${passed.length} passed`)) : null, 42 | + skipped2.length ? c.yellow(`${skipped2.length} skipped`) : null, 43 | + todo.length ? c.gray(`${todo.length} todo`) : null, 44 | + ] 45 | + .filter(Boolean) 46 | + .join(c.dim(" | ")) + (showTotal ? c.gray(` (${tasks.length})`) : "") 47 | + ); 48 | } 49 | function getStateSymbol(task) { 50 | if (task.mode === "skip" || task.mode === "todo") { 51 | @@ -134,7 +138,7 @@ function getStateSymbol(task) { 52 | spinner = elegantSpinner(); 53 | spinnerMap.set(task, spinner); 54 | } 55 | - return c.yellow(spinner()); 56 | + return c.yellow("⇒"); 57 | } 58 | if (task.result.state === "pass") { 59 | return task.meta?.benchmark ? benchmarkPass : testPass; 60 | @@ -157,10 +161,24 @@ function getHookStateSymbol(task, hookName) { 61 | spinner = elegantSpinner(); 62 | spinnerMap2.set(hookName, spinner); 63 | } 64 | - return c.yellow(spinner()); 65 | + return c.yellow("⇒"); 66 | } 67 | } 68 | -const spinnerFrames = process.platform === "win32" ? ["-", "\\", "|", "/"] : ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]; 69 | +const spinnerFrames = 70 | + process.platform === "win32" 71 | + ? ["-", "\\", "|", "/"] 72 | + : [ 73 | + "\u280B", 74 | + "\u2819", 75 | + "\u2839", 76 | + "\u2838", 77 | + "\u283C", 78 | + "\u2834", 79 | + "\u2826", 80 | + "\u2827", 81 | + "\u2807", 82 | + "\u280F", 83 | + ]; 84 | function elegantSpinner() { 85 | let index = 0; 86 | return () => { 87 | @@ -175,12 +193,14 @@ function formatProjectName(name, suffix = " ") { 88 | if (!name) { 89 | return ""; 90 | } 91 | - const index = name.split("").reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0); 92 | + const index = name 93 | + .split("") 94 | + .reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0); 95 | const colors = [c.blue, c.yellow, c.cyan, c.green, c.magenta]; 96 | return colors[index % colors.length](`|${name}|`) + suffix; 97 | } 98 | 99 | -var utils = /*#__PURE__*/Object.freeze({ 100 | +var utils = /*#__PURE__*/ Object.freeze({ 101 | __proto__: null, 102 | benchmarkPass: benchmarkPass, 103 | countTestErrors: countTestErrors, 104 | @@ -202,7 +222,21 @@ var utils = /*#__PURE__*/Object.freeze({ 105 | spinnerMap: spinnerMap, 106 | suiteFail: suiteFail, 107 | taskFail: taskFail, 108 | - testPass: testPass 109 | + testPass: testPass, 110 | }); 111 | 112 | -export { F_RIGHT as F, F_POINTER as a, getStateString as b, formatTimeString as c, countTestErrors as d, divider as e, formatProjectName as f, getStateSymbol as g, getCols as h, getHookStateSymbol as i, renderSnapshotSummary as r, taskFail as t, utils as u }; 113 | +export { 114 | + F_RIGHT as F, 115 | + F_POINTER as a, 116 | + getStateString as b, 117 | + formatTimeString as c, 118 | + countTestErrors as d, 119 | + divider as e, 120 | + formatProjectName as f, 121 | + getStateSymbol as g, 122 | + getCols as h, 123 | + getHookStateSymbol as i, 124 | + renderSnapshotSummary as r, 125 | + taskFail as t, 126 | + utils as u, 127 | +}; -------------------------------------------------------------------------------- /src/__test__/unit/personalSignValidation.test.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer'; 2 | import { Hash } from 'ox'; 3 | import secp256k1 from 'secp256k1'; 4 | import { addRecoveryParam } from '../../ethereum'; 5 | 6 | describe('Personal Sign Validation - Issue Fix', () => { 7 | /** 8 | * This test validates the fix for the personal sign validation bug. 9 | * The issue was in pubToAddrStr function which was incorrectly slicing 10 | * the hash buffer, causing address comparison to always fail. 11 | */ 12 | 13 | it('should correctly validate personal message signature', () => { 14 | // Create a test private key and derive public key 15 | const privateKey = Buffer.from( 16 | '0101010101010101010101010101010101010101010101010101010101010101', 17 | 'hex', 18 | ); 19 | const publicKey = secp256k1.publicKeyCreate(privateKey, false); 20 | 21 | // Create a test message 22 | const message = Buffer.from('Test message', 'utf8'); 23 | 24 | // Build personal sign prefix and hash 25 | const prefix = Buffer.from( 26 | `\u0019Ethereum Signed Message:\n${message.length.toString()}`, 27 | 'utf-8', 28 | ); 29 | const messageHash = Buffer.from( 30 | Hash.keccak256(Buffer.concat([prefix, message])), 31 | ); 32 | 33 | // Sign the message 34 | const sigObj = secp256k1.ecdsaSign(messageHash, privateKey); 35 | 36 | // Prepare signature object 37 | const sig = { 38 | r: Buffer.from(sigObj.signature.slice(0, 32)), 39 | s: Buffer.from(sigObj.signature.slice(32, 64)), 40 | }; 41 | 42 | // Get the Ethereum address from the public key 43 | // This matches what the firmware returns 44 | const pubkeyWithoutPrefix = publicKey.slice(1); // Remove 0x04 prefix 45 | const addressBuffer = Buffer.from( 46 | Hash.keccak256(pubkeyWithoutPrefix), 47 | ).slice(-20); 48 | 49 | // This is the function that was failing before the fix 50 | // It should now correctly add the recovery parameter 51 | const result = addRecoveryParam(messageHash, sig, addressBuffer, { 52 | chainId: 1, 53 | useEIP155: false, 54 | }); 55 | 56 | // Verify the signature has a valid v value (27 or 28) 57 | expect(result.v).toBeDefined(); 58 | const vValue = Buffer.isBuffer(result.v) 59 | ? result.v.readUInt8(0) 60 | : Number(result.v); 61 | expect([27, 28]).toContain(vValue); 62 | 63 | // Verify r and s are buffers of correct length 64 | expect(Buffer.isBuffer(result.r)).toBe(true); 65 | expect(Buffer.isBuffer(result.s)).toBe(true); 66 | expect(result.r.length).toBe(32); 67 | expect(result.s.length).toBe(32); 68 | }); 69 | 70 | it('should throw error when signature does not match address', () => { 71 | // Create a test message hash 72 | const messageHash = Buffer.from(Hash.keccak256(Buffer.from('test'))); 73 | 74 | // Create a random signature 75 | const sig = { 76 | r: Buffer.from('1'.repeat(64), 'hex'), 77 | s: Buffer.from('2'.repeat(64), 'hex'), 78 | }; 79 | 80 | // Use a random address that won't match the signature 81 | const wrongAddress = Buffer.from('3'.repeat(40), 'hex'); 82 | 83 | // This should throw because the signature doesn't match the address 84 | expect(() => { 85 | addRecoveryParam(messageHash, sig, wrongAddress, { 86 | chainId: 1, 87 | useEIP155: false, 88 | }); 89 | }).toThrow(); // Just verify it throws, exact message may vary 90 | }); 91 | 92 | it('should handle the exact scenario from Ambire bug report', () => { 93 | // This is the exact scenario reported by Kalo from Ambire 94 | const testPayload = '0x54657374206d657373616765'; // "Test message" in hex 95 | const payloadBuffer = Buffer.from(testPayload.slice(2), 'hex'); 96 | 97 | // Build personal sign hash 98 | const prefix = Buffer.from( 99 | `\u0019Ethereum Signed Message:\n${payloadBuffer.length.toString()}`, 100 | 'utf-8', 101 | ); 102 | const messageHash = Buffer.from( 103 | Hash.keccak256(Buffer.concat([prefix, payloadBuffer])), 104 | ); 105 | 106 | // Create a valid signature for this message 107 | const privateKey = Buffer.from( 108 | '0101010101010101010101010101010101010101010101010101010101010101', 109 | 'hex', 110 | ); 111 | const publicKey = secp256k1.publicKeyCreate(privateKey, false); 112 | const sigObj = secp256k1.ecdsaSign(messageHash, privateKey); 113 | 114 | const sig = { 115 | r: Buffer.from(sigObj.signature.slice(0, 32)), 116 | s: Buffer.from(sigObj.signature.slice(32, 64)), 117 | }; 118 | 119 | // Get address from public key 120 | const pubkeyWithoutPrefix = publicKey.slice(1); 121 | const addressBuffer = Buffer.from( 122 | Hash.keccak256(pubkeyWithoutPrefix), 123 | ).slice(-20); 124 | 125 | // This should NOT throw with the fix in place 126 | expect(() => { 127 | const result = addRecoveryParam(messageHash, sig, addressBuffer, { 128 | chainId: 1, 129 | useEIP155: false, 130 | }); 131 | 132 | // Verify we got a valid result 133 | expect(result.v).toBeDefined(); 134 | expect(result.r).toBeDefined(); 135 | expect(result.s).toBeDefined(); 136 | }).not.toThrow(); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/__test__/e2e/signing/unformatted.test.ts: -------------------------------------------------------------------------------- 1 | import { Constants } from '../../..'; 2 | import { getNumIter } from '../../utils/builders'; 3 | import { ethPersonalSignMsg, prandomBuf } from '../../utils/helpers'; 4 | import { runGeneric } from '../../utils/runners'; 5 | import { HARDENED_OFFSET } from '../../../constants'; 6 | import { getPrng } from '../../utils/getters'; 7 | import { setupClient } from '../../utils/setup'; 8 | 9 | const prng = getPrng(); 10 | const numIter = getNumIter(); 11 | const DEFAULT_SIGNER = [ 12 | HARDENED_OFFSET + 44, 13 | HARDENED_OFFSET + 60, 14 | HARDENED_OFFSET, 15 | 0, 16 | 0, 17 | ]; 18 | 19 | describe('[Unformatted]', () => { 20 | let client; 21 | 22 | beforeAll(async () => { 23 | client = await setupClient(); 24 | }); 25 | 26 | const getReq = (overrides: any) => ({ 27 | data: { 28 | signerPath: DEFAULT_SIGNER, 29 | curveType: Constants.SIGNING.CURVES.SECP256K1, 30 | hashType: Constants.SIGNING.HASHES.KECCAK256, 31 | payload: null, 32 | ...overrides, 33 | }, 34 | }); 35 | 36 | it('Should test pre-hashed messages', async () => { 37 | const fwConstants = client.getFwConstants(); 38 | const { extraDataFrameSz, extraDataMaxFrames, genericSigning } = 39 | fwConstants; 40 | const { baseDataSz } = genericSigning; 41 | // Max size that won't be prehashed 42 | const maxSz = baseDataSz + extraDataMaxFrames * extraDataFrameSz; 43 | 44 | // Use extraData frames 45 | await runGeneric( 46 | getReq({ 47 | payload: `0x${prandomBuf(prng, maxSz, true).toString('hex')}`, 48 | }), 49 | client, 50 | ); 51 | 52 | // Prehash (keccak256) 53 | await runGeneric( 54 | getReq({ 55 | payload: `0x${prandomBuf(prng, maxSz + 1, true).toString('hex')}`, 56 | }), 57 | client, 58 | ); 59 | 60 | // Prehash (sha256) 61 | await runGeneric( 62 | getReq({ 63 | payload: `0x${prandomBuf(prng, maxSz + 1, true).toString('hex')}`, 64 | hashType: Constants.SIGNING.HASHES.SHA256, 65 | }), 66 | client, 67 | ); 68 | }); 69 | 70 | it('Should test ASCII text formatting', async () => { 71 | // Build a payload that uses spaces and newlines 72 | await runGeneric( 73 | getReq({ 74 | payload: JSON.stringify( 75 | { 76 | testPayload: 'json with spaces', 77 | anotherThing: -1, 78 | }, 79 | null, 80 | 2, 81 | ), 82 | }), 83 | client, 84 | ); 85 | }); 86 | 87 | it('Should validate SECP256K1/KECCAK signature against derived key', async () => { 88 | // ASCII message encoding 89 | await runGeneric(getReq({ payload: 'test' }), client); 90 | 91 | // Hex message encoding 92 | const req = getReq({ payload: '0x123456' }); 93 | await runGeneric(req, client); 94 | }); 95 | 96 | it('Should validate ED25519/NULL signature against derived key', async () => { 97 | const req = getReq({ 98 | payload: '0x123456', 99 | curveType: Constants.SIGNING.CURVES.ED25519, 100 | /* Not doing anything. It is commented out. */ 101 | hashType: Constants.SIGNING.HASHES.NONE, 102 | // ED25519 derivation requires hardened indices 103 | signerPath: DEFAULT_SIGNER.slice(0, 3), 104 | }); 105 | // Make generic signing request 106 | await runGeneric(req, client); 107 | }); 108 | 109 | it('Should validate SECP256K1/KECCAK signature against ETH_MSG request (legacy)', async () => { 110 | // Generic request 111 | const msg = 'Testing personal_sign'; 112 | const psMsg = ethPersonalSignMsg(msg); 113 | // NOTE: The request contains some non ASCII characters so it will get 114 | // encoded as hex automatically. 115 | const req = getReq({ 116 | payload: psMsg, 117 | }); 118 | 119 | const respGeneric = await runGeneric(req, client); 120 | 121 | // Legacy request 122 | const legacyReq = { 123 | currency: 'ETH_MSG', 124 | data: { 125 | signerPath: req.data.signerPath, 126 | payload: msg, 127 | protocol: 'signPersonal', 128 | }, 129 | }; 130 | const respLegacy = await client.sign(legacyReq); 131 | 132 | const genSigR = respGeneric.sig?.r.toString('hex') ?? ''; 133 | const genSigS = respGeneric.sig?.s.toString('hex') ?? ''; 134 | const legSigR = respLegacy.sig?.r.toString('hex') ?? ''; 135 | const legSigS = respLegacy.sig?.s.toString('hex') ?? ''; 136 | 137 | const genSig = `${genSigR}${genSigS}`; 138 | const legSig = `${legSigR}${legSigS}`; 139 | expect(genSig).toEqualElseLog( 140 | legSig, 141 | 'Legacy and generic requests produced different sigs.', 142 | ); 143 | }); 144 | 145 | for (let i = 0; i < numIter; i++) { 146 | it(`Should test random payloads - #${i}`, async () => { 147 | const fwConstants = client.getFwConstants(); 148 | const req = getReq({ 149 | hashType: Constants.SIGNING.HASHES.KECCAK256, 150 | curveType: Constants.SIGNING.CURVES.SECP256K1, 151 | payload: prandomBuf(prng, fwConstants.genericSigning.baseDataSz), 152 | }); 153 | 154 | // 1. Secp256k1/keccak256 155 | await runGeneric(req, client); 156 | 157 | // 2. Secp256k1/sha256 158 | req.data.hashType = Constants.SIGNING.HASHES.SHA256; 159 | await runGeneric(req, client); 160 | 161 | // 3. Ed25519 162 | req.data.curveType = Constants.SIGNING.CURVES.ED25519; 163 | req.data.hashType = Constants.SIGNING.HASHES.NONE; 164 | req.data.signerPath = DEFAULT_SIGNER.slice(0, 3); 165 | await runGeneric(req, client); 166 | }); 167 | } 168 | }); 169 | -------------------------------------------------------------------------------- /src/types/sign.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Address, 3 | Hex, 4 | TypedData, 5 | TypedDataDefinition, 6 | AccessList, 7 | SignedAuthorization, 8 | SignedAuthorizationList, 9 | } from 'viem'; 10 | import { Client } from '../client'; 11 | import { Currency, SigningPath, Wallet } from './client'; 12 | import { FirmwareConstants } from './firmware'; 13 | 14 | export type ETH_MESSAGE_PROTOCOLS = 'eip712' | 'signPersonal'; 15 | 16 | export const TRANSACTION_TYPE = { 17 | LEGACY: 0, 18 | EIP2930: 1, 19 | EIP1559: 2, 20 | EIP7702_AUTH: 4, 21 | EIP7702_AUTH_LIST: 5, 22 | } as const; 23 | 24 | // Base transaction request with common fields 25 | type BaseTransactionRequest = { 26 | from?: Address; 27 | to: Address; 28 | value?: Hex | bigint; 29 | data?: Hex; 30 | chainId: number; 31 | nonce: number; 32 | gasLimit?: Hex | bigint; 33 | }; 34 | 35 | // Legacy transaction request 36 | type LegacyTransactionRequest = BaseTransactionRequest & { 37 | type: typeof TRANSACTION_TYPE.LEGACY; 38 | gasPrice: Hex | bigint; 39 | }; 40 | 41 | // EIP-2930 transaction request 42 | type EIP2930TransactionRequest = BaseTransactionRequest & { 43 | type: typeof TRANSACTION_TYPE.EIP2930; 44 | gasPrice: Hex | bigint; 45 | accessList?: AccessList; 46 | }; 47 | 48 | // EIP-1559 transaction request 49 | type EIP1559TransactionRequest = BaseTransactionRequest & { 50 | type: typeof TRANSACTION_TYPE.EIP1559; 51 | maxFeePerGas: Hex | bigint; 52 | maxPriorityFeePerGas: Hex | bigint; 53 | accessList?: AccessList; 54 | }; 55 | 56 | // EIP-7702 single authorization transaction request (type 4) 57 | export type EIP7702AuthTransactionRequest = BaseTransactionRequest & { 58 | type: typeof TRANSACTION_TYPE.EIP7702_AUTH; 59 | maxFeePerGas: Hex | bigint; 60 | maxPriorityFeePerGas: Hex | bigint; 61 | accessList?: AccessList; 62 | authorization: SignedAuthorization; 63 | }; 64 | 65 | // EIP-7702 authorization list transaction request (type 5) 66 | export type EIP7702AuthListTransactionRequest = BaseTransactionRequest & { 67 | type: typeof TRANSACTION_TYPE.EIP7702_AUTH_LIST; 68 | maxFeePerGas: Hex | bigint; 69 | maxPriorityFeePerGas: Hex | bigint; 70 | accessList?: AccessList; 71 | authorizationList: SignedAuthorizationList; 72 | }; 73 | 74 | // Main discriminated union for transaction requests 75 | export type TransactionRequest = 76 | | LegacyTransactionRequest 77 | | EIP2930TransactionRequest 78 | | EIP1559TransactionRequest 79 | | EIP7702AuthTransactionRequest 80 | | EIP7702AuthListTransactionRequest; 81 | 82 | export interface SigningPayload< 83 | TTypedData extends TypedData | Record = TypedData, 84 | > { 85 | signerPath: SigningPath; 86 | payload: 87 | | Uint8Array 88 | | Uint8Array[] 89 | | Buffer 90 | | Buffer[] 91 | | Hex 92 | | EIP712MessagePayload; 93 | curveType: number; 94 | hashType: number; 95 | encodingType?: number; 96 | protocol?: ETH_MESSAGE_PROTOCOLS; 97 | decoder?: Buffer; 98 | } 99 | 100 | export interface SignRequestParams< 101 | TTypedData extends TypedData | Record = TypedData, 102 | > { 103 | data: SigningPayload | BitcoinSignPayload; 104 | currency?: Currency; 105 | cachedData?: unknown; 106 | nextCode?: Buffer; 107 | } 108 | 109 | export interface SignRequestFunctionParams< 110 | TTypedData extends TypedData | Record = TypedData, 111 | > extends SignRequestParams { 112 | client: Client; 113 | } 114 | 115 | export interface EncodeSignRequestParams { 116 | fwConstants: FirmwareConstants; 117 | wallet: Wallet; 118 | requestData: unknown; 119 | cachedData?: unknown; 120 | nextCode?: Buffer; 121 | } 122 | 123 | export interface SignRequest { 124 | payload: Buffer; 125 | schema: number; 126 | } 127 | 128 | export interface EthSignRequest extends SignRequest { 129 | curveType: number; 130 | encodingType: number; 131 | hashType: number; 132 | omitPubkey: boolean; 133 | origPayloadBuf: Buffer; 134 | extraDataPayloads: Buffer[]; 135 | } 136 | 137 | export interface EthMsgSignRequest extends SignRequest { 138 | input: { 139 | signerPath: SigningPath; 140 | payload: Buffer; 141 | protocol: string; 142 | fwConstants: FirmwareConstants; 143 | }; 144 | } 145 | 146 | export interface BitcoinSignRequest extends SignRequest { 147 | origData: { 148 | prevOuts: PreviousOutput[]; 149 | recipient: string; 150 | value: number; 151 | fee: number; 152 | changePath: number[]; 153 | fwConstants: FirmwareConstants; 154 | }; 155 | changeData?: { value: number }; 156 | } 157 | 158 | export type PreviousOutput = { 159 | txHash: string; 160 | value: number; 161 | index: number; 162 | signerPath: number[]; 163 | }; 164 | 165 | export type BitcoinSignPayload = { 166 | prevOuts: PreviousOutput[]; 167 | recipient: string; 168 | value: number; 169 | fee: number; 170 | changePath: number[]; 171 | }; 172 | 173 | export interface DecodeSignResponseParams { 174 | data: Buffer; 175 | request: SignRequest; 176 | isGeneric: boolean; 177 | currency?: Currency; 178 | } 179 | 180 | // Align EIP712MessagePayload with Viem's TypedDataDefinition 181 | export interface EIP712MessagePayload< 182 | TTypedData extends TypedData | Record = TypedData, 183 | TPrimaryType extends keyof TTypedData | 'EIP712Domain' = keyof TTypedData, 184 | > { 185 | types: TTypedData; 186 | domain: TTypedData extends TypedData 187 | ? TypedDataDefinition['domain'] 188 | : Record; 189 | primaryType: TPrimaryType; 190 | message: TTypedData extends TypedData 191 | ? TypedDataDefinition['message'] 192 | : Record; 193 | } 194 | -------------------------------------------------------------------------------- /src/__test__/e2e/signing/solana/solana.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * REQUIRED TEST MNEMONIC: 3 | * These tests require a SafeCard loaded with the standard test mnemonic: 4 | * "test test test test test test test test test test test junk" 5 | * 6 | * Running with a different mnemonic will cause test failures due to 7 | * incorrect key derivations and signature mismatches. 8 | */ 9 | import { 10 | Keypair as SolanaKeypair, 11 | PublicKey as SolanaPublicKey, 12 | SystemProgram as SolanaSystemProgram, 13 | Transaction as SolanaTransaction, 14 | } from '@solana/web3.js'; 15 | import { Constants } from '../../../..'; 16 | import { HARDENED_OFFSET } from '../../../../constants'; 17 | import { ensureHexBuffer } from '../../../../util'; 18 | import { setupClient } from '../../../utils/setup'; 19 | import { getPrng } from '../../../utils/getters'; 20 | import { deriveED25519Key, prandomBuf } from '../../../utils/helpers'; 21 | import { runGeneric } from '../../../utils/runners'; 22 | import { TEST_SEED } from '../../../utils/testConstants'; 23 | 24 | //--------------------------------------- 25 | // STATE DATA 26 | //--------------------------------------- 27 | const DEFAULT_SOLANA_SIGNER_PATH = [ 28 | HARDENED_OFFSET + 44, 29 | HARDENED_OFFSET + 501, 30 | HARDENED_OFFSET, 31 | HARDENED_OFFSET, 32 | ]; 33 | const prng = getPrng(); 34 | 35 | describe('[Solana]', () => { 36 | let client; 37 | 38 | beforeAll(async () => { 39 | client = await setupClient(); 40 | }); 41 | 42 | const getReq = (overrides: any) => ({ 43 | data: { 44 | curveType: Constants.SIGNING.CURVES.ED25519, 45 | hashType: Constants.SIGNING.HASHES.NONE, 46 | encodingType: Constants.SIGNING.ENCODINGS.SOLANA, 47 | payload: null, 48 | ...overrides, 49 | }, 50 | }); 51 | 52 | it('Should validate Solana transaction encoding', async () => { 53 | // Build a Solana transaction with two signers, each derived from the Lattice's seed. 54 | // This will require two separate general signing requests, one per signer. 55 | 56 | // Get the full set of Solana addresses and keys 57 | // NOTE: Solana addresses are just base58 encoded public keys. We do not 58 | // currently support exporting of Solana addresses in firmware but we can 59 | // derive them here using the exported seed. 60 | const seed = TEST_SEED; 61 | const derivedAPath = [...DEFAULT_SOLANA_SIGNER_PATH]; 62 | const derivedBPath = [...DEFAULT_SOLANA_SIGNER_PATH]; 63 | derivedBPath[3] += 1; 64 | const derivedCPath = [...DEFAULT_SOLANA_SIGNER_PATH]; 65 | derivedCPath[3] += 2; 66 | const derivedA = deriveED25519Key(derivedAPath, seed); 67 | const derivedB = deriveED25519Key(derivedBPath, seed); 68 | const derivedC = deriveED25519Key(derivedCPath, seed); 69 | const pubA = new SolanaPublicKey(derivedA.pub); 70 | const pubB = new SolanaPublicKey(derivedB.pub); 71 | const pubC = new SolanaPublicKey(derivedC.pub); 72 | 73 | // Define transaction instructions 74 | const transfer1 = SolanaSystemProgram.transfer({ 75 | fromPubkey: pubA, 76 | toPubkey: pubC, 77 | lamports: 111, 78 | }); 79 | const transfer2 = SolanaSystemProgram.transfer({ 80 | fromPubkey: pubB, 81 | toPubkey: pubC, 82 | lamports: 222, 83 | }); 84 | 85 | // Generate a pseudorandom blockhash, which is just a public key appearently. 86 | const randBuf = prandomBuf(prng, 32, true); 87 | const recentBlockhash = 88 | SolanaKeypair.fromSeed(randBuf).publicKey.toBase58(); 89 | 90 | // Build a transaction and sign it using Solana's JS lib 91 | const txJs = new SolanaTransaction({ recentBlockhash }).add( 92 | transfer1, 93 | transfer2, 94 | ); 95 | txJs.setSigners(pubA, pubB); 96 | txJs.sign( 97 | SolanaKeypair.fromSeed(derivedA.priv), 98 | SolanaKeypair.fromSeed(derivedB.priv), 99 | ); 100 | const serTxJs = txJs.serialize().toString('hex'); 101 | 102 | // Build a copy of the transaction and get the serialized payload for signing in firmware. 103 | const txFw = new SolanaTransaction({ recentBlockhash }).add( 104 | transfer1, 105 | transfer2, 106 | ); 107 | txFw.setSigners(pubA, pubB); 108 | // We want to sign the Solana message, not the full transaction 109 | const payload = txFw.compileMessage().serialize(); 110 | const payloadHex = `0x${payload.toString('hex')}`; 111 | 112 | // Sign payload from Lattice and add signatures to tx object 113 | const sigA = await runGeneric( 114 | getReq({ 115 | signerPath: derivedAPath, 116 | payload: payloadHex, 117 | }), 118 | client, 119 | ).then((resp) => { 120 | if (!resp.sig?.r || !resp.sig?.s) { 121 | throw new Error('Missing signature components in response'); 122 | } 123 | return Buffer.concat([ 124 | ensureHexBuffer(resp.sig.r as string | Buffer), 125 | ensureHexBuffer(resp.sig.s as string | Buffer), 126 | ]); 127 | }); 128 | 129 | const sigB = await runGeneric( 130 | getReq({ 131 | signerPath: derivedBPath, 132 | payload: payloadHex, 133 | }), 134 | client, 135 | ).then((resp) => { 136 | if (!resp.sig?.r || !resp.sig?.s) { 137 | throw new Error('Missing signature components in response'); 138 | } 139 | return Buffer.concat([ 140 | ensureHexBuffer(resp.sig.r as string | Buffer), 141 | ensureHexBuffer(resp.sig.s as string | Buffer), 142 | ]); 143 | }); 144 | txFw.addSignature(pubA, sigA); 145 | txFw.addSignature(pubB, sigB); 146 | 147 | // Validate the signatures from the Lattice match those of the Solana library 148 | const serTxFw = txFw.serialize().toString('hex'); 149 | expect(serTxFw).toEqual(serTxJs); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------