URI: 
       tMerge pull request #526 from ortutay/buybackplugin - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
   DIR commit 2103fb6254222d635930413e3220cf812a4940fa
   DIR parent fed86e92e2b107de7bc76c87b2ae14fb43041730
  HTML Author: ThomasV <thomasv1@gmx.de>
       Date:   Tue,  7 Jan 2014 22:32:51 -0800
       
       Merge pull request #526 from ortutay/buybackplugin
       
       Coinbase BuyBack plugin
       Diffstat:
         A data/certs/ca-coinbase.crt          |      44 +++++++++++++++++++++++++++++++
         M gui/qt/lite_window.py               |       2 +-
         M gui/qt/main_window.py               |       2 +-
         M gui/stdio.py                        |       2 +-
         M gui/text.py                         |       2 +-
         M lib/wallet.py                       |       5 +++--
         A plugins/coinbase_buyback.py         |     307 +++++++++++++++++++++++++++++++
         M setup-release.py                    |       2 +-
         M setup.py                            |       4 ++++
       
       9 files changed, 363 insertions(+), 7 deletions(-)
       ---
   DIR diff --git a/data/certs/ca-coinbase.crt b/data/certs/ca-coinbase.crt
       t@@ -0,0 +1,44 @@
       +-----BEGIN CERTIFICATE-----
       +MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl
       +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
       +d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv
       +b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG
       +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl
       +cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi
       +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c
       +JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP
       +mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+
       +wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4
       +VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/
       +AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB
       +AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
       +BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun
       +pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC
       +dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf
       +fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm
       +NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx
       +H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe
       ++o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g==
       +-----END CERTIFICATE-----
       +-----BEGIN CERTIFICATE-----
       +MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh
       +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
       +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD
       +QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT
       +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
       +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG
       +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB
       +CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97
       +nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt
       +43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P
       +T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4
       +gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO
       +BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR
       +TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw
       +DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr
       +hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg
       +06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF
       +PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls
       +YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
       +CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
       +-----END CERTIFICATE-----
   DIR diff --git a/gui/qt/lite_window.py b/gui/qt/lite_window.py
       t@@ -758,7 +758,7 @@ class MiniActuator:
        
                    self.waiting_dialog(lambda: False if self.g.wallet.tx_event.isSet() else _("Sending transaction, please wait..."))
                      
       -            status, message = self.g.wallet.receive_tx(h)
       +            status, message = self.g.wallet.receive_tx(h, tx)
        
                    if not status:
                        import tempfile
   DIR diff --git a/gui/qt/main_window.py b/gui/qt/main_window.py
       t@@ -941,7 +941,7 @@ class ElectrumWindow(QMainWindow):
                if tx.is_complete:
                    h = self.wallet.send_tx(tx)
                    waiting_dialog(lambda: False if self.wallet.tx_event.isSet() else _("Please wait..."))
       -            status, msg = self.wallet.receive_tx( h )
       +            status, msg = self.wallet.receive_tx( h, tx )
                    if status:
                        QMessageBox.information(self, '', _('Payment sent.')+'\n'+msg, _('OK'))
                        self.do_clear()
   DIR diff --git a/gui/stdio.py b/gui/stdio.py
       t@@ -208,7 +208,7 @@ class ElectrumGui:
                h = self.wallet.send_tx(tx)
                print(_("Please wait..."))
                self.wallet.tx_event.wait()
       -        status, msg = self.wallet.receive_tx( h )
       +        status, msg = self.wallet.receive_tx( h, tx )
        
                if status:
                    print(_('Payment sent.'))
   DIR diff --git a/gui/text.py b/gui/text.py
       t@@ -319,7 +319,7 @@ class ElectrumGui:
                h = self.wallet.send_tx(tx)
                self.show_message(_("Please wait..."), getchar=False)
                self.wallet.tx_event.wait()
       -        status, msg = self.wallet.receive_tx( h )
       +        status, msg = self.wallet.receive_tx( h, tx )
        
                if status:
                    self.show_message(_('Payment sent.'))
   DIR diff --git a/lib/wallet.py b/lib/wallet.py
       t@@ -1388,7 +1388,7 @@ class Wallet:
                # synchronous
                h = self.send_tx(tx)
                self.tx_event.wait()
       -        return self.receive_tx(h)
       +        return self.receive_tx(h, tx)
        
            def send_tx(self, tx):
                # asynchronous
       t@@ -1400,10 +1400,11 @@ class Wallet:
                self.tx_result = r.get('result')
                self.tx_event.set()
        
       -    def receive_tx(self,tx_hash):
       +    def receive_tx(self, tx_hash, tx):
                out = self.tx_result 
                if out != tx_hash:
                    return False, "error: " + out
       +        run_hook('receive_tx', tx, self)
                return True, out
        
        
   DIR diff --git a/plugins/coinbase_buyback.py b/plugins/coinbase_buyback.py
       t@@ -0,0 +1,307 @@
       +import PyQt4
       +import sys
       +
       +import PyQt4.QtCore as QtCore
       +import urllib
       +import re
       +import time
       +import os
       +import httplib2
       +import datetime
       +import json
       +import string
       +
       +from urllib import urlencode
       +
       +from PyQt4.QtGui import *
       +from PyQt4.QtCore import *
       +from PyQt4.QtWebKit import QWebView
       +
       +from electrum import BasePlugin
       +from electrum.i18n import _, set_language
       +from electrum.util import user_dir
       +from electrum.util import appdata_dir
       +from electrum.util import format_satoshis
       +from electrum_gui.qt import ElectrumGui
       +
       +SATOSHIS_PER_BTC = float(100000000)
       +COINBASE_ENDPOINT = 'https://coinbase.com'
       +CERTS_PATH = appdata_dir() + '/certs/ca-coinbase.crt'
       +SCOPE = 'buy'
       +REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
       +TOKEN_URI = 'https://coinbase.com/oauth/token'
       +CLIENT_ID = '0a930a48b5a6ea10fb9f7a9fec3d093a6c9062ef8a7eeab20681274feabdab06'
       +CLIENT_SECRET = 'f515989e8819f1822b3ac7a7ef7e57f755c9b12aee8f22de6b340a99fd0fd617'
       +# Expiry is stored in RFC3339 UTC format
       +EXPIRY_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
       +
       +class Plugin(BasePlugin):
       +
       +    def fullname(self): return 'Coinbase BuyBack'
       +
       +    def description(self): return 'After sending bitcoin, prompt the user with the option to rebuy them via Coinbase.\n\nMarcell Ortutay, 1FNGQvm29tKM7y3niq63RKi7Qbg7oZ3jrB'
       +
       +    def __init__(self, gui, name):
       +        BasePlugin.__init__(self, gui, name)
       +        self._is_available = self._init()
       +
       +    def _init(self):
       +        return True
       +
       +    def is_available(self):
       +        return self._is_available
       +
       +    def enable(self):
       +        return BasePlugin.enable(self)
       +
       +    def receive_tx(self, tx, wallet):
       +        domain = wallet.get_account_addresses(None)
       +        is_relevant, is_send, v, fee = tx.get_value(domain, wallet.prevout_values)
       +        if isinstance(self.gui, ElectrumGui):
       +            try:
       +                web = propose_rebuy_qt(abs(v))
       +            except OAuth2Exception as e:
       +                rm_local_oauth_credentials()
       +        # TODO(ortutay): android flow
       +
       +
       +def propose_rebuy_qt(amount):
       +    web = QWebView()
       +    box = QMessageBox()
       +    box.setFixedSize(200, 200)
       +
       +    credentials = read_local_oauth_credentials()
       +    questionText = _('Rebuy ') + format_satoshis(amount) + _(' BTC?')
       +    if credentials:
       +        credentials.refresh()
       +    if credentials and not credentials.invalid:
       +        credentials.store_locally()
       +        totalPrice = get_coinbase_total_price(credentials, amount)
       +        questionText += _('\n(Price: ') + totalPrice + _(')')
       +
       +    if not question(box, questionText):
       +        return
       +
       +    if credentials:
       +        do_buy(credentials, amount)
       +    else:
       +        do_oauth_flow(web, amount)
       +    return web
       +
       +def do_buy(credentials, amount):
       +    h = httplib2.Http(ca_certs=CERTS_PATH)
       +    h = credentials.authorize(h)
       +    params = {
       +        'qty': float(amount)/SATOSHIS_PER_BTC,
       +        'agree_btc_amount_varies': False
       +    }
       +    resp, content = h.request(
       +        COINBASE_ENDPOINT + '/api/v1/buys', 'POST', urlencode(params))
       +    if resp['status'] != '200':
       +        message(_('Error, could not buy bitcoin'))
       +        return
       +    content = json.loads(content)
       +    if content['success']:
       +        message(_('Success!\n') + content['transfer']['description'])
       +    else:
       +        if content['errors']:
       +            message(_('Error: ') + string.join(content['errors'], '\n'))
       +        else:
       +            message(_('Error, could not buy bitcoin'))
       +
       +def get_coinbase_total_price(credentials, amount):
       +    h = httplib2.Http(ca_certs=CERTS_PATH)
       +    params={'qty': amount/SATOSHIS_PER_BTC}
       +    resp, content = h.request(COINBASE_ENDPOINT + '/api/v1/prices/buy?' + urlencode(params),'GET')
       +    content = json.loads(content)
       +    if resp['status'] != '200':
       +        return 'unavailable'
       +    return '$' + content['total']['amount']
       +
       +def do_oauth_flow(web, amount):
       +    # QT expects un-escaped URL
       +    auth_uri = step1_get_authorize_url()
       +    web.load(QUrl(auth_uri))
       +    web.setFixedSize(500, 700)
       +    web.show()
       +    web.titleChanged.connect(lambda(title): complete_oauth_flow(title, web, amount) if re.search('^[a-z0-9]+$', title) else False)
       +
       +def complete_oauth_flow(token, web, amount):
       +    web.close()
       +    http = httplib2.Http(ca_certs=CERTS_PATH)
       +    credentials = step2_exchange(str(token), http)
       +    credentials.store_locally()
       +    do_buy(credentials, amount)
       +
       +def token_path():
       +    dir = user_dir() + '/coinbase_buyback'
       +    if not os.access(dir, os.F_OK):
       +        os.mkdir(dir)
       +    return dir + '/token'
       +
       +def read_local_oauth_credentials():
       +    if not os.access(token_path(), os.F_OK):
       +        return None
       +    f = open(token_path(), 'r')
       +    data = f.read()
       +    f.close()
       +    try:
       +        credentials = Credentials.from_json(data)
       +        return credentials
       +    except Exception as e:
       +        return None
       +
       +def rm_local_oauth_credentials():
       +    os.remove(token_path())
       +
       +def step1_get_authorize_url():
       +    return ('https://coinbase.com/oauth/authorize'
       +            + '?scope=' + SCOPE
       +            + '&redirect_uri=' + REDIRECT_URI
       +            + '&response_type=code'
       +            + '&client_id=' + CLIENT_ID
       +            + '&access_type=offline')
       +
       +def step2_exchange(code, http):
       +    body = urllib.urlencode({
       +        'grant_type': 'authorization_code',
       +        'client_id': CLIENT_ID,
       +        'client_secret': CLIENT_SECRET,
       +        'code': code,
       +        'redirect_uri': REDIRECT_URI,
       +        'scope': SCOPE,
       +        })
       +    headers = {
       +        'content-type': 'application/x-www-form-urlencoded',
       +    }
       +
       +    resp, content = http.request(TOKEN_URI, method='POST', body=body,
       +                                 headers=headers)
       +    if resp.status == 200:
       +        d = json.loads(content)
       +        access_token = d['access_token']
       +        refresh_token = d.get('refresh_token', None)
       +        token_expiry = None
       +        if 'expires_in' in d:
       +            token_expiry = datetime.datetime.utcnow() + datetime.timedelta(
       +                seconds=int(d['expires_in']))
       +        return Credentials(access_token, refresh_token, token_expiry)
       +    else:
       +        raise OAuth2Exception(content)
       +
       +class OAuth2Exception(Exception):
       +    """An error related to OAuth2"""
       +
       +class Credentials(object):
       +    def __init__(self, access_token, refresh_token, token_expiry):
       +        self.access_token = access_token
       +        self.refresh_token = refresh_token
       +        self.token_expiry = token_expiry
       +        
       +        # Indicates a failed refresh
       +        self.invalid = False
       +
       +    def to_json(self):
       +        token_expiry = self.token_expiry
       +        if (token_expiry and isinstance(token_expiry, datetime.datetime)):
       +            token_expiry = token_expiry.strftime(EXPIRY_FORMAT)
       +        
       +        d = {
       +            'access_token': self.access_token,
       +            'refresh_token': self.refresh_token,
       +            'token_expiry': token_expiry,
       +        }
       +        return json.dumps(d)
       +
       +    def store_locally(self):
       +        f = open(token_path(), 'w')
       +        f.write(self.to_json())
       +        f.close()
       +
       +    @classmethod
       +    def from_json(cls, s):
       +        data = json.loads(s)
       +        if ('token_expiry' in data
       +            and not isinstance(data['token_expiry'], datetime.datetime)):
       +            try:
       +                data['token_expiry'] = datetime.datetime.strptime(
       +                    data['token_expiry'], EXPIRY_FORMAT)
       +            except:
       +                data['token_expiry'] = None
       +        retval = Credentials(
       +            data['access_token'],
       +            data['refresh_token'],
       +            data['token_expiry'])
       +        return retval
       +
       +    def apply(self, headers):
       +        headers['Authorization'] = 'Bearer ' + self.access_token
       +
       +    def authorize(self, http):
       +        request_orig = http.request
       +
       +        # The closure that will replace 'httplib2.Http.request'.
       +        def new_request(uri, method='GET', body=None, headers=None,
       +                        redirections=httplib2.DEFAULT_MAX_REDIRECTS,
       +                        connection_type=None):
       +            headers = {}
       +            if headers is None:
       +                headers = {}
       +                self.apply(headers)
       +
       +            resp, content = request_orig(uri, method, body, headers,
       +                                         redirections, connection_type)
       +            if resp.status == 401:
       +                self._refresh(request_orig)
       +                self.store_locally()
       +                self.apply(headers)
       +                return request_orig(uri, method, body, headers,
       +                                    redirections, connection_type)
       +            else:
       +                return (resp, content)
       +
       +        http.request = new_request
       +        setattr(http.request, 'credentials', self)
       +        return http
       +
       +    def refresh(self):
       +        h = httplib2.Http(ca_certs=CERTS_PATH)
       +        try:
       +            self._refresh(h.request)
       +        except OAuth2Exception as e:
       +            rm_local_oauth_credentials()
       +            self.invalid = True
       +            raise e
       +
       +    def _refresh(self, http_request):
       +        body = urllib.urlencode({
       +            'grant_type': 'refresh_token',
       +            'refresh_token': self.refresh_token,
       +            'client_id': CLIENT_ID,
       +            'client_secret': CLIENT_SECRET,
       +        })
       +        headers = {
       +            'content-type': 'application/x-www-form-urlencoded',
       +        }
       +        resp, content = http_request(
       +            TOKEN_URI, method='POST', body=body, headers=headers)
       +        if resp.status == 200:
       +            d = json.loads(content)
       +            self.token_response = d
       +            self.access_token = d['access_token']
       +            self.refresh_token = d.get('refresh_token', self.refresh_token)
       +            if 'expires_in' in d:
       +                self.token_expiry = datetime.timedelta(
       +                    seconds=int(d['expires_in'])) + datetime.datetime.utcnow()
       +        else:
       +            raise OAuth2Exception('Refresh failed, ' + content)
       +
       +def message(msg):
       +    box = QMessageBox()
       +    box.setFixedSize(200, 200)
       +    return QMessageBox.information(box, _('Message'), msg)
       +
       +def question(widget, msg):
       +    return (QMessageBox.question(
       +        widget, _('Message'), msg, QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
       +            == QMessageBox.Yes)
   DIR diff --git a/setup-release.py b/setup-release.py
       t@@ -36,7 +36,7 @@ if sys.platform == 'darwin':
                setup_requires=['py2app'],
                app=[mainscript],
                options=dict(py2app=dict(argv_emulation=True,
       -                                 includes=['PyQt4.QtCore', 'PyQt4.QtGui', 'sip'],
       +                                 includes=['PyQt4.QtCore', 'PyQt4.QtGui', 'PyQt4.QtWebKit', 'PyQt4.QtNetwork', 'sip'],
                                         packages=['lib', 'gui', 'plugins'],
                                         iconfile='electrum.icns',
                                         plist=plist,
   DIR diff --git a/setup.py b/setup.py
       t@@ -50,6 +50,9 @@ data_files += [
                "data/dark/background.png",
                "data/dark/name.cfg",
                "data/dark/style.css"
       +    ]),
       +    (os.path.join(util.appdata_dir(), "certs"), [
       +        "data/certs/ca-coinbase.crt",
            ])
        ]
        
       t@@ -107,6 +110,7 @@ setup(
                'electrum_gui.stdio',
                'electrum_gui.text',
                'electrum_plugins.aliases',
       +        'electrum_plugins.coinbase_buyback',
                'electrum_plugins.exchange_rate',
                'electrum_plugins.labels',
                'electrum_plugins.pointofsale',