27a2f51265c4adb9d01f070848e92044975999e5
[electrum-nvc.git] / plugins / coinbase_buyback.py
1 import PyQt4
2 import sys
3
4 import PyQt4.QtCore as QtCore
5 import base64
6 import urllib
7 import re
8 import time
9 import os
10 import httplib
11 import datetime
12 import json
13 import string
14
15 from urllib import urlencode
16
17 from PyQt4.QtGui import *
18 from PyQt4.QtCore import *
19 from PyQt4.QtWebKit import QWebView
20
21 from electrum import BasePlugin
22 from electrum.i18n import _, set_language
23 from electrum.util import user_dir
24 from electrum.util import appdata_dir
25 from electrum.util import format_satoshis
26 from electrum_gui.qt import ElectrumGui
27
28 SATOSHIS_PER_BTC = float(100000000)
29 COINBASE_ENDPOINT = 'https://coinbase.com'
30 SCOPE = 'buy'
31 REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
32 TOKEN_URI = 'https://coinbase.com/oauth/token'
33 CLIENT_ID = '0a930a48b5a6ea10fb9f7a9fec3d093a6c9062ef8a7eeab20681274feabdab06'
34 CLIENT_SECRET = 'f515989e8819f1822b3ac7a7ef7e57f755c9b12aee8f22de6b340a99fd0fd617'
35 # Expiry is stored in RFC3339 UTC format
36 EXPIRY_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
37
38 class Plugin(BasePlugin):
39
40     def fullname(self): return 'Coinbase BuyBack'
41
42     def description(self): return 'After sending bitcoin, prompt the user with the option to rebuy them via Coinbase.\n\nMarcell Ortutay, 1FNGQvm29tKM7y3niq63RKi7Qbg7oZ3jrB'
43
44     def __init__(self, gui, name):
45         BasePlugin.__init__(self, gui, name)
46         self._is_available = self._init()
47
48     def _init(self):
49         return True
50
51     def is_available(self):
52         return self._is_available
53
54     def enable(self):
55         return BasePlugin.enable(self)
56
57     def receive_tx(self, tx, wallet):
58         domain = wallet.get_account_addresses(None)
59         is_relevant, is_send, v, fee = tx.get_value(domain, wallet.prevout_values)
60         if isinstance(self.gui, ElectrumGui):
61             try:
62                 web = propose_rebuy_qt(abs(v))
63             except OAuth2Exception as e:
64                 rm_local_oauth_credentials()
65         # TODO(ortutay): android flow
66
67
68 def propose_rebuy_qt(amount):
69     web = QWebView()
70     box = QMessageBox()
71     box.setFixedSize(200, 200)
72
73     credentials = read_local_oauth_credentials()
74     questionText = _('Rebuy ') + format_satoshis(amount) + _(' BTC?')
75     if credentials:
76         credentials.refresh()
77     if credentials and not credentials.invalid:
78         credentials.store_locally()
79         totalPrice = get_coinbase_total_price(credentials, amount)
80         questionText += _('\n(Price: ') + totalPrice + _(')')
81
82     if not question(box, questionText):
83         return
84
85     if credentials:
86         do_buy(credentials, amount)
87     else:
88         do_oauth_flow(web, amount)
89     return web
90
91 def do_buy(credentials, amount):
92     conn = httplib.HTTPSConnection('coinbase.com')
93     credentials.authorize(conn)
94     params = {
95         'qty': float(amount)/SATOSHIS_PER_BTC,
96         'agree_btc_amount_varies': False
97     }
98     resp = conn.auth_request('POST', '/api/v1/buys', urlencode(params), None)
99
100     if resp.status != 200:
101         message(_('Error, could not buy bitcoin'))
102         return
103     content = json.loads(resp.read())
104     if content['success']:
105         message(_('Success!\n') + content['transfer']['description'])
106     else:
107         if content['errors']:
108             message(_('Error: ') + string.join(content['errors'], '\n'))
109         else:
110             message(_('Error, could not buy bitcoin'))
111
112 def get_coinbase_total_price(credentials, amount):
113     conn = httplib.HTTPSConnection('coinbase.com')
114     params={'qty': amount/SATOSHIS_PER_BTC}
115     conn.request('GET', '/api/v1/prices/buy?' + urlencode(params))
116     resp = conn.getresponse()
117     if resp.status != 200:
118         return 'unavailable'
119     content = json.loads(resp.read())
120     return '$' + content['total']['amount']
121
122 def do_oauth_flow(web, amount):
123     # QT expects un-escaped URL
124     auth_uri = step1_get_authorize_url()
125     web.load(QUrl(auth_uri))
126     web.setFixedSize(500, 700)
127     web.show()
128     web.titleChanged.connect(lambda(title): complete_oauth_flow(title, web, amount) if re.search('^[a-z0-9]+$', title) else False)
129
130 def complete_oauth_flow(token, web, amount):
131     web.close()
132     credentials = step2_exchange(str(token))
133     credentials.store_locally()
134     do_buy(credentials, amount)
135
136 def token_path():
137     dir = user_dir() + '/coinbase_buyback'
138     if not os.access(dir, os.F_OK):
139         os.mkdir(dir)
140     return dir + '/token'
141
142 def read_local_oauth_credentials():
143     if not os.access(token_path(), os.F_OK):
144         return None
145     f = open(token_path(), 'r')
146     data = f.read()
147     f.close()
148     try:
149         credentials = Credentials.from_json(data)
150         return credentials
151     except Exception as e:
152         return None
153
154 def rm_local_oauth_credentials():
155     os.remove(token_path())
156
157 def step1_get_authorize_url():
158     return ('https://coinbase.com/oauth/authorize'
159             + '?scope=' + SCOPE
160             + '&redirect_uri=' + REDIRECT_URI
161             + '&response_type=code'
162             + '&client_id=' + CLIENT_ID
163             + '&access_type=offline')
164
165 def step2_exchange(code):
166     body = urllib.urlencode({
167         'grant_type': 'authorization_code',
168         'client_id': CLIENT_ID,
169         'client_secret': CLIENT_SECRET,
170         'code': code,
171         'redirect_uri': REDIRECT_URI,
172         'scope': SCOPE,
173         })
174     headers = {
175         'content-type': 'application/x-www-form-urlencoded',
176     }
177
178     conn = httplib.HTTPSConnection('coinbase.com')
179     conn.request('POST', TOKEN_URI, body, headers)
180     resp = conn.getresponse()
181     if resp.status == 200:
182         d = json.loads(resp.read())
183         access_token = d['access_token']
184         refresh_token = d.get('refresh_token', None)
185         token_expiry = None
186         if 'expires_in' in d:
187             token_expiry = datetime.datetime.utcnow() + datetime.timedelta(
188                 seconds=int(d['expires_in']))
189         return Credentials(access_token, refresh_token, token_expiry)
190     else:
191         raise OAuth2Exception(content)
192
193 class OAuth2Exception(Exception):
194     """An error related to OAuth2"""
195
196 class Credentials(object):
197     def __init__(self, access_token, refresh_token, token_expiry):
198         self.access_token = access_token
199         self.refresh_token = refresh_token
200         self.token_expiry = token_expiry
201         
202         # Indicates a failed refresh
203         self.invalid = False
204
205     def to_json(self):
206         token_expiry = self.token_expiry
207         if (token_expiry and isinstance(token_expiry, datetime.datetime)):
208             token_expiry = token_expiry.strftime(EXPIRY_FORMAT)
209         
210         d = {
211             'access_token': self.access_token,
212             'refresh_token': self.refresh_token,
213             'token_expiry': token_expiry,
214         }
215         return json.dumps(d)
216
217     def store_locally(self):
218         f = open(token_path(), 'w')
219         f.write(self.to_json())
220         f.close()
221
222     @classmethod
223     def from_json(cls, s):
224         data = json.loads(s)
225         if ('token_expiry' in data
226             and not isinstance(data['token_expiry'], datetime.datetime)):
227             try:
228                 data['token_expiry'] = datetime.datetime.strptime(
229                     data['token_expiry'], EXPIRY_FORMAT)
230             except:
231                 data['token_expiry'] = None
232         retval = Credentials(
233             data['access_token'],
234             data['refresh_token'],
235             data['token_expiry'])
236         return retval
237
238     def apply(self, headers):
239         headers['Authorization'] = 'Bearer ' + self.access_token
240
241     def authorize(self, conn):
242         request_orig = conn.request
243
244         def new_request(method, uri, params, headers):
245             if headers == None:
246                 headers = {}
247                 self.apply(headers)
248             request_orig(method, uri, params, headers)
249             resp = conn.getresponse()
250             if resp.status == 401:
251                 # Refresh and try again
252                 self._refresh(request_orig)
253                 self.store_locally()
254                 self.apply(headers)
255                 request_orig(method, uri, params, headers)
256                 return conn.getresponse()
257             else:
258                 return resp
259         
260         conn.auth_request = new_request
261         return conn
262
263     def refresh(self):
264         try:
265             self._refresh()
266         except OAuth2Exception as e:
267             rm_local_oauth_credentials()
268             self.invalid = True
269             raise e
270
271     def _refresh(self):
272         conn = httplib.HTTPSConnection('coinbase.com')
273         body = urllib.urlencode({
274             'grant_type': 'refresh_token',
275             'refresh_token': self.refresh_token,
276             'client_id': CLIENT_ID,
277             'client_secret': CLIENT_SECRET,
278         })
279         headers = {
280             'content-type': 'application/x-www-form-urlencoded',
281         }
282         conn.request('POST', TOKEN_URI, body, headers)
283         resp = conn.getresponse()
284         if resp.status == 200:
285             d = json.loads(resp.read())
286             self.token_response = d
287             self.access_token = d['access_token']
288             self.refresh_token = d.get('refresh_token', self.refresh_token)
289             if 'expires_in' in d:
290                 self.token_expiry = datetime.timedelta(
291                     seconds=int(d['expires_in'])) + datetime.datetime.utcnow()
292         else:
293             raise OAuth2Exception('Refresh failed, ' + content)
294
295 def message(msg):
296     box = QMessageBox()
297     box.setFixedSize(200, 200)
298     return QMessageBox.information(box, _('Message'), msg)
299
300 def question(widget, msg):
301     return (QMessageBox.question(
302         widget, _('Message'), msg, QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
303             == QMessageBox.Yes)
304
305 def main():
306     app = QApplication(sys.argv)
307     print sys.argv[1]
308     propose_rebuy_qt(int(sys.argv[1]))
309     sys.exit(app.exec_())
310
311 if __name__ == "__main__":
312     main()