btc: invoice error handling

This commit is contained in:
ixv 2021-05-26 12:19:33 -07:00
parent 768b47985d
commit 40d69b5a02
13 changed files with 170 additions and 64 deletions

View File

@ -334,7 +334,8 @@
?~ txbu.poym %.n ?~ txbu.poym %.n
=((get-id:txu:bc (decode:txu:bc signed)) ~(get-txid txb:bl u.txbu.poym)) =((get-id:txu:bc (decode:txu:bc signed)) ~(get-txid txb:bl u.txbu.poym))
:- ?. tx-match :- ?. tx-match
((slog leaf+"txid didn't match txid in wallet") ~) %- (slog leaf+"txid didn't match txid in wallet")
[(give-update %error %broadcast-fail)]~
~[(poke-provider [%broadcast-tx signed])] ~[(poke-provider [%broadcast-tx signed])]
?. tx-match state ?. tx-match state
?~ txbu.poym state ?~ txbu.poym state
@ -486,7 +487,7 @@
=+ fee=~(fee txb:bl u.txbu.poym) =+ fee=~(fee txb:bl u.txbu.poym)
~& >> "{<vb>} vbytes, {<(div fee vb)>} sats/byte, {<fee>} sats fee" ~& >> "{<vb>} vbytes, {<(div fee vb)>} sats/byte, {<fee>} sats fee"
%- (slog [%leaf "PSBT: {<u.pb>}"]~) %- (slog [%leaf "PSBT: {<u.pb>}"]~)
[(give-update [%psbt u.pb])]~ [(give-update [%psbt u.pb fee])]~
:: update outgoing payment with a rawtx, if the txid is in poym's txis :: update outgoing payment with a rawtx, if the txid is in poym's txis
:: ::
++ update-poym-txis ++ update-poym-txis
@ -629,12 +630,14 @@
%fail-broadcast-tx %fail-broadcast-tx
?> =(src.bowl our.bowl) ?> =(src.bowl our.bowl)
~& >>> "%fail-broadcast-tx" ~& >>> "%fail-broadcast-tx"
`state(poym [~ ~]) :_ state(poym [~ ~])
[(give-update %error %broadcast-fail)]~
:: ::
%succeed-broadcast-tx %succeed-broadcast-tx
?> =(src.bowl our.bowl) ?> =(src.bowl our.bowl)
~& > "%succeed-broadcast-tx" ~& > "%succeed-broadcast-tx"
:_ state :_ state
:- (give-update %broadcast-success ~)
?~ prov ~ ?~ prov ~
:- (poke-provider [%tx-info txid.intr]) :- (poke-provider [%tx-info txid.intr])
?~ txbu.poym ~ ?~ txbu.poym ~

View File

@ -104,13 +104,14 @@
%initial (initial upd) %initial (initial upd)
%change-provider (change-provider upd) %change-provider (change-provider upd)
%change-wallet (change-wallet upd) %change-wallet (change-wallet upd)
%psbt s+pb.upd %psbt (psbt upd)
%btc-state (btc-state btc-state.upd) %btc-state (btc-state btc-state.upd)
%new-tx (hest hest.upd) %new-tx (hest hest.upd)
%cancel-tx (hexb txid.upd) %cancel-tx (hexb txid.upd)
%new-address (address address.upd) %new-address (address address.upd)
%balance (balance balance.upd) %balance (balance balance.upd)
%error s+error.upd %error s+error.upd
%broadcast-success ~
== ==
:: ::
++ initial ++ initial
@ -142,6 +143,15 @@
history+(history history.upd) history+(history history.upd)
== ==
:: ::
++ psbt
|= upd=update:btc-wallet
?> ?=(%psbt -.upd)
^- json
%- pairs
:~ pb+s+pb.upd
fee+(numb fee.upd)
==
::
++ balance ++ balance
|= b=(unit [p=@ q=@]) |= b=(unit [p=@ q=@])
^- json ^- json

View File

@ -124,6 +124,7 @@
%no-dust %no-dust
%tx-being-signed %tx-being-signed
%insufficient-balance %insufficient-balance
%broadcast-fail
== ==
:: data to send to the frontend :: data to send to the frontend
:: ::
@ -136,9 +137,10 @@
=btc-state =btc-state
address=(unit address) address=(unit address)
== ==
[%broadcast-success ~]
[%change-provider provider=(unit provider)] [%change-provider provider=(unit provider)]
[%change-wallet wallet=(unit xpub) balance=(unit [p=sats q=sats]) =history] [%change-wallet wallet=(unit xpub) balance=(unit [p=sats q=sats]) =history]
[%psbt pb=@t] [%psbt pb=@t fee=sats]
[%btc-state =btc-state] [%btc-state =btc-state]
[%new-tx =hest] [%new-tx =hest]
[%cancel-tx =txid] [%cancel-tx =txid]

View File

@ -68,6 +68,7 @@ export default class Balance extends Component {
<> <>
{this.state.sending ? {this.state.sending ?
<Send <Send
state={this.props.state}
api={api} api={api}
psbt={this.props.state.psbt} psbt={this.props.state.psbt}
currencyRates={this.props.state.currencyRates} currencyRates={this.props.state.currencyRates}
@ -80,7 +81,7 @@ export default class Balance extends Component {
error={this.props.state.error} error={this.props.state.error}
stopSending={() => { stopSending={() => {
this.setState({sending: false}); this.setState({sending: false});
store.handleEvent({data: {psbt: '', error: ''}}); store.handleEvent({data: {psbt: '', fee: 0, error: '', "broadcast-fail": null}});
}} }}
/> : /> :
<Col <Col

View File

@ -7,12 +7,14 @@ import {
Text, Text,
Button, Button,
Col, Col,
LoadingSpinner,
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import * as bitcoin from 'bitcoinjs-lib'; import * as bitcoin from 'bitcoinjs-lib';
import * as kg from 'urbit-key-generation'; import * as kg from 'urbit-key-generation';
import Sent from './sent.js' import Sent from './sent.js'
import Error from './error.js'
import { satsToCurrency } from '../../lib/util.js'; import { satsToCurrency } from '../../lib/util.js';
@ -24,10 +26,10 @@ export default class BridgeInvoice extends Component {
super(props); super(props);
this.state = { this.state = {
txHex: 'm', txHex: '',
ready: false, ready: false,
error: false, error: this.props.state.error,
sent: false, broadcasting: false,
}; };
this.checkTxHex = this.checkTxHex.bind(this); this.checkTxHex = this.checkTxHex.bind(this);
@ -46,42 +48,56 @@ export default class BridgeInvoice extends Component {
window.open('https://bridge.urbit.org/?kind=btc&utx=' + this.props.psbt); window.open('https://bridge.urbit.org/?kind=btc&utx=' + this.props.psbt);
} }
sendBitcoin(hex) { componentDidUpdate(prevProps){
if (this.state.broadcasting) {
if (this.state.error !== '') {
this.setState({broadcasting: false});
}
}
if (prevProps.state.error !== this.props.state.error) {
this.setState({error: this.props.state.error});
}
}
sendBitcoin(hex) {
try { try {
bitcoin.Transaction.fromHex(hex) bitcoin.Transaction.fromHex(hex)
this.broadCastTx(hex).then(res => this.setState({sent: true})); this.broadCastTx(hex)
this.setState({broadcasting: true});
} }
catch(e) { catch(e) {
this.setState({error: true}); this.setState({error: 'invalid-signed', broadcasting: false});
} }
} }
checkTxHex(e){ checkTxHex(e){
let txHex = e.target.value; let txHex = e.target.value;
let ready = (txHex.length > 0); let ready = (txHex.length > 0);
let error = false; let error = '';
this.setState({txHex, ready, error}); this.setState({txHex, ready, error});
} }
render() { render() {
const { stopSending, payee, denomination, satsAmount, psbt, currencyRates } = this.props; const { stopSending, payee, denomination, satsAmount, psbt, currencyRates } = this.props;
const { sent, error, txHex } = this.state; const { error, txHex } = this.state;
let inputColor = 'black'; let inputColor = 'black';
let inputBg = 'white'; let inputBg = 'white';
let inputBorder = 'lightGray'; let inputBorder = 'lightGray';
if (error) { if (error !== '') {
inputColor = 'red'; inputColor = 'red';
inputBg = 'veryLightRed'; inputBg = 'veryLightRed';
inputBorder = 'red'; inputBorder = 'red';
} }
console.log('bridge invoice', error);
return ( return (
<> <>
{ sent ? { this.props.state.broadcastSuccess ?
<Sent <Sent
payee={payee} payee={payee}
stopSending={stopSending} stopSending={stopSending}
@ -166,19 +182,18 @@ export default class BridgeInvoice extends Component {
style={{'line-height': '4'}} style={{'line-height': '4'}}
onChange={this.checkTxHex} onChange={this.checkTxHex}
/> />
{error && { (error !== '') &&
<Row> <Row>
<Text <Error
error={error}
fontSize='14px' fontSize='14px'
color='red' mt={2}/>
mt={2}>
Invalid signed bitcoin transaction
</Text>
</Row> </Row>
} }
<Row <Row
flexDirection='row-reverse' flexDirection='row-reverse'
mt={4} mt={4}
alignItems="center"
> >
<Button <Button
primary primary
@ -192,6 +207,7 @@ export default class BridgeInvoice extends Component {
disabled={!this.state.ready || error} disabled={!this.state.ready || error}
style={{cursor: (this.state.ready && !error) ? "pointer" : "default"}} style={{cursor: (this.state.ready && !error) ? "pointer" : "default"}}
/> />
{this.state.broadcasting ? <LoadingSpinner mr={3}/> : null}
</Row> </Row>
</Col> </Col>
} }

View File

@ -17,6 +17,15 @@ const errorToString = (error) => {
if (error === 'insufficient-balance') { if (error === 'insufficient-balance') {
return 'Insufficient confirmed balance'; return 'Insufficient confirmed balance';
} }
if (error === 'broadcast-fail') {
return 'Transaction broadcast failed';
}
if (error === 'invalid-master-ticket') {
return 'Invalid master ticket';
}
if (error === 'invalid-signed') {
return 'Invalid signed bitcoin transaction';
}
} }
export default function Error(props) { export default function Error(props) {

View File

@ -7,6 +7,7 @@ import {
Text, Text,
Button, Button,
Col, Col,
LoadingSpinner,
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import * as bitcoin from 'bitcoinjs-lib'; import * as bitcoin from 'bitcoinjs-lib';
@ -14,13 +15,39 @@ import * as kg from 'urbit-key-generation';
import * as bip39 from 'bip39'; import * as bip39 from 'bip39';
import Sent from './sent.js' import Sent from './sent.js'
import { patp2dec, isValidPatq } from 'urbit-ob';
import { satsToCurrency } from '../../lib/util.js'; import { satsToCurrency } from '../../lib/util.js';
import Error from './error.js';
window.bitcoin = bitcoin; window.bitcoin = bitcoin;
window.kg = kg; window.kg = kg;
window.bip39 = bip39; window.bip39 = bip39;
const BITCOIN_MAINNET_INFO = {
messagePrefix: '\x18Bitcoin Signed Message:\n',
bech32: 'bc',
bip32: {
public: 0x04b24746,
private: 0x04b2430c,
},
pubKeyHash: 0x00,
scriptHash: 0x05,
wif: 0x80,
};
const BITCOIN_TESTNET_INFO = {
messagePrefix: '\x18Bitcoin Signed Message:\n',
bech32: 'tb',
bip32: {
public: 0x045f1cf6,
private: 0x045f18bc,
},
pubKeyHash: 0x6f,
scriptHash: 0xc4,
wif: 0xef,
};
export default class Invoice extends Component { export default class Invoice extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -28,8 +55,9 @@ export default class Invoice extends Component {
this.state = { this.state = {
masterTicket: '', masterTicket: '',
ready: false, ready: false,
error: false, error: this.props.state.error,
sent: false, sent: false,
broadcasting: false,
}; };
this.checkTicket = this.checkTicket.bind(this); this.checkTicket = this.checkTicket.bind(this);
@ -37,6 +65,14 @@ export default class Invoice extends Component {
this.sendBitcoin = this.sendBitcoin.bind(this); this.sendBitcoin = this.sendBitcoin.bind(this);
} }
componentDidUpdate(prevProps, prevState) {
if (this.state.broadcasting) {
if (this.state.error !== '') {
this.setState({broadcasting: false});
}
}
}
broadCastTx(psbtHex) { broadCastTx(psbtHex) {
let command = { let command = {
'broadcast-tx': psbtHex 'broadcast-tx': psbtHex
@ -45,42 +81,58 @@ export default class Invoice extends Component {
} }
sendBitcoin(ticket, psbt) { sendBitcoin(ticket, psbt) {
const mnemonic = kg.deriveNodeSeed(ticket, 'bitcoin');
const seed = bip39.mnemonicToSeed(mnemonic);
const hd = bitcoin.bip32.fromSeed(seed);
const newPsbt = bitcoin.Psbt.fromBase64(psbt); const newPsbt = bitcoin.Psbt.fromBase64(psbt);
this.setState({broadcasting: true});
kg.generateWallet({ ticket, ship: parseInt(patp2dec('~' + window.ship)) })
.then(urbitWallet => {
const { xpub } = this.props.network === 'testnet'
? urbitWallet.bitcoinTestnet.keys
: urbitWallet.bitcoinMainnet.keys;
const { xprv: zprv } = urbitWallet.bitcoinMainnet.keys;
const { xprv: vprv } = urbitWallet.bitcoinTestnet.keys;
const isTestnet = (this.props.network === 'testnet');
const derivationPrefix = isTestnet ? "m/84'/1'/0'/" : "m/84'/0'/0'/";
const btcWallet = (isTestnet)
? bitcoin.bip32.fromBase58(vprv, BITCOIN_TESTNET_INFO)
: bitcoin.bip32.fromBase58(zprv, BITCOIN_MAINNET_INFO);
try { try {
const hex = const hex = newPsbt.data.inputs
newPsbt.data.inputs
.reduce((psbt, input, idx) => { .reduce((psbt, input, idx) => {
// removing already derived part, eg m/84'/0'/0'/0/0 becomes 0/0
const path = input.bip32Derivation[0].path const path = input.bip32Derivation[0].path
const prv = hd.derivePath(path).privateKey; .split(derivationPrefix)
.join('');
const prv = btcWallet.derivePath(path).privateKey;
return psbt.signInput(idx, bitcoin.ECPair.fromPrivateKey(prv)); return psbt.signInput(idx, bitcoin.ECPair.fromPrivateKey(prv));
}, newPsbt) }, newPsbt)
.finalizeAllInputs() .finalizeAllInputs()
.extractTransaction() .extractTransaction()
.toHex(); .toHex();
this.broadCastTx(hex).then(res => this.setState({sent: true})); this.broadCastTx(hex);
} }
catch(e) { catch(e) {
this.setState({error: true}); this.setState({error: 'invalid-master-ticket', broadcasting: false});
} }
});
} }
checkTicket(e){ checkTicket(e){
// TODO: port over bridge ticket validation logic // TODO: port over bridge ticket validation logic
let masterTicket = e.target.value; let masterTicket = e.target.value;
let ready = (masterTicket.length > 0); let ready = isValidPatq(masterTicket);
let error = false; let error = (ready) ? '' : 'invalid-master-ticket';
this.setState({masterTicket, ready, error}); this.setState({masterTicket, ready, error});
} }
render() { render() {
const broadcastSuccess = this.props.state.broadcastSuccess;
const { stopSending, payee, denomination, satsAmount, psbt, currencyRates } = this.props; const { stopSending, payee, denomination, satsAmount, psbt, currencyRates } = this.props;
const { sent, error } = this.state; const { sent, error } = this.state;
@ -88,7 +140,7 @@ export default class Invoice extends Component {
let inputBg = 'white'; let inputBg = 'white';
let inputBorder = 'lightGray'; let inputBorder = 'lightGray';
if (error) { if (error !== '') {
inputColor = 'red'; inputColor = 'red';
inputBg = 'veryLightRed'; inputBg = 'veryLightRed';
inputBorder = 'red'; inputBorder = 'red';
@ -96,7 +148,7 @@ export default class Invoice extends Component {
return ( return (
<> <>
{ sent ? { broadcastSuccess ?
<Sent <Sent
payee={payee} payee={payee}
stopSending={stopSending} stopSending={stopSending}
@ -178,19 +230,19 @@ export default class Invoice extends Component {
borderColor={inputBorder} borderColor={inputBorder}
onChange={this.checkTicket} onChange={this.checkTicket}
/> />
{error && {(error !== '') &&
<Row> <Row>
<Text <Error
fontSize='14px' fontSize='14px'
color='red' color='red'
mt={2}> error={error}
Invalid master ticket mt={2}/>
</Text>
</Row> </Row>
} }
<Row <Row
flexDirection='row-reverse' flexDirection='row-reverse'
mt={4} mt={4}
alignItems="center"
> >
<Button <Button
primary primary
@ -201,9 +253,10 @@ export default class Invoice extends Component {
py='24px' py='24px'
px='24px' px='24px'
onClick={() => this.sendBitcoin(this.state.masterTicket, psbt)} onClick={() => this.sendBitcoin(this.state.masterTicket, psbt)}
disabled={!this.state.ready || error} disabled={!this.state.ready || error || this.state.broadcasting}
style={{cursor: (this.state.ready && !error) ? "pointer" : "default"}} style={{cursor: (this.state.ready && !error && !this.state.broadcasting) ? "pointer" : "default"}}
/> />
{ (this.state.broadcasting) ? <LoadingSpinner mr={3}/> : null}
</Row> </Row>
</Col> </Col>
} }

View File

@ -128,7 +128,8 @@ export default class Send extends Component {
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (prevProps.error !== this.props.error && this.props.error !== '') { if ((prevProps.error !== this.props.error) &&
(this.props.error !== '') && (this.props.error !== 'broadcast-fail')) {
this.setState({signing: false}); this.setState({signing: false});
} }
@ -197,7 +198,7 @@ export default class Send extends Component {
} }
const { api, value, conversion, stopSending, denomination, psbt, currencyRates, error } = this.props; const { api, value, conversion, stopSending, denomination, psbt, currencyRates, error, network } = this.props;
const { denomAmount, satsAmount, signing, payee, choosingSignMethod, signMethod } = this.state; const { denomAmount, satsAmount, signing, payee, choosingSignMethod, signMethod } = this.state;
const signReady = (this.state.ready && (parseInt(this.state.satsAmount) > 0)) && !signing; const signReady = (this.state.ready && (parseInt(this.state.satsAmount) > 0)) && !signing;
@ -206,6 +207,7 @@ export default class Send extends Component {
if (signMethod === 'Sign Transaction') { if (signMethod === 'Sign Transaction') {
invoice = invoice =
<Invoice <Invoice
network={network}
api={api} api={api}
psbt={psbt} psbt={psbt}
currencyRates={currencyRates} currencyRates={currencyRates}
@ -213,10 +215,12 @@ export default class Send extends Component {
payee={payee} payee={payee}
denomination={denomination} denomination={denomination}
satsAmount={satsAmount} satsAmount={satsAmount}
state={this.props.state}
/> />
} else if (signMethod === 'Sign with Bridge') { } else if (signMethod === 'Sign with Bridge') {
invoice = invoice =
<BridgeInvoice <BridgeInvoice
state={this.props.state}
api={api} api={api}
psbt={psbt} psbt={psbt}
currencyRates={currencyRates} currencyRates={currencyRates}

View File

@ -44,7 +44,7 @@ export default function Sent(props) {
> >
<Text <Text
color='white' color='white'
fontSize='52px' fontSize='40px'
> >
{satsToCurrency(satsAmount, denomination, currencyRates)} {satsToCurrency(satsAmount, denomination, currencyRates)}
</Text> </Text>

View File

@ -10,7 +10,7 @@ import {
LoadingSpinner, LoadingSpinner,
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import { patp2dec } from 'urbit-ob'; import { patp2dec, isValidPatq } from 'urbit-ob';
const kg = require('urbit-key-generation'); const kg = require('urbit-key-generation');
const bitcoin = require('bitcoinjs-lib'); const bitcoin = require('bitcoinjs-lib');
@ -44,11 +44,11 @@ export default class WalletModal extends Component {
// TODO: port over bridge ticket validation logic // TODO: port over bridge ticket validation logic
if (this.state.confirmingMasterTicket) { if (this.state.confirmingMasterTicket) {
let confirmedMasterTicket = e.target.value; let confirmedMasterTicket = e.target.value;
let readyToSubmit = (confirmedMasterTicket.length > 0); let readyToSubmit = isValidPatq(confirmedMasterTicket);
this.setState({confirmedMasterTicket, readyToSubmit}); this.setState({confirmedMasterTicket, readyToSubmit});
} else { } else {
let masterTicket = e.target.value; let masterTicket = e.target.value;
let readyToSubmit = (masterTicket.length > 0); let readyToSubmit = isValidPatq(masterTicket);
this.setState({masterTicket, readyToSubmit}); this.setState({masterTicket, readyToSubmit});
} }
} }
@ -60,7 +60,6 @@ export default class WalletModal extends Component {
} }
submitMasterTicket(ticket){ submitMasterTicket(ticket){
this.setState({processingSubmission: true}); this.setState({processingSubmission: true});
kg.generateWallet({ ticket, ship: parseInt(patp2dec('~' + window.ship)) }) kg.generateWallet({ ticket, ship: parseInt(patp2dec('~' + window.ship)) })
.then(urbitWallet => { .then(urbitWallet => {

View File

@ -89,7 +89,7 @@ export function satsToCurrency(sats, denomination, rates){
denomination = "BTC"; denomination = "BTC";
} }
let rate = rates[denomination]; let rate = rates[denomination];
let val = (sats * rate.last) * 0.00000001; let val = parseFloat(((sats * rate.last) * 0.00000001).toFixed(8));
let text; let text;
if (denomination === 'BTC'){ if (denomination === 'BTC'){
text = val + ' ' + rate.symbol text = val + ' ' + rate.symbol

View File

@ -6,6 +6,7 @@ export class UpdateReducer {
if (!json) { if (!json) {
return; return;
} }
console.log('reduce', json);
if (json.providerStatus) { if (json.providerStatus) {
this.reduceProviderStatus(json.providerStatus, state); this.reduceProviderStatus(json.providerStatus, state);
} }
@ -39,6 +40,12 @@ export class UpdateReducer {
if (json.hasOwnProperty('error')) { if (json.hasOwnProperty('error')) {
this.reduceError(json.error, state); this.reduceError(json.error, state);
} }
if (json.hasOwnProperty('broadcast-success')){
state.broadcastSuccess = true;
}
if (json.hasOwnProperty('broadcast-fail')){
state.broadcastSuccess = false;
}
} }
reduceProviderStatus(json, state) { reduceProviderStatus(json, state) {
@ -58,7 +65,8 @@ export class UpdateReducer {
} }
reducePsbt(json, state) { reducePsbt(json, state) {
state.psbt = json; state.psbt = json.pb;
state.fee = json.fee;
} }
reduceBtcState(json, state) { reduceBtcState(json, state) {

View File

@ -25,6 +25,7 @@ class Store {
denomination: 'BTC', denomination: 'BTC',
showWarning: true, showWarning: true,
error: '', error: '',
broadcastSuccess: false,
}; };
this.initialReducer = new InitialReducer(); this.initialReducer = new InitialReducer();