Coinbase BuyBack plugin
authorortutay <marcell.ortutay@gmail.com>
Sun, 5 Jan 2014 08:19:23 +0000 (00:19 -0800)
committerortutay <marcell.ortutay@gmail.com>
Sun, 5 Jan 2014 08:19:23 +0000 (00:19 -0800)
data/certs/ca-coinbase.crt [new file with mode: 0644]
gui/qt/lite_window.py
gui/qt/main_window.py
gui/stdio.py
gui/text.py
lib/wallet.py
plugins/coinbase_buyback.py [new file with mode: 0644]
setup-release.py
setup.py

diff --git a/data/certs/ca-coinbase.crt b/data/certs/ca-coinbase.crt
new file mode 100644 (file)
index 0000000..d936654
--- /dev/null
@@ -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-----
index 24be78f..6886743 100644 (file)
@@ -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
index 20b7caa..c3a4cc6 100644 (file)
@@ -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()
index a26925a..54067d5 100644 (file)
@@ -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.'))
index 3c01474..0c6a148 100644 (file)
@@ -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.'))
index 52d4285..fd9a103 100644 (file)
@@ -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
@@ -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
 
 
diff --git a/plugins/coinbase_buyback.py b/plugins/coinbase_buyback.py
new file mode 100644 (file)
index 0000000..2812512
--- /dev/null
@@ -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)
index 8791293..62f9a87 100644 (file)
@@ -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,
index c2eba94..66899f3 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -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",
     ])
 ]
 
@@ -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',