From cd9ae837c7170ac2732ebc2252b4dea13a88535c Mon Sep 17 00:00:00 2001 From: Keith Date: Fri, 16 Aug 2024 22:02:57 +0900 Subject: [PATCH] feat: add recycle sudt cells --- packages/neuron/public/locales/en/tools.json | 9 +- packages/neuron/public/locales/zh/tools.json | 9 +- .../pages/tools/RecycleCells/generate_tx.ts | 81 +++++++++++++++++ .../tools/RecycleCells/index.module.scss | 55 ++++++++++++ .../src/pages/tools/RecycleCells/index.tsx | 90 +++++++++++++++++++ .../neuron/src/pages/tools/index.module.scss | 2 +- .../neuron/src/pages/tools/index.page.tsx | 4 + packages/neuron/src/utils/browser.ts | 11 +++ packages/neuron/src/utils/constants.ts | 2 + .../neuron/src/utils/export-tx-to-sign.ts | 4 +- packages/neuron/src/utils/index.ts | 2 + 11 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 packages/neuron/src/pages/tools/RecycleCells/generate_tx.ts create mode 100644 packages/neuron/src/pages/tools/RecycleCells/index.module.scss create mode 100644 packages/neuron/src/pages/tools/RecycleCells/index.tsx create mode 100644 packages/neuron/src/utils/browser.ts create mode 100644 packages/neuron/src/utils/constants.ts diff --git a/packages/neuron/public/locales/en/tools.json b/packages/neuron/public/locales/en/tools.json index b9e14eaa..e94bd405 100644 --- a/packages/neuron/public/locales/en/tools.json +++ b/packages/neuron/public/locales/en/tools.json @@ -9,5 +9,12 @@ "Processing": "Processing", "Transaction_Complete": "Transaction complete", "Download": "Download", - "Incorrect_JSON": "JSON format is incorrect, Please check the upload file" + "Incorrect_JSON": "JSON format is incorrect, Please check the upload file", + "Holder_Address": "Address to recycle", + "Args_of_SUDT": "Args of sUDT", + "Receiver_Address": "Address to receive CKB", + "Recycle_SUDT_Cells": "Recycle sUDT Cells", + "Generate": "Generate", + "Recycle_SUDT_Cells_Tip": "Generate a transaction to wipe and merge specific sUDT cells, the transaction can be signed and submitted by Neuron => Menu => Tools => Offline Sign / Broadcast Transaction", + "Generate_TX_to_recycle_sudt_cells": "Generated a transaction to recycle {{total}} CKB from {{cellCount}} Cells" } diff --git a/packages/neuron/public/locales/zh/tools.json b/packages/neuron/public/locales/zh/tools.json index 3ce2c2c8..10db9f43 100644 --- a/packages/neuron/public/locales/zh/tools.json +++ b/packages/neuron/public/locales/zh/tools.json @@ -9,5 +9,12 @@ "Processing": "处理中", "Transaction_Complete": "转换完成", "Download": "下载", - "Incorrect_JSON": "JSON 格式不正确,请检查上传的文件" + "Incorrect_JSON": "JSON 格式不正确,请检查上传的文件", + "Holder_Address": "待回收地址", + "Args_of_SUDT": "sUDT 的 Args", + "Receiver_Address": "接收 CKB 地址", + "Recycle_SUDT_Cells": "回收 sUDT Cells", + "Generate": "生成", + "Recycle_SUDT_Cells_Tip": "构建将清除并合并指定 sUDT Cells 的交易, 交易可以通过 Neuron 的 菜单 => 工具 => 离线签名 / 广播交易 功能签名及提交", + "Generate_TX_to_recycle_sudt_cells": "已构建交易从 {{cellCount}} Cells 回收 {{total}} CKB" } diff --git a/packages/neuron/src/pages/tools/RecycleCells/generate_tx.ts b/packages/neuron/src/pages/tools/RecycleCells/generate_tx.ts new file mode 100644 index 00000000..11f3bf48 --- /dev/null +++ b/packages/neuron/src/pages/tools/RecycleCells/generate_tx.ts @@ -0,0 +1,81 @@ +import { Indexer } from '@ckb-lumos/ckb-indexer' +import { BI, helpers, Script, config, Cell, BIish } from '@ckb-lumos/lumos' +import { sealTransaction } from '@ckb-lumos/lumos/helpers' +import { CKB_MAINNET_NODE_URL, CKB_TESTNET_NODE_URL } from '../../../utils/constants' + +export async function generateTxToRecycleSUDTCells(holder: string, sudtArgs: string, receiver: string) { + const IS_MAINNET = holder.startsWith('ckb') + const ENDPOINT = IS_MAINNET ? CKB_MAINNET_NODE_URL : CKB_TESTNET_NODE_URL + const indexer = new Indexer(ENDPOINT) + + const CONFIG = IS_MAINNET ? config.predefined.LINA : config.predefined.AGGRON4 + + const SUDT_CONFIG = CONFIG.SCRIPTS.SUDT + const SUDT_CELL_DEP = { + outPoint: { txHash: SUDT_CONFIG.TX_HASH, index: SUDT_CONFIG.INDEX }, + depType: SUDT_CONFIG.DEP_TYPE, + } + + const ANYONE_CAN_PAY_CONFIG = CONFIG.SCRIPTS.ANYONE_CAN_PAY + const ANYONE_CAN_PAY_DEP = { + outPoint: { txHash: ANYONE_CAN_PAY_CONFIG.TX_HASH, index: ANYONE_CAN_PAY_CONFIG.INDEX }, + depType: ANYONE_CAN_PAY_CONFIG.DEP_TYPE, + } + + const lock: Script = helpers.parseAddress(holder, { config: CONFIG }) + const type: Script = { + codeHash: SUDT_CONFIG.CODE_HASH, + hashType: SUDT_CONFIG.HASH_TYPE, + args: sudtArgs, + } + + const receiverLock = helpers.parseAddress(receiver, { config: CONFIG }) + + const cells: Array = [] + + const collector = indexer.collector({ type, lock }) + + for await (const cell of collector.collect()) { + cells.push(cell) + } + if (!cells.length) { + throw new Error('No cells found') + } + + const totalCKB = cells.reduce((acc, cur) => BI.from(cur.cellOutput.capacity).add(acc), BI.from(0)) + + let txSkeleton = helpers.TransactionSkeleton() + txSkeleton = txSkeleton.update('cellDeps', deps => deps.push(SUDT_CELL_DEP, ANYONE_CAN_PAY_DEP)) + + txSkeleton = txSkeleton = txSkeleton = txSkeleton.update('inputs', inputs => inputs.push(...cells)) + + const SIZE = 340 + 52 * cells.length // precomputed + const fee = calculateFee(SIZE) + + const total = totalCKB.sub(fee).toHexString() + + txSkeleton = txSkeleton = txSkeleton.update('outputs', outputs => { + const receiverCell: Cell = { + cellOutput: { + capacity: total, + lock: receiverLock, + }, + data: '0x', + } + return outputs.push(receiverCell) + }) + + const tx = sealTransaction(txSkeleton, []) + return { tx, total } +} + +function calculateFee(size: number, feeRate: BIish = 1200): BI { + const ratio = BI.from(1000) + const base = BI.from(size).mul(feeRate) + const fee = base.div(ratio) + + if (fee.mul(ratio).lt(base)) { + return fee.add(1) + } + return BI.from(fee) +} diff --git a/packages/neuron/src/pages/tools/RecycleCells/index.module.scss b/packages/neuron/src/pages/tools/RecycleCells/index.module.scss new file mode 100644 index 00000000..a95281c0 --- /dev/null +++ b/packages/neuron/src/pages/tools/RecycleCells/index.module.scss @@ -0,0 +1,55 @@ +.container { + display: flex; + flex-direction: column; + gap: 16px; + align-items: start; + justify-content: center; + width: 100%; + + fieldset { + display: flex; + align-items: center; + width: 100%; + padding: 0; + border: none; + appearance: none; + + label { + flex-basis: 240px; + } + } + + input { + width: 100%; + height: 2em; + padding: 16px 8px; + color: #fff; + background: transparent; + border: 1px solid rgb(255 255 255 / 20%); + border-radius: 8px; + } + + .overview { + height: 1rem; + } + + .err { + color: #f62a2a; + font-weight: 400; + } + + .loading { + width: 1rem; + height: 1rem; + border: #000 2px solid; + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s infinite linear; + } + + @keyframes spin { + 100% { + transform: rotate(1turn); + } + } +} diff --git a/packages/neuron/src/pages/tools/RecycleCells/index.tsx b/packages/neuron/src/pages/tools/RecycleCells/index.tsx new file mode 100644 index 00000000..f2cec08e --- /dev/null +++ b/packages/neuron/src/pages/tools/RecycleCells/index.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from 'next-i18next' +import { useState } from 'react' +import { BI } from '@ckb-lumos/lumos' +import { Button } from '../../../components/Button' +import exportTxToSign from '../../../utils/export-tx-to-sign' +import { generateTxToRecycleSUDTCells } from './generate_tx' +import { downloadFile } from '../../../utils' +import styles from './index.module.scss' + +export const RecycleCells = () => { + const { t } = useTranslation('tools') + const [isLoading, setIsLoading] = useState(false) + const [overview, setOverview] = useState('') + const [err, setErr] = useState('') + + const handleSubmit = (e: React.SyntheticEvent) => { + e.stopPropagation() + e.preventDefault() + setIsLoading(true) + setOverview('') + setErr('') + + const { holder: holderElm, sudt: sudtElm, receiver: receiverElm } = e.currentTarget + + if ( + !(holderElm instanceof HTMLInputElement) || + !(sudtElm instanceof HTMLInputElement) || + !(receiverElm instanceof HTMLInputElement) + ) { + return + } + + const handle = async () => { + try { + const holder = holderElm.value + const sudtArgs = sudtElm.value + const receiver = receiverElm.value + const { tx, total } = await generateTxToRecycleSUDTCells(holder, sudtArgs, receiver) + setOverview( + t('Generate_TX_to_recycle_sudt_cells', { + total: BI.from(total) + .div(10 ** 8) + .toString(), + cellCount: tx.inputs.length, + }).toString(), + ) + + const nodeType = holder.startsWith('ckb') ? 'mainnet' : 'testnet' + + const formatedTx = await exportTxToSign({ json: tx, nodeType }) + + const blob = new Blob([JSON.stringify(formatedTx, undefined, 2)]) + const filename = `tx_to_recycle_cells.json` + downloadFile(blob, filename) + } catch (e) { + if (e instanceof Error) { + setErr(e.message) + } + } finally { + setIsLoading(false) + } + } + void handle() + } + + return ( +
+
{t('Recycle_SUDT_Cells')}
+
{t('Recycle_SUDT_Cells_Tip')}
+
+ + +
+
+ + +
+
+ + +
+ {err ?
{err}
:
{overview}
} + +
+ ) +} + +export default RecycleCells diff --git a/packages/neuron/src/pages/tools/index.module.scss b/packages/neuron/src/pages/tools/index.module.scss index 51e46f23..d9d04664 100644 --- a/packages/neuron/src/pages/tools/index.module.scss +++ b/packages/neuron/src/pages/tools/index.module.scss @@ -27,7 +27,7 @@ gap: 16px; align-items: flex-start; justify-content: center; - margin-bottom: 144px; + margin-bottom: 24px; padding: 24px; background: linear-gradient(180deg, rgb(54 54 54 / 40%) 0%, rgb(29 29 29 / 20%) 100%); border: 1px solid rgb(255 255 255 / 20%); diff --git a/packages/neuron/src/pages/tools/index.page.tsx b/packages/neuron/src/pages/tools/index.page.tsx index 557d1ff6..3e755cb3 100644 --- a/packages/neuron/src/pages/tools/index.page.tsx +++ b/packages/neuron/src/pages/tools/index.page.tsx @@ -14,6 +14,7 @@ import { Page } from '../../components/Page' import styles from './index.module.scss' import { Button } from '../../components/Button' import exportTxToSign, { JSONFormatError } from '../../utils/export-tx-to-sign' +import RecycleCells from './RecycleCells' interface PageProps {} @@ -175,6 +176,9 @@ const Download: NextPage = () => { {isProcess ? : null} +
+ +
) } diff --git a/packages/neuron/src/utils/browser.ts b/packages/neuron/src/utils/browser.ts new file mode 100644 index 00000000..5faf9e03 --- /dev/null +++ b/packages/neuron/src/utils/browser.ts @@ -0,0 +1,11 @@ +export const downloadFile = (blob: Blob | MediaSource, filename: string) => { + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.style.display = 'none' + link.href = url + link.setAttribute('download', filename) + document.body.appendChild(link) + link.click() + URL.revokeObjectURL(url) + document.body.removeChild(link) +} diff --git a/packages/neuron/src/utils/constants.ts b/packages/neuron/src/utils/constants.ts new file mode 100644 index 00000000..246f845b --- /dev/null +++ b/packages/neuron/src/utils/constants.ts @@ -0,0 +1,2 @@ +export const CKB_MAINNET_NODE_URL = 'https://mainnet.ckb.dev/rpc' +export const CKB_TESTNET_NODE_URL = 'https://testnet.ckb.dev/rpc' diff --git a/packages/neuron/src/utils/export-tx-to-sign.ts b/packages/neuron/src/utils/export-tx-to-sign.ts index 0092a5ad..e520d242 100644 --- a/packages/neuron/src/utils/export-tx-to-sign.ts +++ b/packages/neuron/src/utils/export-tx-to-sign.ts @@ -1,7 +1,5 @@ import { type Script, utils } from '@ckb-lumos/base' - -const CKB_MAINNET_NODE_URL = 'https://mainnet.ckb.dev/rpc' -const CKB_TESTNET_NODE_URL = 'https://testnet.ckb.dev/rpc' +import { CKB_MAINNET_NODE_URL, CKB_TESTNET_NODE_URL } from './constants' interface Transaction { hash: string diff --git a/packages/neuron/src/utils/index.ts b/packages/neuron/src/utils/index.ts index 92922446..d1677058 100644 --- a/packages/neuron/src/utils/index.ts +++ b/packages/neuron/src/utils/index.ts @@ -9,3 +9,5 @@ export * from './algolia' export * from './route' export * from './env' export * from './node' +export * from './browser' +export * from './constants'