9 changed files with 363 additions and 7 deletions
@ -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----- |
@ -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) |
Loading…
Reference in new issue