electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit 2103fb6254222d635930413e3220cf812a4940fa
parent fed86e92e2b107de7bc76c87b2ae14fb43041730
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:
Adata/certs/ca-coinbase.crt | 44++++++++++++++++++++++++++++++++++++++++++++
Mgui/qt/lite_window.py | 2+-
Mgui/qt/main_window.py | 2+-
Mgui/stdio.py | 2+-
Mgui/text.py | 2+-
Mlib/wallet.py | 5+++--
Aplugins/coinbase_buyback.py | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msetup-release.py | 2+-
Msetup.py | 4++++
9 files changed, 363 insertions(+), 7 deletions(-)

diff --git a/data/certs/ca-coinbase.crt b/data/certs/ca-coinbase.crt @@ -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----- diff --git a/gui/qt/lite_window.py b/gui/qt/lite_window.py @@ -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 diff --git a/gui/qt/main_window.py b/gui/qt/main_window.py @@ -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() diff --git a/gui/stdio.py b/gui/stdio.py @@ -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.')) diff --git a/gui/text.py b/gui/text.py @@ -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.')) diff --git a/lib/wallet.py b/lib/wallet.py @@ -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 @@ -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) diff --git a/setup-release.py b/setup-release.py @@ -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, diff --git 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',