Merge pull request #526 from ortutay/buybackplugin
Coinbase BuyBack plugin
This commit is contained in:
commit
2103fb6254
|
@ -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-----
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.'))
|
||||
|
|
|
@ -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.'))
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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,
|
||||
|
|
4
setup.py
4
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',
|
||||
|
|
Loading…
Reference in New Issue