From f030925e431e008f6c32fe01d08b1105ebb89bff Mon Sep 17 00:00:00 2001 From: friedger Date: Thu, 7 Dec 2023 17:27:57 +0100 Subject: [PATCH 1/4] feat: add stacks (WIP) --- .husky/commit-msg | 2 +- package.json | 3 + .../blockchain-libs/src/provider/index.ts | 1 + packages/engine/src/index.ts | 1 + packages/engine/src/managers/derivation.ts | 2 + packages/engine/src/managers/impl.ts | 5 + .../src/vaults/factory.createVaultSettings.ts | 4 + packages/engine/src/vaults/factory.ts | 9 + .../impl/stacks/@tests/stacksMockData.ts | 149 ++ .../impl/stacks/@tests/stacksPresetCase.ts | 88 ++ .../engine/src/vaults/impl/stacks/Vault.ts | 434 ++++++ .../src/vaults/impl/stacks/VaultHelper.ts | 22 + .../impl/stacks/keyring/KeyringHardware.ts | 202 +++ .../impl/stacks/keyring/KeyringHd.test.ts | 47 + .../vaults/impl/stacks/keyring/KeyringHd.ts | 116 ++ .../stacks/keyring/KeyringImported.test.ts | 47 + .../impl/stacks/keyring/KeyringImported.ts | 91 ++ .../stacks/keyring/KeyringWatching.test.ts | 91 ++ .../impl/stacks/keyring/KeyringWatching.ts | 58 + .../src/vaults/impl/stacks/keyring/index.ts | 4 + .../src/vaults/impl/stacks/sdk/index.ts | 2 + .../engine/src/vaults/impl/stacks/sdk/sign.ts | 206 +++ .../src/vaults/impl/stacks/sdk/stacks.ts | 103 ++ .../engine/src/vaults/impl/stacks/settings.ts | 42 + .../engine/src/vaults/impl/stacks/types.ts | 3 + .../src/vaults/impl/stacks/utils.test.ts | 1245 +++++++++++++++++ .../engine/src/vaults/impl/stacks/utils.ts | 572 ++++++++ packages/engine/src/vaults/types.ts | 1 + packages/shared/src/engine/engineConsts.ts | 7 + yarn.lock | 116 +- 30 files changed, 3669 insertions(+), 4 deletions(-) create mode 100644 packages/engine/src/vaults/impl/stacks/@tests/stacksMockData.ts create mode 100644 packages/engine/src/vaults/impl/stacks/@tests/stacksPresetCase.ts create mode 100644 packages/engine/src/vaults/impl/stacks/Vault.ts create mode 100644 packages/engine/src/vaults/impl/stacks/VaultHelper.ts create mode 100644 packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts create mode 100644 packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.test.ts create mode 100644 packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.ts create mode 100644 packages/engine/src/vaults/impl/stacks/keyring/KeyringImported.test.ts create mode 100644 packages/engine/src/vaults/impl/stacks/keyring/KeyringImported.ts create mode 100644 packages/engine/src/vaults/impl/stacks/keyring/KeyringWatching.test.ts create mode 100644 packages/engine/src/vaults/impl/stacks/keyring/KeyringWatching.ts create mode 100644 packages/engine/src/vaults/impl/stacks/keyring/index.ts create mode 100644 packages/engine/src/vaults/impl/stacks/sdk/index.ts create mode 100644 packages/engine/src/vaults/impl/stacks/sdk/sign.ts create mode 100644 packages/engine/src/vaults/impl/stacks/sdk/stacks.ts create mode 100644 packages/engine/src/vaults/impl/stacks/settings.ts create mode 100644 packages/engine/src/vaults/impl/stacks/types.ts create mode 100644 packages/engine/src/vaults/impl/stacks/utils.test.ts create mode 100644 packages/engine/src/vaults/impl/stacks/utils.ts diff --git a/.husky/commit-msg b/.husky/commit-msg index 186f71c27cc..f6fb320922c 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -6,7 +6,7 @@ START_LINE=`head -n1 $INPUT_FILE` PATTERN="^(OK)-[[:digit:]]+" -if ! [[ "$START_LINE" =~ $PATTERN|^feat\:|^fix\:|^docs\:|^style\:|^refactor\:|^perf\:|^test\:|^chore\: ]]; then +if ![[ "$START_LINE" =~ $PATTERN|^feat\:|^fix\:|^docs\:|^style\:|^refactor\:|^perf\:|^test\:|^chore\: ]]; then echo " Bad commit message, you must add a jira issue key here. https://onekeyhq.atlassian.net/jira/your-work See example: diff --git a/package.json b/package.json index 14fea38e9ea..3585e5d516d 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "@onekeyfe/hd-transport": "0.3.31", "@onekeyfe/hd-web-sdk": "0.3.31", "@onekeyfe/onekey-cross-webview": "1.1.48", + "@stacks/blockchain-api-client": "^7.3.4", + "@stacks/network": "^6.10.0", + "@stacks/transactions": "^6.10.0", "@starcoin/starcoin": "2.1.5", "@web3-react/core": "8.0.35-beta.0", "@web3-react/empty": "8.0.20-beta.0", diff --git a/packages/blockchain-libs/src/provider/index.ts b/packages/blockchain-libs/src/provider/index.ts index f26481ff0c6..b49650dfbd3 100644 --- a/packages/blockchain-libs/src/provider/index.ts +++ b/packages/blockchain-libs/src/provider/index.ts @@ -134,6 +134,7 @@ const IMPLS: { [key: string]: any } = { tron: mockProvider, kaspa: mockProvider, nexa: mockProvider, + stacks: mockProvider, lightning: mockProvider, tlightning: mockProvider, }; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index a68c311087a..c8be82d400c 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -836,6 +836,7 @@ class Engine { '128': OnekeyNetwork.xmr, '111111': OnekeyNetwork.kaspa, '29223': OnekeyNetwork.nexa, + '5757': OnekeyNetwork.stacks }[coinType]; if (typeof networkId === 'undefined') { throw new NotImplemented('Unsupported network.'); diff --git a/packages/engine/src/managers/derivation.ts b/packages/engine/src/managers/derivation.ts index d2fcb64365c..d98833e1f8c 100644 --- a/packages/engine/src/managers/derivation.ts +++ b/packages/engine/src/managers/derivation.ts @@ -58,6 +58,7 @@ import { import type { DBAccountDerivation } from '../types/accountDerivation'; import type { Wallet } from '../types/wallet'; +import { COINTYPE_STACKS } from '../../../shared/src/engine/engineConsts'; const purposeMap: Record> = { [IMPL_EVM]: [44], @@ -103,6 +104,7 @@ const derivationPathTemplates: Record = { [COINTYPE_LTC]: `m/${PURPOSE_TAG}'/${COINTYPE_LTC}'/${INCREMENT_LEVEL_TAG}'`, [COINTYPE_BCH]: `m/44'/${COINTYPE_BCH}'/${INCREMENT_LEVEL_TAG}'`, [COINTYPE_NEXA]: `m/44'/${COINTYPE_NEXA}'/${INCREMENT_LEVEL_TAG}'`, + [COINTYPE_STACKS]: `m/44'/${COINTYPE_STACKS}'/0'/0/${INCREMENT_LEVEL_TAG}`, [COINTYPE_XRP]: `m/44'/${COINTYPE_XRP}'/${INCREMENT_LEVEL_TAG}'/0/0`, [COINTYPE_COSMOS]: `m/44'/${COINTYPE_COSMOS}'/0'/0/${INCREMENT_LEVEL_TAG}`, [COINTYPE_ADA]: `m/1852'/${COINTYPE_ADA}'/${INCREMENT_LEVEL_TAG}'`, diff --git a/packages/engine/src/managers/impl.ts b/packages/engine/src/managers/impl.ts index bb763641dc5..ad08b15e6cc 100644 --- a/packages/engine/src/managers/impl.ts +++ b/packages/engine/src/managers/impl.ts @@ -44,6 +44,7 @@ import { IMPL_NEAR, IMPL_NEXA, IMPL_SOL, + IMPL_STACKS, IMPL_STC, IMPL_SUI, IMPL_TBTC, @@ -59,6 +60,7 @@ import { createVaultSettings } from '../vaults/factory.createVaultSettings'; import type { DBAccount } from '../types/account'; import type { AccountNameInfo } from '../types/network'; +import { COINTYPE_STACKS } from '../../../shared/src/engine/engineConsts'; enum Curve { SECP256K1 = 'secp256k1', @@ -88,6 +90,7 @@ const implToCoinTypes: Partial> = { [IMPL_XMR]: COINTYPE_XMR, [IMPL_KASPA]: COINTYPE_KASPA, [IMPL_NEXA]: COINTYPE_NEXA, + [IMPL_STACKS]: COINTYPE_STACKS, [IMPL_LIGHTNING]: COINTYPE_LIGHTNING, [IMPL_LIGHTNING_TESTNET]: COINTYPE_LIGHTNING_TESTNET, }; @@ -126,6 +129,7 @@ const implToAccountType: Record = { [IMPL_DOT]: AccountType.VARIANT, [IMPL_XMR]: AccountType.VARIANT, [IMPL_KASPA]: AccountType.SIMPLE, + [IMPL_STACKS]: AccountType.SIMPLE, [IMPL_LIGHTNING]: AccountType.VARIANT, [IMPL_LIGHTNING_TESTNET]: AccountType.VARIANT, }; @@ -159,6 +163,7 @@ const defaultCurveMap: Record = { [IMPL_DOT]: Curve.ED25519, [IMPL_XMR]: Curve.ED25519, [IMPL_KASPA]: Curve.SECP256K1, + [IMPL_STACKS]: Curve.SECP256K1, [IMPL_LIGHTNING]: Curve.SECP256K1, [IMPL_LIGHTNING_TESTNET]: Curve.SECP256K1, }; diff --git a/packages/engine/src/vaults/factory.createVaultSettings.ts b/packages/engine/src/vaults/factory.createVaultSettings.ts index 3a27d42aca0..a1b197e6870 100644 --- a/packages/engine/src/vaults/factory.createVaultSettings.ts +++ b/packages/engine/src/vaults/factory.createVaultSettings.ts @@ -19,6 +19,7 @@ import { IMPL_NEAR, IMPL_NEXA, IMPL_SOL, + IMPL_STACKS, IMPL_STC, IMPL_SUI, IMPL_TBTC, @@ -110,6 +111,9 @@ export function createVaultSettings(options: { if (impl === IMPL_NEXA) { return require('./impl/nexa/settings').default as IVaultSettings; } + if (impl === IMPL_STACKS) { + return require('./impl/stacks/settings').default as IVaultSettings; + } if (impl === IMPL_LIGHTNING) { return require('./impl/lightning-network/settings') .default as IVaultSettings; diff --git a/packages/engine/src/vaults/factory.ts b/packages/engine/src/vaults/factory.ts index 26943c48672..067f635de5f 100644 --- a/packages/engine/src/vaults/factory.ts +++ b/packages/engine/src/vaults/factory.ts @@ -19,6 +19,7 @@ import { IMPL_NEAR, IMPL_NEXA, IMPL_SOL, + IMPL_STACKS, IMPL_STC, IMPL_SUI, IMPL_TBTC, @@ -54,6 +55,7 @@ import VaultHelperLtc from './impl/ltc/VaultHelper'; import VaultHelperNear from './impl/near/VaultHelper'; import VaultHelperNexa from './impl/nexa/VaultHelper'; import VauleHelperSol from './impl/sol/VaultHelper'; +import VaultHelperStacks from './impl/stacks/VaultHelper'; import VaultHelperStc from './impl/stc/VaultHelper'; import VaultHelperSui from './impl/sui/VaultHelper'; import VaultHelperTbtc from './impl/tbtc/VaultHelper'; @@ -136,6 +138,9 @@ export async function createVaultHelperInstance( if (impl === IMPL_NEXA) { return new VaultHelperNexa(options); } + if (impl === IMPL_STACKS) { + return new VaultHelperStacks(options); + } if (impl === IMPL_LIGHTNING || impl === IMPL_LIGHTNING_TESTNET) { return new VaultHelperLightning(options); } @@ -271,6 +276,10 @@ export async function createVaultInstance(options: IVaultOptions) { const VaultNexa = (await import('./impl/nexa/Vault')).default; vault = new VaultNexa(options); } + if (network.impl === IMPL_STACKS) { + const VaultStacks = (await import('./impl/stacks/Vault')).default; + vault = new VaultStacks(options); + } if ( network.impl === IMPL_LIGHTNING || network.impl === IMPL_LIGHTNING_TESTNET diff --git a/packages/engine/src/vaults/impl/stacks/@tests/stacksMockData.ts b/packages/engine/src/vaults/impl/stacks/@tests/stacksMockData.ts new file mode 100644 index 00000000000..663e52db432 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/@tests/stacksMockData.ts @@ -0,0 +1,149 @@ +import mockCredentials from '../../../../../@tests/mockCredentials'; +import { AccountType } from '../../../../types/account'; + +import type { IUnitTestMockAccount } from '../../../../../@tests/types'; +import type { DBNetwork } from '../../../../types/network'; + +// indexedDB -> networks +const network: DBNetwork = { + balance2FeeDecimals: 0, + decimals: 2, + enabled: true, + feeDecimals: 2, + feeSymbol: 'TNEX', + id: 'nexa--testnet', + impl: 'nexa', + logoURI: 'https://onekey-asset.com/assets/nexa/nexa.png', + name: 'Nexa Testnet', + position: 33, + rpcURL: 'wss://testnet-explorer.nexa.org:30004/nexa_ws', + symbol: 'NEXA', +}; + +const hdAccount1: IUnitTestMockAccount = { + // indexedDB -> accounts + account: { + 'name': 'NEXA #1', + 'address': + '02e3027885ce1ed1d21300158ce8f60649e280e2a8f746e9cea6858a3331021d8a', + 'addresses': { + 'nexa--testnet': + '02e3027885ce1ed1d21300158ce8f60649e280e2a8f746e9cea6858a3331021d8a', + }, + 'xpub': '', + 'coinType': '29223', + 'id': "hd-19--m/44'/29223'/0'", + 'path': "m/44'/29223'/0'/0/0", + 'template': "m/44'/29223'/$$INDEX$$'/0/0", + 'type': AccountType.UTXO, + }, + mnemonic: mockCredentials.mnemonic1, + password: mockCredentials.password, +}; + +const importedAccount1: IUnitTestMockAccount = { + // indexedDB -> accounts + account: { + 'address': + '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + 'addresses': { + 'nexa--testnet': + '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + }, + 'coinType': '29223', + 'id': 'imported--29223--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + name: 'Account #1', + path: '', + xpub: '', + type: AccountType.UTXO, + }, + // indexedDB -> credentials + privateKey: + '6b4d9dee8a37f4329cbf7db9a137a2ecdc63be8e6caa881ef05b3a3349ef8db9', + password: mockCredentials.password, +}; + +const importedAccount2: IUnitTestMockAccount = { + account: { + 'address': + '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + 'addresses': { + 'nexa--testnet': + '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + }, + 'coinType': '29223', + 'id': 'imported--29223--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + name: 'Account #1', + path: '', + xpub: '', + type: AccountType.UTXO, + }, + // indexedDB -> credentials + privateKey: + 'b848990d04878c4bbdcb671f45ed02807bcb4b200bfab2d636cb088e921b483fb01e7b872377c5b6dd582f0ca5d16ae5e4565163607df61ec5b5c96cbde8f4bb892865e079c0c4d64f29e5ba8b6a8d80317c1c7a97cf476a7459d24aa80d2a0f', + password: '12345678', +}; + +const watchingAccount1: IUnitTestMockAccount = { + account: { + address: 'nexatest:nqtsq5g5s9cd8fsl9d9a7jhsuzsw7u9exztnnz8n9un89t0k', + 'coinType': '29223', + 'id': 'external--29223--nexatest:nqtsq5g5s9cd8fsl9d9a7jhsuzsw7u9exztnnz8n9un89t0k', + name: 'Account #1', + path: '', + pub: '', + type: AccountType.SIMPLE, + }, + password: '', +}; + +const watchingAccount2: IUnitTestMockAccount = { + account: { + address: 'nexatest:fmza0ttf3pnv5zpg8e2q8lr3t2cesrrv9xdk395r5g5qsqtn', + coinType: '29223', + id: 'external--397--ed25519:8wbWQQkeK9NV1qkiQZ95jbj7JNhpeapHafLPw3qsJdqi', + name: 'Account #1', + path: '', + pub: '', + type: AccountType.SIMPLE, + }, + password: '', +}; + +const watchingAccount3: IUnitTestMockAccount = { + account: { + address: + '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + coinType: '29223', + id: 'external--29223--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + name: 'Account #1', + path: '', + pub: '', + type: AccountType.UTXO, + }, + password: '', +}; + +const watchingAccount4: IUnitTestMockAccount = { + account: { + address: 'nexa:nqtsq5g50frur0vav60gupjlrr8cta8vyqufu7p98vx97c66', + coinType: '29223', + id: 'external--29223--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + name: 'Account #1', + path: '', + pub: '', + type: AccountType.SIMPLE, + }, + password: '', +}; + +export default { + network, + hdAccount1, + importedAccount1, + importedAccount2, + watchingAccount1, + watchingAccount2, + watchingAccount3, + watchingAccount4, +}; diff --git a/packages/engine/src/vaults/impl/stacks/@tests/stacksPresetCase.ts b/packages/engine/src/vaults/impl/stacks/@tests/stacksPresetCase.ts new file mode 100644 index 00000000000..5c1ce1dd06f --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/@tests/stacksPresetCase.ts @@ -0,0 +1,88 @@ +import { wait } from '@onekeyhq/kit/src/utils/helper'; +import { IMPL_NEXA, SEPERATOR } from '@onekeyhq/shared/src/engine/engineConsts'; + +import { prepareMockVault } from '../../../../../@tests/prepareMockVault'; +import { getAccountNameInfoByImpl } from '../../../../managers/impl'; +import { verify } from '../sdk'; +import { publickeyToAddress } from '../utils'; +import Vault from '../Vault'; +import VaultHelper from '../VaultHelper'; + +import type { IPrepareMockVaultOptions } from '../../../../../@tests/types'; +import type { KeyringBase } from '../../../keyring/KeyringBase'; +import type { KeyringSoftwareBase } from '../../../keyring/KeyringSoftwareBase'; +import type { IPrepareAccountsParams } from '../../../types'; +import type { VaultBase } from '../../../VaultBase'; + +const nexaAccountNameInfo = getAccountNameInfoByImpl(IMPL_NEXA); +const prepareAccountsParams = { + indexes: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + coinType: nexaAccountNameInfo.default.coinType, + template: nexaAccountNameInfo.default.template, +}; + +export async function testPrepareAccounts( + prepareOptions: IPrepareMockVaultOptions, + builder: { + keyring: (payload: { vault: VaultBase }) => KeyringBase; + }, +) { + const { options, dbAccount } = prepareMockVault(prepareOptions); + const vault = new Vault(options); + vault.helper = new VaultHelper(options); + const keyring = builder.keyring({ vault }); + const accounts = await keyring.prepareAccounts({ + ...prepareAccountsParams, + name: dbAccount.name, + target: dbAccount.address, + accountIdPrefix: 'external', + password: prepareOptions.password, + privateKey: prepareOptions?.privateKey + ? Buffer.from(prepareOptions.privateKey, 'hex') + : undefined, + } as IPrepareAccountsParams); + expect(accounts[0]).toEqual(dbAccount); +} + +export async function testSignTransaction( + prepareOptions: IPrepareMockVaultOptions, + builder: { + keyring: (payload: { vault: VaultBase }) => KeyringSoftwareBase; + }, +) { + const { options, dbAccount, password, network } = + prepareMockVault(prepareOptions); + + expect(password).toBeTruthy(); + + const vault = new Vault(options); + vault.helper = new VaultHelper(options); + + const keyring = builder.keyring({ vault }); + const chainId = network.id.split(SEPERATOR).pop() || 'testnet'; + const encodeAddress = publickeyToAddress( + Buffer.from(dbAccount.address, 'hex'), + chainId, + ); + const encodedTx = await vault.buildEncodedTxFromTransfer({ + from: encodeAddress, + to: 'nexatest:nqtsq5g5wud2fr7l32as0mfzms3hwnz7dxvsc2h8szatr5p8', + amount: '50', + }); + const unsignedTx = await vault.buildUnsignedTxFromEncodedTx(encodedTx); + const signedTx = await keyring.signTransaction(unsignedTx, { + password, + }); + + const signers = await keyring.getSigners(password || '', [dbAccount.address]); + const signer = signers[dbAccount.address]; + const publicKey = await signer.getPubkey(true); + expect( + verify( + publicKey, + Buffer.from(signedTx.digest || '', 'hex'), + Buffer.from(signedTx.signature || '', 'hex'), + ), + ).toBeTruthy(); + await wait(1000); +} diff --git a/packages/engine/src/vaults/impl/stacks/Vault.ts b/packages/engine/src/vaults/impl/stacks/Vault.ts new file mode 100644 index 00000000000..9c556e45374 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/Vault.ts @@ -0,0 +1,434 @@ +import BigNumber from 'bignumber.js'; +import memoizee from 'memoizee'; + +import { decrypt } from '@onekeyhq/engine/src/secret/encryptors/aes256'; +import { getTimeDurationMs } from '@onekeyhq/kit/src/utils/helper'; +import debugLogger from '@onekeyhq/shared/src/logger/debugLogger'; + +import { InvalidAddress, OneKeyInternalError } from '../../../errors'; +import { + type Account, + AccountType, + type DBAccount, +} from '../../../types/account'; +import { + type IClientEndpointStatus, + type IDecodedTx, + IDecodedTxActionType, + IDecodedTxDirection, + IDecodedTxStatus, + type IEncodedTx, + type IFeeInfo, + type IFeeInfoUnit, + type ITransferInfo, + type IUnsignedTxPro, +} from '../../types'; +import { VaultBase } from '../../VaultBase'; + +import { + KeyringHardware, + KeyringHd, + KeyringImported, + KeyringWatching, +} from './keyring'; +import { Stacks } from './sdk'; +import settings from './settings'; +import { + buildDecodeTxFromTx, + estimateFee, + estimateSize, + getStacksNetworkInfo, + verifyStacksAddress, + verifyStacksAddressPrefix, +} from './utils'; + +import type { BaseClient } from '../../../client/BaseClient'; +import type { + PartialTokenInfo, + TransactionStatus, +} from '../../../types/provider'; +import type { Token } from '../../../types/token'; +import type { KeyringSoftwareBase } from '../../keyring/KeyringSoftwareBase'; +import type { IDecodedTxLegacy, IHistoryTx, ISignedTxPro } from '../../types'; +import type { EVMDecodedItem } from '../evm/decoder/types'; +import { publicKeyToAddress, StacksTransaction } from '@stacks/transactions'; +import type { IEncodedTxStacks } from './types'; + +export default class Vault extends VaultBase { + keyringMap = { + hd: KeyringHd, + hw: KeyringHardware, + imported: KeyringImported, + watching: KeyringWatching, + external: KeyringWatching, + }; + + override settings = settings; + + override createClientFromURL(url: string): BaseClient { + return new Stacks(url); + } + + override getFetchBalanceAddress(dbAccount: DBAccount): Promise { + return this.getDisplayAddress(dbAccount.address); + } + + override async getOutputAccount(): Promise { + const dbAccount = await this.getDbAccount({ noCache: true }); + const displayAddress = + dbAccount.type === AccountType.SIMPLE + ? dbAccount.address + : await this.getDisplayAddress(dbAccount.address); + return { + id: dbAccount.id, + name: dbAccount.name, + type: dbAccount.type, + path: dbAccount.path, + coinType: dbAccount.coinType, + tokens: [], + address: displayAddress, + displayAddress, + template: dbAccount.template, + pubKey: dbAccount.address, + }; + } + + override async getAccountAddress(): Promise { + return (await this.getOutputAccount()).address; + } + + override async getDisplayAddress(address: string): Promise { + if (verifyStacksAddressPrefix(address)) { + return address; + } + return Promise.resolve(address); + } + + override async addressFromBase(account: DBAccount): Promise { + return Promise.resolve(account.address); + } + + createSDKClient = memoizee( + async (rpcUrl: string, networkId: string, isTestnet: boolean) => { + const sdkClient = this.createClientFromURL(rpcUrl) as Stacks; + const chainInfo = + await this.engine.providerManager.getChainInfoByNetworkId(networkId); + // TODO move to base, setChainInfo like what ProviderController.getClient() do + sdkClient.setChainInfo(chainInfo); + return sdkClient; + }, + { + promise: true, + primitive: true, + normalizer( + args: Parameters< + (rpcUrl: string, networkId: string) => Promise + >, + ): string { + return `${args[0]}:${args[1]}`; + }, + max: 1, + maxAge: getTimeDurationMs({ seconds: 15 }), + }, + ); + + async getSDKClient(): Promise { + const { rpcURL, isTestnet } = await this.getNetwork(); + return this.createSDKClient(rpcURL, this.networkId, isTestnet); + } + + override async getClientEndpointStatus(): Promise { + const client = await this.getSDKClient(); + const start = performance.now(); + const latestBlock = (await client.getInfo()).bestBlockNumber; + return { responseTime: Math.floor(performance.now() - start), latestBlock }; + } + + override async validateWatchingCredential(input: string): Promise { + if (this.settings.watchingAccountEnabled) { + if (input.startsWith('stacks')) { + return Promise.resolve(verifyStacksAddress(input).isValid); + } + return verifyStacksAddress(await this.getDisplayAddress(input)).isValid; + } + return Promise.resolve(false); + } + + override async validateAddress(address: string): Promise { + const { isValid, normalizedAddress } = verifyStacksAddress(address); + if (isValid) { + return Promise.resolve(normalizedAddress || address); + } + return Promise.reject(new InvalidAddress()); + } + + override async getBalances( + requests: Array<{ address: string; tokenAddress?: string }>, + ): Promise> { + // Abstract requests + const client = await this.getSDKClient(); + const displayAddresses = await Promise.all( + requests.map(({ address }) => this.getDisplayAddress(address)), + ); + return client.getBalances( + requests.map(({ tokenAddress }, index) => ({ + address: displayAddresses[index], + coin: { ...(typeof tokenAddress === 'string' ? { tokenAddress } : {}) }, + })), + ); + } + + override async attachFeeInfoToEncodedTx(params: { + encodedTx: IEncodedTx; + feeInfoValue: IFeeInfoUnit; + }): Promise { + // TODO set fees + const tx = params.encodedTx as StacksTransaction; + const fee = params.feeInfoValue.feeRate + ? params.feeInfoValue.feeRate + : 10_000; + tx.setFee(fee); + return Promise.resolve(tx as IEncodedTx); + } + + override async decodeTx( + encodedTx: IEncodedTxStacks, + payload?: any, + ): Promise { + decodeTransaction + const displayAddress = await this.getAccountAddress(); + const amountValue = encodedTx.outputs.reduce( + (acc, cur) => acc.plus(new BigNumber(cur.satoshis)), + new BigNumber(0), + ); + + const token: Token = await this.engine.getNativeTokenInfo(this.networkId); + const action = { + type: IDecodedTxActionType.TOKEN_TRANSFER, + direction: IDecodedTxDirection.OUT, + tokenTransfer: { + tokenInfo: token, + from: displayAddress, + to: encodedTx.outputs[0].address, + amount: amountValue.shiftedBy(-token.decimals).toFixed(), + amountValue: amountValue.toString(), + extraInfo: null, + }, + }; + const decodedTx: IDecodedTx = { + txid: '', + owner: displayAddress, + signer: displayAddress, + networkId: this.networkId, + accountId: this.accountId, + encodedTx, + payload, + extraInfo: null, + nonce: 0, + actions: [action], + status: IDecodedTxStatus.Pending, + }; + + return decodedTx; + } + + override getNextNonce(): Promise { + return Promise.resolve(0); + } + + override decodedTxToLegacy(): Promise { + return Promise.resolve({} as IDecodedTxLegacy); + } + + override async buildEncodedTxFromTransfer( + transferInfo: ITransferInfo, + ): Promise { + const client = await this.getSDKClient(); + const fromStacksAddress = transferInfo.from; + const utxos = (await client.getStacksUTXOs(fromStacksAddress)).filter( + (value) => !value.has_token, + ); + + const network = await this.getNetwork(); + return { + inputs: utxos.map((utxo) => ({ + txId: utxo.outpoint_hash, + outputIndex: utxo.tx_pos, + satoshis: new BigNumber(utxo.value).toFixed(), + address: fromStacksAddress, + })), + outputs: [ + { + address: transferInfo.to, + satoshis: new BigNumber(transferInfo.amount) + .shiftedBy(network.decimals) + .toFixed(), + outType: 1, + }, + ], + transferInfo: { + from: fromStacksAddress, + to: transferInfo.to, + amount: transferInfo.amount, + }, + }; + } + + override buildEncodedTxFromApprove(): Promise { + throw new Error('Method not implemented.'); + } + + override updateEncodedTxTokenApprove(): Promise { + throw new Error('Method not implemented.'); + } + + override updateEncodedTx( + encodedTx: IEncodedTxStacks, + ): Promise { + return Promise.resolve(encodedTx); + } + + override buildUnsignedTxFromEncodedTx( + encodedTx: IEncodedTxStacks, + ): Promise { + return Promise.resolve({ + inputs: [], + outputs: [], + payload: { encodedTx }, + encodedTx, + }); + } + + override async getTransactionStatuses( + txids: string[], + ): Promise<(TransactionStatus | undefined)[]> { + const client = await this.getSDKClient(); + return client.getTransactionStatuses(txids); + } + + override async fetchFeeInfo( + encodedTx: IEncodedTxStacks, + signOnly?: boolean, + specifiedFeeRate?: string, + ): Promise { + const network = await this.getNetwork(); + const client = await this.getSDKClient(); + const estimateSizedSize = estimateSize(encodedTx); + const remoteEstimateFee = await client.estimateFee(estimateSizedSize); + const localEstimateFee = estimateFee(encodedTx); + const feeInfo = specifiedFeeRate + ? estimateFee(encodedTx, Number(specifiedFeeRate)) + : Math.max(remoteEstimateFee, localEstimateFee); + return { + nativeSymbol: network.symbol, + nativeDecimals: network.decimals, + feeSymbol: network.feeSymbol, + feeDecimals: network.feeDecimals, + prices: [new BigNumber(1).shiftedBy(-network.decimals).toFixed()], + feeList: [feeInfo], + defaultPresetIndex: '1', + isBtcForkChain: true, + tx: null, + }; + } + + override async getExportedCredential(password: string): Promise { + const dbAccount = await this.getDbAccount(); + if (dbAccount.id.startsWith('hd-') || dbAccount.id.startsWith('imported')) { + const keyring = this.keyring as KeyringSoftwareBase; + const [encryptedPrivateKey] = Object.values( + await keyring.getPrivateKeys(password), + ); + return decrypt(password, encryptedPrivateKey).toString('hex'); + } + throw new OneKeyInternalError( + 'Only credential of HD or imported accounts can be exported', + ); + } + + override fetchTokenInfos(): Promise<(PartialTokenInfo | undefined)[]> { + throw new Error('Method not implemented.'); + } + + override async broadcastTransaction( + signedTx: ISignedTxPro, + ): Promise { + const client = await this.getSDKClient(); + await client.broadcastTransaction(signedTx.rawTx); + return signedTx; + } + + async buildDecodeTx(txHash: string): Promise { + const client = await this.getSDKClient(); + let tx: IStacksTransaction; + try { + tx = await client.getTransaction(txHash); + } catch (error) { + // The result from Stacks Transaction API may be incomplete JSON, resulting in parsing failure. + debugLogger.common.error(`Failed to fetch Stacks transaction. `, txHash); + return false; + } + const dbAccount = (await this.getDbAccount()) as DBUTXOAccount; + const displayAddress = await this.getDisplayAddress(dbAccount.address); + const { decimals } = await this.engine.getNetwork(this.networkId); + const chainId = await this.getNetworkChainId(); + const network = getStacksNetworkInfo(chainId); + const token: Token = await this.engine.getNativeTokenInfo(this.networkId); + return buildDecodeTxFromTx({ + tx, + dbAccountAddress: displayAddress, + decimals, + addressPrefix: network.prefix, + token, + networkId: this.networkId, + accountId: this.accountId, + }); + } + + override async fetchOnChainHistory(options: { + tokenIdOnNetwork?: string | undefined; + localHistory?: IHistoryTx[] | undefined; + password?: string | undefined; + passwordLoadedCallback?: ((isLoaded: boolean) => void) | undefined; + }): Promise { + const { tokenIdOnNetwork, localHistory: localHistories = [] } = options; + if (tokenIdOnNetwork) { + return Promise.resolve([]); + } + + const dbAccount = (await this.getDbAccount()) as DBUTXOAccount; + const client = await this.getSDKClient(); + const displayAddress = await this.getDisplayAddress(dbAccount.address); + const onChainHistories = await client.getHistoryByAddress(displayAddress); + return ( + await Promise.all( + onChainHistories.map(async (history) => { + const historyTxToMerge = localHistories.find( + (item) => item.decodedTx.txid === history.tx_hash, + ); + if (historyTxToMerge) { + if (!historyTxToMerge.decodedTx.isFinal) { + const decodedTx = await this.buildDecodeTx(history.tx_hash); + if (decodedTx) { + decodedTx.createdAt = + historyTxToMerge?.decodedTx.createdAt ?? decodedTx.createdAt; + return this.buildHistoryTx({ + decodedTx, + historyTxToMerge, + }); + } + } + return historyTxToMerge; + } + const decodedTx = await this.buildDecodeTx(history.tx_hash); + if (decodedTx) { + return this.buildHistoryTx({ + decodedTx, + }); + } + return false; + }), + ) + ).filter(Boolean); + } +} diff --git a/packages/engine/src/vaults/impl/stacks/VaultHelper.ts b/packages/engine/src/vaults/impl/stacks/VaultHelper.ts new file mode 100644 index 00000000000..b53d3f4864c --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/VaultHelper.ts @@ -0,0 +1,22 @@ +import { NotImplemented } from '../../../errors'; +import { VaultHelperBase } from '../../VaultHelperBase'; + +import type { IEncodedTx } from '../../types'; + +export default class VaultHelper extends VaultHelperBase { + parseToNativeTx(encodedTx: IEncodedTx): Promise { + return Promise.resolve(encodedTx); + } + + parseToEncodedTx(rawTxOrEncodedTx: any): Promise { + return Promise.resolve(rawTxOrEncodedTx); + } + + nativeTxToJson(): Promise { + throw new NotImplemented(); + } + + jsonToNativeTx(): Promise { + throw new NotImplemented(); + } +} diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts new file mode 100644 index 00000000000..81ce2250e20 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts @@ -0,0 +1,202 @@ +import { OneKeyHardwareError } from '@onekeyhq/engine/src/errors'; +import { slicePathTemplate } from '@onekeyhq/engine/src/managers/derivation'; +import { getAccountNameInfoByImpl } from '@onekeyhq/engine/src/managers/impl'; +import { AccountType } from '@onekeyhq/engine/src/types/account'; +import type { DBUTXOAccount } from '@onekeyhq/engine/src/types/account'; +import type { UnsignedTx } from '@onekeyhq/engine/src/types/provider'; +import { KeyringHardwareBase } from '@onekeyhq/engine/src/vaults/keyring/KeyringHardwareBase'; +import type { + IHardwareGetAddressParams, + IPrepareHardwareAccountsParams, + ISignedTxPro, +} from '@onekeyhq/engine/src/vaults/types'; +import { convertDeviceError } from '@onekeyhq/shared/src/device/deviceErrorUtils'; +import { + IMPL_NEXA as COIN_IMPL, + COINTYPE_NEXA as COIN_TYPE, +} from '@onekeyhq/shared/src/engine/engineConsts'; +import debugLogger from '@onekeyhq/shared/src/logger/debugLogger'; + +import { type IEncodedTxNexa } from '../types'; +import { + buildInputScriptBuffer, + buildRawTx, + buildSignatureBuffer, + buildTxid, + getNexaPrefix, +} from '../utils'; + +import type { INexaInputSignature } from '../types'; +import type { NexaAddress, Success, Unsuccessful } from '@onekeyfe/hd-core'; + +const SIGN_TYPE = 'Schnorr'; + +// @ts-ignore +export class KeyringHardware extends KeyringHardwareBase { + async prepareAccounts( + params: IPrepareHardwareAccountsParams, + ): Promise> { + const { indexes, names, template } = params; + const { pathPrefix, pathSuffix } = slicePathTemplate(template); + const paths = indexes.map( + (index) => + // When the first digit is 0, it represents a receiving account, + // and when it is 0, it indicates a change account. + `${pathPrefix}/${pathSuffix.replace('{index}', index.toString())}`, + ); + const idPaths = indexes.map((index) => `${pathPrefix}/${index}'`); + const showOnOneKey = false; + const HardwareSDK = await this.getHardwareSDKInstance(); + const { connectId, deviceId } = await this.getHardwareInfo(); + const passphraseState = await this.getWalletPassphraseState(); + + const { prefix } = getAccountNameInfoByImpl(COIN_IMPL).default; + const chainId = await this.getNetworkChainId(); + + let addressesResponse: Unsuccessful | Success; + try { + addressesResponse = await HardwareSDK.nexaGetAddress( + connectId, + deviceId, + { + bundle: paths.map((path) => ({ + path, + showOnOneKey, + prefix: getNexaPrefix(chainId), + })), + ...passphraseState, + }, + ); + } catch (error: any) { + debugLogger.common.error(error); + throw new OneKeyHardwareError(error); + } + if (!addressesResponse.success) { + debugLogger.common.error(addressesResponse.payload); + throw convertDeviceError(addressesResponse.payload); + } + + const ret: DBUTXOAccount[] = []; + return addressesResponse.payload.map((addressInfo, index) => { + const { path, pub } = addressInfo; + const name = (names || [])[index] || `${prefix} #${indexes[index] + 1}`; + ret.push(); + return { + id: `${this.walletId}--${idPaths[index]}`, + name, + type: AccountType.UTXO, + path, + coinType: COIN_TYPE, + xpub: '', + address: pub, + addresses: { [this.networkId]: pub }, + template, + }; + }); + } + + async getAddress(params: IHardwareGetAddressParams): Promise { + const HardwareSDK = await this.getHardwareSDKInstance(); + const { connectId, deviceId } = await this.getHardwareInfo(); + const passphraseState = await this.getWalletPassphraseState(); + + const chainId = await this.getNetworkChainId(); + + const response = await HardwareSDK.nexaGetAddress(connectId, deviceId, { + path: params.path, + showOnOneKey: params.showOnOneKey, + prefix: getNexaPrefix(chainId), + scheme: SIGN_TYPE, + ...passphraseState, + }); + if (response.success && !!response.payload?.address) { + return response.payload?.address; + } + throw convertDeviceError(response.payload); + } + + override async batchGetAddress( + params: IHardwareGetAddressParams[], + ): Promise<{ path: string; address: string }[]> { + const HardwareSDK = await this.getHardwareSDKInstance(); + const { connectId, deviceId } = await this.getHardwareInfo(); + const passphraseState = await this.getWalletPassphraseState(); + + const chainId = await this.getNetworkChainId(); + + const response = await HardwareSDK.nexaGetAddress(connectId, deviceId, { + ...passphraseState, + bundle: params.map(({ path, showOnOneKey }) => ({ + path, + showOnOneKey: !!showOnOneKey, + prefix: getNexaPrefix(chainId), + scheme: SIGN_TYPE, + })), + }); + + if (!response.success) { + throw convertDeviceError(response.payload); + } + return response.payload.map((item) => ({ + path: item.path ?? '', + address: item.address ?? '', + })); + } + + async signTransaction(unsignedTx: UnsignedTx): Promise { + debugLogger.common.info('signTransaction', unsignedTx); + const dbAccount = (await this.getDbAccount()) as DBUTXOAccount; + + const chainId = await this.getNetworkChainId(); + + const { encodedTx } = unsignedTx.payload; + const { inputSignatures, outputSignatures, signatureBuffer } = + buildSignatureBuffer( + encodedTx as IEncodedTxNexa, + await this.vault.getDisplayAddress(dbAccount.address), + ); + const { connectId, deviceId } = await this.getHardwareInfo(); + const passphraseState = await this.getWalletPassphraseState(); + + const HardwareSDK = await this.getHardwareSDKInstance(); + const response = await HardwareSDK.nexaSignTransaction( + connectId, + deviceId, + { + ...passphraseState, + inputs: [ + { + path: dbAccount.path, + prefix: getNexaPrefix(chainId), + message: signatureBuffer.toString('hex'), + }, + ], + }, + ); + + if (response.success) { + const nexaSignatures = response.payload; + const publicKey = Buffer.from(dbAccount.address, 'hex'); + const defaultSignature = Buffer.from(nexaSignatures[0].signature, 'hex'); + const inputSigs: INexaInputSignature[] = inputSignatures.map( + (inputSig) => ({ + ...inputSig, + publicKey, + signature: defaultSignature, + scriptBuffer: buildInputScriptBuffer(publicKey, defaultSignature), + }), + ); + + const txid = buildTxid(inputSigs, outputSignatures); + const rawTx = buildRawTx(inputSigs, outputSignatures, 0, true); + + return { + txid, + rawTx: rawTx.toString('hex'), + encodedTx, + }; + } + + throw convertDeviceError(response.payload); + } +} diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.test.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.test.ts new file mode 100644 index 00000000000..f5c93ff4eca --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.test.ts @@ -0,0 +1,47 @@ +import nexaMockData from '../@tests/nexaMockData'; +import { + testPrepareAccounts, + testSignTransaction, +} from '../@tests/nexaPresetCase'; + +import { KeyringHd } from './KeyringHd'; + +jest.setTimeout(3 * 60 * 1000); + +describe('Nexa KeyringHd Tests', () => { + it('Nexa KeyringHd prepareAccounts', async () => { + const { network, hdAccount1 } = nexaMockData; + await testPrepareAccounts( + { + dbNetwork: network, + dbAccount: hdAccount1.account, + mnemonic: hdAccount1.mnemonic, + password: hdAccount1.password, + }, + { + keyring({ vault }) { + return new KeyringHd(vault); + }, + }, + ); + }); + + it('Nexa KeyringHd sign tx', async () => { + const { network, hdAccount1 } = nexaMockData; + await testSignTransaction( + { + dbNetwork: network, + dbAccount: hdAccount1.account, + mnemonic: hdAccount1.mnemonic, + password: hdAccount1.password, + }, + { + keyring({ vault }) { + return new KeyringHd(vault); + }, + }, + ); + }); +}); + +export {}; diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.ts new file mode 100644 index 00000000000..b947141b322 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.ts @@ -0,0 +1,116 @@ +import { COINTYPE_NEXA as COIN_TYPE } from '@onekeyhq/shared/src/engine/engineConsts'; + +import { OneKeyInternalError } from '../../../../errors'; +import { slicePathTemplate } from '../../../../managers/derivation'; +import { Signer } from '../../../../proxy'; +import { batchGetPublicKeys } from '../../../../secret'; +import { AccountType } from '../../../../types/account'; +import { KeyringHdBase } from '../../../keyring/KeyringHdBase'; +import { signEncodedTx } from '../utils'; + +import type { ExportedSeedCredential } from '../../../../dbs/base'; +import type { DBUTXOAccount } from '../../../../types/account'; +import type { + IPrepareSoftwareAccountsParams, + ISignCredentialOptions, + ISignedTxPro, + IUnsignedTxPro, +} from '../../../types'; + +const curve = 'secp256k1'; +export class KeyringHd extends KeyringHdBase { + override async getSigners(password: string, addresses: Array) { + const dbAccount = await this.getDbAccount(); + + if (addresses.length !== 1) { + throw new OneKeyInternalError('NEXA signers number should be 1.'); + } else if (addresses[0] !== dbAccount.address) { + throw new OneKeyInternalError('Wrong address required for signing.'); + } + + const { [dbAccount.path]: privateKey } = await this.getPrivateKeys( + password, + ); + if (typeof privateKey === 'undefined') { + throw new OneKeyInternalError('Unable to get signer.'); + } + + return { + [dbAccount.address]: new Signer(privateKey, password, curve), + }; + } + + async getSigner( + options: ISignCredentialOptions, + { address }: { address: string }, + ) { + const signers = await this.getSigners(options?.password || '', [address]); + const signer = signers[address]; + return signer; + } + + override async signTransaction( + unsignedTx: IUnsignedTxPro, + options: ISignCredentialOptions, + ): Promise { + const dbAccount = await this.getDbAccount(); + const signer = await this.getSigner(options, { + address: dbAccount.address, + }); + const result = await signEncodedTx( + unsignedTx, + signer, + await this.vault.getDisplayAddress(dbAccount.address), + ); + return result; + } + + override async prepareAccounts( + params: IPrepareSoftwareAccountsParams, + ): Promise { + const accountNamePrefix = 'NEXA'; + + const { password, indexes, names, template } = params; + const { seed } = (await this.engine.dbApi.getCredential( + this.walletId, + password, + )) as ExportedSeedCredential; + + const { pathPrefix, pathSuffix } = slicePathTemplate(template); + const pubkeyInfos = batchGetPublicKeys( + curve, + seed, + password, + pathPrefix, + // When the first digit is 0, it represents a receiving account, + // and when it is 0, it indicates a change account. + indexes.map((index) => pathSuffix.replace('{index}', index.toString())), + ); + + const idPaths = indexes.map((index) => `${pathPrefix}/${index}'`); + + if (pubkeyInfos.length !== indexes.length) { + throw new OneKeyInternalError('Unable to get publick key.'); + } + return pubkeyInfos.map((info, index) => { + const { + path, + extendedKey: { key: pubkey }, + } = info; + const name = + (names || [])[index] || `${accountNamePrefix} #${indexes[index] + 1}`; + const pub = pubkey.toString('hex'); + return { + id: `${this.walletId}--${idPaths[index]}`, + name, + type: AccountType.UTXO, + path, + coinType: COIN_TYPE, + xpub: '', + address: pub, + addresses: { [this.networkId]: pub }, + template, + }; + }); + } +} diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringImported.test.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringImported.test.ts new file mode 100644 index 00000000000..338a36dcc5e --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringImported.test.ts @@ -0,0 +1,47 @@ +import nexaMockData from '../@tests/nexaMockData'; +import { + testPrepareAccounts, + testSignTransaction, +} from '../@tests/nexaPresetCase'; + +import { KeyringImported } from './KeyringImported'; + +jest.setTimeout(3 * 60 * 1000); + +describe('Nexa KeyringImported Tests', () => { + it('Nexa KeyringImported prepareAccounts', async () => { + const { network, importedAccount1 } = nexaMockData; + await testPrepareAccounts( + { + dbNetwork: network, + dbAccount: importedAccount1.account, + privateKey: importedAccount1.privateKey, + password: importedAccount1.password, + }, + { + keyring({ vault }) { + return new KeyringImported(vault); + }, + }, + ); + }); + + it('Nexa KeyringImported sign tx', async () => { + const { network, importedAccount2 } = nexaMockData; + await testSignTransaction( + { + dbNetwork: network, + dbAccount: importedAccount2.account, + privateKey: importedAccount2.privateKey, + password: importedAccount2.password, + }, + { + keyring({ vault }) { + return new KeyringImported(vault); + }, + }, + ); + }); +}); + +export {}; diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringImported.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringImported.ts new file mode 100644 index 00000000000..8d6a1c91e67 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringImported.ts @@ -0,0 +1,91 @@ +import { secp256k1 } from '@onekeyhq/engine/src/secret/curves'; +import { COINTYPE_NEXA as COIN_TYPE } from '@onekeyhq/shared/src/engine/engineConsts'; + +import { OneKeyInternalError } from '../../../../errors'; +import { Signer } from '../../../../proxy'; +import { AccountType } from '../../../../types/account'; +import { KeyringImportedBase } from '../../../keyring/KeyringImportedBase'; +import { signEncodedTx } from '../utils'; + +import type { DBUTXOAccount } from '../../../../types/account'; +import type { + IPrepareImportedAccountsParams, + ISignCredentialOptions, + ISignedTxPro, + IUnsignedTxPro, +} from '../../../types'; + +const curve = 'secp256k1'; +export class KeyringImported extends KeyringImportedBase { + override async prepareAccounts( + params: IPrepareImportedAccountsParams, + ): Promise> { + const { name, privateKey } = params; + if (privateKey.length !== 32) { + throw new OneKeyInternalError('Invalid private key.'); + } + + const pub = secp256k1.publicFromPrivate(privateKey); + const pubHex = pub.toString('hex'); + return Promise.resolve([ + { + id: `imported--${COIN_TYPE}--${pubHex}`, + name: name || '', + type: AccountType.UTXO, + path: '', + coinType: COIN_TYPE, + xpub: '', + address: pubHex, + addresses: { [this.networkId]: pubHex }, + }, + ]); + } + + override async getSigners(password: string, addresses: Array) { + const dbAccount = await this.getDbAccount(); + + if (addresses.length !== 1) { + throw new OneKeyInternalError('NEXA signers number should be 1.'); + } else if (addresses[0] !== dbAccount.address) { + throw new OneKeyInternalError('Wrong address required for signing.'); + } + + const { [dbAccount.path]: privateKey } = await this.getPrivateKeys( + password, + ); + if (typeof privateKey === 'undefined') { + throw new OneKeyInternalError('Unable to get signer.'); + } + + return { + [dbAccount.address]: new Signer(privateKey, password, curve), + }; + } + + async getSigner( + options: ISignCredentialOptions, + { address }: { address: string }, + ) { + const signers = await this.getSigners(options.password || '', [address]); + const signer = signers[address]; + return signer; + } + + override async signTransaction( + unsignedTx: IUnsignedTxPro, + options: ISignCredentialOptions, + ): Promise { + const dbAccount = await this.getDbAccount(); + const signer = await this.getSigner(options, dbAccount); + const result = await signEncodedTx( + unsignedTx, + signer, + await this.vault.getDisplayAddress(dbAccount.address), + ); + return result; + } + + override signMessage(messages: any[], options: ISignCredentialOptions): any { + console.log(messages, options); + } +} diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringWatching.test.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringWatching.test.ts new file mode 100644 index 00000000000..7292f91eb58 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringWatching.test.ts @@ -0,0 +1,91 @@ +import nexaMockData from '../@tests/nexaMockData'; +import { testPrepareAccounts } from '../@tests/nexaPresetCase'; + +import { KeyringWatching } from './KeyringWatching'; + +import type { InvalidAddress } from '../../../../errors'; + +jest.setTimeout(3 * 60 * 1000); + +describe('Nexa KeyringWatching Tests', () => { + it('Nexa KeyringWatching prepareAccounts', async () => { + const { network, watchingAccount1 } = nexaMockData; + await testPrepareAccounts( + { + dbNetwork: network, + dbAccount: watchingAccount1.account, + password: watchingAccount1.password, + accountIdPrefix: 'external', + }, + { + keyring({ vault }) { + return new KeyringWatching(vault); + }, + }, + ); + }); + it('Nexa KeyringWatching prepareAccounts with wrong address', async () => { + const { network, watchingAccount2 } = nexaMockData; + try { + await testPrepareAccounts( + { + dbNetwork: network, + dbAccount: watchingAccount2.account, + password: watchingAccount2.password, + accountIdPrefix: 'external', + }, + { + keyring({ vault }) { + return new KeyringWatching(vault); + }, + }, + ); + // this is where the code hits the fan, indicating an error. + expect(false).toBeTruthy(); + } catch (e: unknown) { + expect((e as InvalidAddress).message).toBe('InvalidAddress.'); + } + }); + + it('Nexa KeyringWatching prepareAccounts with public key', async () => { + const { network, watchingAccount3 } = nexaMockData; + await testPrepareAccounts( + { + dbNetwork: network, + dbAccount: watchingAccount3.account, + password: watchingAccount3.password, + accountIdPrefix: 'external', + }, + { + keyring({ vault }) { + return new KeyringWatching(vault); + }, + }, + ); + }); + + it('Nexa KeyringWatching prepareAccounts with non-identical network address.', async () => { + const { network, watchingAccount4 } = nexaMockData; + try { + await testPrepareAccounts( + { + dbNetwork: network, + dbAccount: watchingAccount4.account, + password: watchingAccount4.password, + accountIdPrefix: 'external', + }, + { + keyring({ vault }) { + return new KeyringWatching(vault); + }, + }, + ); + // this is where the code hits the fan, indicating an error. + expect(false).toBeTruthy(); + } catch (e: unknown) { + expect((e as InvalidAddress).message).toBe('InvalidAddress.'); + } + }); +}); + +export {}; diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringWatching.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringWatching.ts new file mode 100644 index 00000000000..ea690fbc836 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringWatching.ts @@ -0,0 +1,58 @@ +import { COINTYPE_NEXA as COIN_TYPE } from '@onekeyhq/shared/src/engine/engineConsts'; + +import { InvalidAddress } from '../../../../errors'; +import { AccountType } from '../../../../types/account'; +import { KeyringWatchingBase } from '../../../keyring/KeyringWatchingBase'; +import { + getNexaNetworkInfo, + verifyNexaAddress, + verifyNexaAddressPrefix, +} from '../utils'; + +import type { DBSimpleAccount } from '../../../../types/account'; +import type { IPrepareWatchingAccountsParams } from '../../../types'; + +export class KeyringWatching extends KeyringWatchingBase { + override async prepareAccounts( + params: IPrepareWatchingAccountsParams, + ): Promise> { + const { name, target: address, accountIdPrefix } = params; + let normalizedAddress = ''; + let accountType = AccountType.SIMPLE; + + if (verifyNexaAddressPrefix(address)) { + const addressPrefix = address.split(':')[0]; + const chainId = await this.vault.getNetworkChainId(); + const network = getNexaNetworkInfo(chainId); + if (network.prefix !== addressPrefix) { + throw new InvalidAddress(); + } + const { normalizedAddress: nexaAddress, isValid } = + verifyNexaAddress(address); + normalizedAddress = nexaAddress || ''; + if (!isValid || typeof normalizedAddress === 'undefined') { + throw new InvalidAddress(); + } + } else { + try { + verifyNexaAddress(await this.vault.getDisplayAddress(address)); + normalizedAddress = address; + accountType = AccountType.UTXO; + } catch (error) { + throw new InvalidAddress(); + } + } + + return Promise.resolve([ + { + id: `${accountIdPrefix}--${COIN_TYPE}--${address}`, + name: name || '', + type: accountType, + path: '', + coinType: COIN_TYPE, + pub: '', // TODO: only address is supported for now. + address: normalizedAddress, + }, + ]); + } +} diff --git a/packages/engine/src/vaults/impl/stacks/keyring/index.ts b/packages/engine/src/vaults/impl/stacks/keyring/index.ts new file mode 100644 index 00000000000..2e1cab13039 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/keyring/index.ts @@ -0,0 +1,4 @@ +export * from './KeyringHardware'; +export * from './KeyringHd'; +export * from './KeyringImported'; +export * from './KeyringWatching'; diff --git a/packages/engine/src/vaults/impl/stacks/sdk/index.ts b/packages/engine/src/vaults/impl/stacks/sdk/index.ts new file mode 100644 index 00000000000..eca08b54d4f --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/sdk/index.ts @@ -0,0 +1,2 @@ +export * from './stacks'; +export * from './sign'; diff --git a/packages/engine/src/vaults/impl/stacks/sdk/sign.ts b/packages/engine/src/vaults/impl/stacks/sdk/sign.ts new file mode 100644 index 00000000000..36da08d30cc --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/sdk/sign.ts @@ -0,0 +1,206 @@ +import BN from 'bn.js'; +import elliptic from 'elliptic'; + +import { hmacSHA256, sha256 } from '../../../../secret/hash'; + + +import type { curve } from 'elliptic'; + +const EC = elliptic.ec; +const ec = new EC('secp256k1'); + +export function reverseBuffer(buffer: Buffer | string): Buffer { + const buf = typeof buffer === 'string' ? Buffer.from(buffer, 'hex') : buffer; + const { length } = buf; + const reversed = Buffer.alloc(length); + for (let i = 0; i < length; i += 1) { + reversed[i] = buf[length - i - 1]; + } + return reversed; +} + +function getBN(buffer: Buffer, isLittleEndian = false) { + const buf = isLittleEndian ? reverseBuffer(buffer) : buffer; + const hex = buf.toString('hex'); + return new BN(hex, 16); +} + +function nonceFunctionRFC6979(privkey: Buffer, msgbuf: Buffer): BN { + let V = Buffer.from( + '0101010101010101010101010101010101010101010101010101010101010101', + 'hex', + ); + let K = Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex', + ); + + const blob = Buffer.concat([ + privkey, + msgbuf, + Buffer.from('', 'ascii'), + Buffer.from('Schnorr+SHA256 ', 'ascii'), + ]); + + K = hmacSHA256(K, Buffer.concat([V, Buffer.from('00', 'hex'), blob])); + V = hmacSHA256(K, V); + + K = hmacSHA256(K, Buffer.concat([V, Buffer.from('01', 'hex'), blob])); + V = hmacSHA256(K, V); + + let k = new BN(0); + let T; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const N = new BN(ec.curve.n.toArray()); + // eslint-disable-next-line no-constant-condition + while (true) { + V = hmacSHA256(K, V); + T = getBN(V); + + k = T; + if (k.gt(new BN(0)) && k.lt(N)) { + break; + } + K = hmacSHA256(K, Buffer.concat([V, Buffer.from('00', 'hex')])); + V = hmacSHA256(K, V); + } + return k; +} + +function isSquare(x: BN): boolean { + const p = new BN( + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F', + 'hex', + ); + const x0 = new BN(x); + const base = x0.toRed(BN.red(p)); + const res = base.redPow(p.sub(new BN(1)).div(new BN(2))).fromRed(); // refactor to BN arithmetic operations + return res.eq(new BN(1)); +} + +function hasSquare(point: curve.base.BasePoint): boolean { + return !point.isInfinity() && isSquare(new BN(point.getY().toArray())); +} + +function getrBuffer(r: BN): Buffer { + const rNaturalLength = getBufferFromBN(r).length; + if (rNaturalLength < 32) { + return getBufferFromBN(r, 'be', 32); + } + return getBufferFromBN(r); +} + +function pointToCompressed(point: curve.base.BasePoint): Buffer { + const xbuf = getBufferFromBN(point.getX(), 'be', 32); + const ybuf = getBufferFromBN(point.getY(), 'be', 32); + + let prefix; + const odd = ybuf[ybuf.length - 1] % 2; + if (odd) { + prefix = Buffer.from([0x03]); + } else { + prefix = Buffer.from([0x02]); + } + return Buffer.concat([prefix, xbuf]); +} + +function findSignature(d: BN, e: BN) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-unsafe-member-access + const G: curve.base.BasePoint = ec.curve.g as curve.base.BasePoint; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const n: BN = new BN(ec.curve.n.toArray()); + let k = nonceFunctionRFC6979( + getBufferFromBN(d, 'be', 32), + getBufferFromBN(e, 'be', 32), + ); + const P = G.mul(d as any); + const R = G.mul(k as any); + + if (!hasSquare(R)) { + k = n.sub(k); + } + + const r = R.getX(); + const e0 = getBN( + sha256( + Buffer.concat([ + getrBuffer(r), + pointToCompressed(P), + getBufferFromBN(e, 'be', 32), + ]), + ), + ); + + const s = e0.mul(d).add(k).mod(n); + return { + r, + s, + }; +} + +export function sign(privateKey: Buffer, digest: Buffer): Buffer { + sign + const privateKeyBN = getBN(privateKey); + const digestBN = getBN(digest); + const { r, s } = findSignature(privateKeyBN, digestBN); + return Buffer.concat([ + getBufferFromBN(r, 'be', 32), + getBufferFromBN(s, 'be', 32), + ]); +} + +export function verify( + publicKey: Buffer, + digest: Buffer, + signature: Buffer, +): boolean { + if (signature.length !== 64) { + return false; + } + const r = getBN(signature.slice(0, 32)); + const s = getBN(signature.slice(32)); + + const hashbuf = digest; + + const xbuf = publicKey.slice(1); + const x = getBN(xbuf); + // publicKey[0] === 0x02 + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + let P: curve.base.BasePoint = ec.curve.pointFromX(x, false); + + if (publicKey[0] === 0x03) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + P = ec.curve.pointFromX(x, true); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const G: curve.base.BasePoint = ec.curve.g as curve.base.BasePoint; + if (P.isInfinity()) { + return true; + } + const p = new BN( + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F', + 'hex', + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const n = new BN(ec.curve.n.toArray()); + if (r.gte(p) || s.gte(n)) { + // ("Failed >= condition") + return false; + } + const Br = getrBuffer(r); + const Bp = pointToCompressed(P); + const hash = sha256(Buffer.concat([Br, Bp, hashbuf])); + + // const e = BN.fromBuffer(hash, 'big').umod(n); + const e = new BN(hash, 'be').umod(n); + const sG = G.mul(s as any); + const eP = P.mul(n.sub(e as any) as any); + const R = sG.add(eP); + + if (R.isInfinity() || !hasSquare(R) || !R.getX().eq(r as any)) { + return false; + } + + return true; +} diff --git a/packages/engine/src/vaults/impl/stacks/sdk/stacks.ts b/packages/engine/src/vaults/impl/stacks/sdk/stacks.ts new file mode 100644 index 00000000000..27a9374d80a --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/sdk/stacks.ts @@ -0,0 +1,103 @@ +import { + AccountsApi, + Configuration, + FeesApi, + InfoApi, + TransactionsApi, +} from '@stacks/blockchain-api-client'; +import { StacksMainnet, type StacksNetwork } from '@stacks/network'; +import BigNumber from 'bignumber.js'; + +import { SimpleClient } from '../../../../client/BaseClient'; +import { TransactionStatus } from '../../../../types/provider'; + +import type { + AddressInfo, + ClientInfo, + FeePricePerUnit, +} from '../../../../types/provider'; + +export class Stacks extends SimpleClient { + private readonly stacksNetwork: StacksNetwork; + + private readonly infoApi: InfoApi; + + private readonly feesApi: FeesApi; + + private readonly accountsApi: AccountsApi; + + private readonly transactionsApi: TransactionsApi; + + constructor( + url: string, + readonly defaultFinality: 'optimistic' | 'final' = 'optimistic', + ) { + super(); + this.stacksNetwork = new StacksMainnet({ url }); + const config = new Configuration({ basePath: url }); + this.infoApi = new InfoApi(config); + this.feesApi = new FeesApi(config); + this.accountsApi = new AccountsApi(config); + this.transactionsApi = new TransactionsApi(config); + } + + override getAddress(): Promise { + throw new Error('Method not implemented.'); + } + + override async getInfo(): Promise { + const { stacks_tip_height: bestBlockNumber } = + await this.infoApi.getCoreApiInfo(); + return { + bestBlockNumber, + isReady: true, + }; + } + + override async getFeePricePerUnit(): Promise { + const feeRate = (await this.feesApi.getFeeTransfer()) as unknown as number; + return { + normal: { + price: new BigNumber(feeRate), + }, + }; + } + + override async broadcastTransaction(rawTx: string): Promise { + return this.broadcastTransaction(rawTx); + } + + override async getBalance(address: string): Promise { + const balanceInfo = await this.accountsApi.getAccountBalance({ + principal: address, + }); + return new BigNumber(balanceInfo.stx.balance); + } + + async estimateFee(size: number): Promise { + const fee = await this.feesApi.postFeeTransaction({ + transactionFeeEstimateRequest: { + transaction_payload: '', + estimated_len: size, + }, + }); + return Number(fee) || 0; + } + + override async getTransactionStatus( + txId: string, + ): Promise { + const tx = (await this.transactionsApi.getTransactionById({ + txId, + })) as unknown as { tx_status: string }; + return tx.tx_status === 'success' + ? TransactionStatus.CONFIRM_AND_SUCCESS + : TransactionStatus.PENDING; + } + + override getTransactionStatuses( + txids: string[], + ): Promise<(TransactionStatus | undefined)[]> { + return Promise.all(txids.map((txid) => this.getTransactionStatus(txid))); + } +} diff --git a/packages/engine/src/vaults/impl/stacks/settings.ts b/packages/engine/src/vaults/impl/stacks/settings.ts new file mode 100644 index 00000000000..b26a872dd97 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/settings.ts @@ -0,0 +1,42 @@ +import { + COINTYPE_STACKS, + INDEX_PLACEHOLDER, +} from '@onekeyhq/shared/src/engine/engineConsts'; + +import type { IVaultSettings } from '../../types'; + +const settings: IVaultSettings = Object.freeze({ + feeInfoEditable: true, + privateKeyExportEnabled: true, + tokenEnabled: false, + txCanBeReplaced: false, + + isFeeRateMode: true, + importedAccountEnabled: true, + hardwareAccountEnabled: true, + externalAccountEnabled: false, + watchingAccountEnabled: true, + + displayChars: 6, + + minGasLimit: 10, + + minTransferAmount: '5.46', + + hideInAllNetworksMode: false, + + isUTXOModel: false, + + accountNameInfo: { + default: { + prefix: 'STACKS', + category: `44'/${COINTYPE_STACKS}'`, + template: `m/44'/${COINTYPE_STACKS}'/0'/0/${INDEX_PLACEHOLDER}`, + coinType: COINTYPE_STACKS, + label: 'Default', + subDesc: 'BIP44, P2PKH, c32check', + }, + }, +}); + +export default settings; diff --git a/packages/engine/src/vaults/impl/stacks/types.ts b/packages/engine/src/vaults/impl/stacks/types.ts new file mode 100644 index 00000000000..6cee0474bd5 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/types.ts @@ -0,0 +1,3 @@ +import type { StacksTransaction } from '@stacks/transactions'; + +export type IEncodedTxStacks = StacksTransaction; diff --git a/packages/engine/src/vaults/impl/stacks/utils.test.ts b/packages/engine/src/vaults/impl/stacks/utils.test.ts new file mode 100644 index 00000000000..fe7295ce099 --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/utils.test.ts @@ -0,0 +1,1245 @@ +import { sign, verify } from './sdk'; +import { + buildDecodeTxFromTx, + decodeScriptBufferToStacksAddress, + sha256sha256, + signEncodedTx, +} from './utils'; + +import type { Signer } from '../../../proxy'; + +jest.setTimeout(3 * 60 * 1000); + +describe('Stacks Utils Tests', () => { + it('Stacks Utils signEncodedTx #1', async () => { + const signedTx = await signEncodedTx( + { + 'inputs': [], + 'outputs': [], + 'payload': {}, + 'encodedTx': { + 'inputs': [ + { + 'txId': + 'b33f172176b8e901c36f060bdaf332087924aaeddb2e4b483c9b74edc53cc078', + 'outputIndex': 0, + 'satoshis': '10000', + 'address': + 'stackstest:nqtsq5g5llmjhut9fuzst4993zmk62m89rw2gztuvl376dp0', + }, + { + 'txId': + 'a1164e3713a8c6fd794a289680b075181c4284b9da8c8c0b9ae864bbcf9b8458', + 'outputIndex': 0, + 'satoshis': '50000', + 'address': + 'stackstest:nqtsq5g5llmjhut9fuzst4993zmk62m89rw2gztuvl376dp0', + }, + { + 'txId': + '86e221c03aa9e7b95b58ef0c7938c5dbcaa6ae736f3f41deb968cffbcaa427ba', + 'outputIndex': 0, + 'satoshis': '5000', + 'address': + 'stackstest:nqtsq5g5llmjhut9fuzst4993zmk62m89rw2gztuvl376dp0', + }, + { + 'txId': + '0ec395ff9213bd4726b91d682246177ccbc99f7459eafac6b69fc8a22c744539', + 'outputIndex': 0, + 'satoshis': '50000', + 'address': + 'stackstest:nqtsq5g5llmjhut9fuzst4993zmk62m89rw2gztuvl376dp0', + }, + ], + 'outputs': [ + { + 'address': + 'stackstest:nqtsq5g5wud2fr7l32as0mfzms3hwnz7dxvsc2h8szatr5p8', + 'satoshis': '5000', + 'outType': 1, + }, + ], + 'gas': undefined, + 'transferInfo': { + 'from': 'stackstest:nqtsq5g5llmjhut9fuzst4993zmk62m89rw2gztuvl376dp0', + 'to': 'stackstest:nqtsq5g5wud2fr7l32as0mfzms3hwnz7dxvsc2h8szatr5p8', + 'amount': '50', + }, + }, + }, + { + getPrvkey: () => + Promise.resolve( + Buffer.from( + '55a8021920dcc897b189bd8c1bd40205c977dd2068b880fc94f984eaf3db40ef', + 'hex', + ), + ), + getPubkey: () => + Promise.resolve( + Buffer.from( + '02f6e52d3ae26271c9afe56f6bc513727207d976651bc6f3843714fc59721a79d2', + 'hex', + ), + ), + } as unknown as Signer, + 'stackstest:nqtsq5g5llmjhut9fuzst4993zmk62m89rw2gztuvl376dp0', + ); + expect(signedTx.txid).toBe( + '1e04ea46dbbddf5291df961bf02f4d9158e0e90421379165c6a0b5fe897b9f33', + ); + expect(signedTx.rawTx).toBe( + '00040078c03cc5ed749b3c484b2edbedaa24790832f3da0b066fc301e9b87621173fb364222102f6e52d3ae26271c9afe56f6bc513727207d976651bc6f3843714fc59721a79d2400a0c5f8078469e22a5431b91891fbe300e2bbd840347f3b3e6668fc85ac6f8370d01cba0b36663009f02ba94d5dccca6731eb437a3082a18493ce2daabb5e53bffffffff10270000000000000058849bcfbb64e89a0b8c8cdab984421c1875b08096284a79fdc6a813374e16a164222102f6e52d3ae26271c9afe56f6bc513727207d976651bc6f3843714fc59721a79d2400a0c5f8078469e22a5431b91891fbe300e2bbd840347f3b3e6668fc85ac6f8370d01cba0b36663009f02ba94d5dccca6731eb437a3082a18493ce2daabb5e53bffffffff50c300000000000000ba27a4cafbcf68b9de413f6f73aea6cadbc538790cef585bb9e7a93ac021e28664222102f6e52d3ae26271c9afe56f6bc513727207d976651bc6f3843714fc59721a79d2400a0c5f8078469e22a5431b91891fbe300e2bbd840347f3b3e6668fc85ac6f8370d01cba0b36663009f02ba94d5dccca6731eb437a3082a18493ce2daabb5e53bffffffff8813000000000000003945742ca2c89fb6c6faea59749fc9cb7c174622681db92647bd1392ff95c30e64222102f6e52d3ae26271c9afe56f6bc513727207d976651bc6f3843714fc59721a79d2400a0c5f8078469e22a5431b91891fbe300e2bbd840347f3b3e6668fc85ac6f8370d01cba0b36663009f02ba94d5dccca6731eb437a3082a18493ce2daabb5e53bffffffff50c30000000000000201881300000000000017005114771aa48fdf8abb07ed22dc23774c5e69990c2ae701fda501000000000017005114fff72bf1654f0505d4a588b76d2b6728dca4097c00000000', + ); + }); + + it('Stacks Utils signEncodedTx #2', async () => { + const signedTx = await signEncodedTx( + { + 'inputs': [], + 'outputs': [], + 'payload': {}, + 'encodedTx': { + 'inputs': [ + { + 'txId': + '8e25139e37161728ecfcf1ca736cc66039daa32543c4f6f59c295e10d07dfc88', + 'outputIndex': 1, + 'satoshis': '99758530', + 'address': + 'stackstest:nqtsq5g5l7rgf6mrvuhrjke8vsm4ng60q86vdycptqn79epv', + }, + { + 'txId': + 'ec29ed8b294c307f955b004f1c602bb76fe5363921afd56abdf0476975dbc838', + 'outputIndex': 0, + 'satoshis': '1000', + 'address': + 'stackstest:nqtsq5g5l7rgf6mrvuhrjke8vsm4ng60q86vdycptqn79epv', + }, + ], + 'outputs': [ + { + 'address': + 'stackstest:nqtsq5g5wud2fr7l32as0mfzms3hwnz7dxvsc2h8szatr5p8', + 'satoshis': '5000', + 'outType': 1, + }, + ], + 'gas': undefined, + 'transferInfo': { + 'from': 'stackstest:nqtsq5g5l7rgf6mrvuhrjke8vsm4ng60q86vdycptqn79epv', + 'to': 'stackstest:nqtsq5g5wud2fr7l32as0mfzms3hwnz7dxvsc2h8szatr5p8', + 'amount': '50', + }, + }, + }, + { + getPrvkey: () => + Promise.resolve( + Buffer.from( + '6b4d9dee8a37f4329cbf7db9a137a2ecdc63be8e6caa881ef05b3a3349ef8db9', + 'hex', + ), + ), + getPubkey: () => + Promise.resolve( + Buffer.from( + '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + 'hex', + ), + ), + } as unknown as Signer, + 'stackstest:nqtsq5g5l7rgf6mrvuhrjke8vsm4ng60q86vdycptqn79epv', + ); + expect(signedTx.txid).toBe( + 'c0a8d7f91b662021ac35040e0ed77d32780ea00cb10622f4e33d5f10e1de5161', + ); + expect(signedTx.rawTx).toBe( + '00020088fc7dd0105e299cf5f6c44325a3da3960c66c73caf1fcec281716379e13258e64222103560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c3140ec76707ee9a316e2fddd8c7393f0981a42977b46278fc121556a84d6234c18aa36117960312df325249f144ad1c35c71af72822477e3e61ca2bbab1635f142b3ffffffffc231f205000000000038c8db756947f0bd6ad5af213936e56fb72b601c4f005b957f304c298bed29ec64222103560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c3140ec76707ee9a316e2fddd8c7393f0981a42977b46278fc121556a84d6234c18aa36117960312df325249f144ad1c35c71af72822477e3e61ca2bbab1635f142b3ffffffffe8030000000000000201881300000000000017005114771aa48fdf8abb07ed22dc23774c5e69990c2ae701db1df2050000000017005114ff8684eb63672e395b27643759a34f01f4c6930100000000', + ); + }); + + it('Stacks Utils signEncodedTx #3: Transfer out all the balances.', async () => { + const signedTx = await signEncodedTx( + { + 'inputs': [], + 'outputs': [], + 'payload': {}, + 'encodedTx': { + 'inputs': [ + { + 'txId': + '1c5ee6d6f6c6dc9cdb07e9142294fd2135c6e445d75050f8277f5d1d21389b7f', + 'outputIndex': 0, + 'satoshis': '20000', + 'address': + 'stackstest:nqtsq5g5skxwlgtmsl99hj6tt8hmsa52cxft09um2md36p07', + }, + { + 'txId': + '9a97e06716c638b1191a335cbbe7619c47815e4d9dd6825c97d8c9756cd72493', + 'outputIndex': 0, + 'satoshis': '20000', + 'address': + 'stackstest:nqtsq5g5skxwlgtmsl99hj6tt8hmsa52cxft09um2md36p07', + }, + { + 'txId': + '7ba7d80db1f3e9e94fe654b54bffa5475d90f05ffedb85617c39c9b4ba4de365', + 'outputIndex': 0, + 'satoshis': '20000', + 'address': + 'stackstest:nqtsq5g5skxwlgtmsl99hj6tt8hmsa52cxft09um2md36p07', + }, + { + 'txId': + '30753a924d9c9ed91411f20ace809b5879c12f06714152176bf55f66ce9c6e1e', + 'outputIndex': 0, + 'satoshis': '20000', + 'address': + 'stackstest:nqtsq5g5skxwlgtmsl99hj6tt8hmsa52cxft09um2md36p07', + }, + { + 'txId': + '1d3fc867415ac5d3a6de7509f4010b23640e2ce1a27cdfe0eee71ed52764f560', + 'outputIndex': 0, + 'satoshis': '10000', + 'address': + 'stackstest:nqtsq5g5skxwlgtmsl99hj6tt8hmsa52cxft09um2md36p07', + }, + ], + 'outputs': [ + { + 'address': + 'stackstest:nqtsq5g50frur0vav60gupjlrr8cta8vyqufu7p9gskt92mz', + 'satoshis': '90000', + 'outType': 1, + }, + ], + 'transferInfo': { + 'from': 'stackstest:nqtsq5g5skxwlgtmsl99hj6tt8hmsa52cxft09um2md36p07', + 'to': 'stackstest:nqtsq5g50frur0vav60gupjlrr8cta8vyqufu7p9gskt92mz', + 'amount': '900', + }, + 'gas': '2409', + }, + }, + { + getPrvkey: () => + Promise.resolve( + Buffer.from( + '3d04eff77414801ce0bc6c73f52af7f198af3e5cba0935c19e9fa25dd383d10e', + 'hex', + ), + ), + getPubkey: () => + Promise.resolve( + Buffer.from( + '03710436c0047bfdc6e148f22633527be74b3da6b2e926721f8475b56cfba9ec77', + 'hex', + ), + ), + } as unknown as Signer, + 'stackstest:nqtsq5g5skxwlgtmsl99hj6tt8hmsa52cxft09um2md36p07', + ); + expect(signedTx.txid).toBe( + '3f0e91feeb682422e4fc1fa1ba69e70abfa344a9b0b2b0d5ca06230340ea18f0', + ); + expect(signedTx.rawTx).toBe( + '0005007f9b38211d5d7f27f85050d745e4c63521fd942214e907db9cdcc6f6d6e65e1c64222103710436c0047bfdc6e148f22633527be74b3da6b2e926721f8475b56cfba9ec7740a501f2380cedbac45c09f234659bf3178290cb689fd61473379cd4e96c50b659c86b1e0abea4b18a72b530ffa29f15a0f803c2853858de28e050956fa849888dffffffff204e000000000000009324d76c75c9d8975c82d69d4d5e81479c61e7bb5c331a19b138c61667e0979a64222103710436c0047bfdc6e148f22633527be74b3da6b2e926721f8475b56cfba9ec7740a501f2380cedbac45c09f234659bf3178290cb689fd61473379cd4e96c50b659c86b1e0abea4b18a72b530ffa29f15a0f803c2853858de28e050956fa849888dffffffff204e0000000000000065e34dbab4c9397c6185dbfe5ff0905d47a5ff4bb554e64fe9e9f3b10dd8a77b64222103710436c0047bfdc6e148f22633527be74b3da6b2e926721f8475b56cfba9ec7740a501f2380cedbac45c09f234659bf3178290cb689fd61473379cd4e96c50b659c86b1e0abea4b18a72b530ffa29f15a0f803c2853858de28e050956fa849888dffffffff204e000000000000001e6e9cce665ff56b17524171062fc179589b80ce0af21114d99e9c4d923a753064222103710436c0047bfdc6e148f22633527be74b3da6b2e926721f8475b56cfba9ec7740a501f2380cedbac45c09f234659bf3178290cb689fd61473379cd4e96c50b659c86b1e0abea4b18a72b530ffa29f15a0f803c2853858de28e050956fa849888dffffffff204e0000000000000060f56427d51ee7eee0df7ca2e12c0e64230b01f40975dea6d3c55a4167c83f1d64222103710436c0047bfdc6e148f22633527be74b3da6b2e926721f8475b56cfba9ec7740a501f2380cedbac45c09f234659bf3178290cb689fd61473379cd4e96c50b659c86b1e0abea4b18a72b530ffa29f15a0f803c2853858de28e050956fa849888dffffffff102700000000000001012756010000000000170051147a47c1bd9d669e8e065f18cf85f4ec20389e782500000000', + ); + }); + + it('Stacks Utils decodeScriptHexToStacksAddress #1', () => { + const hex = + '222103560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c3140302394d39bae3e8b1fe05df664113901ec516dc29a30e5eb913219f70a2ed61d8e7ee53cfedbd30953f4919956edce5710dbfaf5f95c9dc3f6322e7bb17057ff'; + expect( + decodeScriptBufferToStacksAddress(Buffer.from(hex, 'hex'), 'stackstest'), + ).toBe('stackstest:nqtsq5g5l7rgf6mrvuhrjke8vsm4ng60q86vdycptqn79epv'); + }); + + it('Stacks Utils decodeScriptHexToStacksAddress #2', () => { + const hex = '005114345209375631d693fca3865141cc6de052e11797'; + expect( + decodeScriptBufferToStacksAddress(Buffer.from(hex, 'hex'), 'stackstest'), + ).toBe('stackstest:nqtsq5g5x3fqjd6kx8tf8l9rseg5rnrdupfwz9uhauzga026'); + }); + + it('Stacks Utils Sgin Transaction', () => { + const privateKey = + '91632aaa4de97d24c58ff234aa371c7a7c8363808a73fa9189cb5ee3d55a0cd3'; + const digest = + 'ae11c0c8f2576bd05fcde9d0d1f78f0fdaf679476d499c8cd366b81b476350fc'; + expect( + sign(Buffer.from(privateKey, 'hex'), Buffer.from(digest, 'hex')).toString( + 'hex', + ), + ).toBe( + 'dbe0b176c8f425321302aa42d144544f3a7701d07d1666c0a90a642e0351b22a6e687b2c08030415c714843111bac0cfe6ba5e5aac4acb166caee9ae35e12dba', + ); + }); + + it('Stacks Utils Sgin Transaction With signatureBuffer', () => { + const privateKey = + '91632aaa4de97d24c58ff234aa371c7a7c8363808a73fa9189cb5ee3d55a0cd3'; + const signatureBuffer = + '0094d3de9aa564a4fa760a6b16c76a0a15b724c38b39a7249215e397ed1bbf07d40084af2da0940d66153580ce18b185ac78ca4237d3ccaec3dfb32f4fd4134fb63bb13029ce7b1f559ef5e747fcac439f1455a2ec7c5f09b72290795e70665044026cad7049e07c205ae2c4f11bae23b1ead0de47bd52031fdc875e74b590e784c9228a0000000000'; + const digest = sha256sha256(Buffer.from(signatureBuffer, 'hex')); + expect(digest.toString('hex')).toBe( + 'fe1717e9f1d1315ab2a6048fcb51231f56a88512d1ed1ce552045bf7a9225b4d', + ); + expect(sign(Buffer.from(privateKey, 'hex'), digest).toString('hex')).toBe( + '7e5edff03500cec509bf55c4983560de8f794a88ae3539f7804cf2c34cad39d73ef115cf8d8cfa25ec841b38c123c19740a971a7de188319eaac5c4db897ce51', + ); + }); + + it('Stacks verify test', () => { + expect( + verify( + Buffer.from( + new Uint8Array([ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, + 0x62, 0x95, 0xce, 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, + 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, 0xf8, 0x17, 0x98, + ]), + ), + Buffer.from( + new Uint8Array([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]), + ), + Buffer.from( + new Uint8Array([ + 0x78, 0x7a, 0x84, 0x8e, 0x71, 0x04, 0x3d, 0x28, 0x0c, 0x50, 0x47, + 0x0e, 0x8e, 0x15, 0x32, 0xb2, 0xdd, 0x5d, 0x20, 0xee, 0x91, 0x2a, + 0x45, 0xdb, 0xdd, 0x2b, 0xd1, 0xdf, 0xbf, 0x18, 0x7e, 0xf6, 0x70, + 0x31, 0xa9, 0x88, 0x31, 0x85, 0x9d, 0xc3, 0x4d, 0xff, 0xee, 0xdd, + 0xa8, 0x68, 0x31, 0x84, 0x2c, 0xcd, 0x00, 0x79, 0xe1, 0xf9, 0x2a, + 0xf1, 0x77, 0xf7, 0xf2, 0x2c, 0xc1, 0xdc, 0xed, 0x05, + ]), + ), + ), + ).toBeTruthy(); + + expect( + verify( + Buffer.from( + new Uint8Array([ + 0x03, 0xde, 0xfd, 0xea, 0x4c, 0xdb, 0x67, 0x77, 0x50, 0xa4, 0x20, + 0xfe, 0xe8, 0x07, 0xea, 0xcf, 0x21, 0xeb, 0x98, 0x98, 0xae, 0x79, + 0xb9, 0x76, 0x87, 0x66, 0xe4, 0xfa, 0xa0, 0x4a, 0x2d, 0x4a, 0x34, + ]), + ), + Buffer.from( + new Uint8Array([ + 0x4d, 0xf3, 0xc3, 0xf6, 0x8f, 0xcc, 0x83, 0xb2, 0x7e, 0x9d, 0x42, + 0xc9, 0x04, 0x31, 0xa7, 0x24, 0x99, 0xf1, 0x78, 0x75, 0xc8, 0x1a, + 0x59, 0x9b, 0x56, 0x6c, 0x98, 0x89, 0xb9, 0x69, 0x67, 0x03, + ]), + ), + Buffer.from( + new Uint8Array([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x3b, 0x78, 0xce, 0x56, 0x3f, 0x89, 0xa0, 0xed, 0x94, 0x14, 0xf5, + 0xaa, 0x28, 0xad, 0x0d, 0x96, 0xd6, 0x79, 0x5f, 0x9c, 0x63, 0x02, + 0xa8, 0xdc, 0x32, 0xe6, 0x4e, 0x86, 0xa3, 0x33, 0xf2, 0x0e, 0xf5, + 0x6e, 0xac, 0x9b, 0xa3, 0x0b, 0x72, 0x46, 0xd6, 0xd2, 0x5e, 0x22, + 0xad, 0xb8, 0xc6, 0xbe, 0x1a, 0xeb, 0x08, 0xd4, 0x9d, + ]), + ), + ), + ).toBeTruthy(); + }); + + it('Stacks buildDecodeTxFromTx test', () => { + const testData0 = { + 'tx': { + 'blockhash': + '883d9ab547bd3627671589481f741531cf0f4a5c3365305d97a4408ff5678b28', + 'blocktime': 1690620568, + 'confirmations': 10, + 'fee': 6.57, + 'fee_satoshi': 657, + 'hash': + '83733a87c75cd486f5de6232eb79544f8cd186f4cdc8dc7b05291bde3a078a54', + 'height': 327692, + 'hex': + '000100f65e88bbae07d0a1f62903d7ac3711824861c741bccc6d46cdbb9ddf0e2618de64222103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd40d3bfe5850c05b225c93e5312eb52025b59074cad775a43e061ae87a89e7e78c8b8fc6e279bbe09bcf1fc9675881c6f647d61be99d5fc922a51303dae9203178affffffffd1800000000000000101407e000000000000170051147cf62b8a1314b059e25d06a441e02dac445d287200000000', + 'locktime': 0, + 'size': 186, + 'time': 1690620568, + 'txid': + '83733a87c75cd486f5de6232eb79544f8cd186f4cdc8dc7b05291bde3a078a54', + 'txidem': + '1c7387541400b5ce1965c8c5b831405be8b6cb0c85f263b1cfd6061256a4bb83', + 'version': 0, + 'vin': [ + { + 'coinbase': null, + 'outpoint': + 'de18260edf9dbbcd466dccbc41c76148821137acd70329f6a1d007aebb885ef6', + 'scriptSig': { + 'asm': + 'OP_PUSHBYTES_34 2103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd OP_PUSHBYTES_64 d3bfe5850c05b225c93e5312eb52025b59074cad775a43e061ae87a89e7e78c8b8fc6e279bbe09bcf1fc9675881c6f647d61be99d5fc922a51303dae9203178a', + 'hex': + '222103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd40d3bfe5850c05b225c93e5312eb52025b59074cad775a43e061ae87a89e7e78c8b8fc6e279bbe09bcf1fc9675881c6f647d61be99d5fc922a51303dae9203178a', + }, + 'sequence': 4294967295, + 'value': 329.77, + 'value_coin': 329.77, + 'value_satoshi': 32977, + }, + ], + 'vout': [ + { + 'n': 0, + 'scriptPubKey': { + 'addresses': [], + 'argsHash': '7cf62b8a1314b059e25d06a441e02dac445d2872', + 'asm': + 'OP_0 OP_PUSHNUM_1 OP_PUSHBYTES_20 7cf62b8a1314b059e25d06a441e02dac445d2872', + 'group': null, + 'groupAuthority': 0, + 'groupQuantity': null, + 'hex': '0051147cf62b8a1314b059e25d06a441e02dac445d2872', + 'scriptHash': 'pay2pubkeytemplate', + 'token_id_hex': null, + 'type': 'scripttemplate', + }, + 'type': 1, + 'value': 323.2, + 'value_coin': 323.2, + 'value_satoshi': 32320, + }, + ], + }, + 'dbAccountAddress': + 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'addressPrefix': 'Stacks', + 'decimals': 2, + 'token': { + 'id': 'Stacks--0', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'tokenIdOnNetwork': '', + 'symbol': 'NEX', + 'decimals': 2, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'impl': 'Stacks', + 'chainId': '0', + 'address': '', + 'source': '', + 'isNative': true, + }, + 'networkId': 'Stacks--0', + 'accountId': "hd-1--m/44'/29223'/0'", + }; + expect(buildDecodeTxFromTx(testData0)).toEqual({ + 'accountId': "hd-1--m/44'/29223'/0'", + 'actions': [ + { + 'direction': 'IN', + 'tokenTransfer': { + 'amount': '329.77', + 'amountValue': '329.77', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'to': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'createdAt': 1690620568000, + 'extraInfo': null, + 'isFinal': true, + 'networkId': 'Stacks--0', + 'nonce': 0, + 'outputActions': [ + { + 'direction': 'IN', + 'tokenTransfer': { + 'amount': '323.2', + 'amountValue': '323.2', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'to': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'owner': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'signer': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'status': 'Confirmed', + 'totalFeeInNative': '6.57', + 'txid': + '83733a87c75cd486f5de6232eb79544f8cd186f4cdc8dc7b05291bde3a078a54', + 'updatedAt': 1690620568000, + }); + const testData1 = { + 'tx': { + 'blockhash': + 'f45fb9167e49411aca54817b2e45399e91795553204742015ddfafb59c3f062f', + 'blocktime': 1690620486, + 'confirmations': 11, + 'fee': 10.95, + 'fee_satoshi': 1095, + 'hash': + 'e3bb34578c69d7d705b9fe054a4acb76c59d1026d719fd37520564dd28c0d22f', + 'height': 327691, + 'hex': + '00020004e5fac31a92877fa08a8d7d9847b6864128a8e927a96645b5a24fd63a7c67e464222103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd40c447c903813548077c52ae6db55f208ec000dbf25d12e1f39a6ebbe42a2fe5069146e0230928ad521c0ec827a75ad7afd560a4ef5a9db855e2b61c1f859a7ec8ffffffff220200000000000000875cded686fe492a6636c99e1b5a9df8c3ec7264926391f7d0d898dad2b7c06764222103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd40c447c903813548077c52ae6db55f208ec000dbf25d12e1f39a6ebbe42a2fe5069146e0230928ad521c0ec827a75ad7afd560a4ef5a9db855e2b61c1f859a7ec8ffffffffa98700000000000002012202000000000000170051147cf62b8a1314b059e25d06a441e02dac445d287201628300000000000017005114375f5e5be9ddf719d8635306663c7903b530dbab00000000', + 'locktime': 0, + 'size': 365, + 'time': 1690620486, + 'txid': + 'e3bb34578c69d7d705b9fe054a4acb76c59d1026d719fd37520564dd28c0d22f', + 'txidem': + '78fb104762a67eaea9092806e6c9c723058bc3a5ed4641630ea6d87c1ef7b326', + 'version': 0, + 'vin': [ + { + 'coinbase': null, + 'outpoint': + 'e4677c3ad64fa2b54566a927e9a8284186b647987d8d8aa07f87921ac3fae504', + 'scriptSig': { + 'asm': + 'OP_PUSHBYTES_34 2103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd OP_PUSHBYTES_64 c447c903813548077c52ae6db55f208ec000dbf25d12e1f39a6ebbe42a2fe5069146e0230928ad521c0ec827a75ad7afd560a4ef5a9db855e2b61c1f859a7ec8', + 'hex': + '222103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd40c447c903813548077c52ae6db55f208ec000dbf25d12e1f39a6ebbe42a2fe5069146e0230928ad521c0ec827a75ad7afd560a4ef5a9db855e2b61c1f859a7ec8', + }, + 'sequence': 4294967295, + 'value': 5.46, + 'value_coin': 5.46, + 'value_satoshi': 546, + }, + { + 'coinbase': null, + 'outpoint': + '67c0b7d2da98d8d0f79163926472ecc3f89d5a1b9ec936662a49fe86d6de5c87', + 'scriptSig': { + 'asm': + 'OP_PUSHBYTES_34 2103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd OP_PUSHBYTES_64 c447c903813548077c52ae6db55f208ec000dbf25d12e1f39a6ebbe42a2fe5069146e0230928ad521c0ec827a75ad7afd560a4ef5a9db855e2b61c1f859a7ec8', + 'hex': + '222103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd40c447c903813548077c52ae6db55f208ec000dbf25d12e1f39a6ebbe42a2fe5069146e0230928ad521c0ec827a75ad7afd560a4ef5a9db855e2b61c1f859a7ec8', + }, + 'sequence': 4294967295, + 'value': 347.29, + 'value_coin': 347.29, + 'value_satoshi': 34729, + }, + ], + 'vout': [ + { + 'n': 0, + 'scriptPubKey': { + 'addresses': [], + 'argsHash': '7cf62b8a1314b059e25d06a441e02dac445d2872', + 'asm': + 'OP_0 OP_PUSHNUM_1 OP_PUSHBYTES_20 7cf62b8a1314b059e25d06a441e02dac445d2872', + 'group': null, + 'groupAuthority': 0, + 'groupQuantity': null, + 'hex': '0051147cf62b8a1314b059e25d06a441e02dac445d2872', + 'scriptHash': 'pay2pubkeytemplate', + 'token_id_hex': null, + 'type': 'scripttemplate', + }, + 'type': 1, + 'value': 5.46, + 'value_coin': 5.46, + 'value_satoshi': 546, + }, + { + 'n': 1, + 'scriptPubKey': { + 'addresses': [], + 'argsHash': '375f5e5be9ddf719d8635306663c7903b530dbab', + 'asm': + 'OP_0 OP_PUSHNUM_1 OP_PUSHBYTES_20 375f5e5be9ddf719d8635306663c7903b530dbab', + 'group': null, + 'groupAuthority': 0, + 'groupQuantity': null, + 'hex': '005114375f5e5be9ddf719d8635306663c7903b530dbab', + 'scriptHash': 'pay2pubkeytemplate', + 'token_id_hex': null, + 'type': 'scripttemplate', + }, + 'type': 1, + 'value': 336.34, + 'value_coin': 336.34, + 'value_satoshi': 33634, + }, + ], + }, + 'dbAccountAddress': + 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'addressPrefix': 'Stacks', + 'decimals': 2, + 'token': { + 'id': 'Stacks--0', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'tokenIdOnNetwork': '', + 'symbol': 'NEX', + 'decimals': 2, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'impl': 'Stacks', + 'chainId': '0', + 'address': '', + 'source': '', + 'isNative': true, + }, + 'networkId': 'Stacks--0', + 'accountId': "hd-1--m/44'/29223'/0'", + }; + expect(buildDecodeTxFromTx(testData1)).toEqual({ + 'accountId': "hd-1--m/44'/29223'/0'", + 'actions': [ + { + 'direction': 'IN', + 'tokenTransfer': { + 'amount': '5.46', + 'amountValue': '5.46', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'to': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + { + 'direction': 'IN', + 'tokenTransfer': { + 'amount': '347.29', + 'amountValue': '347.29', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'to': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'createdAt': 1690620486000, + 'extraInfo': null, + 'isFinal': true, + 'networkId': 'Stacks--0', + 'nonce': 0, + 'outputActions': [ + { + 'direction': 'IN', + 'tokenTransfer': { + 'amount': '5.46', + 'amountValue': '5.46', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'to': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'owner': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'signer': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'status': 'Confirmed', + 'totalFeeInNative': '10.95', + 'txid': + 'e3bb34578c69d7d705b9fe054a4acb76c59d1026d719fd37520564dd28c0d22f', + 'updatedAt': 1690620486000, + }); + + const testData2 = { + 'tx': { + 'blockhash': + '9d091abed6ca67fbd37afeb358d8e1a80eb97d8a018dc0b5cd9f6b9c0317e5f1', + 'blocktime': 1690527851, + 'confirmations': 786, + 'fee': 6.57, + 'fee_satoshi': 657, + 'hash': + '527224633117cd391a361b1781a8693ddf0bfa8cd5cd3e74f82501cb359c0339', + 'height': 326916, + 'hex': + '000100146071316651d85607ea31ba13bb7122a4de2e4b9f4db037827e4b9294f2de766422210340cd9f307400d42887f4e5231a14fa16749290f2775daa6bd7e403e28440d9024067415afb814e05bc8c54f27bbccddd784ff9b3d3512c981a0aa0c5d0eae184ceb4c1092810242e3b4088aadab05ba9b6e0ade3dfdd04b6ccb4426a481c21c905ffffffff0e556905000000000201204e00000000000017005114ee199ed68abcda4139c8439527080f3a6aee3bf2015d04690500000000170051147cf62b8a1314b059e25d06a441e02dac445d287200000000', + 'locktime': 0, + 'size': 219, + 'time': 1690527851, + 'txid': + '527224633117cd391a361b1781a8693ddf0bfa8cd5cd3e74f82501cb359c0339', + 'txidem': + '506d9a442a7c725a152c73e1328cf8a157db89070543728e200b46e3c3b4e2a6', + 'version': 0, + 'vin': [ + { + 'coinbase': null, + 'outpoint': + '76def294924b7e8237b04d9f4b2edea42271bb13ba31ea0756d8516631716014', + 'scriptSig': { + 'asm': + 'OP_PUSHBYTES_34 210340cd9f307400d42887f4e5231a14fa16749290f2775daa6bd7e403e28440d902 OP_PUSHBYTES_64 67415afb814e05bc8c54f27bbccddd784ff9b3d3512c981a0aa0c5d0eae184ceb4c1092810242e3b4088aadab05ba9b6e0ade3dfdd04b6ccb4426a481c21c905', + 'hex': + '22210340cd9f307400d42887f4e5231a14fa16749290f2775daa6bd7e403e28440d9024067415afb814e05bc8c54f27bbccddd784ff9b3d3512c981a0aa0c5d0eae184ceb4c1092810242e3b4088aadab05ba9b6e0ade3dfdd04b6ccb4426a481c21c905', + }, + 'sequence': 4294967295, + 'value': 907891.34, + 'value_coin': 907891.34, + 'value_satoshi': 90789134, + }, + ], + 'vout': [ + { + 'n': 0, + 'scriptPubKey': { + 'addresses': [], + 'argsHash': 'ee199ed68abcda4139c8439527080f3a6aee3bf2', + 'asm': + 'OP_0 OP_PUSHNUM_1 OP_PUSHBYTES_20 ee199ed68abcda4139c8439527080f3a6aee3bf2', + 'group': null, + 'groupAuthority': 0, + 'groupQuantity': null, + 'hex': '005114ee199ed68abcda4139c8439527080f3a6aee3bf2', + 'scriptHash': 'pay2pubkeytemplate', + 'token_id_hex': null, + 'type': 'scripttemplate', + }, + 'type': 1, + 'value': 200, + 'value_coin': 200, + 'value_satoshi': 20000, + }, + { + 'n': 1, + 'scriptPubKey': { + 'addresses': [], + 'argsHash': '7cf62b8a1314b059e25d06a441e02dac445d2872', + 'asm': + 'OP_0 OP_PUSHNUM_1 OP_PUSHBYTES_20 7cf62b8a1314b059e25d06a441e02dac445d2872', + 'group': null, + 'groupAuthority': 0, + 'groupQuantity': null, + 'hex': '0051147cf62b8a1314b059e25d06a441e02dac445d2872', + 'scriptHash': 'pay2pubkeytemplate', + 'token_id_hex': null, + 'type': 'scripttemplate', + }, + 'type': 1, + 'value': 907684.77, + 'value_coin': 907684.77, + 'value_satoshi': 90768477, + }, + ], + }, + 'dbAccountAddress': + 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'addressPrefix': 'Stacks', + 'decimals': 2, + 'token': { + 'id': 'Stacks--0', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'tokenIdOnNetwork': '', + 'symbol': 'NEX', + 'decimals': 2, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'impl': 'Stacks', + 'chainId': '0', + 'address': '', + 'source': '', + 'isNative': true, + }, + 'networkId': 'Stacks--0', + 'accountId': "hd-1--m/44'/29223'/0'", + }; + expect(buildDecodeTxFromTx(testData2)).toEqual({ + 'accountId': "hd-1--m/44'/29223'/0'", + 'actions': [ + { + 'direction': 'OUT', + 'tokenTransfer': { + 'amount': '907891.34', + 'amountValue': '907891.34', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'to': 'Stacks:nqtsq5g5acvea452hndyzwwggw2jwzq08f4wuwljenjtdvhc', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'createdAt': 1690527851000, + 'extraInfo': null, + 'isFinal': true, + 'networkId': 'Stacks--0', + 'nonce': 0, + 'outputActions': [ + { + 'direction': 'OUT', + 'tokenTransfer': { + 'amount': '200', + 'amountValue': '200', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'to': 'Stacks:nqtsq5g5acvea452hndyzwwggw2jwzq08f4wuwljenjtdvhc', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + { + 'direction': 'SELF', + 'tokenTransfer': { + 'amount': '907684.77', + 'amountValue': '907684.77', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'to': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'owner': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'signer': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'status': 'Confirmed', + 'totalFeeInNative': '6.57', + 'txid': + '527224633117cd391a361b1781a8693ddf0bfa8cd5cd3e74f82501cb359c0339', + 'updatedAt': 1690527851000, + }); + + const testData3 = { + 'tx': { + 'blockhash': + 'f45fb9167e49411aca54817b2e45399e91795553204742015ddfafb59c3f062f', + 'blocktime': 1690620486, + 'confirmations': 25, + 'fee': 6.57, + 'fee_satoshi': 657, + 'hash': + 'cfd56e4702dc445e1150c144ce1c0ff91aaedb2d3ec580d3c50df7d8bc6c0fbc', + 'height': 327691, + 'hex': + '0001000e2acf067dae90dfa42bba210f53f9956ad9ba2f62959be1b26daf97e6d220cb64222103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd408f88d1555f0ae0036eba870a7e966c98934dcfde60f571184ca560d8f2dae18ad0ef95e3f927da5fbf7ceba672f99d1d4380a078316556b0e04541d64b052bfcffffffff62830000000000000101d18000000000000017005114375f5e5be9ddf719d8635306663c7903b530dbab00000000', + 'locktime': 0, + 'size': 186, + 'time': 1690620486, + 'txid': + 'cfd56e4702dc445e1150c144ce1c0ff91aaedb2d3ec580d3c50df7d8bc6c0fbc', + 'txidem': + '3fe1e04cf624e06d11e6849acd3f6c028d3764f526e57235821d9b23f483fd98', + 'version': 0, + 'vin': [ + { + 'coinbase': null, + 'outpoint': + 'cb20d2e697af6db2e19b95622fbad96a95f9530f21ba2ba4df90ae7d06cf2a0e', + 'scriptSig': { + 'asm': + 'OP_PUSHBYTES_34 2103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd OP_PUSHBYTES_64 8f88d1555f0ae0036eba870a7e966c98934dcfde60f571184ca560d8f2dae18ad0ef95e3f927da5fbf7ceba672f99d1d4380a078316556b0e04541d64b052bfc', + 'hex': + '222103d1becb4798abb1d016be6889cbb0ba0b19c9f1d5e07961779c089af3c63984bd408f88d1555f0ae0036eba870a7e966c98934dcfde60f571184ca560d8f2dae18ad0ef95e3f927da5fbf7ceba672f99d1d4380a078316556b0e04541d64b052bfc', + }, + 'sequence': 4294967295, + 'value': 336.34, + 'value_coin': 336.34, + 'value_satoshi': 33634, + }, + ], + 'vout': [ + { + 'n': 0, + 'scriptPubKey': { + 'addresses': [], + 'argsHash': '375f5e5be9ddf719d8635306663c7903b530dbab', + 'asm': + 'OP_0 OP_PUSHNUM_1 OP_PUSHBYTES_20 375f5e5be9ddf719d8635306663c7903b530dbab', + 'group': null, + 'groupAuthority': 0, + 'groupQuantity': null, + 'hex': '005114375f5e5be9ddf719d8635306663c7903b530dbab', + 'scriptHash': 'pay2pubkeytemplate', + 'token_id_hex': null, + 'type': 'scripttemplate', + }, + 'type': 1, + 'value': 329.77, + 'value_coin': 329.77, + 'value_satoshi': 32977, + }, + ], + }, + 'dbAccountAddress': + 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'addressPrefix': 'Stacks', + 'decimals': 2, + 'token': { + 'id': 'Stacks--0', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'tokenIdOnNetwork': '', + 'symbol': 'NEX', + 'decimals': 2, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'impl': 'Stacks', + 'chainId': '0', + 'address': '', + 'source': '', + 'isNative': true, + }, + 'networkId': 'Stacks--0', + 'accountId': "hd-1--m/44'/29223'/1'", + }; + expect(buildDecodeTxFromTx(testData3)).toEqual({ + 'accountId': "hd-1--m/44'/29223'/1'", + 'actions': [ + { + 'direction': 'SELF', + 'tokenTransfer': { + 'amount': '336.34', + 'amountValue': '336.34', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'to': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'createdAt': 1690620486000, + 'extraInfo': null, + 'isFinal': true, + 'networkId': 'Stacks--0', + 'nonce': 0, + 'outputActions': [ + { + 'direction': 'SELF', + 'tokenTransfer': { + 'amount': '329.77', + 'amountValue': '329.77', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'to': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'owner': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'signer': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'status': 'Confirmed', + 'totalFeeInNative': '6.57', + 'txid': + 'cfd56e4702dc445e1150c144ce1c0ff91aaedb2d3ec580d3c50df7d8bc6c0fbc', + 'updatedAt': 1690620486000, + }); + + const testData4 = { + 'tx': { + 'blockhash': + 'ef5c6db9a60f12476e3158753dc46477a8615bee32b11e9aa2223a15b6366647', + 'blocktime': 1690440535, + 'confirmations': 1541, + 'fee': 6.57, + 'fee_satoshi': 657, + 'hash': + 'ec435f39d0d2ad77e0a0046c26d9586deeb185e2b7e7146275ac7a5951939d7b', + 'height': 326194, + 'hex': + '00010034aefaf89ad98e8029c6f5f0984dd3f26f3b6a47a4873d94044e51bb937fb7ae6422210340cd9f307400d42887f4e5231a14fa16749290f2775daa6bd7e403e28440d90240431834e9f5de207aab26ddba0cd0945a8bf61e848fcb737553d43455eb3000981315c844660575286be056e4b4b1cad2d2f377ee134d132433e872d45000de4dffffffff78c36905000000000201e80300000000000017005114375f5e5be9ddf719d8635306663c7903b530dbab01ffbc690500000000170051147cf62b8a1314b059e25d06a441e02dac445d287200000000', + 'locktime': 0, + 'size': 219, + 'time': 1690440535, + 'txid': + 'ec435f39d0d2ad77e0a0046c26d9586deeb185e2b7e7146275ac7a5951939d7b', + 'txidem': + '4439c235e5b63d4eb662d686dfbfa28f9bc6c0922f7f1aecf1b01ffe4225b880', + 'version': 0, + 'vin': [ + { + 'coinbase': null, + 'outpoint': + 'aeb77f93bb514e04943d87a4476a3b6ff2d34d98f0f5c629808ed99af8faae34', + 'scriptSig': { + 'asm': + 'OP_PUSHBYTES_34 210340cd9f307400d42887f4e5231a14fa16749290f2775daa6bd7e403e28440d902 OP_PUSHBYTES_64 431834e9f5de207aab26ddba0cd0945a8bf61e848fcb737553d43455eb3000981315c844660575286be056e4b4b1cad2d2f377ee134d132433e872d45000de4d', + 'hex': + '22210340cd9f307400d42887f4e5231a14fa16749290f2775daa6bd7e403e28440d90240431834e9f5de207aab26ddba0cd0945a8bf61e848fcb737553d43455eb3000981315c844660575286be056e4b4b1cad2d2f377ee134d132433e872d45000de4d', + }, + 'sequence': 4294967295, + 'value': 908174, + 'value_coin': 908174, + 'value_satoshi': 90817400, + }, + ], + 'vout': [ + { + 'n': 0, + 'scriptPubKey': { + 'addresses': [], + 'argsHash': '375f5e5be9ddf719d8635306663c7903b530dbab', + 'asm': + 'OP_0 OP_PUSHNUM_1 OP_PUSHBYTES_20 375f5e5be9ddf719d8635306663c7903b530dbab', + 'group': null, + 'groupAuthority': 0, + 'groupQuantity': null, + 'hex': '005114375f5e5be9ddf719d8635306663c7903b530dbab', + 'scriptHash': 'pay2pubkeytemplate', + 'token_id_hex': null, + 'type': 'scripttemplate', + }, + 'type': 1, + 'value': 10, + 'value_coin': 10, + 'value_satoshi': 1000, + }, + { + 'n': 1, + 'scriptPubKey': { + 'addresses': [], + 'argsHash': '7cf62b8a1314b059e25d06a441e02dac445d2872', + 'asm': + 'OP_0 OP_PUSHNUM_1 OP_PUSHBYTES_20 7cf62b8a1314b059e25d06a441e02dac445d2872', + 'group': null, + 'groupAuthority': 0, + 'groupQuantity': null, + 'hex': '0051147cf62b8a1314b059e25d06a441e02dac445d2872', + 'scriptHash': 'pay2pubkeytemplate', + 'token_id_hex': null, + 'type': 'scripttemplate', + }, + 'type': 1, + 'value': 908157.43, + 'value_coin': 908157.43, + 'value_satoshi': 90815743, + }, + ], + }, + 'dbAccountAddress': + 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'addressPrefix': 'Stacks', + 'decimals': 2, + 'token': { + 'id': 'Stacks--0', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'tokenIdOnNetwork': '', + 'symbol': 'NEX', + 'decimals': 2, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'impl': 'Stacks', + 'chainId': '0', + 'address': '', + 'source': '', + 'isNative': true, + }, + 'networkId': 'Stacks--0', + 'accountId': "hd-1--m/44'/29223'/1'", + }; + expect(buildDecodeTxFromTx(testData4)).toEqual({ + 'accountId': "hd-1--m/44'/29223'/1'", + 'actions': [ + { + 'direction': 'IN', + 'tokenTransfer': { + 'amount': '908174', + 'amountValue': '908174', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'to': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'createdAt': 1690440535000, + 'extraInfo': null, + 'isFinal': true, + 'networkId': 'Stacks--0', + 'nonce': 0, + 'outputActions': [ + { + 'direction': 'IN', + 'tokenTransfer': { + 'amount': '10', + 'amountValue': '10', + 'extraInfo': null, + 'from': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'to': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'tokenInfo': { + 'address': '', + 'chainId': '0', + 'decimals': 2, + 'id': 'Stacks--0', + 'impl': 'Stacks', + 'isNative': true, + 'logoURI': 'https://onekey-asset.com/assets/Stacks/Stacks.png', + 'name': 'NEX', + 'networkId': 'Stacks--0', + 'source': '', + 'symbol': 'NEX', + 'tokenIdOnNetwork': '', + }, + }, + 'type': 'TOKEN_TRANSFER', + }, + ], + 'owner': 'Stacks:nqtsq5g5xa04uklfmhm3nkrr2vrxv0reqw6npkatl7dx7fjz', + 'signer': 'Stacks:nqtsq5g50nmzhzsnzjc9ncjaq6jyrcpd43z962rjffhrvpe0', + 'status': 'Confirmed', + 'totalFeeInNative': '6.57', + 'txid': + 'ec435f39d0d2ad77e0a0046c26d9586deeb185e2b7e7146275ac7a5951939d7b', + 'updatedAt': 1690440535000, + }); + }); +}); + +export {}; diff --git a/packages/engine/src/vaults/impl/stacks/utils.ts b/packages/engine/src/vaults/impl/stacks/utils.ts new file mode 100644 index 00000000000..8e73ea208cb --- /dev/null +++ b/packages/engine/src/vaults/impl/stacks/utils.ts @@ -0,0 +1,572 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import BigNumber from 'bignumber.js'; +import BN from 'bn.js'; + +import { InvalidAddress } from '../../../errors'; +import { hash160, sha256 } from '../../../secret/hash'; +import { + type IDecodedTx, + IDecodedTxActionType, + IDecodedTxDirection, + IDecodedTxStatus, + type ISignedTxPro, + type IUnsignedTxPro, +} from '../../types'; + +import { + bufferToScripChunk, + decode, + decodeScriptBufferToScriptChunks, + encode, + getScriptBufferFromScriptTemplateOut, + isPublicKeyTemplateIn, + isPublicKeyTemplateOut, + reverseBuffer, + scriptChunksToBuffer, + sign, + varintBufNum, + writeInt32LE, + writeUInt32LE, + writeUInt64LEBN, + writeUInt8, +} from './sdk'; + +import type { Signer } from '../../../proxy'; +import type { Token } from '../../../types/token'; + + +export function verifyStacksAddress(address: string) { + try { + decode(address); + return { + isValid: true, + normalizedAddress: address, + }; + } catch (error) { + return { + isValid: false, + }; + } +} + +const NETWORKS = { + mainnet: { + name: 'mainnet', + alias: 'mainnet', + prefix: 'stacks', + pubkeyhash: 0x19, + privatekey: 0x23, + scripthash: 0x44, + xpubkey: 0x42696720, + xprivkey: 0x426c6b73, + networkMagic: 0x72271221, + }, + testnet: { + name: 'stackstestnet', + prefix: 'stackstestnet', + pubkeyhash: 0x6f, + privatekey: 0xef, + scripthash: 0xc4, + xpubkey: 0x043587cf, + xprivkey: 0x04358394, + networkMagic: 0xf4e5f3f4, + }, +}; + +export function getStacksNetworkInfo(chanid: string): { + name: string; + prefix: string; + pubkeyhash: number; + privatekey: number; + scripthash: number; + xpubkey: number; + xprivkey: number; + networkMagic: number; +} { + return chanid === 'testnet' ? NETWORKS.testnet : NETWORKS.mainnet; +} + +export function getStacksPrefix(chanid: string): string { + return getStacksNetworkInfo(chanid).prefix; +} + +function convertScriptToPushBuffer(key: Buffer): Buffer { + const templateChunk = bufferToScripChunk(key); + return scriptChunksToBuffer([templateChunk]); +} + +export function verifyStacksAddressPrefix(address: string) { + return address.startsWith('stacks:') || address.startsWith('stackstest:'); +} + +export function sha256sha256(buffer: Buffer): Buffer { + return sha256(sha256(buffer)); +} + +const MAXINT = 0xffffffff; +const DEFAULT_SEQNUMBER = MAXINT; +const FEE_PER_KB = 1000 * 3; +const CHANGE_OUTPUT_MAX_SIZE = 1 + 8 + 1 + 23; + +export function estimateSize(encodedTx: IEncodedTxStacks) { + let estimatedSize = 4 + 1; // locktime + version + estimatedSize += encodedTx.inputs.length < 253 ? 1 : 3; + encodedTx.inputs.forEach(() => { + // type + outpoint + scriptlen + script + sequence + amount + estimatedSize += 1 + 32 + 1 + 100 + 4 + 8; + }); + encodedTx.outputs.forEach((output) => { + const bfr = getScriptBufferFromScriptTemplateOut(output.address); + estimatedSize += convertScriptToPushBuffer(bfr).length + 1 + 8 + 1; + }); + return estimatedSize; +} + +export function estimateFee( + encodedTx: IEncodedTxStacks, + feeRate = FEE_PER_KB / 1000, +): number { + const size = estimateSize(encodedTx); + const feeWithChange = Math.ceil( + size * feeRate + CHANGE_OUTPUT_MAX_SIZE * feeRate, + ); + return feeWithChange; +} + +export function buildInputScriptBuffer(publicKey: Buffer, signature: Buffer) { + const scriptBuffer = scriptChunksToBuffer([ + bufferToScripChunk(scriptChunksToBuffer([bufferToScripChunk(publicKey)])), + bufferToScripChunk(signature), + ]); + return scriptBuffer; +} + +function buildInputIdemWithSignature({ + sigtype, + prevTxId, + scriptBuffer, + sequenceNumber, + amount, +}: IStacksInputSignature): Buffer { + return Buffer.concat([ + writeUInt8(sigtype), + reverseBuffer(prevTxId), + varintBufNum(scriptBuffer.length), + scriptBuffer, + writeUInt32LE(sequenceNumber), + writeUInt64LEBN(amount), + ]); +} + +function buildInputIdem({ + sigtype, + prevTxId, + sequenceNumber, + amount, +}: IStacksInputSignature): Buffer { + return Buffer.concat([ + writeUInt8(sigtype), + reverseBuffer(prevTxId), + writeUInt32LE(sequenceNumber), + writeUInt64LEBN(amount), + ]); +} + +function buildOutputIdem({ + outType, + satoshi, + scriptBuffer, +}: IStacksOutputSignature): Buffer { + return Buffer.concat([ + writeUInt8(outType), + writeUInt64LEBN(satoshi), + varintBufNum(scriptBuffer.length), + scriptBuffer, + ]); +} + +export function buildRawTx( + inputSignatures: IStacksInputSignature[], + outputSignatures: IStacksOutputSignature[], + nLockTime = 0, + isSignature = false, +) { + const inputIdem = Buffer.concat( + inputSignatures.map( + isSignature ? buildInputIdemWithSignature : buildInputIdem, + ), + ); + const outputIdem = Buffer.concat(outputSignatures.map(buildOutputIdem)); + const idemBuffer = Buffer.concat([ + // Transaction version + writeUInt8(0), + varintBufNum(inputSignatures.length), + inputIdem, + varintBufNum(outputSignatures.length), + outputIdem, + writeUInt32LE(nLockTime), + ]); + return idemBuffer; +} + +export function buildTxid( + inputSignatures: IStacksInputSignature[], + outputSignatures: IStacksOutputSignature[], + nLockTime = 0, +): string { + // build input Idem buffer + // const inputIdem = Buffer.concat(inputSignatures.map(buildInputIdem)); + // const outputIdem = Buffer.concat(outputSignatures.map(buildOutputIdem)); + + // const idemBuffer = Buffer.concat([ + // // Transaction version + // writeUInt8(0), + // varintBufNum(inputSignatures.length), + // inputIdem, + // varintBufNum(outputSignatures.length), + // outputIdem, + // writeUInt32LE(nLockTime), + // ]); + const idemBuffer = buildRawTx(inputSignatures, outputSignatures, nLockTime); + const idemHash = sha256sha256(idemBuffer); + + const satisfierBuffer = Buffer.concat([ + writeInt32LE(inputSignatures.length), + Buffer.concat( + inputSignatures.map(({ scriptBuffer }) => + Buffer.concat([scriptBuffer, writeUInt8(Opcode.OP_INVALIDOPCODE)]), + ), + ), + ]); + + const satisfierHash = sha256sha256(satisfierBuffer); + const txIdHash = reverseBuffer( + sha256sha256(Buffer.concat([idemHash, satisfierHash])), + ).toString('hex'); + return txIdHash; +} + +function buildSignatures(encodedTx: IEncodedTxStacks, dbAccountAddress: string) { + const { inputs, outputs, gas } = encodedTx; + const newOutputs = outputs.slice(); + const inputAmount: BN = inputs.reduce( + (acc, input) => acc.add(new BN(input.satoshis)), + new BN(0), + ); + const outputAmount: BN = newOutputs.reduce( + (acc, output) => acc.add(new BN(output.satoshis)), + new BN(0), + ); + + const fee = new BN(gas || estimateFee(encodedTx)); + const available = inputAmount.sub(fee); + if (available.lt(new BN(0))) { + console.error(inputAmount.toString(), fee.toString()); + throw new Error( + `Available balance cannot be less than 0, inputAmount: ${inputAmount.toString()}, dust: ${fee.toString()}`, + ); + } + + if (available.gt(outputAmount)) { + // change address + newOutputs.push({ + address: dbAccountAddress, + satoshis: available.sub(outputAmount).toString(), + outType: 1, + }); + } else { + newOutputs[0].satoshis = available.gt(outputAmount) + ? newOutputs[0].satoshis + : available.toString(); + } + + const outputSignatures = newOutputs.map((output) => ({ + address: output.address, + satoshi: new BN(output.satoshis), + outType: 1, + scriptBuffer: getScriptBufferFromScriptTemplateOut(output.address), + })); + + return { + outputSignatures, + inputSignatures: inputs.map((input, index) => ({ + prevTxId: input.txId, + outputIndex: input.outputIndex, + inputIndex: index, + sequenceNumber: input.sequenceNumber || DEFAULT_SEQNUMBER, + amount: new BN(input.satoshis), + sigtype: StacksSignature.SIGHASH_STACKS_ALL, + })), + }; +} + +export function buildSignatureBuffer( + encodedTx: IEncodedTxStacks, + dbAccountAddress: string, +) { + const { inputs } = encodedTx; + const prevoutsBuffer = Buffer.concat( + inputs.map((input) => + Buffer.concat([ + writeUInt8(0), + reverseBuffer(Buffer.from(input.txId, 'hex')), + ]), + ), + ); + const prevoutsHash = sha256sha256(prevoutsBuffer); + + const sequenceNumberBuffer = Buffer.concat( + inputs.map((input) => + writeUInt32LE(input.sequenceNumber || DEFAULT_SEQNUMBER), + ), + ); + const sequenceNumberHash = sha256sha256(sequenceNumberBuffer); + + const inputAmountBuffer = Buffer.concat( + inputs.map((input) => writeUInt64LEBN(new BN(input.satoshis))), + ); + const inputAmountHash = sha256sha256(inputAmountBuffer); + + const { outputSignatures, inputSignatures } = buildSignatures( + encodedTx, + dbAccountAddress, + ); + + const outputBuffer = Buffer.concat( + // scriptBuffer + outputSignatures.map((output) => + Buffer.concat([ + // 1: p2pkt outType + writeUInt8(output.outType), + writeUInt64LEBN(new BN(output.satoshi)), + varintBufNum(output.scriptBuffer.length), + output.scriptBuffer, + ]), + ), + ); + const outputHash = sha256sha256(outputBuffer); + const subScriptBuffer = scriptChunksToBuffer([ + { + opcodenum: Opcode.OP_FROMALTSTACK, + }, + { + opcodenum: Opcode.OP_CHECKSIGVERIFY, + }, + ]); + const signatureBuffer = Buffer.concat([ + // transaction.version + writeUInt8(0), + prevoutsHash, + inputAmountHash, + sequenceNumberHash, + varintBufNum(subScriptBuffer.length), + subScriptBuffer, + outputHash, + // transaction.nLockTime + writeUInt32LE(0), + // sighashType + writeUInt8(0), + ]); + return { + signatureBuffer, + inputSignatures, + outputSignatures, + }; +} + +// signed by 'schnorr' +export async function signEncodedTx( + unsignedTx: IUnsignedTxPro, + signer: Signer, + dbAccountAddress: string, +): Promise { + const encodedTx = unsignedTx.encodedTx as IEncodedTxStacks; + const privateKey = await signer.getPrvkey(); + const publicKey = await signer.getPubkey(true); + const { + outputSignatures, + inputSignatures: inputSigs, + signatureBuffer, + } = buildSignatureBuffer(encodedTx, dbAccountAddress); + const ret = sha256sha256(signatureBuffer); + const signature = sign(privateKey, ret); + const inputSignatures: IStacksInputSignature[] = inputSigs.map((inputSig) => ({ + ...inputSig, + publicKey, + signature, + scriptBuffer: buildInputScriptBuffer(publicKey, signature), + })); + + const txid = buildTxid(inputSignatures, outputSignatures); + const rawTx = buildRawTx(inputSignatures, outputSignatures, 0, true); + return { + txid, + rawTx: rawTx.toString('hex'), + signature: signature.toString('hex'), + digest: ret.toString('hex'), + encodedTx, + }; +} + +export function decodeScriptBufferToStacksAddress( + buffer: Buffer, + prefix: string, +) { + const chunks = decodeScriptBufferToScriptChunks(buffer); + // lib/script/script.js 1364L + if (isPublicKeyTemplateIn(chunks) && chunks[0].buf) { + const constraintHash = hash160(chunks[0].buf); + const scriptTemplate = scriptChunksToBuffer([ + { + opcodenum: Opcode.OP_FALSE, + }, + { + opcodenum: Opcode.OP_1, + }, + bufferToScripChunk(constraintHash), + ]); + const hashBuffer = convertScriptToPushBuffer(scriptTemplate); + return encode(prefix, StacksAddressType.PayToScriptTemplate, hashBuffer); + } + if (isPublicKeyTemplateOut(chunks) && chunks[2].buf) { + const scriptTemplate = scriptChunksToBuffer([ + { + opcodenum: Opcode.OP_FALSE, + }, + { + opcodenum: Opcode.OP_1, + }, + bufferToScripChunk(chunks[2].buf), + ]); + const hashBuffer = convertScriptToPushBuffer(scriptTemplate); + return encode(prefix, StacksAddressType.PayToScriptTemplate, hashBuffer); + } + return ''; +} + +const calDirection = ( + account: string, + isFromSelf: boolean, + isToSelf: boolean, +) => { + if (isFromSelf && isToSelf) { + return IDecodedTxDirection.SELF; + } + + if (isFromSelf) { + return IDecodedTxDirection.OUT; + } + + if (isToSelf) { + return IDecodedTxDirection.IN; + } + + return IDecodedTxDirection.OTHER; +}; + +export function buildDecodeTxFromTx({ + tx, + dbAccountAddress, + addressPrefix, + decimals, + token, + networkId, + accountId, +}: { + tx: IStacksTransaction; + dbAccountAddress: string; + addressPrefix: string; + decimals: number; + token: Token; + networkId: string; + accountId: string; +}) { + const fromAddresses = tx.vin.map((vin) => + decodeScriptBufferToStacksAddress( + Buffer.from(vin.scriptSig.hex, 'hex'), + addressPrefix, + ), + ); + const toAddresses = tx.vout.map((vout) => + decodeScriptBufferToStacksAddress( + Buffer.from(vout.scriptPubKey.hex, 'hex'), + addressPrefix, + ), + ); + + const fromAddress = !fromAddresses.includes(dbAccountAddress) + ? fromAddresses[0] + : dbAccountAddress; + const toAddress = fromAddresses.includes(dbAccountAddress) + ? toAddresses[0] + : dbAccountAddress; + + const decodedTx: IDecodedTx = { + txid: tx.txid, + owner: dbAccountAddress, + signer: fromAddress, + nonce: 0, + actions: tx.vin.map((vin, index) => { + const amount = new BigNumber(vin.value_satoshi).shiftedBy( + -token.decimals, + ); + const from = fromAddresses[index]; + return { + type: IDecodedTxActionType.TOKEN_TRANSFER, + direction: calDirection( + from, + from === dbAccountAddress, + toAddress === dbAccountAddress, + ), + tokenTransfer: { + tokenInfo: token, + from, + to: toAddress, + amount: amount.toFixed(), + amountValue: amount.toFixed(), + extraInfo: null, + }, + }; + }), + outputActions: tx.vout + .map((vout, index) => { + const amount = new BigNumber(vout.value_satoshi).shiftedBy( + -token.decimals, + ); + const to = toAddresses[index]; + if (fromAddress !== dbAccountAddress && to !== dbAccountAddress) { + return false; + } + return { + type: IDecodedTxActionType.TOKEN_TRANSFER, + direction: calDirection( + to, + fromAddress === dbAccountAddress, + to === dbAccountAddress, + ), + tokenTransfer: { + tokenInfo: token, + from: fromAddress, + to, + amount: amount.toFixed(), + amountValue: amount.toFixed(), + extraInfo: null, + }, + }; + }) + .filter(Boolean), + status: tx.confirmations + ? IDecodedTxStatus.Confirmed + : IDecodedTxStatus.Pending, + networkId, + accountId, + extraInfo: null, + totalFeeInNative: new BigNumber(tx.fee_satoshi) + .shiftedBy(-decimals) + .toFixed(), + }; + decodedTx.updatedAt = tx.time ? tx.time * 1000 : Date.now(); + decodedTx.createdAt = decodedTx.updatedAt; + decodedTx.isFinal = decodedTx.status === IDecodedTxStatus.Confirmed; + return decodedTx; +} diff --git a/packages/engine/src/vaults/types.ts b/packages/engine/src/vaults/types.ts index d5150399492..fcd4cb707f5 100644 --- a/packages/engine/src/vaults/types.ts +++ b/packages/engine/src/vaults/types.ts @@ -276,6 +276,7 @@ export type IEncodedTx = | IEncodedTxXmr | IEncodedTxKaspa | IEncodedTxNexa + | IEncodedTxStacks | IEncodedTxLightning; export type INativeTx = diff --git a/packages/shared/src/engine/engineConsts.ts b/packages/shared/src/engine/engineConsts.ts index 1ba5f7c9845..d0d7e133d55 100644 --- a/packages/shared/src/engine/engineConsts.ts +++ b/packages/shared/src/engine/engineConsts.ts @@ -71,6 +71,10 @@ const COINTYPE_KASPA = '111111'; const IMPL_NEXA = 'nexa'; const COINTYPE_NEXA = '29223'; + +const IMPL_STACKS = 'stacks'; +const COINTYPE_STACKS = '5757'; + const IMPL_LIGHTNING = 'lightning'; // To determine the coin type, we first assign numerical values to each letter based on their position in the alphabet. // For example, "L" is assigned a value of 12, "I" is assigned a value of 9, "G" is assigned a value of 7, and so on. @@ -113,6 +117,7 @@ const SUPPORTED_IMPLS = new Set([ IMPL_XMR, IMPL_KASPA, IMPL_NEXA, + IMPL_STACKS, IMPL_LIGHTNING, IMPL_LIGHTNING_TESTNET, IMPL_ALLNETWORKS, @@ -143,6 +148,7 @@ const PRODUCTION_IMPLS = new Set([ IMPL_LIGHTNING, IMPL_LIGHTNING_TESTNET, IMPL_NEXA, + IMPL_STACKS, IMPL_ALLNETWORKS, ]); @@ -194,6 +200,7 @@ export { COINTYPE_NEAR, COINTYPE_NEXA, COINTYPE_SOL, + COINTYPE_STACKS, COINTYPE_STC, COINTYPE_SUI, COINTYPE_TBTC, diff --git a/yarn.lock b/yarn.lock index df6973fb6ea..0ba34f9edc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5904,7 +5904,7 @@ __metadata: languageName: node linkType: hard -"@noble/secp256k1@npm:^1.6.3, @noble/secp256k1@npm:^1.7.1": +"@noble/secp256k1@npm:1.7.1, @noble/secp256k1@npm:^1.6.3, @noble/secp256k1@npm:^1.7.1": version: 1.7.1 resolution: "@noble/secp256k1@npm:1.7.1" checksum: d2301f1f7690368d8409a3152450458f27e54df47e3f917292de3de82c298770890c2de7c967d237eff9c95b70af485389a9695f73eb05a43e2bd562d18b18cb @@ -6519,6 +6519,10 @@ __metadata: "@onekeyfe/onekey-cross-webview": 1.1.48 "@open-wc/webpack-import-meta-loader": ^0.4.7 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.10 + "@stacks/blockchain-api-client": ^7.3.4 + "@stacks/encryption": ^6.10.0 + "@stacks/network": ^6.10.0 + "@stacks/transactions": ^6.10.0 "@starcoin/starcoin": 2.1.5 "@types/babel__preset-env": ^7 "@types/chrome": ^0.0.161 @@ -9662,6 +9666,79 @@ __metadata: languageName: node linkType: hard +"@stacks/blockchain-api-client@npm:^7.3.4": + version: 7.3.4 + resolution: "@stacks/blockchain-api-client@npm:7.3.4" + dependencies: + "@stacks/stacks-blockchain-api-types": "*" + "@types/ws": 7.4.7 + cross-fetch: 3.1.5 + eventemitter3: 4.0.7 + jsonrpc-lite: 2.2.0 + socket.io-client: 4.6.1 + ws: 7.5.6 + checksum: 6d2db64c48b46129862981b9f3d2e359d92d4c5f01416255999eeb514cd0dc267f12afc036a227dc6c686bfb3e30ed1bd00e10714ff21996bf972a61271c78ea + languageName: node + linkType: hard + +"@stacks/common@npm:^6.10.0": + version: 6.10.0 + resolution: "@stacks/common@npm:6.10.0" + dependencies: + "@types/bn.js": ^5.1.0 + "@types/node": ^18.0.4 + checksum: fc83a26b1f3857b7e19eaf5e0db67584a47ca5432a50958b4dacbcc4e9850249cb088b4d448f636a45f772a3407c064fa6982dbc63a39479bf1270ba76b97780 + languageName: node + linkType: hard + +"@stacks/encryption@npm:^6.10.0": + version: 6.10.0 + resolution: "@stacks/encryption@npm:6.10.0" + dependencies: + "@noble/hashes": 1.1.5 + "@noble/secp256k1": 1.7.1 + "@scure/bip39": 1.1.0 + "@stacks/common": ^6.10.0 + "@types/node": ^18.0.4 + base64-js: ^1.5.1 + bs58: ^5.0.0 + ripemd160-min: ^0.0.6 + varuint-bitcoin: ^1.1.2 + checksum: 1b71fede9fa32d60280204486f7a76dd8d215ae11eef4040bc06bedc4ff6c9251917056e2d411a9b9a3066d98a3bcf3e222dab45ad1330bcac95b40308a778c0 + languageName: node + linkType: hard + +"@stacks/network@npm:^6.10.0": + version: 6.10.0 + resolution: "@stacks/network@npm:6.10.0" + dependencies: + "@stacks/common": ^6.10.0 + cross-fetch: ^3.1.5 + checksum: 960fa88a66fad36c3502d86db603dd429e9f004a8e35cf02daf10adf6cac553c2f97c3aa70113c0b0639b8be602b851a243e4aa37f8a145c75543467a580cd28 + languageName: node + linkType: hard + +"@stacks/stacks-blockchain-api-types@npm:*": + version: 7.3.4 + resolution: "@stacks/stacks-blockchain-api-types@npm:7.3.4" + checksum: 3eab8092d83e096061e4f6099b81f7e52b95b9a70839d7d4084472ecca8298d1e0c245baed88f5ae0e1091e4a553e0628a841a3673f5a89c34fcfd1873d403c2 + languageName: node + linkType: hard + +"@stacks/transactions@npm:^6.10.0": + version: 6.10.0 + resolution: "@stacks/transactions@npm:6.10.0" + dependencies: + "@noble/hashes": 1.1.5 + "@noble/secp256k1": 1.7.1 + "@stacks/common": ^6.10.0 + "@stacks/network": ^6.10.0 + c32check: ^2.0.0 + lodash.clonedeep: ^4.5.0 + checksum: f1e69afa2aeda8d3f294a14a76a1a3fc9d2506a5357dcbeb07f2f28c331d98396c3d43899380d8399a19e59a613855af25731f79e836a03781069d5776322f39 + languageName: node + linkType: hard + "@starcoin/starcoin@npm:2.1.5": version: 2.1.5 resolution: "@starcoin/starcoin@npm:2.1.5" @@ -10412,6 +10489,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.0.4": + version: 18.19.3 + resolution: "@types/node@npm:18.19.3" + dependencies: + undici-types: ~5.26.4 + checksum: 58c4fa45a78fcec75c78182a4b266395905957633654eb0311c5f9c30ac15c179ea2287ab1af034e46c2db7bb0589ef0000ee64c1de8f568a0aad29eaadb100c + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -10684,7 +10770,7 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^7.4.4": +"@types/ws@npm:7.4.7, @types/ws@npm:^7.4.4": version: 7.4.7 resolution: "@types/ws@npm:7.4.7" dependencies: @@ -14563,6 +14649,16 @@ __metadata: languageName: node linkType: hard +"c32check@npm:^2.0.0": + version: 2.0.0 + resolution: "c32check@npm:2.0.0" + dependencies: + "@noble/hashes": ^1.1.2 + base-x: ^4.0.0 + checksum: af555f5d5cb14780936ea2f055d0013f57046200483c53b992e64ce8b2ef7041f66cd81e873b2a2f5bb5e863033a9f4c3877e254e8d6db9a9a55cd9d1c61d9b2 + languageName: node + linkType: hard + "cacache@npm:^15.0.5, cacache@npm:^15.3.0": version: 15.3.0 resolution: "cacache@npm:15.3.0" @@ -24474,6 +24570,13 @@ __metadata: languageName: node linkType: hard +"jsonrpc-lite@npm:2.2.0": + version: 2.2.0 + resolution: "jsonrpc-lite@npm:2.2.0" + checksum: 3062101d3c93401d176c1c24b90e0feebdd063546f8ed89c299531dd792c4d37c6766666d160efb83b94f17f7e2deed4346cdd9124b99581ed4620779e8733bb + languageName: node + linkType: hard + "jsonschema@npm:^1.4.1": version: 1.4.1 resolution: "jsonschema@npm:1.4.1" @@ -31433,7 +31536,7 @@ __metadata: languageName: node linkType: hard -"ripemd160-min@npm:0.0.6": +"ripemd160-min@npm:0.0.6, ripemd160-min@npm:^0.0.6": version: 0.0.6 resolution: "ripemd160-min@npm:0.0.6" checksum: 3253fec273aee407e736df0baf69f90c65f56573d6fc537532041112e7c09a2f665ee2e618ef4a88eb494923d36614322eac26ddf35a504fcfedb422fd414c75 @@ -34621,6 +34724,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 3192ef6f3fd5df652f2dc1cd782b49d6ff14dc98e5dced492aa8a8c65425227da5da6aafe22523c67f035a272c599bb89cfe803c1db6311e44bed3042fc25487 + languageName: node + linkType: hard + "unfetch@npm:^4.2.0": version: 4.2.0 resolution: "unfetch@npm:4.2.0" From 8305feb04d643d2c4212e8f283a70bce91c76aba Mon Sep 17 00:00:00 2001 From: friedger Date: Fri, 15 Dec 2023 10:00:45 +0100 Subject: [PATCH 2/4] chore: add stacks icon --- packages/components/src/Icon/Icons.ext-bg.tsx | 1 + packages/components/src/Icon/react/illus/Stacks.tsx | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 packages/components/src/Icon/react/illus/Stacks.tsx diff --git a/packages/components/src/Icon/Icons.ext-bg.tsx b/packages/components/src/Icon/Icons.ext-bg.tsx index 093295c32b0..6c7aaff1184 100644 --- a/packages/components/src/Icon/Icons.ext-bg.tsx +++ b/packages/components/src/Icon/Icons.ext-bg.tsx @@ -70,6 +70,7 @@ const icons = { SecretNetworkIllus: () => null, SolanaIllus: () => null, StarBadgeIllus: () => null, + Stacks: () => null, StarcoinIllus: () => null, SuiIllus: () => null, TerraIllus: () => null, diff --git a/packages/components/src/Icon/react/illus/Stacks.tsx b/packages/components/src/Icon/react/illus/Stacks.tsx new file mode 100644 index 00000000000..a940a4cdb3f --- /dev/null +++ b/packages/components/src/Icon/react/illus/Stacks.tsx @@ -0,0 +1,11 @@ +import Svg, { SvgProps, Path, Circle } from 'react-native-svg'; +const SvgStacks = (props: SvgProps) => ( + + + + +); +export default SvgStacks; From 3f0feec058e330b39fb0ca102ad73f353b50cdce Mon Sep 17 00:00:00 2001 From: friedger Date: Fri, 22 Mar 2024 11:31:15 +0100 Subject: [PATCH 3/4] chore: update stacks keyring (WIP) --- .../impl/stacks/@tests/stacksMockData.ts | 66 +++--- .../impl/stacks/keyring/KeyringHardware.ts | 43 ++-- .../impl/stacks/keyring/KeyringHd.test.ts | 14 +- .../vaults/impl/stacks/keyring/KeyringHd.ts | 6 +- .../engine/src/vaults/impl/stacks/sdk/sign.ts | 196 ++---------------- .../engine/src/vaults/impl/stacks/utils.ts | 60 ++---- yarn.lock | 56 ++--- 7 files changed, 116 insertions(+), 325 deletions(-) diff --git a/packages/engine/src/vaults/impl/stacks/@tests/stacksMockData.ts b/packages/engine/src/vaults/impl/stacks/@tests/stacksMockData.ts index 663e52db432..fe85da059fc 100644 --- a/packages/engine/src/vaults/impl/stacks/@tests/stacksMockData.ts +++ b/packages/engine/src/vaults/impl/stacks/@tests/stacksMockData.ts @@ -7,23 +7,23 @@ import type { DBNetwork } from '../../../../types/network'; // indexedDB -> networks const network: DBNetwork = { balance2FeeDecimals: 0, - decimals: 2, + decimals: 6, enabled: true, - feeDecimals: 2, - feeSymbol: 'TNEX', - id: 'nexa--testnet', - impl: 'nexa', - logoURI: 'https://onekey-asset.com/assets/nexa/nexa.png', - name: 'Nexa Testnet', + feeDecimals: 6, + feeSymbol: 'STX', + id: 'stacks--testnet', + impl: 'stacks', + logoURI: 'https://assets.stacks.co/Logos/Stacks%20Logo%20png.png', + name: 'Stacks Testnet', position: 33, - rpcURL: 'wss://testnet-explorer.nexa.org:30004/nexa_ws', - symbol: 'NEXA', + rpcURL: 'wss://api.testnet.hiro.so', + symbol: 'STX', }; const hdAccount1: IUnitTestMockAccount = { // indexedDB -> accounts account: { - 'name': 'NEXA #1', + 'name': 'Stacks #1', 'address': '02e3027885ce1ed1d21300158ce8f60649e280e2a8f746e9cea6858a3331021d8a', 'addresses': { @@ -31,10 +31,10 @@ const hdAccount1: IUnitTestMockAccount = { '02e3027885ce1ed1d21300158ce8f60649e280e2a8f746e9cea6858a3331021d8a', }, 'xpub': '', - 'coinType': '29223', - 'id': "hd-19--m/44'/29223'/0'", - 'path': "m/44'/29223'/0'/0/0", - 'template': "m/44'/29223'/$$INDEX$$'/0/0", + 'coinType': '5757', + 'id': "hd-19--m/44'/5757'/0'", + 'path': "m/44'/5757'/0'/0/0", + 'template': "m/44'/5757'/0'/0/$$INDEX$$", 'type': AccountType.UTXO, }, mnemonic: mockCredentials.mnemonic1, @@ -47,15 +47,15 @@ const importedAccount1: IUnitTestMockAccount = { 'address': '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', 'addresses': { - 'nexa--testnet': + 'stacks--testnet': '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', }, - 'coinType': '29223', - 'id': 'imported--29223--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + 'coinType': '5757', + 'id': 'imported--5757--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', name: 'Account #1', path: '', xpub: '', - type: AccountType.UTXO, + type: AccountType.SIMPLE, }, // indexedDB -> credentials privateKey: @@ -68,15 +68,15 @@ const importedAccount2: IUnitTestMockAccount = { 'address': '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', 'addresses': { - 'nexa--testnet': + 'stacks--testnet': '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', }, - 'coinType': '29223', - 'id': 'imported--29223--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + 'coinType': '5757', + 'id': 'imported--5757--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', name: 'Account #1', path: '', xpub: '', - type: AccountType.UTXO, + type: AccountType.SIMPLE, }, // indexedDB -> credentials privateKey: @@ -86,9 +86,9 @@ const importedAccount2: IUnitTestMockAccount = { const watchingAccount1: IUnitTestMockAccount = { account: { - address: 'nexatest:nqtsq5g5s9cd8fsl9d9a7jhsuzsw7u9exztnnz8n9un89t0k', - 'coinType': '29223', - 'id': 'external--29223--nexatest:nqtsq5g5s9cd8fsl9d9a7jhsuzsw7u9exztnnz8n9un89t0k', + address: 'stackstest:nqtsq5g5s9cd8fsl9d9a7jhsuzsw7u9exztnnz8n9un89t0k', + 'coinType': '5757', + 'id': 'external--5757--stackstest:nqtsq5g5s9cd8fsl9d9a7jhsuzsw7u9exztnnz8n9un89t0k', name: 'Account #1', path: '', pub: '', @@ -99,8 +99,8 @@ const watchingAccount1: IUnitTestMockAccount = { const watchingAccount2: IUnitTestMockAccount = { account: { - address: 'nexatest:fmza0ttf3pnv5zpg8e2q8lr3t2cesrrv9xdk395r5g5qsqtn', - coinType: '29223', + address: 'stackstest:fmza0ttf3pnv5zpg8e2q8lr3t2cesrrv9xdk395r5g5qsqtn', + coinType: '5757', id: 'external--397--ed25519:8wbWQQkeK9NV1qkiQZ95jbj7JNhpeapHafLPw3qsJdqi', name: 'Account #1', path: '', @@ -114,21 +114,21 @@ const watchingAccount3: IUnitTestMockAccount = { account: { address: '03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', - coinType: '29223', - id: 'external--29223--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + coinType: '5757', + id: 'external--5757--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', name: 'Account #1', path: '', pub: '', - type: AccountType.UTXO, + type: AccountType.SIMPLE, }, password: '', }; const watchingAccount4: IUnitTestMockAccount = { account: { - address: 'nexa:nqtsq5g50frur0vav60gupjlrr8cta8vyqufu7p98vx97c66', - coinType: '29223', - id: 'external--29223--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', + address: 'stacks:nqtsq5g50frur0vav60gupjlrr8cta8vyqufu7p98vx97c66', + coinType: '5757', + id: 'external--5757--03560d4451deeef0d1bcc46ff062372400ecf7b6e4e058ef01792f140ce2a97c31', name: 'Account #1', path: '', pub: '', diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts index 81ce2250e20..72d50442980 100644 --- a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts @@ -2,7 +2,10 @@ import { OneKeyHardwareError } from '@onekeyhq/engine/src/errors'; import { slicePathTemplate } from '@onekeyhq/engine/src/managers/derivation'; import { getAccountNameInfoByImpl } from '@onekeyhq/engine/src/managers/impl'; import { AccountType } from '@onekeyhq/engine/src/types/account'; -import type { DBUTXOAccount } from '@onekeyhq/engine/src/types/account'; +import type { + DBSimpleAccount, + DBUTXOAccount, +} from '@onekeyhq/engine/src/types/account'; import type { UnsignedTx } from '@onekeyhq/engine/src/types/provider'; import { KeyringHardwareBase } from '@onekeyhq/engine/src/vaults/keyring/KeyringHardwareBase'; import type { @@ -12,22 +15,22 @@ import type { } from '@onekeyhq/engine/src/vaults/types'; import { convertDeviceError } from '@onekeyhq/shared/src/device/deviceErrorUtils'; import { - IMPL_NEXA as COIN_IMPL, - COINTYPE_NEXA as COIN_TYPE, + IMPL_STACKS as COIN_IMPL, + COINTYPE_STACKS as COIN_TYPE, } from '@onekeyhq/shared/src/engine/engineConsts'; import debugLogger from '@onekeyhq/shared/src/logger/debugLogger'; -import { type IEncodedTxNexa } from '../types'; +import { type IEncodedTxStacks } from '../types'; import { buildInputScriptBuffer, buildRawTx, buildSignatureBuffer, buildTxid, - getNexaPrefix, + getStacksPrefix, } from '../utils'; -import type { INexaInputSignature } from '../types'; -import type { NexaAddress, Success, Unsuccessful } from '@onekeyfe/hd-core'; +import type { IStacksInputSignature } from '../types'; +import type { StacksAddress, Success, Unsuccessful } from '@onekeyfe/hd-core'; const SIGN_TYPE = 'Schnorr'; @@ -35,7 +38,7 @@ const SIGN_TYPE = 'Schnorr'; export class KeyringHardware extends KeyringHardwareBase { async prepareAccounts( params: IPrepareHardwareAccountsParams, - ): Promise> { + ): Promise> { const { indexes, names, template } = params; const { pathPrefix, pathSuffix } = slicePathTemplate(template); const paths = indexes.map( @@ -53,16 +56,16 @@ export class KeyringHardware extends KeyringHardwareBase { const { prefix } = getAccountNameInfoByImpl(COIN_IMPL).default; const chainId = await this.getNetworkChainId(); - let addressesResponse: Unsuccessful | Success; + let addressesResponse: Unsuccessful | Success; try { - addressesResponse = await HardwareSDK.nexaGetAddress( + addressesResponse = await HardwareSDK.stacksGetAddress( connectId, deviceId, { bundle: paths.map((path) => ({ path, showOnOneKey, - prefix: getNexaPrefix(chainId), + prefix: getStacksPrefix(chainId), })), ...passphraseState, }, @@ -84,7 +87,7 @@ export class KeyringHardware extends KeyringHardwareBase { return { id: `${this.walletId}--${idPaths[index]}`, name, - type: AccountType.UTXO, + type: AccountType.SIMPLE, path, coinType: COIN_TYPE, xpub: '', @@ -102,10 +105,10 @@ export class KeyringHardware extends KeyringHardwareBase { const chainId = await this.getNetworkChainId(); - const response = await HardwareSDK.nexaGetAddress(connectId, deviceId, { + const response = await HardwareSDK.stacksGetAddress(connectId, deviceId, { path: params.path, showOnOneKey: params.showOnOneKey, - prefix: getNexaPrefix(chainId), + prefix: getStacksPrefix(chainId), scheme: SIGN_TYPE, ...passphraseState, }); @@ -124,12 +127,12 @@ export class KeyringHardware extends KeyringHardwareBase { const chainId = await this.getNetworkChainId(); - const response = await HardwareSDK.nexaGetAddress(connectId, deviceId, { + const response = await HardwareSDK.stacksGetAddress(connectId, deviceId, { ...passphraseState, bundle: params.map(({ path, showOnOneKey }) => ({ path, showOnOneKey: !!showOnOneKey, - prefix: getNexaPrefix(chainId), + prefix: getStacksPrefix(chainId), scheme: SIGN_TYPE, })), }); @@ -152,14 +155,14 @@ export class KeyringHardware extends KeyringHardwareBase { const { encodedTx } = unsignedTx.payload; const { inputSignatures, outputSignatures, signatureBuffer } = buildSignatureBuffer( - encodedTx as IEncodedTxNexa, + encodedTx as IEncodedTxStacks, await this.vault.getDisplayAddress(dbAccount.address), ); const { connectId, deviceId } = await this.getHardwareInfo(); const passphraseState = await this.getWalletPassphraseState(); const HardwareSDK = await this.getHardwareSDKInstance(); - const response = await HardwareSDK.nexaSignTransaction( + const response = await HardwareSDK.stacksSignTransaction( connectId, deviceId, { @@ -167,7 +170,7 @@ export class KeyringHardware extends KeyringHardwareBase { inputs: [ { path: dbAccount.path, - prefix: getNexaPrefix(chainId), + prefix: getStacksPrefix(chainId), message: signatureBuffer.toString('hex'), }, ], @@ -178,7 +181,7 @@ export class KeyringHardware extends KeyringHardwareBase { const nexaSignatures = response.payload; const publicKey = Buffer.from(dbAccount.address, 'hex'); const defaultSignature = Buffer.from(nexaSignatures[0].signature, 'hex'); - const inputSigs: INexaInputSignature[] = inputSignatures.map( + const inputSigs: IStacksInputSignature[] = inputSignatures.map( (inputSig) => ({ ...inputSig, publicKey, diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.test.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.test.ts index f5c93ff4eca..a9f603aea89 100644 --- a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.test.ts +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.test.ts @@ -1,16 +1,16 @@ -import nexaMockData from '../@tests/nexaMockData'; +import stacksMockData from '../@tests/stacksMockData'; import { testPrepareAccounts, testSignTransaction, -} from '../@tests/nexaPresetCase'; +} from '../@tests/stacksPresetCase'; import { KeyringHd } from './KeyringHd'; jest.setTimeout(3 * 60 * 1000); -describe('Nexa KeyringHd Tests', () => { - it('Nexa KeyringHd prepareAccounts', async () => { - const { network, hdAccount1 } = nexaMockData; +describe('Stacks KeyringHd Tests', () => { + it('Stacks KeyringHd prepareAccounts', async () => { + const { network, hdAccount1 } = stacksMockData; await testPrepareAccounts( { dbNetwork: network, @@ -26,8 +26,8 @@ describe('Nexa KeyringHd Tests', () => { ); }); - it('Nexa KeyringHd sign tx', async () => { - const { network, hdAccount1 } = nexaMockData; + it('Stacks KeyringHd sign tx', async () => { + const { network, hdAccount1 } = stacksMockData; await testSignTransaction( { dbNetwork: network, diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.ts index b947141b322..8a4a3bb214d 100644 --- a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.ts +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHd.ts @@ -1,4 +1,4 @@ -import { COINTYPE_NEXA as COIN_TYPE } from '@onekeyhq/shared/src/engine/engineConsts'; +import { COINTYPE_STACKS as COIN_TYPE } from '@onekeyhq/shared/src/engine/engineConsts'; import { OneKeyInternalError } from '../../../../errors'; import { slicePathTemplate } from '../../../../managers/derivation'; @@ -23,7 +23,7 @@ export class KeyringHd extends KeyringHdBase { const dbAccount = await this.getDbAccount(); if (addresses.length !== 1) { - throw new OneKeyInternalError('NEXA signers number should be 1.'); + throw new OneKeyInternalError('Stacks signers number should be 1.'); } else if (addresses[0] !== dbAccount.address) { throw new OneKeyInternalError('Wrong address required for signing.'); } @@ -68,7 +68,7 @@ export class KeyringHd extends KeyringHdBase { override async prepareAccounts( params: IPrepareSoftwareAccountsParams, ): Promise { - const accountNamePrefix = 'NEXA'; + const accountNamePrefix = 'STACKS'; const { password, indexes, names, template } = params; const { seed } = (await this.engine.dbApi.getCredential( diff --git a/packages/engine/src/vaults/impl/stacks/sdk/sign.ts b/packages/engine/src/vaults/impl/stacks/sdk/sign.ts index 36da08d30cc..eb0c4eea2da 100644 --- a/packages/engine/src/vaults/impl/stacks/sdk/sign.ts +++ b/packages/engine/src/vaults/impl/stacks/sdk/sign.ts @@ -1,13 +1,8 @@ -import BN from 'bn.js'; -import elliptic from 'elliptic'; - -import { hmacSHA256, sha256 } from '../../../../secret/hash'; - - -import type { curve } from 'elliptic'; - -const EC = elliptic.ec; -const ec = new EC('secp256k1'); +import { + createStacksPrivateKey, + signMessageHashRsv, + verifySignature, +} from '@stacks/transactions'; export function reverseBuffer(buffer: Buffer | string): Buffer { const buf = typeof buffer === 'string' ? Buffer.from(buffer, 'hex') : buffer; @@ -19,134 +14,12 @@ export function reverseBuffer(buffer: Buffer | string): Buffer { return reversed; } -function getBN(buffer: Buffer, isLittleEndian = false) { - const buf = isLittleEndian ? reverseBuffer(buffer) : buffer; - const hex = buf.toString('hex'); - return new BN(hex, 16); -} - -function nonceFunctionRFC6979(privkey: Buffer, msgbuf: Buffer): BN { - let V = Buffer.from( - '0101010101010101010101010101010101010101010101010101010101010101', - 'hex', - ); - let K = Buffer.from( - '0000000000000000000000000000000000000000000000000000000000000000', - 'hex', - ); - - const blob = Buffer.concat([ - privkey, - msgbuf, - Buffer.from('', 'ascii'), - Buffer.from('Schnorr+SHA256 ', 'ascii'), - ]); - - K = hmacSHA256(K, Buffer.concat([V, Buffer.from('00', 'hex'), blob])); - V = hmacSHA256(K, V); - - K = hmacSHA256(K, Buffer.concat([V, Buffer.from('01', 'hex'), blob])); - V = hmacSHA256(K, V); - - let k = new BN(0); - let T; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const N = new BN(ec.curve.n.toArray()); - // eslint-disable-next-line no-constant-condition - while (true) { - V = hmacSHA256(K, V); - T = getBN(V); - - k = T; - if (k.gt(new BN(0)) && k.lt(N)) { - break; - } - K = hmacSHA256(K, Buffer.concat([V, Buffer.from('00', 'hex')])); - V = hmacSHA256(K, V); - } - return k; -} - -function isSquare(x: BN): boolean { - const p = new BN( - 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F', - 'hex', - ); - const x0 = new BN(x); - const base = x0.toRed(BN.red(p)); - const res = base.redPow(p.sub(new BN(1)).div(new BN(2))).fromRed(); // refactor to BN arithmetic operations - return res.eq(new BN(1)); -} - -function hasSquare(point: curve.base.BasePoint): boolean { - return !point.isInfinity() && isSquare(new BN(point.getY().toArray())); -} - -function getrBuffer(r: BN): Buffer { - const rNaturalLength = getBufferFromBN(r).length; - if (rNaturalLength < 32) { - return getBufferFromBN(r, 'be', 32); - } - return getBufferFromBN(r); -} - -function pointToCompressed(point: curve.base.BasePoint): Buffer { - const xbuf = getBufferFromBN(point.getX(), 'be', 32); - const ybuf = getBufferFromBN(point.getY(), 'be', 32); - - let prefix; - const odd = ybuf[ybuf.length - 1] % 2; - if (odd) { - prefix = Buffer.from([0x03]); - } else { - prefix = Buffer.from([0x02]); - } - return Buffer.concat([prefix, xbuf]); -} - -function findSignature(d: BN, e: BN) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-unsafe-member-access - const G: curve.base.BasePoint = ec.curve.g as curve.base.BasePoint; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const n: BN = new BN(ec.curve.n.toArray()); - let k = nonceFunctionRFC6979( - getBufferFromBN(d, 'be', 32), - getBufferFromBN(e, 'be', 32), - ); - const P = G.mul(d as any); - const R = G.mul(k as any); - - if (!hasSquare(R)) { - k = n.sub(k); - } - - const r = R.getX(); - const e0 = getBN( - sha256( - Buffer.concat([ - getrBuffer(r), - pointToCompressed(P), - getBufferFromBN(e, 'be', 32), - ]), - ), - ); - - const s = e0.mul(d).add(k).mod(n); - return { - r, - s, - }; -} - export function sign(privateKey: Buffer, digest: Buffer): Buffer { - sign - const privateKeyBN = getBN(privateKey); - const digestBN = getBN(digest); - const { r, s } = findSignature(privateKeyBN, digestBN); - return Buffer.concat([ - getBufferFromBN(r, 'be', 32), - getBufferFromBN(s, 'be', 32), - ]); + const signature = signMessageHashRsv({ + messageHash: digest.toString('hex'), + privateKey: createStacksPrivateKey(privateKey), + }); + return Buffer.from(signature.data); } export function verify( @@ -157,50 +30,9 @@ export function verify( if (signature.length !== 64) { return false; } - const r = getBN(signature.slice(0, 32)); - const s = getBN(signature.slice(32)); - - const hashbuf = digest; - - const xbuf = publicKey.slice(1); - const x = getBN(xbuf); - // publicKey[0] === 0x02 - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - let P: curve.base.BasePoint = ec.curve.pointFromX(x, false); - - if (publicKey[0] === 0x03) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - P = ec.curve.pointFromX(x, true); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const G: curve.base.BasePoint = ec.curve.g as curve.base.BasePoint; - if (P.isInfinity()) { - return true; - } - const p = new BN( - 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F', - 'hex', + return verifySignature( + signature.toString('hex'), + digest.toString('hex'), + publicKey.toString('hex'), ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const n = new BN(ec.curve.n.toArray()); - if (r.gte(p) || s.gte(n)) { - // ("Failed >= condition") - return false; - } - const Br = getrBuffer(r); - const Bp = pointToCompressed(P); - const hash = sha256(Buffer.concat([Br, Bp, hashbuf])); - - // const e = BN.fromBuffer(hash, 'big').umod(n); - const e = new BN(hash, 'be').umod(n); - const sG = G.mul(s as any); - const eP = P.mul(n.sub(e as any) as any); - const R = sG.add(eP); - - if (R.isInfinity() || !hasSquare(R) || !R.getX().eq(r as any)) { - return false; - } - - return true; } diff --git a/packages/engine/src/vaults/impl/stacks/utils.ts b/packages/engine/src/vaults/impl/stacks/utils.ts index 8e73ea208cb..c9bd3c27a11 100644 --- a/packages/engine/src/vaults/impl/stacks/utils.ts +++ b/packages/engine/src/vaults/impl/stacks/utils.ts @@ -13,27 +13,9 @@ import { type IUnsignedTxPro, } from '../../types'; -import { - bufferToScripChunk, - decode, - decodeScriptBufferToScriptChunks, - encode, - getScriptBufferFromScriptTemplateOut, - isPublicKeyTemplateIn, - isPublicKeyTemplateOut, - reverseBuffer, - scriptChunksToBuffer, - sign, - varintBufNum, - writeInt32LE, - writeUInt32LE, - writeUInt64LEBN, - writeUInt8, -} from './sdk'; - import type { Signer } from '../../../proxy'; import type { Token } from '../../../types/token'; - +import { IEncodedTxStacks } from './types'; export function verifyStacksAddress(address: string) { try { @@ -73,7 +55,7 @@ const NETWORKS = { }, }; -export function getStacksNetworkInfo(chanid: string): { +export function getStacksNetworkInfo(chainid: string): { name: string; prefix: string; pubkeyhash: number; @@ -83,16 +65,11 @@ export function getStacksNetworkInfo(chanid: string): { xprivkey: number; networkMagic: number; } { - return chanid === 'testnet' ? NETWORKS.testnet : NETWORKS.mainnet; + return chainid === 'testnet' ? NETWORKS.testnet : NETWORKS.mainnet; } -export function getStacksPrefix(chanid: string): string { - return getStacksNetworkInfo(chanid).prefix; -} - -function convertScriptToPushBuffer(key: Buffer): Buffer { - const templateChunk = bufferToScripChunk(key); - return scriptChunksToBuffer([templateChunk]); +export function getStacksPrefix(chainId: string): string { + return getStacksNetworkInfo(chainId).prefix; } export function verifyStacksAddressPrefix(address: string) { @@ -133,14 +110,6 @@ export function estimateFee( return feeWithChange; } -export function buildInputScriptBuffer(publicKey: Buffer, signature: Buffer) { - const scriptBuffer = scriptChunksToBuffer([ - bufferToScripChunk(scriptChunksToBuffer([bufferToScripChunk(publicKey)])), - bufferToScripChunk(signature), - ]); - return scriptBuffer; -} - function buildInputIdemWithSignature({ sigtype, prevTxId, @@ -246,7 +215,10 @@ export function buildTxid( return txIdHash; } -function buildSignatures(encodedTx: IEncodedTxStacks, dbAccountAddress: string) { +function buildSignatures( + encodedTx: IEncodedTxStacks, + dbAccountAddress: string, +) { const { inputs, outputs, gas } = encodedTx; const newOutputs = outputs.slice(); const inputAmount: BN = inputs.reduce( @@ -390,12 +362,14 @@ export async function signEncodedTx( } = buildSignatureBuffer(encodedTx, dbAccountAddress); const ret = sha256sha256(signatureBuffer); const signature = sign(privateKey, ret); - const inputSignatures: IStacksInputSignature[] = inputSigs.map((inputSig) => ({ - ...inputSig, - publicKey, - signature, - scriptBuffer: buildInputScriptBuffer(publicKey, signature), - })); + const inputSignatures: IStacksInputSignature[] = inputSigs.map( + (inputSig) => ({ + ...inputSig, + publicKey, + signature, + scriptBuffer: buildInputScriptBuffer(publicKey, signature), + }), + ); const txid = buildTxid(inputSignatures, outputSignatures); const rawTx = buildRawTx(inputSignatures, outputSignatures, 0, true); diff --git a/yarn.lock b/yarn.lock index 4376b8de71e..8a625a29d0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6644,10 +6644,9 @@ __metadata: "@onekeyfe/onekey-cross-webview": 1.1.55 "@open-wc/webpack-import-meta-loader": ^0.4.7 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.10 - "@stacks/blockchain-api-client": ^7.3.4 - "@stacks/encryption": ^6.10.0 - "@stacks/network": ^6.10.0 - "@stacks/transactions": ^6.10.0 + "@stacks/blockchain-api-client": ^7.9.0 + "@stacks/network": ^6.11.3 + "@stacks/transactions": ^6.12.1 "@starcoin/starcoin": 2.1.5 "@types/babel__preset-env": ^7 "@types/chrome": ^0.0.161 @@ -9839,18 +9838,18 @@ __metadata: languageName: node linkType: hard -"@stacks/blockchain-api-client@npm:^7.3.4": - version: 7.3.4 - resolution: "@stacks/blockchain-api-client@npm:7.3.4" +"@stacks/blockchain-api-client@npm:^7.9.0": + version: 7.9.0 + resolution: "@stacks/blockchain-api-client@npm:7.9.0" dependencies: "@stacks/stacks-blockchain-api-types": "*" "@types/ws": 7.4.7 cross-fetch: 3.1.5 eventemitter3: 4.0.7 jsonrpc-lite: 2.2.0 - socket.io-client: 4.6.1 - ws: 7.5.6 - checksum: 6d2db64c48b46129862981b9f3d2e359d92d4c5f01416255999eeb514cd0dc267f12afc036a227dc6c686bfb3e30ed1bd00e10714ff21996bf972a61271c78ea + socket.io-client: 4.7.3 + ws: 8.16.0 + checksum: 105a9e0409aa43829da4a158cf2d62a9d87b7585ff146b50421a5c3e3085f9fb262f4119ec8e0aea51d2087b3295245ea5d29da8b7945d9fc8db0fc593390b12 languageName: node linkType: hard @@ -9864,30 +9863,13 @@ __metadata: languageName: node linkType: hard -"@stacks/encryption@npm:^6.10.0": - version: 6.10.0 - resolution: "@stacks/encryption@npm:6.10.0" - dependencies: - "@noble/hashes": 1.1.5 - "@noble/secp256k1": 1.7.1 - "@scure/bip39": 1.1.0 - "@stacks/common": ^6.10.0 - "@types/node": ^18.0.4 - base64-js: ^1.5.1 - bs58: ^5.0.0 - ripemd160-min: ^0.0.6 - varuint-bitcoin: ^1.1.2 - checksum: 1b71fede9fa32d60280204486f7a76dd8d215ae11eef4040bc06bedc4ff6c9251917056e2d411a9b9a3066d98a3bcf3e222dab45ad1330bcac95b40308a778c0 - languageName: node - linkType: hard - -"@stacks/network@npm:^6.10.0": - version: 6.10.0 - resolution: "@stacks/network@npm:6.10.0" +"@stacks/network@npm:^6.11.3": + version: 6.11.3 + resolution: "@stacks/network@npm:6.11.3" dependencies: "@stacks/common": ^6.10.0 cross-fetch: ^3.1.5 - checksum: 960fa88a66fad36c3502d86db603dd429e9f004a8e35cf02daf10adf6cac553c2f97c3aa70113c0b0639b8be602b851a243e4aa37f8a145c75543467a580cd28 + checksum: a17b9ad41fc36bd55c5acd141594c8dfe9d1399d198fb81ae757fd7248b941793923e9589cd254af489dd5cb810eba9a16110fcc8170edfcbfc51ef965d5b725 languageName: node linkType: hard @@ -9898,17 +9880,17 @@ __metadata: languageName: node linkType: hard -"@stacks/transactions@npm:^6.10.0": - version: 6.10.0 - resolution: "@stacks/transactions@npm:6.10.0" +"@stacks/transactions@npm:^6.12.1": + version: 6.12.1 + resolution: "@stacks/transactions@npm:6.12.1" dependencies: "@noble/hashes": 1.1.5 "@noble/secp256k1": 1.7.1 "@stacks/common": ^6.10.0 - "@stacks/network": ^6.10.0 + "@stacks/network": ^6.11.3 c32check: ^2.0.0 lodash.clonedeep: ^4.5.0 - checksum: f1e69afa2aeda8d3f294a14a76a1a3fc9d2506a5357dcbeb07f2f28c331d98396c3d43899380d8399a19e59a613855af25731f79e836a03781069d5776322f39 + checksum: 94745910d17c3877e2b254e0a4092ae95b82c249a3c7c31edae6982905028cb4a083bdc11ef544b35d797d57bb5fb1c6a889de690a7c47a43e977d0f87117fe5 languageName: node linkType: hard @@ -31774,7 +31756,7 @@ __metadata: languageName: node linkType: hard -"ripemd160-min@npm:0.0.6, ripemd160-min@npm:^0.0.6": +"ripemd160-min@npm:0.0.6": version: 0.0.6 resolution: "ripemd160-min@npm:0.0.6" checksum: 3253fec273aee407e736df0baf69f90c65f56573d6fc537532041112e7c09a2f665ee2e618ef4a88eb494923d36614322eac26ddf35a504fcfedb422fd414c75 From 819cb5f08608af41bad945f974f64ddd3a4b70c3 Mon Sep 17 00:00:00 2001 From: friedger Date: Fri, 22 Mar 2024 11:36:03 +0100 Subject: [PATCH 4/4] chore: update stacks keyring (WIP) --- .../src/vaults/impl/stacks/keyring/KeyringHardware.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts index 72d50442980..a8b937b770a 100644 --- a/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts +++ b/packages/engine/src/vaults/impl/stacks/keyring/KeyringHardware.ts @@ -178,9 +178,12 @@ export class KeyringHardware extends KeyringHardwareBase { ); if (response.success) { - const nexaSignatures = response.payload; + const stacksSignatures = response.payload; const publicKey = Buffer.from(dbAccount.address, 'hex'); - const defaultSignature = Buffer.from(nexaSignatures[0].signature, 'hex'); + const defaultSignature = Buffer.from( + stacksSignatures[0].signature, + 'hex', + ); const inputSigs: IStacksInputSignature[] = inputSignatures.map( (inputSig) => ({ ...inputSig,