Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add alby support to send bulk #67

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ test.html
.vscode/

.env
.npmrc
4 changes: 0 additions & 4 deletions .npmrc

This file was deleted.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nosft-core",
"version": "2.5.0",
"version": "2.5.11",
"private": false,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand All @@ -12,7 +12,7 @@
"description": "Tools for making a Nosft client.",
"repository": {
"type": "git",
"url": "https://github.com/deezy-inc/nosft-core"
"url": "git+https://github.com/deezy-inc/nosft-core.git"
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts-resolve",
Expand Down
11 changes: 5 additions & 6 deletions src/app/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ const Crypto = function (config) {
const txSize = baseTxSize + vins * inSize + vouts * outSize + includeChangeOutput * outSize;

const fee = Math.round(txSize * recommendedFeeRate);

return fee;
},

Expand Down Expand Up @@ -95,11 +94,11 @@ const Crypto = function (config) {
d?.get
? d
: {
enumerable: true,
get: function () {
return e[k];
},
}
enumerable: true,
get: function () {
return e[k];
},
}
);
}
});
Expand Down
217 changes: 203 additions & 14 deletions src/app/psbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import { Crypto } from './crypto';
import { Address } from './address';
import { NETWORK, NETWORK_NAME, BOOST_UTXO_VALUE } from '../config/constants';
import { isMetamaskProvider } from './wallet';
import { Utxo } from './utxo';

type Metadata = {
inputs: { index: number; type: 'Ordinal' | 'Cardinal' }[];
outputs: { type: 'Ordinal' | 'Change' | 'Cardinal' }[];
};

bitcoin.initEccLib(ecc);

Expand All @@ -28,11 +34,11 @@ function isHexadecimal(str) {
const getPsbt = (psbtContent) => {
const psbt = isHexadecimal(psbtContent)
? bitcoin.Psbt.fromHex(psbtContent, {
network: NETWORK,
})
network: NETWORK,
})
: bitcoin.Psbt.fromBase64(psbtContent, {
network: NETWORK,
});
network: NETWORK,
});

return psbt;
};
Expand All @@ -45,6 +51,7 @@ const getPsbtBase64 = (psbtContent) => {
const Psbt = function (config) {
const addressModule = Address(config);
const cryptoModule = Crypto(config);
const utxoModule = Utxo(config);

const psbtModule = {
getPsbt,
Expand Down Expand Up @@ -154,8 +161,8 @@ const Psbt = function (config) {

signPsbtForBoostByXverse: async ({ psbt, address }) => {
const signPsbtOptions: SignTransactionOptions = {
onFinish: () => {},
onCancel: () => {},
onFinish: () => { },
onCancel: () => { },
payload: {
network: {
type: NETWORK_NAME,
Expand Down Expand Up @@ -253,8 +260,8 @@ const Psbt = function (config) {
});

const signPsbtOptions: SignTransactionOptions = {
onFinish: () => {},
onCancel: () => {},
onFinish: () => { },
onCancel: () => { },
payload: {
network: {
type: NETWORK_NAME,
Expand Down Expand Up @@ -330,6 +337,188 @@ const Psbt = function (config) {
// Send it!
return psbtModule.broadcastPsbt(psbt);
},
// preparePsbtForMultipleSend allows safe bulk UTXO transfers, with appropriate fees.
// 1. Any input UTXOs with an inscription will be created as a distinct output UTXO of the same size, and appear before any non-inscription UTXOs in the resultant PSBT
// 2. Any input UTXOs without an inscription will have their amounts consolidated into a single output UTXO
// 3. A check is run to ensure that enough cardinal funds are available to perform the transfer. If step 2 does not contain enough cardinal funds, cardinal funds will be appended to the PSBT, and another change output will be created
preparePsbtForMultipleSend: async ({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe add comment to describe general flow:

preparePsbtForMultipleSend allows safe bulk UTXO transfers, with appropriate fees.
1. Any input UTXOs with an inscription will be created as a distinct output UTXO of the same size, and appear before any non-inscription UTXOs in the resultant PSBT
2. Any input UTXOs without an inscription will have their amounts consolidated into a single output UTXO
3. A check is run to ensure that enough cardinal funds are available to perform the transfer. If step 2 does not contain enough cardinal funds, cardinal funds will be appended to the PSBT, and another change output will be created

address, pubKey, selectedUtxos,
ownedUtxos, destinationBtcAddress, sendFeeRate
}) => {
const utxosWithInscription = selectedUtxos.filter(utxo => utxo.inscriptionId);
const utxosWithoutInscription = selectedUtxos.filter(utxo => !utxo.inscriptionId);
if (utxosWithInscription.length === 0 && utxosWithoutInscription.length === 0) {
throw new Error('At least one ordinal or utxo is required.');
}
const provider = SessionStorage.get(SessionsStorageKeys.DOMAIN);
if (provider !== 'alby') {
throw new Error('Signing not supported.');
}

// only used if selectedCardinalAmount is not large enough to cover fees
const cardinalUtxos = ownedUtxos.filter(utxo => !selectedUtxos.some(ownedUtxo => ownedUtxo.txid === utxo.txid))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe add comment

// only used if selectedCardinalAmount is not large enough to cover fees

.filter((x) => x.status.confirmed)
.filter((x) => x.value > 10000)
.filter(utxo => !utxo.inscriptionId)
.sort((a, b) => b.value - a.value);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe actually better to do smallest to largest

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignore this, nevermind. You are trying to use the largest spendable UTXO as a cardinal if you need to add one

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should select and use the largest first

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it uses the biggest first.


const selectedCardinalAmount = utxosWithoutInscription.reduce((acc, utxo) => acc + utxo.value, 0);
const inputs = [...utxosWithInscription, ...utxosWithoutInscription];

let totalCardinalAmount = selectedCardinalAmount;
let calculatedFee = 0;
let isCardinalAdded = false;

while (cardinalUtxos.length > 0 || calculatedFee === 0) {
calculatedFee = cryptoModule.calculateFee({
topether21 marked this conversation as resolved.
Show resolved Hide resolved
vins: inputs.length,
vouts: utxosWithInscription.length + (selectedCardinalAmount > 0 ? 1 : 0) + (isCardinalAdded ? 1 : 0), // # of ordinals + 1 cardinal amount + 1 change
recommendedFeeRate: sendFeeRate,
includeChangeOutput: 0,
});

if (totalCardinalAmount >= calculatedFee) break;

const utxo = cardinalUtxos.shift();
if (!utxo) {
throw new Error(`Please add more cardinal funds to your wallet.`);
}
inputs.push(utxo);
totalCardinalAmount += utxo.value;
isCardinalAdded = true;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

love it

}

if (totalCardinalAmount < calculatedFee) {
throw new Error(`Please add more cardinal funds to your wallet.`);
}

const inputAddressInfo = await addressModule.getAddressInfo(pubKey);
const psbt = new bitcoin.Psbt({ network: config.NETWORK });

for (const utxo of inputs) {
const inputParams = psbtModule.getInputParams({ utxo, inputAddressInfo });
psbt.addInput(inputParams);
}

const ordinals = inputs.filter(utxo => utxo.inscriptionId);

const metadata: Metadata = {
inputs: inputs.map((utxo, index) => ({
index,
type: utxo.inscriptionId ? 'Ordinal' : 'Cardinal',
value: utxo.value,
})),
outputs: [],
};

// Add ordinal outputs
ordinals.forEach(utxo => {
psbt.addOutput({
address: destinationBtcAddress,
value: utxo.value,
});
metadata.outputs.push({ type: 'Ordinal' });
});

let changeAmount = 0;

// Add change output if we added cardinal funds
if (isCardinalAdded) {
changeAmount = totalCardinalAmount - selectedCardinalAmount - calculatedFee;
psbt.addOutput({
address,
value: changeAmount,
});
metadata.outputs.push({ type: 'Change' });
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

excellent

if (selectedCardinalAmount > 0) {
psbt.addOutput({
address: destinationBtcAddress,
value: selectedCardinalAmount,
});
metadata.outputs.push({ type: 'Cardinal' });
}
} else {
psbt.addOutput({
address: destinationBtcAddress,
value: selectedCardinalAmount - calculatedFee,
});
metadata.outputs.push({ type: 'Cardinal' });
}

return {
unsignedPsbtHex: psbt.toHex(),
metadata
};
},

signPsbtForMultipleSend: async (unsignedPsbtHex) => {
const provider = SessionStorage.get(SessionsStorageKeys.DOMAIN);
if (provider !== 'alby') {
throw new Error('Signing not supported.');
}

const psbt = bitcoin.Psbt.fromHex(unsignedPsbtHex, { network: NETWORK });

const witnessScripts = [];
const witnessValues = [];

psbt.data.inputs.forEach((input, i) => {
if (!input.finalScriptWitness && !input.witnessUtxo) {
// @ts-ignore
const tx = bitcoin.Transaction.fromBuffer(psbt.data.inputs[i].nonWitnessUtxo);
const output = tx.outs[psbt.txInputs[i].index];
psbt.updateInput(i, {
witnessUtxo: output,
});
// @ts-ignore
witnessScripts.push(output.script);
// @ts-ignore
witnessValues.push(output.value);
} else {
// @ts-ignore
witnessScripts.push(psbt.data.inputs[i].witnessUtxo.script);
// @ts-ignore
witnessValues.push(psbt.data.inputs[i].witnessUtxo.value);
}
});

const psbtOutputs = psbt.data.inputs.map((_, index) => {
// @ts-ignore
const sigHash = psbt.__CACHE.__TX.hashForWitnessV1(
index,
witnessScripts,
witnessValues,
bitcoin.Transaction.SIGHASH_DEFAULT
);

return {
index,
sigHash: sigHash.toString('hex')
};
});

topether21 marked this conversation as resolved.
Show resolved Hide resolved
await Promise.all(psbtOutputs.map(async output => {
const signature = await window.nostr.signSchnorr(output.sigHash);
psbt.updateInput(output.index, {
tapKeySig: serializeTaprootSignature(Buffer.from(signature, 'hex')),
});
return signature;
}));

const finalSignedPsbt = psbt.finalizeAllInputs();
const finalFee = finalSignedPsbt.getFee();
const finalTx = finalSignedPsbt.extractTransaction();
const finalVBytes = finalTx.virtualSize();
const finalFeeRate = (finalFee / finalVBytes).toFixed(1);
const finalSignedHexPsbt = finalSignedPsbt.toHex();

return {
finalFeeRate,
finalFee,
finalSignedHexPsbt,
finalSignedPsbt,
};
},

createAndSignPsbtForBoost: async ({ pubKey, utxo, destinationBtcAddress, sighashType }) => {
const inputAddressInfo = await addressModule.getAddressInfo(pubKey);
Expand Down Expand Up @@ -372,8 +561,8 @@ const Psbt = function (config) {

signPsbtListingXverse: async ({ psbt, address }) => {
const signPsbtOptions: SignTransactionOptions = {
onFinish: () => {},
onCancel: () => {},
onFinish: () => { },
onCancel: () => { },
payload: {
network: {
type: NETWORK_NAME,
Expand Down Expand Up @@ -578,8 +767,8 @@ const Psbt = function (config) {
}

const signPsbtOptions: SignTransactionOptions = {
onFinish: () => {},
onCancel: () => {},
onFinish: () => { },
onCancel: () => { },
payload: {
network: {
type: NETWORK_NAME,
Expand Down Expand Up @@ -677,8 +866,8 @@ const Psbt = function (config) {
}

const signPsbtOptions: SignTransactionOptions = {
onFinish: () => {},
onCancel: () => {},
onFinish: () => { },
onCancel: () => { },
payload: {
network: {
type: NETWORK_NAME,
Expand Down
1 change: 0 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"outDir": "./dist",
"declaration": true,
"rootDir": "./src",
Expand Down
Loading