diff --git a/app/background/node/client.js b/app/background/node/client.js index e77f9abd1..c7e1fdbf2 100644 --- a/app/background/node/client.js +++ b/app/background/node/client.js @@ -14,5 +14,7 @@ export const clientStub = ipcRendererInjector => makeClient(ipcRendererInjector, 'broadcastRawTx', 'sendRawAirdrop', 'getFees', - 'getAverageBlockTime' + 'getAverageBlockTime', + 'finalizeWithPayment', + 'claimPaidTransfer' ]); diff --git a/app/background/node/service.js b/app/background/node/service.js index 523da0547..abeab20a8 100644 --- a/app/background/node/service.js +++ b/app/background/node/service.js @@ -211,6 +211,40 @@ export class NodeService extends EventEmitter { return Math.floor((sum / count) * 1000); } + async finalizeWithPayment(name, fundingAddr, nameReceiveAddr, price) { + this._ensureStarted(); + const ret = new Promise((resolve, reject) => { + this.hsdWindow.webContents.once('ipc-message', (_, channel, reply) => { + if (channel !== 'finalize-with-payment-reply') { + return; + } + if (reply.error) { + return reject(reply.error); + } + resolve(reply); + }); + }); + this.hsdWindow.webContents.send('finalize-with-payment', name, fundingAddr, nameReceiveAddr, price); + return ret; + } + + async claimPaidTransfer(txHex) { + this._ensureStarted(); + const ret = new Promise((resolve, reject) => { + this.hsdWindow.webContents.once('ipc-message', (_, channel, reply) => { + if (channel !== 'claim-paid-transfer-reply') { + return; + } + if (reply.error) { + return reject(reply.error); + } + resolve(reply); + }); + }); + this.hsdWindow.webContents.send('claim-paid-transfer', txHex); + return ret; + } + _ensureStarted() { if (!this.hsdWindow) { throw new Error('hsd not started.'); @@ -275,6 +309,8 @@ const methods = { sendRawAirdrop: (data) => service.sendRawAirdrop(data), getFees: () => service.getFees(), getAverageBlockTime: () => service.getAverageBlockTime(), + finalizeWithPayment: (name, fundingAddr, nameReceiveAddr, price) => service.finalizeWithPayment(name, fundingAddr, nameReceiveAddr, price), + claimPaidTransfer: (txHex) => service.claimPaidTransfer(txHex), }; export async function start(server) { diff --git a/app/hsdMain.js b/app/hsdMain.js index 8c5bca25d..cd2f5658d 100644 --- a/app/hsdMain.js +++ b/app/hsdMain.js @@ -3,7 +3,10 @@ require('./sentry'); const ipc = require('electron').ipcRenderer; const FullNode = require('hsd/lib/node/fullnode'); const WalletPlugin = require('hsd/lib/wallet/plugin'); +const Script = require('hsd/lib/script/script'); const remote = require('electron').remote; +const {hashName, types} = require('hsd/lib/covenants/rules'); +const {Output, MTX, Address} = require('hsd/lib/primitives'); let hsd = null; ipc.on('start', (_, prefix, net, apiKey) => { @@ -45,7 +48,7 @@ ipc.on('start', (_, prefix, net, apiKey) => { .then(() => hsd.startSync()) .catch((e) => { console.log(e); - ipc.send('error', e) + ipc.send('error', e); }); }); @@ -57,3 +60,122 @@ ipc.on('close', () => { hsd.close() .then(() => remote.getCurrentWindow().close()); }); + +ipc.on('finalize-with-payment', (event, name, fundingAddr, nameReceiveAddr, price) => { + (async () => { + const {wdb} = hsd.require('walletdb'); + const wallet = await wdb.get('allison'); + const ns = await wallet.getNameStateByName(name); + const owner = ns.owner; + const coin = await wallet.getCoin(owner.hash, owner.index); + const nameHash = hashName(name); + + const output0 = new Output(); + output0.value = coin.value; + output0.address = new Address().fromString(nameReceiveAddr); + output0.covenant.type = types.FINALIZE; + output0.covenant.pushHash(nameHash); + output0.covenant.pushU32(ns.height); + output0.covenant.push(Buffer.from(name, 'ascii')); + output0.covenant.pushU8(0); // flags, may be required if name was CLAIMed + output0.covenant.pushU32(ns.claimed); + output0.covenant.pushU32(ns.renewals); + output0.covenant.pushHash(await wdb.getRenewalBlock()); + + const output1 = new Output(); + output1.address = new Address().fromString(fundingAddr); + output1.value = price; + + const mtx = new MTX(); + mtx.addCoin(coin); + mtx.outputs.push(output0); + mtx.outputs.push(output1); + + // Sign + const rings = await wallet.deriveInputs(mtx); + assert(rings.length === 1); + const signed = await mtx.sign( + rings, + Script.hashType.SINGLEREVERSE | Script.hashType.ANYONECANPAY, + ); + assert(signed === 1); + + assert(mtx.verify()); + return mtx.encode().toString('hex'); + })().then((hex) => { + ipc.send('finalize-with-payment-reply', hex); + }).catch((e) => { + ipc.send('finalize-with-payment-reply', { + error: e.message, + }); + }); +}); + +ipc.on('claim-paid-transfer', (event, txHex) => { + (async () => { + const {wdb} = hsd.require('walletdb'); + const wallet = await wdb.get('allison'); + const mtx = MTX.decode(Buffer.from(txHex, 'hex')); + + // Bob should verify all the data in the MTX to ensure everything is valid, + // but this is the minimum. + const input0 = mtx.input(0).clone(); // copy input with Alice's signature + const coinEntry = await hsd.chain.db.readCoin(input0.prevout); + assert(coinEntry); // ensures that coin exists and is still unspent + + const coin = coinEntry.toCoin(input0.prevout); + assert(coin.covenant.type === types.TRANSFER); + + // Fund the TX. + // The hsd wallet is not designed to handle partially-signed TXs + // or coins from outside the wallet, so a little hacking is needed. + const changeAddress = await wallet.changeAddress(); + const rate = await wdb.estimateFee(); + const coins = await wallet.getSmartCoins(); + // Add the external coin to the coin selector so we don't fail assertions + coins.push(coin); + await mtx.fund(coins, {changeAddress, rate}); + // The funding mechanism starts by wiping out existing inputs + // which for us includes Alice's signature. Replace it from our backup. + mtx.inputs[0].inject(input0); + + // Rearrange outputs. + // Since we added a change output, the SINGELREVERSE is now broken: + // + // input 0: TRANSFER UTXO --> output 0: FINALIZE covenant + // input 1: Bob's funds --- output 1: payment to Alice + // (null) --- output 2: change to Bob + const outputs = mtx.outputs.slice(); + mtx.outputs = [outputs[0], outputs[2], outputs[1]]; + + // Prepare to wait for mempool acceptance (race condition) + const waiter = new Promise((resolve, reject) => { + hsd.mempool.once('tx', resolve); + }); + + // Sign & Broadcast + // Bob uses SIGHASHALL. The final TX looks like this: + // + // input 0: TRANSFER UTXO --> output 0: FINALIZE covenant + // input 1: Bob's funds --- output 1: change to Bob + // (null) --- output 2: payment to Alice + const tx = await wallet.sendMTX(mtx); + assert(tx.verify(mtx.view)); + + // Wait for mempool and check + await waiter; + assert(hsd.mempool.hasEntry(tx.hash())); + })().then((hex) => { + ipc.send('claim-paid-transfer-reply', {}); + }).catch((e) => { + ipc.send('claim-paid-transfer-reply', { + error: e.message, + }); + }); +}); + +function assert(value) { + if (!value) { + throw new Error('Assertion error.'); + } +} diff --git a/app/pages/DomainManager/ClaimNameForPayment.js b/app/pages/DomainManager/ClaimNameForPayment.js new file mode 100644 index 000000000..6b32e5062 --- /dev/null +++ b/app/pages/DomainManager/ClaimNameForPayment.js @@ -0,0 +1,162 @@ +import React, { Component } from 'react'; +import { MiniModal } from '../../components/Modal/MiniModal'; +import { MTX } from 'hsd/lib/primitives'; +import { connect } from 'react-redux'; +import { clientStub as nClientStub } from '../../background/node/client'; +import { showSuccess } from '../../ducks/notifications'; + +const node = nClientStub(() => require('electron').ipcRenderer); + +@connect( + (state) => ({ + network: state.node.network, + }), + (dispatch) => ({ + showSuccess: (message) => dispatch(showSuccess(message)), + }), +) +export default class ClaimNameForPayment extends Component { + constructor(props) { + super(props); + this.state = { + step: 0, + hex: '', + }; + } + + onClickVerify = () => { + try { + const {network} = this.props; + const mtx = MTX.decode(Buffer.from(this.state.hex, 'hex')); + const firstOutput = mtx.outputs[0]; + const nameReceiveAddr = firstOutput.address.toString(network); + const name = firstOutput.covenant.items[2].toString('ascii'); + const secondOutput = mtx.outputs[1]; + const fundingAddr = secondOutput.address.toString(network); + const price = secondOutput.value; + + this.setState({ + step: 1, + name, + nameReceiveAddr, + fundingAddr, + price, + }); + } catch (e) { + this.setState({ + hexError: 'Invalid hex value.', + }); + } + }; + + onClickTransfer = async () => { + try { + await node.claimPaidTransfer(this.state.hex); + this.props.onClose(); + this.props.showSuccess('Successfully claimed paid transfer. Please wait 1 block for the name to appear.'); + } catch (e) { + this.setState({ + transferError: e.message, + }); + } + }; + + render() { + return ( + + {this.renderSteps()} + + ); + } + + renderSteps() { + switch (this.state.step) { + case 0: + return this.renderEnterHex(); + case 1: + return this.renderVerify(); + } + } + + renderEnterHex() { + return ( + <> +

+ If your counterparty has sent you a name transfer for payment, + paste the hex string into the box below to verify it and claim + your name. +

+ + {this.state.hexError && ( +

+ {this.state.hexError} +

+ )} + +
+