diff --git a/packages/ckit/src/__tests__/secp256k1-sudt.spec.ts b/packages/ckit/src/__tests__/secp256k1-sudt.spec.ts index 8eeff25..080b14c 100644 --- a/packages/ckit/src/__tests__/secp256k1-sudt.spec.ts +++ b/packages/ckit/src/__tests__/secp256k1-sudt.spec.ts @@ -1,9 +1,10 @@ // because of hot cell problem // we need to ensure that different genesisSigner is used in different cases -import { utils } from '@ckb-lumos/base'; +import { Cell, utils } from '@ckb-lumos/base'; import { key } from '@ckb-lumos/hd'; import { Amount, CkbAmount } from '../helpers'; +import { Pw } from '../helpers/pw'; import { AcpTransferSudtBuilder, MintOptions, @@ -13,6 +14,8 @@ import { ChequeDepositBuilder, ChequeClaimBuilder, ChequeWithdrawBuilder, + ExchangeSudtForCkbBuilder, + ExchangeSudtForCkbOptions, } from '../tx-builders'; import { randomHexString, asyncSleep, nonNullable } from '../utils'; import { InternalNonAcpPwLockSigner } from '../wallets/PwWallet'; @@ -819,6 +822,111 @@ test('deposit sudt cheque and claim it', async () => { eqAmount(await provider.getUdtBalance(receiver.getAddress(), sudt), 500); }); +test('exchange sudt for ckb', async () => { + const provider = new TestProvider(); + await provider.init(); + + const { debug } = provider; + debug('exchange sudt for ckb'); + + const exchangeProviderSigner = provider.generateAcpSigner('SECP256K1_BLAKE160'); + const exchangeProviderAddr = exchangeProviderSigner.getAddress(); + + const sudtSenderSigner = provider.generateAcpSigner('SECP256K1_BLAKE160'); + const sudtSenderAddr = sudtSenderSigner.getAddress(); + + const recipientSigner = provider.generateAcpSigner('SECP256K1_BLAKE160'); + const recipientAddr = recipientSigner.getAddress(); + + const issuerPrivateKey = provider.testPrivateKeys[testPrivateKeyIndex]!; + const issuerLock = provider.newScript('SECP256K1_BLAKE160', key.privateKeyToBlake160(issuerPrivateKey)); + const issuerLockHash = utils.computeScriptHash(issuerLock); + + const testUdt = provider.newScript('SUDT', issuerLockHash); + + const exchangeProviderCells = await prepareForExchange(provider, exchangeProviderAddr, sudtSenderAddr); + + // assume: 1 SUDT = 380 CKB + // transfer 1 SUDT and exchange 1 SUDT for 380 CKB + const builderOptions: ExchangeSudtForCkbOptions = { + sudt: testUdt, + sudtSender: sudtSenderAddr, + sudtAmountForExchange: Amount.from(1, 8).toHex(), + sudtAmountForRecipient: Amount.from(1, 8).toHex(), + exchangeProvider: exchangeProviderCells as Cell[], + // exchangeProvider: exchangeProviderAddr, + ckbAmountForRecipient: CkbAmount.fromCkb(380).toHex(), + exchangeRecipient: recipientAddr, + }; + + const unsigned = await new ExchangeSudtForCkbBuilder(builderOptions, provider).build(); + const partialSignedTx = await exchangeProviderSigner.partialSeal(unsigned); + + expect(partialSignedTx != null).toBe(true); + eqAmount(await provider.getUdtBalance(recipientAddr, testUdt), '0x0'); + eqAmount(await provider.getUdtBalance(sudtSenderAddr, testUdt), '0xbebc200'); + eqAmount(await provider.getUdtBalance(exchangeProviderAddr, testUdt), '0x0'); + + const fullySignedTx = await sudtSenderSigner.seal(partialSignedTx); + const fullySignedTxHash = await provider.sendTransaction(fullySignedTx); + const exchangeTx = await provider.waitForTransactionCommitted(fullySignedTxHash); + + expect(exchangeTx != null).toBe(true); + eqAmount(await provider.getUdtBalance(recipientAddr, testUdt), '0x5f5e100'); + eqAmount(await provider.getUdtBalance(sudtSenderAddr, testUdt), '0x0'); + eqAmount(await provider.getUdtBalance(exchangeProviderAddr, testUdt), '0x5f5e100'); +}); + +async function prepareForExchange( + provider: TestProvider, + exchangeProviderAddr: string, + sudtSenderAddr: string, +): Promise { + const { debug } = provider; + + // mint sudt for exchangeProvider + const { sudt, txHash } = await provider.mintSudtFromGenesis( + { + recipients: [ + { + recipient: exchangeProviderAddr, + amount: Amount.from(0).toHex(), + additionalCapacity: CkbAmount.fromCkb(600).toHex(), + capacityPolicy: 'createCell', + }, + ], + }, + { testPrivateKeysIndex: testPrivateKeyIndex }, + ); + debug('[prepare] mint ckb for exchangeProvider: 600 ckb, txHash: %s', txHash); + + const exchangProviderPoint = await provider.collectUdtCells(exchangeProviderAddr, sudt, '0'); + expect(exchangProviderPoint).toHaveLength(1); + + // mint sudt for sudtSender + const res = await provider.mintSudtFromGenesis( + { + recipients: [ + { + recipient: sudtSenderAddr, + amount: Amount.from(2, 8).toHex(), + additionalCapacity: CkbAmount.fromCkb(200).toHex(), + capacityPolicy: 'createCell', + }, + ], + }, + { testPrivateKeysIndex: testPrivateKeyIndex }, + ); + debug('[prepare] mint sudt for sudtSender: 2 unit, tx hash: %s', res.txHash); + + const senderReceived = await provider.collectUdtCells(sudtSenderAddr, sudt, '0'); + expect(senderReceived).toHaveLength(1); + + const exchangProviderPwCells = exchangProviderPoint.map(Pw.toPwCell); + const exchangProviderCells = exchangProviderPwCells.map(Pw.fromPwCell); + return exchangProviderCells; +} + /** * Using Tippy to test this case. * Before testing, modify the value in the [miner.workers] section of the ckb-miner.toml file diff --git a/packages/ckit/src/helpers/pw.ts b/packages/ckit/src/helpers/pw.ts index 7dd0507..29f56b3 100644 --- a/packages/ckit/src/helpers/pw.ts +++ b/packages/ckit/src/helpers/pw.ts @@ -53,7 +53,7 @@ function fromPwCell(x: PwCell): LumosCell { lock: fromPwScript(x.lock), }, out_point: x.outPoint ? { tx_hash: x.outPoint.txHash, index: x.outPoint.index } : undefined, - data: x.getData(), + data: x.getHexData(), }; } diff --git a/packages/ckit/src/providers/mercury/MercuryProvider.ts b/packages/ckit/src/providers/mercury/MercuryProvider.ts index ab10c3c..1744573 100644 --- a/packages/ckit/src/providers/mercury/MercuryProvider.ts +++ b/packages/ckit/src/providers/mercury/MercuryProvider.ts @@ -152,13 +152,22 @@ export class MercuryProvider extends AbstractProvider { })); } - collectCells({ searchKey }: { searchKey: SearchKey }, takeWhile_: (cell: Cell[]) => boolean): Promise { + collectCells( + { searchKey }: { searchKey: SearchKey }, + takeWhile_: (cell: Cell[]) => boolean, + options?: { inclusive?: boolean }, + ): Promise { + let inclusive = false; + if (options && options.inclusive) { + inclusive = options.inclusive; + } + const cells$ = from(this.mercury.get_cells({ search_key: searchKey })).pipe( expand((res) => this.mercury.get_cells({ search_key: searchKey, after_cursor: res.last_cursor }), 1), takeWhile((res) => res.objects.length > 0), concatMap((res) => res.objects.map(toCell)), scan((acc, next) => acc.concat(next), [] as Cell[]), - takeWhile((acc) => takeWhile_(acc)), + takeWhile((acc) => takeWhile_(acc), inclusive), ); return lastValueFrom(cells$, { defaultValue: [] }); diff --git a/packages/ckit/src/tx-builders/ExchangeSudtForCkbBuilder.ts b/packages/ckit/src/tx-builders/ExchangeSudtForCkbBuilder.ts new file mode 100644 index 0000000..c3de9f7 --- /dev/null +++ b/packages/ckit/src/tx-builders/ExchangeSudtForCkbBuilder.ts @@ -0,0 +1,279 @@ +import { Address, Cell, HexNumber, Script } from '@ckb-lumos/base'; +import { minimalCellCapacity } from '@ckb-lumos/helpers'; +import { CkbTypeScript } from '@ckitjs/base'; +import { + Amount, + AmountUnit, + Builder, + Cell as PwCell, + RawTransaction, + Transaction, + cellOccupiedBytes, + WitnessArgs, +} from '@lay2/pw-core'; +import { SearchKey } from '@ckitjs/mercury-client'; +import { BigNumber } from 'bignumber.js'; +import { Pw } from '../helpers/pw'; +import { CkitProvider } from '../providers'; +import { AbstractPwSenderBuilder } from './pw/AbstractPwSenderBuilder'; +import { NoEnoughCkbError } from '../errors'; + +const feeCkb = new Amount('1', AmountUnit.ckb); + +export interface ExchangeSudtForCkbOptions { + sudt: CkbTypeScript; + sudtSender: Address; + // exchange sudtAmountForExchange for ckbAmountForRecipient + sudtAmountForExchange: HexNumber; + sudtAmountForRecipient: HexNumber; + + exchangeProvider: Cell[] | Address; + ckbAmountForRecipient: HexNumber; + + // receive the ckbAmountForRecipient + sudtAmountForRecipient + exchangeRecipient: Address; +} + +export class ExchangeSudtForCkbBuilder extends AbstractPwSenderBuilder { + constructor(private options: ExchangeSudtForCkbOptions, protected provider: CkitProvider) { + super(provider); + } + + /* + inputs + - exchangeProviderCells + - capacity: exchangeCkbSum + - data.amount: exchangeSudtSum + - sudtCells + - capacity: sudtCkbSum + - data.amount: sudtSum + outputs + - exchangeProviderCell + - capacity: exchangeCkbSum - ckbAmountForRecipient - fee + - data.amount: exchangeSudtSum + sudtAmountForExchange + - sudtCell + - capacity: sudtCkbSum + - data.amount: sudtSum - sudtAmountForExchange - sudtAmountForRecipient + - recipientCell + - capacity: ckbAmountForRecipient + - data.amount: sudtAmountForRecipient + */ + async build(): Promise { + const inExchangeProviderCells = await this.collectExchangeProviderCells(this.calcMinExchangeProviderCkb()); + const inSudtCells = await this.collectSudtCells(this.calcNeededSudtAmount()); + + const outExchangeCell = this.createOutExchangeCell(inExchangeProviderCells); + const outSudtCell = this.createOutSudtCell(inSudtCells); + const outRecipientCell = this.createOutRecipientCell(); + + const inputs = [...inExchangeProviderCells, ...inSudtCells]; + const outputs = [outExchangeCell, outSudtCell, outRecipientCell]; + const cellDeps = await this.getCellDepsByCells(inputs, outputs); + const rawTx = new RawTransaction(inputs, outputs, cellDeps); + + const tx = new Transaction(rawTx, this.createWitness(inExchangeProviderCells, inSudtCells) as WitnessArgs[]); + + const fee = Builder.calcFee(tx, Number(this.provider.config.MIN_FEE_RATE)); + outExchangeCell.capacity = outExchangeCell.capacity.sub(fee); + + this.checkTransaction(tx); + + return tx; + } + + private async collectSudtCells(neededSudtAmount: Amount) { + const sudtOutpoints = await this.provider.collectUdtCells( + this.options.sudtSender, + this.options.sudt, + neededSudtAmount.toHexString(), + ); + + const inSudtCells = sudtOutpoints.map(Pw.toPwCell); + return inSudtCells; + } + + private async collectExchangeProviderCells(minimalCapacity: Amount): Promise { + let inExchangeProviderCells: PwCell[] = []; + let lock: Script; + if (typeof this.options.exchangeProvider === 'string') { + lock = this.provider.parseToScript(this.options.exchangeProvider); + const searchKey: SearchKey = { + script: lock, + script_type: 'lock', + filter: { + script: this.options.sudt, + }, + }; + const exchangeOutputs = await this.provider.collectCells( + { searchKey }, + (cells) => + cells.reduce((sum, cell) => sum.add(new Amount(cell.cell_output.capacity)), Amount.ZERO).lt(minimalCapacity), + { inclusive: true }, + ); + + inExchangeProviderCells = exchangeOutputs.map(Pw.toPwCell); + } else { + lock = this.options.exchangeProvider[0]!.cell_output.lock; + inExchangeProviderCells = this.options.exchangeProvider.map(Pw.toPwCell); + } + + const inExchangeProviderCkbSum = inExchangeProviderCells.reduce((sum, cell) => sum.add(cell.capacity), Amount.ZERO); + + if (inExchangeProviderCkbSum.lt(minimalCapacity)) { + throw new NoEnoughCkbError({ + lock, + expected: minimalCapacity.toString(), + actual: inExchangeProviderCkbSum.toString(), + }); + } + + return inExchangeProviderCells; + } + + private createOutExchangeCell(inExchangeProviderCells: PwCell[]): PwCell { + const outExchangeCell = inExchangeProviderCells[0]!.clone(); + + const [inExchangeProviderCkbSum, inExchangeProviderSudtSum] = this.calcCkbAndSudtSum(inExchangeProviderCells); + + const ckbAmountForRecipient = new Amount( + new BigNumber(this.options.ckbAmountForRecipient).toString(), + AmountUnit.shannon, + ); + outExchangeCell.capacity = inExchangeProviderCkbSum.sub(ckbAmountForRecipient); + + const sudtAmountForExchange = new Amount( + new BigNumber(this.options.sudtAmountForExchange).toString(), + AmountUnit.shannon, + ); + outExchangeCell.setSUDTAmount(inExchangeProviderSudtSum.add(sudtAmountForExchange)); + + return outExchangeCell; + } + + private createOutSudtCell(inSudtCells: PwCell[]) { + const outSudtCell = inSudtCells[0]!.clone(); + const inSudtSum = inSudtCells.reduce((sum, cell) => sum.add(cell.getSUDTAmount()), Amount.ZERO); + outSudtCell.setSUDTAmount(inSudtSum.sub(this.calcNeededSudtAmount())); + return outSudtCell; + } + + private createOutRecipientCell(): PwCell { + const ckbAmountForRecipient = new Amount( + new BigNumber(this.options.ckbAmountForRecipient).toString(), + AmountUnit.shannon, + ); + + const outRecipientCell = new PwCell( + ckbAmountForRecipient, + Pw.toPwScript(this.provider.parseToScript(this.options.exchangeRecipient)), + Pw.toPwScript(this.options.sudt), + ); + + const sudtAmountForRecipient = new Amount( + new BigNumber(this.options.sudtAmountForRecipient).toString(), + AmountUnit.shannon, + ); + outRecipientCell.setSUDTAmount(sudtAmountForRecipient); + + return outRecipientCell; + } + + private createWitness(firstInputs: PwCell[], secondInputs: PwCell[]): WitnessArgs[] { + const witness: WitnessArgs[] = []; + if (typeof this.options.exchangeProvider === 'string') { + firstInputs.map(() => witness.push(this.getWitnessPlaceholder(this.options.exchangeProvider as Address))); + } else { + const cell = this.options.exchangeProvider![0] as Cell; + firstInputs.map(() => + witness.push(this.getWitnessPlaceholder(this.provider.parseToAddress(cell.cell_output.lock))), + ); + } + secondInputs.map(() => witness.push(this.getWitnessPlaceholder(this.options.sudtSender as Address))); + return witness; + } + + private calcMinExchangeProviderCkb() { + let lock: Script; + if (typeof this.options.exchangeProvider === 'string') { + lock = this.provider.parseToScript(this.options.exchangeProvider as string); + } else { + const cell = this.options.exchangeProvider[0]! as Cell; + lock = cell.cell_output.lock; + } + + const exchangeProviderCell = { + cell_output: { + capacity: '0x0', + lock: lock, + type: this.options.sudt, + }, + data: new Amount('0').toUInt128LE(), + }; + + const ckbAmountForRecipient = new Amount( + new BigNumber(this.options.ckbAmountForRecipient).toString(), + AmountUnit.shannon, + ); + const ckbAmount = ckbAmountForRecipient.add( + new Amount(minimalCellCapacity(exchangeProviderCell).toString(), AmountUnit.shannon), + ); + return ckbAmount.add(feeCkb); + } + + private calcNeededSudtAmount() { + const sudtAmountForExchange = new Amount( + new BigNumber(this.options.sudtAmountForExchange).toString(), + AmountUnit.shannon, + ); + const sudtAmountForRecipient = new Amount( + new BigNumber(this.options.sudtAmountForRecipient).toString(), + AmountUnit.shannon, + ); + return sudtAmountForExchange.add(sudtAmountForRecipient); + } + + private calcCkbAndSudtSum(cells: PwCell[]): [Amount, Amount] { + const [ckbSum, sudtSum] = cells.reduce( + ([cellCapacity, sudtAmount], input) => { + cellCapacity = cellCapacity.add(input.capacity); + sudtAmount = sudtAmount.add(input.getSUDTAmount()); + return [cellCapacity, sudtAmount]; + }, + [Amount.ZERO, Amount.ZERO], + ); + return [ckbSum, sudtSum]; + } + + private checkTransaction(tx: Transaction) { + const inputs = tx.raw.inputCells; + const outputs = tx.raw.outputs; + + this.checkCellsCapacity(inputs); + this.checkCellsCapacity(outputs); + + const [inputCkbSum, inputSudtSum] = this.calcCkbAndSudtSum(inputs); + const [outputCkbSum, outputSudtSum] = this.calcCkbAndSudtSum(outputs); + + if (inputCkbSum.lt(outputCkbSum)) { + throw new Error(`Sum(inputs.capacity) < Sum(outputs.capacity)! \ + Sum(inputs.capacity): ${inputCkbSum}, Sum(outputs.capacity): ${outputCkbSum}`); + } + + if (inputSudtSum.lt(outputSudtSum)) { + throw new Error(`Sum(inputs.sudt) < Sum(outputs.sudt)! \ + Sum(inputs.sudt): ${inputSudtSum}, Sum(outputs.sudt): ${outputSudtSum}`); + } + } + + private checkCellsCapacity(cells: PwCell[]) { + for (const cell of cells) { + const minCapacity = new Amount(cellOccupiedBytes(cell).toString(), AmountUnit.ckb); + + if (minCapacity.gt(cell.capacity)) { + throw new Error( + `Capacity of the cell is too small! Capacity: ${cell.capacity}, Occupied bytes: ${minCapacity}`, + ); + } + } + } +} diff --git a/packages/ckit/src/tx-builders/index.ts b/packages/ckit/src/tx-builders/index.ts index 8d85e76..61a4206 100644 --- a/packages/ckit/src/tx-builders/index.ts +++ b/packages/ckit/src/tx-builders/index.ts @@ -6,3 +6,4 @@ export * from './rc'; export * from './ChequeDepositBuilder'; export * from './ChequeClaimBuilder'; export * from './ChequeWithdrawBuilder'; +export * from './ExchangeSudtForCkbBuilder'; diff --git a/packages/ckit/src/wallets/AbstractSingleEntrySigner.ts b/packages/ckit/src/wallets/AbstractSingleEntrySigner.ts index 3c4fcc8..f19410a 100644 --- a/packages/ckit/src/wallets/AbstractSingleEntrySigner.ts +++ b/packages/ckit/src/wallets/AbstractSingleEntrySigner.ts @@ -19,6 +19,10 @@ export abstract class AbstractSingleEntrySigner implements EntrySigner { abstract getAddress(): Promise | string; abstract signMessage(message: HexString): Promise | HexString; + async partialSeal(tx: PwTransaction): Promise { + return await this.adapter.sign(tx as PwTransaction); + } + // TODO refactor to sig entry to adapt mercury /** * diff --git a/packages/ckit/src/wallets/pw/PwAdapterSigner.ts b/packages/ckit/src/wallets/pw/PwAdapterSigner.ts index 2f45fde..a9a7959 100644 --- a/packages/ckit/src/wallets/pw/PwAdapterSigner.ts +++ b/packages/ckit/src/wallets/pw/PwAdapterSigner.ts @@ -15,6 +15,8 @@ import { import { Pw } from '../../helpers/pw'; import { AbstractSingleEntrySigner } from '../AbstractSingleEntrySigner'; +const emptySig = ''; + export class PwAdapterSigner { constructor( private rawSigner: AbstractSingleEntrySigner, @@ -27,7 +29,14 @@ export class PwAdapterSigner { const signerLock = Pw.toPwScript(this.provider.parseToScript(await this.rawSigner.getAddress())); for (const item of messages) { - if (item.lock.codeHash !== signerLock.codeHash || item.lock.hashType !== signerLock.hashType) continue; + if ( + item.lock.codeHash !== signerLock.codeHash || + item.lock.hashType !== signerLock.hashType || + item.lock.args.substring(0, 40) !== signerLock.args.substring(0, 40) + ) { + sigs.push(emptySig); + continue; + } const sig = await this.rawSigner.signMessage(item.message); sigs.push(sig); @@ -42,7 +51,7 @@ export class PwAdapterSigner { for (let i = 0; i < messages.length; i++) { const { index } = messages[i]!; - if (index < tx.witnessArgs.length && typeof tx.witnessArgs[index] !== 'string') { + if (index < tx.witnessArgs.length && witnesses[i] != emptySig && typeof tx.witnessArgs[index] !== 'string') { witnesses[i] = new Reader( SerializeWitnessArgs( normalizers.NormalizeWitnessArgs({ @@ -110,6 +119,9 @@ function FillSignedWitnesses(tx: Transaction, messages: Message[], witnesses: st throw new Error('Invalid number of witnesses!'); } for (let i = 0; i < messages.length; i++) { + if (witnesses[i] === emptySig) { + continue; + } tx.witnesses[messages[i]!.index] = witnesses[i]!; } return tx;