diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/README.md b/README.md index 024c2ee..f7fe111 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The currently available examples are: - [Personal Access Token (PAT)](authentication/personal-access-token/) - Transactions - [User-to-user transaction](transactions/user-to-user-transaction/) + - [User-to-user queued transaction](transactions/rate-limits-queued-transactions/) ## Contributing diff --git a/transactions/rate-limits-queued-transactions/.env.example b/transactions/rate-limits-queued-transactions/.env.example new file mode 100644 index 0000000..cdcfda3 --- /dev/null +++ b/transactions/rate-limits-queued-transactions/.env.example @@ -0,0 +1,8 @@ +# Base Url endpoint +BASE_URL = 'https://api-sandbox.uphold.com' + +REDIS_HOST =127.0.0.1 +REDIS_PORT =6379 + +ACCESS_TOKEN = '' +DESTINATION_EMAIL_ACCOUNT = '' diff --git a/transactions/rate-limits-queued-transactions/.gitignore b/transactions/rate-limits-queued-transactions/.gitignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/transactions/rate-limits-queued-transactions/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/transactions/rate-limits-queued-transactions/README.md b/transactions/rate-limits-queued-transactions/README.md new file mode 100644 index 0000000..dac02c0 --- /dev/null +++ b/transactions/rate-limits-queued-transactions/README.md @@ -0,0 +1,55 @@ +# Rate-limits-queued transaction + +This sample project demonstrates how to perform several transactions from one Uphold user to another, +with the latter identified by their email address. +For further background, please refer to the [API documentation](https://uphold.com/en/developer/api/documentation). + +This example makes use of [Bull queue management system](https://github.com/OptimalBits/bull) + +## Summary + +This code it's **NOT PRODUCTION READY** and is only an example, and we are assuming that: +- 2FA is disabled. + +This sample project performs the following actions: + +- Create and commit multiple, random value (.1 USD ~ .5 USD) transactions using a queue system. +- Display the data about each transaction + +## Requirements + +- [Docker Desktop](https://www.docker.com/products/docker-desktop) for Mac or Windows. +- [Docker Compose](https://docs.docker.com/compose) will be automatically installed. On Linux, make sure you have the latest version of [Compose](https://docs.docker.com/compose/install/). +- Node.js v13.14.0 or later +- An account at with at least $10 USD of available test funds +- An access token from that account, to perform authenticated requests to the Uphold API + (see the [authentication](../../authentication) examples for how to obtain one) + +## Setup Redis + +- Bull needs the [Redis](https://redis.io/) service to store and manage jobs and messages: + You can set up redis service using `docker-compose` + +```bash + docker-compose -f docker-compose.yml up -d +``` + +## Setup sample + +- Run `npm install` (or `yarn install`) +- Create a `.env` file based on the `.env.example` file, and populate it with the required data. + +## Run + +Run `node index.js`. + +The code will locate a card with nonzero balance in the source account, and prepare multiple random USD transactions +from that card to the account identified by the email in the `.env` file. + +The result will depend on the status of the destination email: + +- If it is already associated with an existing Sandbox account, the transaction will be completed immediately, and the funds will become available in the recipient's account. +- If no Sandbox account exists with that email, an "invite"-type transaction will be created, + which will be executed when the associated account is created. + This invite can be cancelled by the sender while the recipient hasn't registered + (which is useful if you use a dummy email address for this). diff --git a/transactions/rate-limits-queued-transactions/docker-compose.yml b/transactions/rate-limits-queued-transactions/docker-compose.yml new file mode 100644 index 0000000..e97e6f2 --- /dev/null +++ b/transactions/rate-limits-queued-transactions/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.2" +services: + redis: + image: "redis:alpine" + command: redis-server + ports: + - "6379:6379" + environment: + - REDIS_REPLICATION_MODE=master + networks: + node_net: + ipv4_address: 172.28.1.4 + +# networking for the Redis container +networks: + node_net: + ipam: + driver: default + config: + - subnet: 172.28.0.0/16 + diff --git a/transactions/rate-limits-queued-transactions/index.js b/transactions/rate-limits-queued-transactions/index.js new file mode 100644 index 0000000..28457a5 --- /dev/null +++ b/transactions/rate-limits-queued-transactions/index.js @@ -0,0 +1,32 @@ +/** + * Dependencies. + */ + +import {getCardWithFunds, processQueuedJobs, queueAllJobs} from "./utils.js"; +import fs from "fs"; + +(async () => { + // Check for the .env file. + if (fs.existsSync('./.env') === false) { + console.log("Missing .env file. Please follow the steps described in the README."); + return; + } + + try { + // Locate a card that can be used as the source for the transaction. + const sourceCard = await getCardWithFunds(); + + if (sourceCard?.id) { + // Queue 10 random job transactions + await queueAllJobs(sourceCard.id); + + // Process the jobs + await processQueuedJobs(); + + } else { + console.log("No card with sufficient funds...") + } + } catch { + // Unexpected error. + } +})(); diff --git a/transactions/rate-limits-queued-transactions/jobs/transaction-job.js b/transactions/rate-limits-queued-transactions/jobs/transaction-job.js new file mode 100644 index 0000000..11a8a5e --- /dev/null +++ b/transactions/rate-limits-queued-transactions/jobs/transaction-job.js @@ -0,0 +1,41 @@ +/** + * Dependencies. + */ + +import axios from "axios"; +import dotenv from "dotenv"; +import path from "path"; + +// Dotenv configuration. +dotenv.config({path: path.resolve() + "/.env"}); + +/* + * Run transaction job + */ +export async function runTransactionJob(job) { + const {data} = job; + const {sourceAccountID} = {...data}; + try { + + const response = await axios.request({ + method: "POST", + url: `${process.env.BASE_URL}/v0/me/cards/${sourceAccountID}/transactions?commit=true`, + headers: { + Authorization: `Bearer ${process.env.ACCESS_TOKEN}`, + "content-type": "application/json" + }, + data: data.data + }); + + + // Trigger transaction + const ret = response.data; + + //move to completed jobs! + await job.moveToCompleted(ret); + + } catch (err) { + return Promise.reject(err); + } + +} diff --git a/transactions/rate-limits-queued-transactions/package.json b/transactions/rate-limits-queued-transactions/package.json new file mode 100644 index 0000000..c84e5b2 --- /dev/null +++ b/transactions/rate-limits-queued-transactions/package.json @@ -0,0 +1,16 @@ +{ + "name": "uphold-queued-transaction-sample", + "version": "0.0.1", + "description": "Demo project to perform multiple transaction from one Uphold user to another using Uphold's API", + "license": "MIT", + "main": "index.js", + "type": "module", + "dependencies": { + "axios": "^0.20.0", + "bull": "^3.18.1", + "dotenv": "^8.2.0" + }, + "engines": { + "node": ">=13.14" + } +} diff --git a/transactions/rate-limits-queued-transactions/queues/transaction-queue.js b/transactions/rate-limits-queued-transactions/queues/transaction-queue.js new file mode 100644 index 0000000..50682f3 --- /dev/null +++ b/transactions/rate-limits-queued-transactions/queues/transaction-queue.js @@ -0,0 +1,87 @@ +/** + * Dependencies. + */ + +import dotenv from "dotenv"; +import Queue from 'bull'; +import path from "path"; +import {runTransactionJob} from '../jobs/transaction-job.js'; + +// Dotenv configuration. +dotenv.config({path: path.resolve() + "/.env"}); + +// Set queue options, please read [Bull queue management system](https://github.com/OptimalBits/bull) +// For testing purposes will set ´4´ has Max number of jobs processed with a 10s duration +// The timings used here are just for testing purposes, please read (https://uphold.com/en/developer/api/documentation/#rate-limits) to +// implement timings in a real scenario +const queueName = "upholdTransactionQueue"; +const upholdTransactionQueue = new Queue('transaction Queue', `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`, { + limiter: { + max: 4, + duration: 10000 + } +}); + +/* + * Transaction queue completed event + */ +upholdTransactionQueue.on('global:completed', async (jobId, result) => { + + // Monitors completed jobs! + console.log( + `Success :`, + JSON.stringify({ + JobNumber: jobId, + response: JSON.parse(result) + }, null, 2) + ); + + // noinspection JSUnresolvedFunction + const {waiting, delayed} = await upholdTransactionQueue.getJobCounts(); + + // Exits if we have no more waiting or delayed jobs to deal ! + if (waiting === 0 && delayed === 0) { + console.log(`No more jobs ... Exiting !`); + await upholdTransactionQueue.close(); + } else { + console.log(`Waiting jobs: ${waiting} Delayed jobs: ${delayed}`); + } +}); + + +/* + * Transaction queue failed event + */ +upholdTransactionQueue.on('failed', async (job, err) => { + + // Monitor failed jobs! + console.dir(err); + + // noinspection JSUnresolvedFunction + const {waiting, delayed} = await upholdTransactionQueue.getJobCounts(); + + // Exits if we have no more waiting or delayed jobs to deal ! + if (waiting === 0 && delayed === 0) { + console.log(`No more jobs ... Exiting !`); + await upholdTransactionQueue.close(); + } else { + console.log(`Waiting jobs: ${waiting} Delayed jobs: ${delayed}`); + } +}); + +/* + * Add Job to queue + */ +export async function addJobToQueue(data, jobOptions) { + await upholdTransactionQueue.add(queueName, data, jobOptions); +} + +/* + * Process jobs + */ +export async function processJobs() { + await upholdTransactionQueue.process(queueName, async job => { + await runTransactionJob(job); + }); +} + diff --git a/transactions/rate-limits-queued-transactions/utils.js b/transactions/rate-limits-queued-transactions/utils.js new file mode 100644 index 0000000..489715c --- /dev/null +++ b/transactions/rate-limits-queued-transactions/utils.js @@ -0,0 +1,121 @@ +/** + * Dependencies. + */ + +import axios from "axios"; +import dotenv from "dotenv"; +import path from "path"; +import {addJobToQueue, processJobs} from "./queues/transaction-queue.js"; + +// Dotenv configuration. +dotenv.config({path: path.resolve() + "/.env"}); + + +/** + * Format API error response for printing in console. + */ + +export function formatError(error) { + const responseStatus = `${error.response.status} (${error.response.statusText})`; + + console.log( + `Request failed with HTTP status code ${responseStatus}`, + JSON.stringify({ + url: error.config.url, + response: error.response.data + }, null, 2) + ); + + throw error; +} + +/** + * Get the first card with available balance (if one exists). + */ + +export async function getCardWithFunds() { + try { + const response = await axios.request({ + method: "GET", + url: `${process.env.BASE_URL}/v0/me/cards`, + headers: { + Authorization: `Bearer ${process.env.ACCESS_TOKEN}`, + }, + }); + + // Get the the first card with, at least 10 USD of available balance. + return response.data.filter(card => { + return Number(card.available) >= 10 + })[0]; + } catch (error) { + formatError(error); + } +} + +/** + * Queue 10 (default) transaction jobs from sourceAccountId to DESTINATION_EMAIL_ACCOUNT user. + */ + +export async function queueAllJobs(sourceAccountID, numberOfJobs = 10) { + try { + + + // Destination account + const destination = `${process.env.DESTINATION_EMAIL_ACCOUNT}`; + + // General job object + const jobData = { + data: { + denomination: { + amount: "0", + currency: "USD", + }, + destination + }, + sourceAccountID + } + + // Bull queue options. (https://github.com/OptimalBits/bull) + // For this demo we will set a delay of 5 seconds before process and a retry limit of 3. + // The timings used here are just for testing purposes, please read (https://uphold.com/en/developer/api/documentation/#rate-limits) to + // implement timings in a real scenario + const jobOptions = { + delay: 5000, + attempts: 3, + lifo: true, //Last in First Out + removeOnComplete: true, // Please update to your particular use case. + removeOnFailed: true // Please update to your particular use case. + }; + + // Just inject job transactions into the queue + for (let i = 0; i < numberOfJobs; i++) { + // Random value between 0.1 USD and 0.5 USD + jobData.data.denomination.amount = (Math.random() * (0.5 - 0.1) + 0.1).toPrecision(3); + + // Add jobs to queue + await addJobToQueue({...jobData}, jobOptions); + } + + } catch (error) { + formatError(error); + } +} + + +/** + * Process queued jobs + */ + +export async function processQueuedJobs() { + try { + + //Ok, lets process all transactions + await processJobs(); + + // Out! + console.log("All done, thanks!"); + + } catch (error) { + formatError(error); + } +}