Giter VIP home page Giter VIP logo

photon-lib's Introduction

photon-lib Node.js CI

A high level library for building bitcoin wallets with react native.

Scope

Provide an easy-to-use high level api for the following:

  • hd wallets (bech32 and p2sh)
  • multisig support
  • an electrum light client
  • secure enclave backed key storage on iOS and Android (where available)
  • encrypted key backup on iCloud/GDrive + 2FA (see photon-keyserver)

Demo

restore flow

Threat model

Please see the threat model doc for a discussion about attack vectors and mitigation strategies.

Usage

In your react-native app...

Installing

Make sure to install all peer dependencies:

npm install --save @photon-sdk/photon-lib react-native-randombytes react-native-keychain @photon-sdk/react-native-icloudstore @react-native-async-storage/async-storage @photon-sdk/react-native-tcp @react-native-google-signin/google-signin @robinbobin/react-native-google-drive-api-wrapper node-libs-react-native react-native-device-info

Update cocoapods

npx pod-install

Configure Xcode project

In your target's "capabilities" tab in Xcode, make sure that iCloud is switched on as well as make sure that the "Key-value storage" option is checked.

Wire up node libs

Follow the usage instructions for node-libs-react-native.

Sample app

An example app using photon-lib can be found here photon-sdk/photon-app.

This PR shows what the diff should look like when installing photon-lib to your react-native app.

Example

Init Key Server

First we'll need to tell the key backup module which key server to use. See photon-sdk/photon-keyserver for how to deploy a key server instance for your app.

import { KeyBackup } from '@photon-sdk/photon-lib';

KeyBackup.init({
  keyServerURI: 'http://localhost:3000'  // your key server instance
});

Authenticate Cloud Storage

The encrypted backup is stored on the user's cloud storage account. On Android the user is required to grant access to an app specific Google Drive folder with an OAuth dialog. For iOS apps this step can be ignored as iCloud does not require extra authentication.

await KeyBackup.authenticate({
  clientId: '<FROM DEVELOPER CONSOLE>'   // see the Google Drive API docs
});

Key Backup

Now let's do an encrypted backup of a user's wallet to their iCloud account. The encryption key will be stored on your app's key server. A random Key ID (stored automatically on the user's iCloud) and a user chosen PIN is used for authentication with the key server.

import { HDSegwitBech32Wallet, KeyBackup } from '@photon-sdk/photon-lib';

const wallet = new HDSegwitBech32Wallet();
await wallet.generate();                         // generate a new seed phrase
const mnemonic = await wallet.getSecret();       // the seed phrase to backup

const data = { mnemonic };                       // backup payload (any attributes possible)
const pin = '1234';                              // PIN for auth to key server
await KeyBackup.createBackup({ data, pin });     // create encrypted cloud backup

Key Restore

Now let's restore the user's wallet on their new device. This will download their encrypted mnemonic from iCloud and decrypt it using the encryption key from the key server. The random Key ID (stored on the user's iCloud) and the PIN that was set during wallet backup will be used to authenticate with the key server. N.B. encryption key download is locked for 7 days after 10 failed authentication attempts to mitigate brute forcing of the PIN.

import { HDSegwitBech32Wallet, KeyBackup, WalletStore } from '@photon-sdk/photon-lib';

const exists = await KeyBackup.checkForExistingBackup();
if (!exists) return;

const pin = '1234';                                    // PIN for auth to key server
const data = await KeyBackup.restoreBackup({ pin });   // fetch and decrypt user's seed

const wallet = new HDSegwitBech32Wallet();
wallet.setSecret(data.mnemonic);                       // restore from the seed

const store = new WalletStore();
store.wallets.push(wallet);
await store.saveToDisk();                              // store securely in device keychain

Lightning Channel State Backup

Now let's do an encrypted backup of a user's lightning channel state to their iCloud account. The app must do an encrypted backup of the wallet private key first using the KeyBackup.createBackup() api (see above). Then the same encryption Key ID will be used for channel state as for the wallet private key. N.B. This api is still in beta. Please read here for how race conditions are mitigated.

import { KeyBackup } from '@photon-sdk/photon-lib';

const data = { ldkBackup };                           // backup payload (any attributes possible)
const pin = '1234';                                   // PIN for auth to key server
await KeyBackup.createChannelBackup({ data, pin });   // create encrypted cloud backup

Lightning Channel State Restore

Now let's restore the user's lightning channel state on their new device. This will download their encrypted channel state from iCloud and decrypt it using the encryption key from the key server. The KeyBackup.restoreBackup() api must be called first to restore the wallet private key to the device (see above).

import { KeyBackup, WalletStore } from '@photon-sdk/photon-lib';

const exists = await KeyBackup.checkForChannelBackup();
if (!exists) return;

const pin = '1234';                                           // PIN for auth to key server
const data = await KeyBackup.restoreChannelBackup({ pin });   // fetch and decrypt user's data

const store = new WalletStore();
const ldkBackupJson = JSON.stringify(data.ldkBackup);
await walletStore.setItem(LDK_WALLET, ldkBackupJson);         // store securely in device keychain

Change the PIN

Users can change the authentication PIN simply by calling the following api. A PIN must be at least 4 digits, but can also be a complex passphrase up to 256 chars in length.

import { KeyBackup } from '@photon-sdk/photon-lib';

const pin = '1234';
const newPin = 'complex passphrases are also possible';
await KeyBackup.changePin({ pin, newPin });

Add Recovery Phone Number (optional)

In order to allow for wallet recovery in case the user forgets their PIN, a recovery phone number can be set. A 30 day time delay is enforced for PIN recovery to mitigate SIM swap attacks. The phone number is stored in plaintext only on the user's iCloud. A hash of the phone number is stored on the key server for authentication (hashed with scrypt and a random salt).

import { KeyBackup } from '@photon-sdk/photon-lib';

const userId = '+4917512345678';                 // the user's number for 2FA
const pin = '1234';
await KeyBackup.registerPhone({ userId, pin });  // sends code via SMS

const code = '000000';                           // received via SMS
await KeyBackup.verifyPhone({ userId, code });   // verify phone number

Add Recovery Email Address (optional)

In order to allow for wallet recovery in case the user forgets their PIN, a recovery email address can be set. A 30 day time delay is enforced for PIN recovery to mitigate SIM swap attacks. The email address is stored in plaintext only on the user's iCloud. A hash of the email address is stored on the key server for authentication (hashed with scrypt and a random salt).

import { KeyBackup } from '@photon-sdk/photon-lib';

const userId = '[email protected]';                // the user's number for 2FA
const pin = '1234';
await KeyBackup.registerEmail({ userId, pin });  // sends code via Email

const code = '000000';                           // received via Email
await KeyBackup.verifyEmail({ userId, code });   // verify phone number

Reset the PIN via Recovery Email Address (works the same via phone)

In case the user forgets their PIN, apps should encourage users to set a recovery phone number or email address during sign up. This can be used later to reset the PIN with a 30 day time delay.

import { KeyBackup } from '@photon-sdk/photon-lib';

const userId = await KeyBackup.getEmail()              // get registered email address
await KeyBackup.initPinReset({ userId });              // start time delay in key server

const code = '123456';                                 // received via SMS or Email
const newPin = '5678';                                 // let user chose new pin
const delay = await KeyBackup.verifyPinReset({ userId, code, newPin });
if (delay) {
  // display delay in the UI and tell user to wait (30 days by default)
  return
}

// if delay is null the time lock is over and pin reset can be confirmed ...

await KeyBackup.initPinReset({ userId });              // call again after 30 day delay

const code = '654321';                                 // received via SMS or Email
await KeyBackup.verifyPinReset({ userId, code, newPin });

const pin = '5678';                                    // use the new pin for recovery
const data = await KeyBackup.restoreBackup({ pin });   // fetch and decrypt user's seed

Init Electrum Client

First we'll need to init the electrum client by specifying the host and port of our full node.

import { ElectrumClient } from '@photon-sdk/photon-lib';

const options = {
  host: 'blockstream.info',
  ssl: '700'
};
await ElectrumClient.connectMain(options);       // connect to your full node
await ElectrumClient.waitTillConnected();

Wallet Balance & Transaction Data

Now we'll generate a new wallet key, store it securely in the device keychain and fetch transactions and balances using the electrum client.

import { HDSegwitBech32Wallet, WalletStore } from '@photon-sdk/photon-lib';

const wallet = new HDSegwitBech32Wallet();
await wallet.generate();                         // or use restored (see above)

const store = new WalletStore();
store.wallets.push(wallet);
await store.saveToDisk();                        // store securely in device keychain

await store.fetchWalletBalances();               // get wallet balances from electrum
await store.fetchWalletTransactions();           // get wallet transactions from electrum

const balance = store.getBalance();              // the wallet balance to display in the ui

const address = await wallet.getAddressAsync();  // a new address to receive bitcoin

Create & Broadcast Transaction

Finally we'll fetch the wallets utxos, create a new transaction, and broadcast it using the electrum client.

import { HDSegwitBech32Wallet, WalletStore } from '@photon-sdk/photon-lib';

const wallet = new HDSegwitBech32Wallet();
await wallet.generate();                         // or use restored (see above)

await wallet.fetchUtxo();                        // fetch UTXOs
const utxo = wallet.getUtxo();                   // set UTXO as input
const target = [{                                // set output address and value in sats
  value: 1000,
  address: 'some-address'
}];
const feeRate = 1;                               // set fee rate in sat/vbyte
const changeTo = await wallet.getAddressAsync(); // get change address
const newTx = wallet.createTransaction(utxo, target, feeRate, changeTo);

await wallet.broadcastTx(newTx.tx.toHex());      // broadcast tx to the network

Create Multisig Wallet & cosign PSBT

In this example we'll create a 2-of-2 multisig wallet. Cosigners can be added as either xpubs or mnemonics. Once created, the wallet can be interacted with using the same apis as above.

import { MultisigHDWallet, WalletStore } from '@photon-sdk/photon-lib';

const path = "m/48'/0'/0'/2'";
const key1_mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const key2_fp = '05C0D4E1';
const key2_zpub = 'Zpub755JaEN81qADr1Hq22Q6AbiRutDnCMdWghxUrpxkPB5JhdcAzWzQGMiSS58oxEjTqZkxBJ1q6TwvQ1EkiNEsrD18aeVnuJgEDjg1S3ETtd6';

const wallet = new MultisigHDWallet();
wallet.addCosigner(key1_mnemonic);
wallet.addCosigner(key2_zpub, key2_fp);
wallet.setDerivationPath(path);
wallet.setM(2);

const newTx = wallet.createTransaction(utxo, target, feeRate, changeTo); // see above for how to specify args
const signedTx = wallet.cosignPsbt(newTx.psbt);     // cosign the psbt (must be done by both cosigners)

await wallet.broadcastTx(signedTx.tx.toHex());      // broadcast tx to the network

Development and testing

Clone the git repo and then:

npm install && npm test

Credit

photon-lib's People

Contributors

bitcoinzavior avatar dependabot[bot] avatar tanx avatar xsats avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

photon-lib's Issues

All test cases don't pass

All test cases don't pass.

To Replicate:

npm run npm run test or npm run test:integration and npm run test:unit results in the following:

Screenshot 2022-01-26 at 10 35 33

This is because of env file is not being read to provide the the mnemonics.

Screenshot 2022-01-26 at 10 36 36

Screenshot 2022-01-26 at 10 36 28

Screenshot 2022-01-26 at 10 36 22

Screenshot 2022-01-26 at 10 36 10

Issue and suggested Solution:

  1. To read the env variables from a .env file a dotenv dev dependency is required or an env variables file to be read via process.env.

Once this dev dependency is added it will need to configured in jest or in photon-lib/__tests__/setup.js

  1. Once env file is configured and available, following variables will be needed in the .env file:
MNEMONICS_COBO
MNEMONICS_COLDCARD
HD_MNEMONIC_BIP84
HD_MNEMONIC
FAULTY_ZPUB
HD_MNEMONIC_BIP49
HD_MNEMONIC_BIP49_MANY_TX

  1. Adding the above will ensure all env variables required for running test cases are available HOWEVER the test cases expect specific mnemonics, the test cases have been written to pass only with specific mnemonics for example HD_MNEMONIC_BIP49_MANY_TX should be a mnemonic with a 107 transactions and a total balance of 51432

There could be some other instances where specific values are required.

Also the hardcoded peers might need to be updated as well in case this is an issue.

If all the test cases were passing before then It would be great if someone who has worked on the project before can share a env file which works with the test cases.

Question: Interoperability with bitcoin lightning wallets

Hi,

Is it possible with this technology someone create an app that just store a seed encrypted with 2fa on Google Drive or ICloud and this app can have interoperability with other bitcoin lightning wallets? So for example: I install this "app", then I install Phoenix bitcoin lightning wallet, so on Phoenix when I click to restore or import wallet, will ask me permission to read my Google Drive account to see if exist any file of bitcoin lightning that can be used.

The app to store the seed on Google Drive will be a (Identity app) that could be used to "restore" lightning wallets and could be used too to login on sites with Lnurl technology.

Obs, maybe could have problems of the two apps be running on the same time and force close channels, but maybe already exist a feature on Mobile OS that automatically close an app when we try to open another one?

Adding to existing project build issue

Hi,

I would love to explore using this library in an existing app.
This is the existing wallet app https://github.com/bithyve/hexa where I would like to import and use this library.

However there seems to be an issue in building, i am getting the following build error in XCode:

13 duplicate symbols for architecture arm64

with the following details:

duplicate symbol 'OBJC_CLASS$_TcpSocketClient' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSocketClient.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSocketClient.o)
duplicate symbol 'OBJC_METACLASS$_TcpSocketClient' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSocketClient.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSocketClient.o)
duplicate symbol 'OBJC_IVAR$_TcpSocketClient._tcpSocket' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSocketClient.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSocketClient.o)
duplicate symbol 'OBJC_IVAR$_TcpSocketClient._pendingSends' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSocketClient.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSocketClient.o)
duplicate symbol '_RCTTCPErrorDomain' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSocketClient.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSocketClient.o)
duplicate symbol 'OBJC_IVAR$_TcpSocketClient._lock' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSocketClient.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSocketClient.o)
duplicate symbol 'OBJC_IVAR$_TcpSocketClient._sendTag' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSocketClient.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSocketClient.o)
duplicate symbol 'OBJC_IVAR$_TcpSocketClient._clientDelegate' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSocketClient.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSocketClient.o)
duplicate symbol 'OBJC_IVAR$_TcpSocketClient._id' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSocketClient.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSocketClient.o)
duplicate symbol 'OBJC_IVAR$_TcpSockets._clients' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSockets.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSockets.o)
duplicate symbol 'OBJC_CLASS$_TcpSockets' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSockets.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSockets.o)
duplicate symbol 'OBJC_METACLASS$_TcpSockets' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSockets.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSockets.o)
duplicate symbol 'OBJC_IVAR$_TcpSockets._counter' in:
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/TcpSockets/libTcpSockets.a(TcpSockets.o)
/Users/ali/Library/Developer/Xcode/DerivedData/HEXA-gsiembgiloupdqgkpctpopctnicw/Build/Products/Debug-iphoneos/react-native-tcp/libreact-native-tcp.a(TcpSockets.o)
ld: 13 duplicate symbols for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

I have used the photon-app and that builds and runs fine on its own.

I have also tried adding just the photon-lib without the keychain and icloudstore modules as well but still getting the same issue.

update thread model

@tanx noted in the design community: "Regarding the threat model on the photon github โ€ฆ I need to update that to reflect the new PIN authenticated model."

Email address from iCloud is not displayed after restore

I was testing this morning (Xs, 13.4.1) by deleting and reinstalling the app.

  • I set a pin and registered my email address on the first install
  • Transferred some sats
  • Deleted app
  • Reinstalled from testflight
  • Wallet balance was restored, but I noticed my email wasn't displayed under Settings
  • When I tried to reset it to the same, I got a msg saying the user ID was already in use.

Network Level Privacy: Onion route traffic between client, keyserver and other services

Rationale

photon-lib communicates with a keyserver, and the applications built from it could likely also have connections to nodes and third party services. Communicating over clearnet with these services would allow them to build quite a robust profile of someone from the leaked meta data.

Methods

To help application developers preserve their users privacy on the network level, having a Tor interface added to the library could provide an easy way of doing so.

There has recently been the creation of a react-native-tor library which BlueWallet is also implementing BlueWallet/BlueWallet#2295

Notes

One by product of implementing Tor that I am also interested in is the availability to setting up hidden services on the device that can be used for wallet to wallet communications. It's something I have been looking into for the exchange of output descriptors, invoices and other arbitrary messages for coordination.

For the sake of this proposal, I will scope the discussion around the privacy leaks on the network level, and if this is this gets implemented we can then discuss such exotic use cases.

Add Google Drive api support on Android

Currently the CloudStore module only supports iCloud on iOS and currently falls back to AsyncStorage on Android. This allows encrypted backup payloads such as private key seeds to be synced to Google Drive using Android's native backup feature. Unfortunately this backup option isn't really reliable since the schedule of the backup is not transparent to the app. Even worse if users don't use Android backups at all and they lose their phone, their photon backup will not be synced to the GDrive.

An alternative method would be to use the Google Drive api:

https://developers.google.com/drive/api/v3/about-sdk

There already seems to be a Google Drive react native module that wraps this functionality behind a JS api:

https://github.com/RobinBobin/react-native-google-drive-api-wrapper

We could integrate this behind the same api in the CloudStore module so that backups are synced to the cloud using a common api on both iOS and Android:

https://github.com/photon-sdk/photon-lib/blob/master/src/cloudstore.js

2-of-2 Multisig Timelock decaying to single

Upon my first discovery of the photon-sdk, the keyserver architecture reminded me a bit of the way Blockstream Green's standard account functions.

One major difference between the two is Blockstream's CSV timelock, where utxos received in the wallet decay to a single sig after a year, permitting recovery of funds in the event of loss of user access to their wallet/device. I understand that this would mean that the keyserver would need to play a much more active role during wallet usage, since it would then be required to sign any transactions made inside the timelock period, but I am curious to know whether this is something that has been considered for Photon and hear more thoughts from others on whether this might be a beneficial additional possible wallet setup as part of this project.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.