Skip to content

Commit cd9ae83

Browse files
committed
feat: add recycle sudt cells
1 parent 3fd89f1 commit cd9ae83

File tree

11 files changed

+263
-6
lines changed

11 files changed

+263
-6
lines changed

packages/neuron/public/locales/en/tools.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,12 @@
99
"Processing": "Processing",
1010
"Transaction_Complete": "Transaction complete",
1111
"Download": "Download",
12-
"Incorrect_JSON": "JSON format is incorrect, Please check the upload file"
12+
"Incorrect_JSON": "JSON format is incorrect, Please check the upload file",
13+
"Holder_Address": "Address to recycle",
14+
"Args_of_SUDT": "Args of sUDT",
15+
"Receiver_Address": "Address to receive CKB",
16+
"Recycle_SUDT_Cells": "Recycle sUDT Cells",
17+
"Generate": "Generate",
18+
"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",
19+
"Generate_TX_to_recycle_sudt_cells": "Generated a transaction to recycle {{total}} CKB from {{cellCount}} Cells"
1320
}

packages/neuron/public/locales/zh/tools.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,12 @@
99
"Processing": "处理中",
1010
"Transaction_Complete": "转换完成",
1111
"Download": "下载",
12-
"Incorrect_JSON": "JSON 格式不正确,请检查上传的文件"
12+
"Incorrect_JSON": "JSON 格式不正确,请检查上传的文件",
13+
"Holder_Address": "待回收地址",
14+
"Args_of_SUDT": "sUDT 的 Args",
15+
"Receiver_Address": "接收 CKB 地址",
16+
"Recycle_SUDT_Cells": "回收 sUDT Cells",
17+
"Generate": "生成",
18+
"Recycle_SUDT_Cells_Tip": "构建将清除并合并指定 sUDT Cells 的交易, 交易可以通过 Neuron 的 菜单 => 工具 => 离线签名 / 广播交易 功能签名及提交",
19+
"Generate_TX_to_recycle_sudt_cells": "已构建交易从 {{cellCount}} Cells 回收 {{total}} CKB"
1320
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Indexer } from '@ckb-lumos/ckb-indexer'
2+
import { BI, helpers, Script, config, Cell, BIish } from '@ckb-lumos/lumos'
3+
import { sealTransaction } from '@ckb-lumos/lumos/helpers'
4+
import { CKB_MAINNET_NODE_URL, CKB_TESTNET_NODE_URL } from '../../../utils/constants'
5+
6+
export async function generateTxToRecycleSUDTCells(holder: string, sudtArgs: string, receiver: string) {
7+
const IS_MAINNET = holder.startsWith('ckb')
8+
const ENDPOINT = IS_MAINNET ? CKB_MAINNET_NODE_URL : CKB_TESTNET_NODE_URL
9+
const indexer = new Indexer(ENDPOINT)
10+
11+
const CONFIG = IS_MAINNET ? config.predefined.LINA : config.predefined.AGGRON4
12+
13+
const SUDT_CONFIG = CONFIG.SCRIPTS.SUDT
14+
const SUDT_CELL_DEP = {
15+
outPoint: { txHash: SUDT_CONFIG.TX_HASH, index: SUDT_CONFIG.INDEX },
16+
depType: SUDT_CONFIG.DEP_TYPE,
17+
}
18+
19+
const ANYONE_CAN_PAY_CONFIG = CONFIG.SCRIPTS.ANYONE_CAN_PAY
20+
const ANYONE_CAN_PAY_DEP = {
21+
outPoint: { txHash: ANYONE_CAN_PAY_CONFIG.TX_HASH, index: ANYONE_CAN_PAY_CONFIG.INDEX },
22+
depType: ANYONE_CAN_PAY_CONFIG.DEP_TYPE,
23+
}
24+
25+
const lock: Script = helpers.parseAddress(holder, { config: CONFIG })
26+
const type: Script = {
27+
codeHash: SUDT_CONFIG.CODE_HASH,
28+
hashType: SUDT_CONFIG.HASH_TYPE,
29+
args: sudtArgs,
30+
}
31+
32+
const receiverLock = helpers.parseAddress(receiver, { config: CONFIG })
33+
34+
const cells: Array<Cell> = []
35+
36+
const collector = indexer.collector({ type, lock })
37+
38+
for await (const cell of collector.collect()) {
39+
cells.push(cell)
40+
}
41+
if (!cells.length) {
42+
throw new Error('No cells found')
43+
}
44+
45+
const totalCKB = cells.reduce((acc, cur) => BI.from(cur.cellOutput.capacity).add(acc), BI.from(0))
46+
47+
let txSkeleton = helpers.TransactionSkeleton()
48+
txSkeleton = txSkeleton.update('cellDeps', deps => deps.push(SUDT_CELL_DEP, ANYONE_CAN_PAY_DEP))
49+
50+
txSkeleton = txSkeleton = txSkeleton = txSkeleton.update('inputs', inputs => inputs.push(...cells))
51+
52+
const SIZE = 340 + 52 * cells.length // precomputed
53+
const fee = calculateFee(SIZE)
54+
55+
const total = totalCKB.sub(fee).toHexString()
56+
57+
txSkeleton = txSkeleton = txSkeleton.update('outputs', outputs => {
58+
const receiverCell: Cell = {
59+
cellOutput: {
60+
capacity: total,
61+
lock: receiverLock,
62+
},
63+
data: '0x',
64+
}
65+
return outputs.push(receiverCell)
66+
})
67+
68+
const tx = sealTransaction(txSkeleton, [])
69+
return { tx, total }
70+
}
71+
72+
function calculateFee(size: number, feeRate: BIish = 1200): BI {
73+
const ratio = BI.from(1000)
74+
const base = BI.from(size).mul(feeRate)
75+
const fee = base.div(ratio)
76+
77+
if (fee.mul(ratio).lt(base)) {
78+
return fee.add(1)
79+
}
80+
return BI.from(fee)
81+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
.container {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 16px;
5+
align-items: start;
6+
justify-content: center;
7+
width: 100%;
8+
9+
fieldset {
10+
display: flex;
11+
align-items: center;
12+
width: 100%;
13+
padding: 0;
14+
border: none;
15+
appearance: none;
16+
17+
label {
18+
flex-basis: 240px;
19+
}
20+
}
21+
22+
input {
23+
width: 100%;
24+
height: 2em;
25+
padding: 16px 8px;
26+
color: #fff;
27+
background: transparent;
28+
border: 1px solid rgb(255 255 255 / 20%);
29+
border-radius: 8px;
30+
}
31+
32+
.overview {
33+
height: 1rem;
34+
}
35+
36+
.err {
37+
color: #f62a2a;
38+
font-weight: 400;
39+
}
40+
41+
.loading {
42+
width: 1rem;
43+
height: 1rem;
44+
border: #000 2px solid;
45+
border-top-color: transparent;
46+
border-radius: 50%;
47+
animation: spin 1s infinite linear;
48+
}
49+
50+
@keyframes spin {
51+
100% {
52+
transform: rotate(1turn);
53+
}
54+
}
55+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { useTranslation } from 'next-i18next'
2+
import { useState } from 'react'
3+
import { BI } from '@ckb-lumos/lumos'
4+
import { Button } from '../../../components/Button'
5+
import exportTxToSign from '../../../utils/export-tx-to-sign'
6+
import { generateTxToRecycleSUDTCells } from './generate_tx'
7+
import { downloadFile } from '../../../utils'
8+
import styles from './index.module.scss'
9+
10+
export const RecycleCells = () => {
11+
const { t } = useTranslation('tools')
12+
const [isLoading, setIsLoading] = useState(false)
13+
const [overview, setOverview] = useState('')
14+
const [err, setErr] = useState('')
15+
16+
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {
17+
e.stopPropagation()
18+
e.preventDefault()
19+
setIsLoading(true)
20+
setOverview('')
21+
setErr('')
22+
23+
const { holder: holderElm, sudt: sudtElm, receiver: receiverElm } = e.currentTarget
24+
25+
if (
26+
!(holderElm instanceof HTMLInputElement) ||
27+
!(sudtElm instanceof HTMLInputElement) ||
28+
!(receiverElm instanceof HTMLInputElement)
29+
) {
30+
return
31+
}
32+
33+
const handle = async () => {
34+
try {
35+
const holder = holderElm.value
36+
const sudtArgs = sudtElm.value
37+
const receiver = receiverElm.value
38+
const { tx, total } = await generateTxToRecycleSUDTCells(holder, sudtArgs, receiver)
39+
setOverview(
40+
t('Generate_TX_to_recycle_sudt_cells', {
41+
total: BI.from(total)
42+
.div(10 ** 8)
43+
.toString(),
44+
cellCount: tx.inputs.length,
45+
}).toString(),
46+
)
47+
48+
const nodeType = holder.startsWith('ckb') ? 'mainnet' : 'testnet'
49+
50+
const formatedTx = await exportTxToSign({ json: tx, nodeType })
51+
52+
const blob = new Blob([JSON.stringify(formatedTx, undefined, 2)])
53+
const filename = `tx_to_recycle_cells.json`
54+
downloadFile(blob, filename)
55+
} catch (e) {
56+
if (e instanceof Error) {
57+
setErr(e.message)
58+
}
59+
} finally {
60+
setIsLoading(false)
61+
}
62+
}
63+
void handle()
64+
}
65+
66+
return (
67+
<form className={styles.container} onSubmit={handleSubmit}>
68+
<div id="recycle_cells">{t('Recycle_SUDT_Cells')}</div>
69+
<div>{t('Recycle_SUDT_Cells_Tip')}</div>
70+
<fieldset>
71+
<label htmlFor="holder">{t('Holder_Address')}</label>
72+
<input id="holder" placeholder={t('Holder_Address')!} />
73+
</fieldset>
74+
<fieldset>
75+
<label htmlFor="sudt">{t('Args_of_SUDT')}</label>
76+
<input id="sudt" placeholder={t('Args_of_SUDT')!} />
77+
</fieldset>
78+
<fieldset>
79+
<label htmlFor="receiver">{t('Receiver_Address')}</label>
80+
<input id="receiver" placeholder={t('Receiver_Address')!} />
81+
</fieldset>
82+
{err ? <div className={styles.err}>{err}</div> : <div className={styles.overview}>{overview}</div>}
83+
<Button style={{ width: 144 }} disabled={isLoading} type="submit">
84+
{isLoading ? <div className={styles.loading} /> : t('Generate')}
85+
</Button>
86+
</form>
87+
)
88+
}
89+
90+
export default RecycleCells

packages/neuron/src/pages/tools/index.module.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
gap: 16px;
2828
align-items: flex-start;
2929
justify-content: center;
30-
margin-bottom: 144px;
30+
margin-bottom: 24px;
3131
padding: 24px;
3232
background: linear-gradient(180deg, rgb(54 54 54 / 40%) 0%, rgb(29 29 29 / 20%) 100%);
3333
border: 1px solid rgb(255 255 255 / 20%);

packages/neuron/src/pages/tools/index.page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Page } from '../../components/Page'
1414
import styles from './index.module.scss'
1515
import { Button } from '../../components/Button'
1616
import exportTxToSign, { JSONFormatError } from '../../utils/export-tx-to-sign'
17+
import RecycleCells from './RecycleCells'
1718

1819
interface PageProps {}
1920

@@ -175,6 +176,9 @@ const Download: NextPage<PageProps> = () => {
175176
{isProcess ? <RefreshSvg /> : null}
176177
</Button>
177178
</div>
179+
<div className={styles.body}>
180+
<RecycleCells />
181+
</div>
178182
</Page>
179183
)
180184
}

packages/neuron/src/utils/browser.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const downloadFile = (blob: Blob | MediaSource, filename: string) => {
2+
const url = window.URL.createObjectURL(blob)
3+
const link = document.createElement('a')
4+
link.style.display = 'none'
5+
link.href = url
6+
link.setAttribute('download', filename)
7+
document.body.appendChild(link)
8+
link.click()
9+
URL.revokeObjectURL(url)
10+
document.body.removeChild(link)
11+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const CKB_MAINNET_NODE_URL = 'https://mainnet.ckb.dev/rpc'
2+
export const CKB_TESTNET_NODE_URL = 'https://testnet.ckb.dev/rpc'

packages/neuron/src/utils/export-tx-to-sign.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { type Script, utils } from '@ckb-lumos/base'
2-
3-
const CKB_MAINNET_NODE_URL = 'https://mainnet.ckb.dev/rpc'
4-
const CKB_TESTNET_NODE_URL = 'https://testnet.ckb.dev/rpc'
2+
import { CKB_MAINNET_NODE_URL, CKB_TESTNET_NODE_URL } from './constants'
53

64
interface Transaction {
75
hash: string

packages/neuron/src/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ export * from './algolia'
99
export * from './route'
1010
export * from './env'
1111
export * from './node'
12+
export * from './browser'
13+
export * from './constants'

0 commit comments

Comments
 (0)