Skip to content

Commit

Permalink
Lock utxos (#317)
Browse files Browse the repository at this point in the history
* adds two new actions, LOCK_UTXO and UNLOCK_UTXO

* lock utxos on selection

* Remove UNLOCK_UTXO action - not used
Add lockedUtxos to state (in state.wallet.lockedUtxos)
Removes customCoinSelection (and respective file) - strategy abandoned
Make coin selection avoid locked utxos
Make createSendPset and createTaxiTxFromTopup return selectedUtxos
Lock utxos used in successful broadcast

* refactor broadcastTx call

* new test

* deleting experiment

* remove console.log

* prettier

* bug fix

* small code refactor

* This commit creates an unconfirmed utxo from the change and adds it immediately to the utxo set for selection. This allows:
- No more balances going to 0 when the transaction used all coins
- Being able to use change utxo immediately without having to wait for confirmation

For each change address, we hand craft the utxo with:
- The txid
  - Given by the result of broadcastTx()
- The vout
  - Get the transaction from the signed tx hex
    - transaction = Transaction.fromHex(signedTxHex)
  - Calculate changeOutputScript for the change address
    - changeOutputScript = address.toOutputScript(address, network);
  - Find index in transaction.outs where script = outputScript
    - vout = transaction.outs.findIndex(...)
- The prevout
  - Equal to transaction.outs[vout]
- The unblindData
  - Get the blinding private key from the Identity Interface
    - blindPrivKey =identities[0].getBlindingPrivateKey(prevout.script)
  - Unblind data
    - utxo = unblindOutput({ txid, vout, prevout }, blindPrivKey);

After this, the utxo is added to the utxosMap on wallet state.

* make a constant of minimum time utxos are locked

* run unlock utxos on every successful update
  • Loading branch information
bordalix authored Mar 10, 2022
1 parent 1084103 commit 604bb1c
Show file tree
Hide file tree
Showing 17 changed files with 296 additions and 48 deletions.
3 changes: 3 additions & 0 deletions src/application/redux/actions/action-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export const SET_VERIFIED = 'SET_VERIFIED';
export const RESET_WALLET = 'RESET_WALLET';
export const POP_UPDATER_LOADER = 'POP_UPDATER_LOADER';
export const PUSH_UPDATER_LOADER = 'PUSH_UPDATER_LOADER';
export const LOCK_UTXO = 'LOCK_UTXO';
export const UNLOCK_UTXOS = 'UNLOCK_UTXOS';
export const ADD_UNCONFIRMED_UTXOS = 'ADD_UNCONFIRMED_UTXOS';

// App
export const AUTHENTICATION_SUCCESS = 'AUTHENTICATION_SUCCESS';
Expand Down
33 changes: 32 additions & 1 deletion src/application/redux/actions/utxos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import { NetworkString, UnblindedOutput } from 'ldk';
import { AnyAction } from 'redux';
import { AccountID } from '../../../domain/account';
import { ActionWithPayload } from '../../../domain/common';
import { ADD_UTXO, DELETE_UTXO, FLUSH_UTXOS } from './action-types';
import { UnconfirmedOutput } from '../../../domain/unconfirmed';
import { makeUnconfirmedUtxos } from '../../utils/utxos';
import {
ADD_UTXO,
DELETE_UTXO,
FLUSH_UTXOS,
LOCK_UTXO,
ADD_UNCONFIRMED_UTXOS,
UNLOCK_UTXOS,
} from './action-types';

export type AddUtxoAction = ActionWithPayload<{
accountID: AccountID;
Expand Down Expand Up @@ -30,3 +39,25 @@ export function deleteUtxo(
export function flushUtxos(accountID: AccountID, network: NetworkString): AnyAction {
return { type: FLUSH_UTXOS, payload: { accountID, network } };
}

export function lockUtxo(utxo: UnblindedOutput): AnyAction {
return { type: LOCK_UTXO, payload: { utxo } };
}

export function unlockUtxos(): AnyAction {
return { type: UNLOCK_UTXOS, payload: {} };
}

export async function addUnconfirmedUtxos(
txHex: string,
unconfirmedOutputs: UnconfirmedOutput[],
accountID: AccountID,
network: NetworkString
): Promise<AnyAction> {
const unconfirmedUtxos = await makeUnconfirmedUtxos(txHex, unconfirmedOutputs);
console.debug('add unconfirmedUtxos', unconfirmedUtxos);
return {
type: ADD_UNCONFIRMED_UTXOS,
payload: { unconfirmedUtxos, accountID, network },
};
}
66 changes: 66 additions & 0 deletions src/application/redux/reducers/wallet-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AnyAction } from 'redux';
import { AccountID, initialRestorerOpts, MainAccountID } from '../../../domain/account';
import { newEmptyUtxosAndTxsHistory, TxDisplayInterface } from '../../../domain/transaction';
import { NetworkString, UnblindedOutput } from 'ldk';
import { lockedUtxoMinimunTime } from '../../utils/constants';

export const walletInitState: WalletState = {
[MainAccountID]: {
Expand All @@ -28,6 +29,18 @@ export const walletInitState: WalletState = {
},
updaterLoaders: 0,
isVerified: false,
lockedUtxos: {},
};

// returns only utxos locked for less than 5 minutes
const filterOnlyRecentLockedUtxos = (state: WalletState) => {
const expiredTime = Date.now() - lockedUtxoMinimunTime; // 5 minutes
const lockedUtxos: Record<string, number> = {};
for (const key of Object.keys(state.lockedUtxos)) {
const isRecent = state.lockedUtxos[key] > expiredTime;
if (isRecent) lockedUtxos[key] = state.lockedUtxos[key];
}
return lockedUtxos;
};

const addUnspent =
Expand All @@ -51,6 +64,35 @@ const addUnspent =
};
};

const addUnconfirmed =
(state: WalletState) =>
(
unconfirmedUtxos: UnblindedOutput[],
accountID: AccountID,
network: NetworkString
): WalletState => {
const unconfirmedUtxosMap: Record<string, UnblindedOutput> = {};
for (const utxo of unconfirmedUtxos) {
unconfirmedUtxosMap[toStringOutpoint(utxo)] = utxo;
}
return {
...state,
unspentsAndTransactions: {
...state.unspentsAndTransactions,
[accountID]: {
...state.unspentsAndTransactions[accountID],
[network]: {
...state.unspentsAndTransactions[accountID][network],
utxosMap: {
...state.unspentsAndTransactions[accountID][network].utxosMap,
...unconfirmedUtxosMap,
},
},
},
},
};
};

const addTx =
(state: WalletState) =>
(accountID: AccountID, tx: TxDisplayInterface, network: NetworkString): WalletState => {
Expand Down Expand Up @@ -176,6 +218,30 @@ export function walletReducer(
};
}

case ACTION_TYPES.LOCK_UTXO: {
const utxo = payload.utxo as UnblindedOutput;
return {
...state,
lockedUtxos: {
...state.lockedUtxos,
[toStringOutpoint(utxo)]: Date.now(),
},
};
}

case ACTION_TYPES.UNLOCK_UTXOS: {
const lockedUtxos = filterOnlyRecentLockedUtxos(state);
return {
...state,
lockedUtxos,
};
}

case ACTION_TYPES.ADD_UNCONFIRMED_UTXOS: {
const { unconfirmedUtxos, accountID, network } = payload;
return addUnconfirmed(state)(unconfirmedUtxos, accountID, network);
}

case ACTION_TYPES.ADD_TX: {
return addTx(state)(payload.accountID, payload.tx, payload.network);
}
Expand Down
8 changes: 7 additions & 1 deletion src/application/redux/sagas/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { Account, AccountID } from '../../../domain/account';
import { UtxosAndTxs } from '../../../domain/transaction';
import { addTx } from '../actions/transaction';
import { addUtxo, AddUtxoAction, deleteUtxo } from '../actions/utxos';
import { addUtxo, AddUtxoAction, deleteUtxo, unlockUtxos } from '../actions/utxos';
import { selectUnspentsAndTransactions } from '../selectors/wallet.selector';
import {
createChannel,
Expand Down Expand Up @@ -102,6 +102,12 @@ function* utxosUpdater(
for (const utxo of toDelete) {
yield* putDeleteUtxoAction(accountID, network)(utxo);
}

// only run on successful update
if (Object.keys(receivedUtxos).length > 0) {
yield put(unlockUtxos());
}

console.debug(`${new Date()} utxos received`, receivedUtxos);
}

Expand Down
10 changes: 6 additions & 4 deletions src/application/redux/selectors/wallet.selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { MasterPublicKey, NetworkString, UnblindedOutput } from 'ldk';
import { RootReducerState } from '../../../domain/common';
import { TxDisplayInterface, UtxosAndTxs } from '../../../domain/transaction';
import { toStringOutpoint } from '../../utils/utxos';

export function masterPubKeySelector(state: RootReducerState): Promise<MasterPublicKey> {
return selectMainAccount(state).getWatchIdentity(state.app.network);
Expand All @@ -16,7 +17,10 @@ export function masterPubKeySelector(state: RootReducerState): Promise<MasterPub
export const selectUtxos =
(...accounts: AccountID[]) =>
(state: RootReducerState): UnblindedOutput[] => {
return accounts.flatMap((ID) => selectUtxosForAccount(ID)(state));
const lockedOutpoints = Object.keys(state.wallet.lockedUtxos);
return accounts
.flatMap((ID) => selectUtxosForAccount(ID)(state))
.filter((utxo) => !lockedOutpoints.includes(toStringOutpoint(utxo)));
};

const selectUtxosForAccount =
Expand All @@ -26,9 +30,7 @@ const selectUtxosForAccount =
accountID,
net ?? state.app.network
)(state)?.utxosMap;
if (utxos) {
return Object.values(utxos);
}
if (utxos) return Object.values(utxos);
return [];
};

Expand Down
3 changes: 3 additions & 0 deletions src/application/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ export function getAssetImage(assetHash: string): string {
}

export const defaultPrecision = 8;

// minimum time utxos are locked (5 minutes)
export const lockedUtxoMinimunTime = 300_000;
12 changes: 8 additions & 4 deletions src/application/utils/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export function createTaxiTxFromTopup(
recipients: RecipientInterface[],
coinSelector: CoinSelector,
changeAddressGetter: ChangeAddressFromAssetGetter
): string {
): { pset: string; selectedUtxos: UnblindedOutput[] } {
const { selectedUtxos, changeOutputs } = coinSelector(throwErrorCoinSelector)(
unspents,
recipients.concat({
Expand All @@ -184,7 +184,8 @@ export function createTaxiTxFromTopup(
}),
changeAddressGetter
);
return addToTx(taxiTopup.partial, selectedUtxos, recipients.concat(changeOutputs));
const pset = addToTx(taxiTopup.partial, selectedUtxos, recipients.concat(changeOutputs));
return { pset, selectedUtxos };
}

/**
Expand All @@ -201,7 +202,10 @@ export async function createSendPset(
changeAddressGetter: ChangeAddressFromAssetGetter,
network: NetworkString,
data?: DataRecipient[]
): Promise<string> {
): Promise<{
pset: string;
selectedUtxos: UnblindedOutput[];
}> {
const coinSelector = greedyCoinSelector();

if (feeAssetHash === lbtcAssetByNetwork(network)) {
Expand Down Expand Up @@ -240,7 +244,7 @@ export async function createSendPset(
pset = withDataOutputs(pset, data);
}

return pset;
return { pset, selectedUtxos: selection.selectedUtxos };
}

const topup = (await fetchTopupFromTaxi(taxiURL[network], feeAssetHash)).topup;
Expand Down
19 changes: 19 additions & 0 deletions src/application/utils/utxos.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
import { UnblindedOutput, unblindOutput } from 'ldk';
import { Transaction } from 'liquidjs-lib';
import { UnconfirmedOutput } from '../../domain/unconfirmed';

export const toStringOutpoint = (outpoint: { txid: string; vout: number }) => {
return `${outpoint.txid}:${outpoint.vout}`;
};

// for each unconfirmed output get unblindData and return utxo
export const makeUnconfirmedUtxos = async (
txHex: string,
unconfirmedOutputs: UnconfirmedOutput[]
): Promise<UnblindedOutput[]> => {
const unconfirmedUtxos: UnblindedOutput[] = [];
const transaction = Transaction.fromHex(txHex);
for (const { txid, vout, blindPrivKey } of unconfirmedOutputs) {
const prevout = transaction.outs[vout];
const utxo = await unblindOutput({ txid, vout, prevout }, blindPrivKey);
unconfirmedUtxos.push(utxo);
}
return unconfirmedUtxos;
};
31 changes: 22 additions & 9 deletions src/content/marina/marinaBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { selectTaxiAssets } from '../../application/redux/selectors/taxi.selecto
import { sleep } from '../../application/utils/common';
import { BrokerProxyStore } from '../brokerProxyStore';
import { updateTaskAction } from '../../application/redux/actions/updater';
import { addUnconfirmedUtxos, lockUtxo } from '../../application/redux/actions/utxos';

export default class MarinaBroker extends Broker {
private static NotSetUpError = new Error('proxy store and/or cache are not set up');
Expand Down Expand Up @@ -222,24 +223,36 @@ export default class MarinaBroker extends Broker {
await this.store.dispatchAsync(
setTxData(this.hostname, addressRecipients, feeAsset, selectNetwork(state), data)
);
const { accepted, signedTxHex } = await this.openAndWaitPopup<SpendPopupResponse>(
'spend'
);

const { accepted, signedTxHex, selectedUtxos, unconfirmedOutputs } =
await this.openAndWaitPopup<SpendPopupResponse>('spend');

if (!accepted) throw new Error('the user rejected the create tx request');
if (!signedTxHex) throw new Error('something went wrong with the tx crafting');

console.debug('signedTxHex', signedTxHex);

let txid;
const txid = await broadcastTx(selectEsploraURL(state), signedTxHex);
if (!txid) throw new Error('something went wrong with the tx broadcasting');

try {
txid = await broadcastTx(selectEsploraURL(state), signedTxHex);
} catch (error) {
throw new Error(`error broadcasting tx: ${error}`);
// lock utxos used in successful broadcast
if (selectedUtxos) {
for (const utxo of selectedUtxos) {
await this.store.dispatchAsync(lockUtxo(utxo));
}
}

if (!txid) throw new Error('something went wrong with the tx broadcasting');
// add unconfirmed utxos from change addresses to utxo set
if (unconfirmedOutputs && unconfirmedOutputs.length > 0) {
this.store.dispatch(
await addUnconfirmedUtxos(
signedTxHex,
unconfirmedOutputs,
MainAccountID,
selectNetwork(state)
)
);
}

return successMsg({ txid, hex: signedTxHex });
}
Expand Down
3 changes: 2 additions & 1 deletion src/domain/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { WalletState } from './wallet';
// inspired by: https://gist.github.com/lafiosca/b7bbb569ae3fe5c1ce110bf71d7ee153

export type WalletPersistedStateV2 = WalletState & Partial<PersistedState>; // the current version
type keysAddedInV2 = 'unspentsAndTransactions' | 'mainAccount' | 'updaterLoaders';
type keysAddedInV2 = 'unspentsAndTransactions' | 'mainAccount' | 'updaterLoaders' | 'lockedUtxos';
type deletedInV2 = {
encryptedMnemonic: EncryptedMnemonic;
masterBlindingKey: MasterBlindingKey;
Expand All @@ -35,6 +35,7 @@ export const walletMigrations = {
},
updaterLoaders: 0,
isVerified: state.isVerified,
lockedUtxos: walletInitState.lockedUtxos,
};
},
};
Expand Down
5 changes: 5 additions & 0 deletions src/domain/unconfirmed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface UnconfirmedOutput {
txid: string;
vout: number;
blindPrivKey: string;
}
1 change: 1 addition & 0 deletions src/domain/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export interface WalletState {
};
updaterLoaders: number;
isVerified: boolean;
lockedUtxos: Record<string, number>;
}
Loading

0 comments on commit 604bb1c

Please sign in to comment.