diff --git a/.changeset/bright-ducks-wonder.md b/.changeset/bright-ducks-wonder.md new file mode 100644 index 0000000..eafeb75 --- /dev/null +++ b/.changeset/bright-ducks-wonder.md @@ -0,0 +1,5 @@ +--- +'@joyid/ckb': minor +--- + +feat: Add calculateChallenge and buildSignedTx functions diff --git a/packages/ckb/src/index.ts b/packages/ckb/src/index.ts index 0723dd4..cc0faed 100644 --- a/packages/ckb/src/index.ts +++ b/packages/ckb/src/index.ts @@ -23,10 +23,21 @@ import { } from '@joyid/common' import * as ckbUtils from '@nervosnetwork/ckb-sdk-utils' import { Aggregator } from './aggregator' +import { deserializeWitnessArgs } from './utils' export * from './verify' -const { addressToScript, blake160, serializeScript } = ckbUtils +const { + PERSONAL, + addressToScript, + blake160, + blake2b, + hexToBytes, + toUint64Le, + serializeScript, + rawTransactionToHash, + serializeWitnessArgs, +} = ckbUtils const appendPrefix = (tokenKey?: string): string | undefined => { if (!tokenKey) { @@ -63,6 +74,7 @@ export const initConfig = (config?: CkbDappConfig): CkbDappConfig => { export const getConfig = (): CkbDappConfig => internalDappConfig // The witnessIndexes represents the positions of the JoyID cells in inputs, the default value is an empty array +// e.g. If the transaction inputs have two JoyID cells whose positions are 1 and 3(zero-based index), the witnessIndexes should be [1, 3] export type SignConfig = CkbDappConfig & Pick & { witnessIndexes?: number[] @@ -234,6 +246,104 @@ export const signRawTransaction = async ( return res.tx } +const SECP256R1_PUBKEY_SIG_LEN = 129 +// The witnessIndexes represents the positions of the JoyID cells in inputs, the default value is an array containing only 0 +// e.g. If the transaction inputs have two JoyID cells whose positions are 1 and 3(zero-based index), the witnessIndexes should be [1, 3] +export const calculateChallenge = async ( + tx: CKBTransaction, + witnessIndexes = [0] +): Promise => { + const { witnesses } = tx + if (witnesses.length === 0) { + throw new Error('Witnesses cannot be empty') + } + + if (witnessIndexes.length === 0) { + throw new Error('JoyID witnesses can not be empty') + } + + const firstWitnessIndex = witnessIndexes[0] ?? 0 + if (typeof tx.witnesses[firstWitnessIndex] !== 'string') { + throw new TypeError( + 'The first JoyID witness must be serialized hex string of WitnessArgs' + ) + } + const transactionHash = rawTransactionToHash(tx) + const witnessArgs = deserializeWitnessArgs(tx.witnesses[firstWitnessIndex]!) + + const emptyWitness: CKBComponents.WitnessArgs = { + ...witnessArgs, + lock: `0x${'00'.repeat(SECP256R1_PUBKEY_SIG_LEN)}`, + } + + console.log(console.log(emptyWitness)) + + const serializedEmptyWitnessBytes = hexToBytes( + serializeWitnessArgs(emptyWitness) + ) + const serializedEmptyWitnessSize = serializedEmptyWitnessBytes.length + + const hasher = blake2b(32, null, null, PERSONAL) + hasher.update(hexToBytes(transactionHash)) + hasher.update( + hexToBytes(toUint64Le(`0x${serializedEmptyWitnessSize.toString(16)}`)) + ) + hasher.update(serializedEmptyWitnessBytes) + + for (const witnessIndex of witnessIndexes.slice(1)) { + const witness = witnesses[witnessIndex] + if (witness) { + const bytes = hexToBytes( + typeof witness === 'string' ? witness : serializeWitnessArgs(witness) + ) + hasher.update(hexToBytes(toUint64Le(`0x${bytes.length.toString(16)}`))) + hasher.update(bytes) + } + } + + if (witnesses.length > tx.inputs.length) { + for (const witness of witnesses.slice(tx.inputs.length)) { + const bytes = hexToBytes( + typeof witness === 'string' ? witness : serializeWitnessArgs(witness) + ) + hasher.update(hexToBytes(toUint64Le(`0x${bytes.length.toString(16)}`))) + hasher.update(bytes) + } + } + + const challenge = `${hasher.digest('hex')}` + return challenge +} + +const WITNESS_NATIVE_MODE = '01' +const WITNESS_SUBKEY_MODE = '02' +// The witnessIndexes represents the positions of the JoyID cells in inputs, the default value is an array containing only 0 +// e.g. If the transaction inputs have two JoyID cells whose positions are 1 and 3(zero-based index), the witnessIndexes should be [1, 3] +export const buildSignedTx = ( + unsignedTx: CKBTransaction, + signedData: SignMessageResponseData, + witnessIndexes = [0] +): CKBTransaction => { + if (unsignedTx.witnesses.length === 0) { + throw new Error('Witnesses length error') + } + const firstWitnessIndex = witnessIndexes[0] ?? 0 + const firstWitness = unsignedTx.witnesses[firstWitnessIndex]! + const witnessArgs = deserializeWitnessArgs(firstWitness) + + const { message, signature, pubkey, keyType } = signedData + + const mode = keyType === 'sub_key' ? WITNESS_SUBKEY_MODE : WITNESS_NATIVE_MODE + witnessArgs.lock = `0x${mode}${pubkey}${signature}${message}` + + const signedWitness = append0x(serializeWitnessArgs(witnessArgs)) + + const signedTx = unsignedTx + signedTx.witnesses[firstWitnessIndex] = signedWitness + + return signedTx +} + export const getSubkeyUnlock = async ( aggregatorUrl: string, connection: AuthResponseData diff --git a/packages/ckb/src/utils.spec.ts b/packages/ckb/src/utils.spec.ts new file mode 100644 index 0000000..1e16b6c --- /dev/null +++ b/packages/ckb/src/utils.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest' +import { deserializeWitnessArgs } from './utils' +import { calculateChallenge, CKBTransaction } from '.' + +describe('utils', () => { + describe('deserializeWitnessArgs', () => { + it('default_witness_args', async () => { + const witnessArgs = deserializeWitnessArgs( + '0x55000000100000005500000055000000410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' + ) + expect(witnessArgs.lock).toBe(`0x${'00'.repeat(65)}`) + expect(witnessArgs.inputType).toBe('0x') + expect(witnessArgs.outputType).toBe('0x') + }) + + it('custom_witness_args', async () => { + const witnessArgs = deserializeWitnessArgs( + '0x1a00000010000000100000001500000001000000100100000020' + ) + expect(witnessArgs.lock).toBe('0x') + expect(witnessArgs.inputType).toBe('0x10') + expect(witnessArgs.outputType).toBe('0x20') + }) + }) + + describe('calculateChallenge', () => { + it('verify_challenge_with_raw_tx', async () => { + const ckbTx: CKBComponents.RawTransactionToSign = { + version: '0x00', + cellDeps: [ + { + outPoint: { + txHash: + '0x069ae648ecc682caa52b1a6a5854ec3545a8513dd9681f452049a59be33465b0', + index: '0x0', + }, + depType: 'depGroup', + }, + { + outPoint: { + txHash: + '0xd8c7396f955348bd74a8ed4398d896dad931977b7c1e3f117649765cd3d75b86', + index: '0x0', + }, + depType: 'depGroup', + }, + ], + headerDeps: [], + inputs: [ + { + previousOutput: { + txHash: + '0x00ff48f637e12c8aa873d76cd1a9c3e3756f3e7df006760270f23a3a25782f87', + index: '0x1', + }, + since: '0x0', + }, + ], + outputs: [ + { + capacity: '0x37e11d0ed', + lock: { + codeHash: + '0xd23761b364210735c19c60561d213fb3beae2fd6172743719eff6920e020baac', + hashType: 'type', + args: '0x0001fd879d61c8187b4fa1e87296bb00371bfebc3a4e', + }, + type: { + codeHash: + '0x89cd8003a0eaf8e65e0c31525b7d1d5c1becefd2ea75bb4cff87810ae37764d8', + hashType: 'type', + args: '0x226192f9ca4bcd697bd2fe4429f73a254de25e61', + }, + }, + ], + outputsData: [ + '0x020000000000000000000000000000000000000000000000000000000000000000', + ], + witnesses: [ + '0xc3010000100000001000000010000000af0100007b226964223a2243544d657461222c22766572223a22312e30222c226d65746164617461223a7b22746172676574223a226f75747075742330222c2274797065223a226a6f795f6964222c2264617461223a7b22616c67223a2230783031222c22636f746143656c6c4964223a22307830303030303030303030303030303030222c2263726564656e7469616c4964223a22307837363431643666613336316434326431663830613638396339376430656166376662643336663366303430343332626338313564333932643461366433356162222c2266726f6e745f656e64223a226a6f7969642d6170702d6465762e76657263656c2e617070222c226e616d65223a2273746f6e656b696e67222c227075625f6b6579223a2230786462333135323532356133353836616636346230636638376331636636626633613861663864633961323365383761356161366164316430633665303434376635316631343763363038613831613566383435303936326662383634386465653961636435343863623438626430656438383634326237356530373963616365222c2276657273696f6e223a2230227d7d7d', + ], + } + + const challenge = await calculateChallenge(ckbTx as CKBTransaction) + expect(challenge).toBe( + '8a4ae0acf8e6481a748627543225f497eaea5d754c9d72cfba8db800ac6d4d1b' + ) + }) + }) +}) diff --git a/packages/ckb/src/utils.ts b/packages/ckb/src/utils.ts index 56a0436..88284c4 100644 --- a/packages/ckb/src/utils.ts +++ b/packages/ckb/src/utils.ts @@ -1,4 +1,8 @@ /* eslint-disable unicorn/prefer-string-slice */ + +import { append0x, remove0x } from '@joyid/common' +import { hexToBytes } from '@nervosnetwork/ckb-sdk-utils' + /** * Web crypto use IEEE P1363 ECDSA signature format * ref: https://stackoverflow.com/questions/39554165/ecdsa-signatures-between-node-js-and-webcrypto-appear-to-be-incompatible @@ -18,3 +22,28 @@ export function derToIEEE(sig: ArrayBuffer): Uint8Array { p1363Sig.match(/[\da-f]{2}/gi)!.map((h) => Number.parseInt(h, 16)) ) } + +export function leHexStringToU32(hex: string): number { + const bytes = hexToBytes(append0x(hex)) + const beHex = `0x${bytes.reduceRight((pre, cur) => pre + cur.toString(16).padStart(2, '0'), '')}` + return Number.parseInt(beHex) +} + +export function deserializeWitnessArgs(hex: string): CKBComponents.WitnessArgs { + const args = remove0x(hex) + // full_size(4bytes) + offsets(4bytes * 3) + body(lock + input_type + output_type) + const lockOffset = leHexStringToU32(args.slice(8, 16)) * 2 + const inputTypeOffset = leHexStringToU32(args.slice(16, 24)) * 2 + const outputTypeOffset = leHexStringToU32(args.slice(24, 32)) * 2 + + // lock = size(4bytes) + body + const lock = args.slice(lockOffset, inputTypeOffset).slice(8) + const inputType = args.slice(inputTypeOffset, outputTypeOffset).slice(8) + const outputType = args.slice(outputTypeOffset).slice(8) + + return { + lock: lock.length === 0 ? '0x' : `0x${lock}`, + inputType: inputType.length === 0 ? '0x' : `0x${inputType}`, + outputType: outputType.length === 0 ? '0x' : `0x${outputType}`, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfcfc57..fde8205 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -5259,7 +5259,7 @@ packages: qr-code-styling: 1.6.0-rc.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-i18next: 13.5.0(i18next@23.12.2)(react-dom@18.2.0)(react-native@0.72.6)(react@18.2.0) + react-i18next: 13.5.0(i18next@22.5.1)(react-dom@18.2.0)(react-native@0.72.6)(react@18.2.0) react-native: 0.72.6(@babel/core@7.23.2)(@babel/preset-env@7.24.7)(react@18.2.0) /@metamask/sdk@0.20.5(react-dom@18.2.0)(react-i18next@13.5.0)(react-native@0.72.6)(react@18.2.0): @@ -9690,6 +9690,7 @@ packages: /eslint@8.57.0: resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) @@ -10613,11 +10614,6 @@ packages: dependencies: '@babel/runtime': 7.24.7 - /i18next@23.12.2: - resolution: {integrity: sha512-XIeh5V+bi8SJSWGL3jqbTEBW5oD6rbP5L+E7dVQh1MNTxxYef0x15rhJVcRb7oiuq4jLtgy2SD8eFlf6P2cmqg==} - dependencies: - '@babel/runtime': 7.24.7 - /iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -13049,7 +13045,7 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /react-i18next@13.5.0(i18next@23.12.2)(react-dom@18.2.0)(react-native@0.72.6)(react@18.2.0): + /react-i18next@13.5.0(i18next@22.5.1)(react-dom@18.2.0)(react-native@0.72.6)(react@18.2.0): resolution: {integrity: sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==} peerDependencies: i18next: '>= 23.2.3' @@ -13064,7 +13060,7 @@ packages: dependencies: '@babel/runtime': 7.24.7 html-parse-stringify: 3.0.1 - i18next: 23.12.2 + i18next: 22.5.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-native: 0.72.6(@babel/core@7.23.2)(@babel/preset-env@7.24.7)(react@18.2.0)