Skip to content

Commit

Permalink
Merge pull request #7 from 1razoo/master
Browse files Browse the repository at this point in the history
Improve UTXO consolidation
  • Loading branch information
Antares-RXD authored Oct 10, 2024
2 parents e972cc9 + 8fc58f6 commit 2fbf2f6
Show file tree
Hide file tree
Showing 21 changed files with 634 additions and 63 deletions.
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@photonic/app",
"private": true,
"version": "1.0.5",
"version": "1.0.7",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import Electrum from "./electrum/Electrum";
import WalletNotifier from "./components/WalletNotifier";
import { loadWalletFromSaved } from "./wallet";
import useActivityDetector from "./hooks/useActivityDetector";
import ConsolidationModal from "./components/ConsolidationModal";

function Main() {
useActivityDetector();
Expand All @@ -35,6 +36,7 @@ function Main() {
<>
<Unlock />
<SendReceive />
<ConsolidationModal />
</>
)}
<ReloadPrompt />
Expand Down
262 changes: 262 additions & 0 deletions packages/app/src/components/ConsolidationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { useState } from "react";
import db from "@app/db";
import { PrivateKey } from "@radiantblockchain/radiantjs";
import { ContractType } from "@app/types";
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
ModalCloseButton,
useDisclosure,
Box,
useToast,
} from "@chakra-ui/react";
import { t } from "@lingui/macro";
import { useLiveQuery } from "dexie-react-hooks";
import ContractName from "./ContractName";
import { p2pkhScript, p2pkhScriptSigSize, txSize } from "@lib/script";
import { feeRate, openModal, wallet } from "@app/signals";
import { buildTx } from "@lib/tx";
import { UnfinalizedInput, Utxo } from "@lib/types";
import { electrumWorker } from "@app/electrum/Electrum";
import { fundTx } from "@lib/coinSelect";

const unlock = (fn: () => void) => {
if (wallet.value.locked) {
openModal.value = {
modal: "unlock",
onClose: (success) => success && fn(),
};
} else {
fn();
}
};

async function consolidate() {
// Consolidate RXD UTXOs first
const rxd: UnfinalizedInput[] = await db.txo
.where({ contractType: ContractType.RXD, spent: 0 })
.toArray();

// Use the consolidated UTXO for funding FT transactions
const { consolidated } = await consolidateUtxos(rxd, [], false);

if (!consolidated) {
console.debug("No funding");
throw new Error("No funding");
}

let funding: Utxo[] = [consolidated];

const allFts = await db.txo
.where({ contractType: ContractType.FT, spent: 0 })
.toArray();

// Group by token
const fts: { [key: string]: Utxo[] } = {};
allFts.map((ft) => {
if (!fts[ft.script]) {
fts[ft.script] = [];
}
fts[ft.script].push(ft);
});

for (const utxos of Object.values(fts)) {
const result = await consolidateUtxos(utxos, funding, true);
funding = result.funding;
}

db.kvp.put(false, "consolidationRequired");
}

async function consolidateUtxos(
utxos: Utxo[],
funding: UnfinalizedInput[],
requiresFunding: boolean
) {
if (!utxos.length) {
return { funding, consolidated: undefined };
}

if (utxos.length === 1) {
return { funding, consolidated: utxos[0] };
}

const maxInputs = 50;
const totalUtxos = utxos.length;
let consolidated: Utxo | undefined = undefined;

// Output script will be the same
const script = utxos[0].script;
const scriptSize = script.length / 2;
const p2pkh = p2pkhScript(wallet.value.address);

for (let i = 0; i < totalUtxos; i += maxInputs) {
const inputs: Utxo[] = utxos.slice(i, i + maxInputs);

if (consolidated) {
inputs.push(consolidated);
}

const total = inputs.reduce((sum, cur) => sum + cur.value, 0);

const privKey = PrivateKey.fromString(wallet.value.wif as string);
const outputs = [];

let fund;

if (requiresFunding) {
outputs.push({ script, value: total });

// Need funding input for FTs
fund = fundTx(
wallet.value.address,
funding,
inputs,
outputs,
p2pkh,
feeRate.value
);
if (!fund.funded) {
console.log("Failed to fund");
throw new Error("Failed to fund");
}
inputs.push(...fund.funding);
outputs.push(...fund.change);
} else {
const size = txSize(new Array(inputs.length).fill(p2pkhScriptSigSize), [
scriptSize,
]);
const fee = size * feeRate.value;
outputs.push({ script, value: total - fee });
}

const tx = buildTx(
wallet.value.address,
privKey.toString(),
inputs,
outputs,
false
);

if (requiresFunding) {
funding = fund?.remaining || [];
if (fund?.change.length) {
funding.push(
...fund.change.map((change, index) => ({
txid: tx.id,
vout: index + 1,
...change,
}))
);
}
}

const rawTx = tx.toString();
const txid = await electrumWorker.value.broadcast(rawTx);
db.broadcast.put({
txid,
date: Date.now(),
description: "consolidate",
});
consolidated = { ...outputs[0], vout: 0, txid };
}
return { funding, consolidated };
}

const OutputCounts = () => {
const subs = useLiveQuery(async () => {
return (await Promise.all(
(
await db.subscriptionStatus.toArray()
).map(async (sub) => {
const count = await db.txo
.where({ contractType: sub.contractType, spent: 0 })
.count();

return [sub.contractType, count];
})
)) as [ContractType, number][];
});
return subs?.map(
([contractType, count]) =>
contractType !== ContractType.NFT && (
<div key={contractType}>
<ContractName contractType={contractType} />: {count}{" "}
{count === 1 ? "UTXO" : "UTXOs"}
</div>
)
);
};

export default function ConsolidationModal() {
const toast = useToast();
const disclosure = useDisclosure();
const [waiting, setWaiting] = useState(false);

useLiveQuery(async () => {
const consolidationRequired = await db.kvp.get("consolidationRequired");
if (consolidationRequired && !disclosure.isOpen) {
disclosure.onOpen();
}
});

const onClick = () => {
setWaiting(true);
unlock(async () => {
try {
await consolidate();
setWaiting(false);
disclosure.onClose();
toast({
title: t`UTXO consolidation complete`,
status: "success",
});
} catch (error) {
setWaiting(false);
if (error instanceof Error) {
toast({
title: error.message,
status: "error",
});
} else {
toast({
title: "Could not consolidate UTXOs",
status: "error",
});
}
}
});
};

return (
<Modal
isOpen={disclosure.isOpen}
onClose={disclosure.onClose}
closeOnOverlayClick={false}
isCentered
size="lg"
>
<ModalOverlay />
<ModalContent>
<ModalHeader>{t`Consolidation required`}</ModalHeader>
<ModalCloseButton />
<ModalBody>
Your wallet contains many unspent outputs that will cause long sync
times. Output consolidation is required.
<Box mt={2}>{disclosure.isOpen && <OutputCounts />}</Box>
</ModalBody>

<ModalFooter>
<Button isLoading={waiting} onClick={onClick}>
{waiting ? t`Consolidating UTXOs` : t`Start consolidation process`}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
13 changes: 13 additions & 0 deletions packages/app/src/components/ContractName.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ContractType } from "@app/types";

export default function ContractName({
contractType,
}: {
contractType: ContractType;
}) {
return {
[ContractType.FT]: "Fungible tokens",
[ContractType.NFT]: "Non-fungible tokens",
[ContractType.RXD]: "RXD",
}[contractType];
}
Loading

0 comments on commit 2fbf2f6

Please sign in to comment.