1 changed files with 312 additions and 0 deletions
@ -0,0 +1,312 @@ |
|||||||
|
import PyQt4 |
||||||
|
import sys |
||||||
|
|
||||||
|
import PyQt4.QtCore as QtCore |
||||||
|
import base64 |
||||||
|
import urllib |
||||||
|
import re |
||||||
|
import time |
||||||
|
import os |
||||||
|
import httplib |
||||||
|
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' |
||||||
|
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): |
||||||
|
conn = httplib.HTTPSConnection('coinbase.com') |
||||||
|
credentials.authorize(conn) |
||||||
|
params = { |
||||||
|
'qty': float(amount)/SATOSHIS_PER_BTC, |
||||||
|
'agree_btc_amount_varies': False |
||||||
|
} |
||||||
|
resp = conn.auth_request('POST', '/api/v1/buys', urlencode(params), None) |
||||||
|
|
||||||
|
if resp.status != 200: |
||||||
|
message(_('Error, could not buy bitcoin')) |
||||||
|
return |
||||||
|
content = json.loads(resp.read()) |
||||||
|
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): |
||||||
|
conn = httplib.HTTPSConnection('coinbase.com') |
||||||
|
params={'qty': amount/SATOSHIS_PER_BTC} |
||||||
|
conn.request('GET', '/api/v1/prices/buy?' + urlencode(params)) |
||||||
|
resp = conn.getresponse() |
||||||
|
if resp.status != 200: |
||||||
|
return 'unavailable' |
||||||
|
content = json.loads(resp.read()) |
||||||
|
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() |
||||||
|
credentials = step2_exchange(str(token)) |
||||||
|
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): |
||||||
|
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', |
||||||
|
} |
||||||
|
|
||||||
|
conn = httplib.HTTPSConnection('coinbase.com') |
||||||
|
conn.request('POST', TOKEN_URI, body, headers) |
||||||
|
resp = conn.getresponse() |
||||||
|
if resp.status == 200: |
||||||
|
d = json.loads(resp.read()) |
||||||
|
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, conn): |
||||||
|
request_orig = conn.request |
||||||
|
|
||||||
|
def new_request(method, uri, params, headers): |
||||||
|
if headers == None: |
||||||
|
headers = {} |
||||||
|
self.apply(headers) |
||||||
|
request_orig(method, uri, params, headers) |
||||||
|
resp = conn.getresponse() |
||||||
|
if resp.status == 401: |
||||||
|
# Refresh and try again |
||||||
|
self._refresh(request_orig) |
||||||
|
self.store_locally() |
||||||
|
self.apply(headers) |
||||||
|
request_orig(method, uri, params, headers) |
||||||
|
return conn.getresponse() |
||||||
|
else: |
||||||
|
return resp |
||||||
|
|
||||||
|
conn.auth_request = new_request |
||||||
|
return conn |
||||||
|
|
||||||
|
def refresh(self): |
||||||
|
try: |
||||||
|
self._refresh() |
||||||
|
except OAuth2Exception as e: |
||||||
|
rm_local_oauth_credentials() |
||||||
|
self.invalid = True |
||||||
|
raise e |
||||||
|
|
||||||
|
def _refresh(self): |
||||||
|
conn = httplib.HTTPSConnection('coinbase.com') |
||||||
|
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', |
||||||
|
} |
||||||
|
conn.request('POST', TOKEN_URI, body, headers) |
||||||
|
resp = conn.getresponse() |
||||||
|
if resp.status == 200: |
||||||
|
d = json.loads(resp.read()) |
||||||
|
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) |
||||||
|
|
||||||
|
def main(): |
||||||
|
app = QApplication(sys.argv) |
||||||
|
print sys.argv[1] |
||||||
|
propose_rebuy_qt(int(sys.argv[1])) |
||||||
|
sys.exit(app.exec_()) |
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
Loading…
Reference in new issue