installwizard: set network to None if offline
[electrum-nvc.git] / plugins / coinbase_buyback.py
1 import PyQt4
2 import sys
3
4 import PyQt4.QtCore as QtCore
5 import urllib
6 import re
7 import time
8 import os
9 import httplib2
10 import datetime
11 import json
12 import string
13
14 from urllib import urlencode
15
16 from PyQt4.QtGui import *
17 from PyQt4.QtCore import *
18 from PyQt4.QtWebKit import QWebView
19
20 from electrum import BasePlugin
21 from electrum.i18n import _, set_language
22 from electrum.util import user_dir
23 from electrum.util import appdata_dir
24 from electrum.util import format_satoshis
25 from electrum_gui.qt import ElectrumGui
26
27 SATOSHIS_PER_BTC = float(100000000)
28 COINBASE_ENDPOINT = 'https://coinbase.com'
29 CERTS_PATH = appdata_dir() + '/certs/ca-coinbase.crt'
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     h = httplib2.Http(ca_certs=CERTS_PATH)
93     h = credentials.authorize(h)
94     params = {
95         'qty': float(amount)/SATOSHIS_PER_BTC,
96         'agree_btc_amount_varies': False
97     }
98     resp, content = h.request(
99         COINBASE_ENDPOINT + '/api/v1/buys', 'POST', urlencode(params))
100     if resp['status'] != '200':
101         message(_('Error, could not buy bitcoin'))
102         return
103     content = json.loads(content)
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     h = httplib2.Http(ca_certs=CERTS_PATH)
114     params={'qty': amount/SATOSHIS_PER_BTC}
115     resp, content = h.request(COINBASE_ENDPOINT + '/api/v1/prices/buy?' + urlencode(params),'GET')
116     content = json.loads(content)
117     if resp['status'] != '200':
118         return 'unavailable'
119     return '$' + content['total']['amount']
120
121 def do_oauth_flow(web, amount):
122     # QT expects un-escaped URL
123     auth_uri = step1_get_authorize_url()
124     web.load(QUrl(auth_uri))
125     web.setFixedSize(500, 700)
126     web.show()
127     web.titleChanged.connect(lambda(title): complete_oauth_flow(title, web, amount) if re.search('^[a-z0-9]+$', title) else False)
128
129 def complete_oauth_flow(token, web, amount):
130     web.close()
131     http = httplib2.Http(ca_certs=CERTS_PATH)
132     credentials = step2_exchange(str(token), http)
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, http):
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     resp, content = http.request(TOKEN_URI, method='POST', body=body,
179                                  headers=headers)
180     if resp.status == 200:
181         d = json.loads(content)
182         access_token = d['access_token']
183         refresh_token = d.get('refresh_token', None)
184         token_expiry = None
185         if 'expires_in' in d:
186             token_expiry = datetime.datetime.utcnow() + datetime.timedelta(
187                 seconds=int(d['expires_in']))
188         return Credentials(access_token, refresh_token, token_expiry)
189     else:
190         raise OAuth2Exception(content)
191
192 class OAuth2Exception(Exception):
193     """An error related to OAuth2"""
194
195 class Credentials(object):
196     def __init__(self, access_token, refresh_token, token_expiry):
197         self.access_token = access_token
198         self.refresh_token = refresh_token
199         self.token_expiry = token_expiry
200         
201         # Indicates a failed refresh
202         self.invalid = False
203
204     def to_json(self):
205         token_expiry = self.token_expiry
206         if (token_expiry and isinstance(token_expiry, datetime.datetime)):
207             token_expiry = token_expiry.strftime(EXPIRY_FORMAT)
208         
209         d = {
210             'access_token': self.access_token,
211             'refresh_token': self.refresh_token,
212             'token_expiry': token_expiry,
213         }
214         return json.dumps(d)
215
216     def store_locally(self):
217         f = open(token_path(), 'w')
218         f.write(self.to_json())
219         f.close()
220
221     @classmethod
222     def from_json(cls, s):
223         data = json.loads(s)
224         if ('token_expiry' in data
225             and not isinstance(data['token_expiry'], datetime.datetime)):
226             try:
227                 data['token_expiry'] = datetime.datetime.strptime(
228                     data['token_expiry'], EXPIRY_FORMAT)
229             except:
230                 data['token_expiry'] = None
231         retval = Credentials(
232             data['access_token'],
233             data['refresh_token'],
234             data['token_expiry'])
235         return retval
236
237     def apply(self, headers):
238         headers['Authorization'] = 'Bearer ' + self.access_token
239
240     def authorize(self, http):
241         request_orig = http.request
242
243         # The closure that will replace 'httplib2.Http.request'.
244         def new_request(uri, method='GET', body=None, headers=None,
245                         redirections=httplib2.DEFAULT_MAX_REDIRECTS,
246                         connection_type=None):
247             headers = {}
248             if headers is None:
249                 headers = {}
250                 self.apply(headers)
251
252             resp, content = request_orig(uri, method, body, headers,
253                                          redirections, connection_type)
254             if resp.status == 401:
255                 self._refresh(request_orig)
256                 self.store_locally()
257                 self.apply(headers)
258                 return request_orig(uri, method, body, headers,
259                                     redirections, connection_type)
260             else:
261                 return (resp, content)
262
263         http.request = new_request
264         setattr(http.request, 'credentials', self)
265         return http
266
267     def refresh(self):
268         h = httplib2.Http(ca_certs=CERTS_PATH)
269         try:
270             self._refresh(h.request)
271         except OAuth2Exception as e:
272             rm_local_oauth_credentials()
273             self.invalid = True
274             raise e
275
276     def _refresh(self, http_request):
277         body = urllib.urlencode({
278             'grant_type': 'refresh_token',
279             'refresh_token': self.refresh_token,
280             'client_id': CLIENT_ID,
281             'client_secret': CLIENT_SECRET,
282         })
283         headers = {
284             'content-type': 'application/x-www-form-urlencoded',
285         }
286         resp, content = http_request(
287             TOKEN_URI, method='POST', body=body, headers=headers)
288         if resp.status == 200:
289             d = json.loads(content)
290             self.token_response = d
291             self.access_token = d['access_token']
292             self.refresh_token = d.get('refresh_token', self.refresh_token)
293             if 'expires_in' in d:
294                 self.token_expiry = datetime.timedelta(
295                     seconds=int(d['expires_in'])) + datetime.datetime.utcnow()
296         else:
297             raise OAuth2Exception('Refresh failed, ' + content)
298
299 def message(msg):
300     box = QMessageBox()
301     box.setFixedSize(200, 200)
302     return QMessageBox.information(box, _('Message'), msg)
303
304 def question(widget, msg):
305     return (QMessageBox.question(
306         widget, _('Message'), msg, QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
307             == QMessageBox.Yes)