invoiced ... WIP transactions

This commit is contained in:
keyan 2021-05-11 10:52:50 -05:00
parent 764a683500
commit bc0389e622
18 changed files with 357 additions and 51 deletions

View File

@ -107,7 +107,7 @@ export default {
throw new UserInputError('Sats must be positive', { argumentName: 'sats' })
}
// check if we've already voted for the item
// TODO: check if we've already voted for the item ... XXX this isn't transactional
const boosted = await models.vote.findFirst({
where: {
itemId: parseInt(id),
@ -143,7 +143,7 @@ export default {
SELECT count(*)
FROM "Item"
WHERE path <@ text2ltree(${item.path}) AND id != ${item.id}`
return count
return count || 0
},
sats: async (item, args, { models }) => {
const { sum: { sats } } = await models.vote.aggregate({
@ -156,7 +156,7 @@ export default {
}
})
return sats
return sats || 0
},
boost: async (item, args, { models }) => {
const { sum: { sats } } = await models.vote.aggregate({
@ -169,7 +169,7 @@ export default {
}
})
return sats
return sats || 0
},
meSats: async (item, args, { me, models }) => {
if (!me) return 0
@ -184,7 +184,7 @@ export default {
}
})
return sats
return sats || 0
}
}
}

View File

@ -1,7 +1,7 @@
export default {
Query: {
me: async (parent, args, { models, me }) =>
me ? await models.user.findUnique({ where: { id: me.id } }) : null,
me ? await models.user.findUnique({ where: { name: me.name } }) : null,
user: async (parent, { name }, { models }) => {
return await models.user.findUnique({ where: { name } })
},
@ -22,8 +22,10 @@ export default {
FROM "Item"
LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id
WHERE "Item"."userId" = ${user.id}`
return sum
return sum || 0
},
sats: () => 0
sats: async (user, args, { models }) => {
return Math.floor(user.msats / 1000)
}
}
}

View File

@ -1,13 +1,52 @@
import { createInvoice } from 'ln-service'
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
export default {
Query: {
invoice: async (parent, { id }, { me, models, lnd }) => {
return 'lnbc1500n1psfxyaypp5tmlgpudspqed4qf32xxmc7dhlqrd4glc09x794exz4t2pw8ms38sdpa2fjkzep6yptks7fqt9hh2gzwv4jkggz5dus9gatjdcsyzmrvypvk7atjypzx7cqzpgxqr23ssp529tup4vaxlxnst0lwh9kljpl9n6zg6n6vma5hw78lmnws32x278s9qyyssqxe73jclrlz3u7v7ruwee3n7h70ktsdsfmvpfjkccqxq5wg5h6njhqxar0a9fef5hd09ethwhvsj0dha2qy4tjjdxu08nkqymfs8wghqp6d7kth'
return await models.invoice.findUnique({ where: { id: Number(id) } })
}
},
Mutation: {
createInvoice: async (parent, { amount }, { me, models, lnd }) => {
return 'lnbc1500n1psfxyaypp5tmlgpudspqed4qf32xxmc7dhlqrd4glc09x794exz4t2pw8ms38sdpa2fjkzep6yptks7fqt9hh2gzwv4jkggz5dus9gatjdcsyzmrvypvk7atjypzx7cqzpgxqr23ssp529tup4vaxlxnst0lwh9kljpl9n6zg6n6vma5hw78lmnws32x278s9qyyssqxe73jclrlz3u7v7ruwee3n7h70ktsdsfmvpfjkccqxq5wg5h6njhqxar0a9fef5hd09ethwhvsj0dha2qy4tjjdxu08nkqymfs8wghqp6d7kth'
if (!me) {
throw new AuthenticationError('You must be logged in')
}
if (!amount || amount <= 0) {
throw new UserInputError('Amount must be positive', { argumentName: 'amount' })
}
/*
chain_address: undefined,
created_at: '2021-05-06T22:16:28.000Z',
description: 'hi there',
id: '30946d6ff432933e30f6c180cce982c92b509a80bf6c2e896e6579cbda4c1677',
mtokens: '1000',
payment: 'e3deb7a0471bf050aa5dd0ef9b546887ab1fdf0306a7cb67d9dda8473f9542f2',
request: 'lnbcrt10n1psfg64upp5xz2x6ml5x2fnuv8kcxqve6vzey44px5qhakzaztwv4uuhkjvzemsdqddp5jqargv4ex2cqzpgxqr23ssp5u00t0gz8r0c9p2ja6rhek4rgs743lhcrq6nuke7emk5yw0u4gteq9q8zqqyssq92epsvsap3pyfcj4kex5vysew4tqg6c8vxux5nfmc7yqx36l6dk49pafs62dlr92lm5ekzftl7nq6r4wvjhwydtekg6lpj0xgjm5auqpwflxyk',
secret: '82abf620f82dc9a61cf3921f77432e31d4a11e1dc066ccc177d31937c473eb30',
tokens: 1
*/
// set expires at to 3 hours into future
const expiresAt = new Date(new Date().setHours(new Date().getHours() + 3))
const description = `${amount} sats for @${me.name} on stacker.news`
const invoice = await createInvoice({ description, lnd, tokens: amount, expires_at: expiresAt })
const data = {
hash: invoice.id,
bolt11: invoice.request,
expiresAt: expiresAt,
msatsRequested: amount * 1000,
user: {
connect: {
name: me.name
}
}
}
return await models.invoice.create({ data })
}
}
}

View File

@ -2,10 +2,20 @@ import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
invoice(id: ID!): String!
invoice(id: ID!): Invoice!
}
extend type Mutation {
createInvoice(amount: Int!): String!
createInvoice(amount: Int!): Invoice!
}
type Invoice {
id: ID!
createdAt: String!
bolt11: String!
expiresAt: String!
cancelled: Boolean!
confirmedAt: String
msatsReceived: Int
}
`

View File

@ -6,6 +6,21 @@ import styles from './header.module.css'
import { useRouter } from 'next/router'
import { Button, Container, NavDropdown } from 'react-bootstrap'
import Price from './price'
import { gql, useQuery } from '@apollo/client'
function WalletSummary () {
const query = gql`
{
me {
sats
stacked
}
}`
const { data } = useQuery(query, { pollInterval: 1000 })
if (!data) return null
return `[${data.me.stacked},${data.me.sats}]`
}
export default function Header () {
const [session, loading] = useSession()
@ -42,7 +57,7 @@ export default function Header () {
</NavDropdown>
<Nav.Item>
<Link href='/wallet' passHref>
<Nav.Link className='text-success px-0'>[0,0]</Nav.Link>
<Nav.Link className='text-success px-0'><WalletSummary /></Nav.Link>
</Link>
</Nav.Item>
</div>

View File

@ -6,51 +6,84 @@ import Thumb from '../svgs/thumb-up-fill.svg'
import { useState } from 'react'
import BootstrapForm from 'react-bootstrap/Form'
import Button from 'react-bootstrap/Button'
import Check from '../svgs/check-double-line.svg'
import Fail from '../svgs/close-line.svg'
export function Invoice ({ invoice }) {
const [copied, setCopied] = useState(false)
const qrValue = 'lightning:' + invoice.toUpperCase()
const qrValue = 'lightning:' + invoice.bolt11.toUpperCase()
let InvoiceStatus = InvoiceDefaultStatus
let status = 'waiting for you'
if (invoice.confirmedAt) {
InvoiceStatus = InvoiceConfirmedStatus
status = `${invoice.msatsReceived / 1000} sats deposited`
} else if (invoice.cancelled) {
InvoiceStatus = InvoiceFailedStatus
status = 'cancelled'
} else if (invoice.expiresAt <= new Date()) {
InvoiceStatus = InvoiceFailedStatus
status = 'expired'
}
return (
<>
<div>
<QRCode className='h-auto mw-100' value={qrValue} size={300} />
<QRCode className='h-auto mw-100' value={qrValue} renderAs='svg' size={300} />
</div>
<div className='mt-3 w-100'>
<InputGroup onClick={() => {
copy(invoice)
copy(invoice.bolt11)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}}
>
<BootstrapForm.Control type='text' placeholder={invoice} readOnly />
<BootstrapForm.Control type='text' placeholder={invoice.bolt11} readOnly />
<InputGroup.Append>
<Button>{copied ? <Thumb width={20} height={20} /> : 'copy'}</Button>
</InputGroup.Append>
</InputGroup>
</div>
<InvoiceStatus />
<InvoiceStatus status={status} />
</>
)
}
export function InvoiceStatus ({ skeleton }) {
export function InvoiceDefaultStatus ({ status }) {
return (
<div className='d-flex mt-4'>
<Moon className='spin fill-grey' />
<div className='ml-3 text-muted' style={{ fontWeight: '600' }}>{skeleton ? 'generating' : 'waiting for you'}</div>
<div className='ml-3 text-muted' style={{ fontWeight: '600' }}>{status}</div>
</div>
)
}
export function InvoiceSkeleton () {
export function InvoiceConfirmedStatus ({ status }) {
return (
<div className='d-flex mt-4'>
<Check className='fill-success' />
<div className='ml-3 text-success' style={{ fontWeight: '600' }}>{status}</div>
</div>
)
}
export function InvoiceFailedStatus ({ status }) {
return (
<div className='d-flex mt-4'>
<Fail className='fill-danger' />
<div className='ml-3 text-danger' style={{ fontWeight: '600' }}>{status}</div>
</div>
)
}
export function InvoiceSkeleton ({ status }) {
return (
<>
<div className='h-auto w-100 clouds' style={{ paddingTop: 'min(300px, 100%)', maxWidth: '300px' }} />
<div className='mt-3 w-100'>
<div className='w-100 clouds form-control' />
</div>
<InvoiceStatus skeleton />
<InvoiceDefaultStatus status={status} />
</>
)
}

View File

@ -20,11 +20,11 @@ export default function UserHeader ({ user }) {
<Nav.Link>{user.ncomments} comments</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
{/* <Nav.Item>
<Link href={'/' + user.name + '/sativity'} passHref>
<Nav.Link>sativity</Nav.Link>
</Link>
</Nav.Item>
</Nav.Item> */}
</Nav>
</>
)

101
invoiced/index.js Normal file
View File

@ -0,0 +1,101 @@
const { PrismaClient } = require('@prisma/client')
const { authenticatedLndGrpc, subscribeToInvoices, getInvoice } = require('ln-service')
const dotenv = require('dotenv')
dotenv.config({ path: '..' })
const { lnd } = authenticatedLndGrpc({
cert: process.env.LND_CERT,
macaroon: process.env.LND_MACAROON,
socket: process.env.LND_SOCKET
})
const models = new PrismaClient()
async function recordStatus (inv) {
console.log(inv)
if (inv.is_confirmed) {
const received = Number(inv.received_mtokens)
// only increment iff this invoice has not yet confirmed
const updateUser = models.user.updateMany({
where: {
invoices: {
some: {
hash: inv.id,
confirmedAt: {
equals: null
}
}
}
},
data: {
msats: {
increment: received
}
}
})
// ATOMICALLY (with above) mark the invoice as confirmed
const updateInvoice = models.invoice.updateMany({
where: {
hash: inv.id,
AND: [
{
confirmedAt: {
equals: null
}
}
]
},
data: {
confirmedAt: inv.confirmed_at,
msatsReceived: received
}
})
models.$transaction([updateUser, updateInvoice])
} else if (inv.is_canceled) {
// mark as cancelled
models.invoice.update({
where: {
hash: inv.id
},
data: {
cancelled: true
}
})
}
}
// 1. subscribe to all invoices async
const sub = subscribeToInvoices({ lnd })
sub.on('invoice_updated', recordStatus)
// 2. check all pending invoices from db in lnd
async function checkPending () {
const now = new Date()
const active = await models.invoice.findMany({
where: {
expiresAt: {
gt: now
},
cancelled: false,
confirmedAt: {
equals: null
}
}
})
active.forEach(async invoice => {
try {
const inv = await getInvoice({ id: invoice.hash, lnd })
recordStatus(inv)
} catch (error) {
console.log(error)
process.exit(1)
}
})
}
checkPending()

View File

@ -9,7 +9,7 @@
},
"dependencies": {
"@apollo/client": "^3.3.13",
"@prisma/client": "^2.19.0",
"@prisma/client": "^2.22.1",
"apollo-server-micro": "^2.21.2",
"bootstrap": "^4.6.0",
"clipboard-copy": "^4.0.1",
@ -43,7 +43,7 @@
"babel-plugin-inline-react-svg": "^2.0.1",
"eslint": "^7.22.0",
"eslint-plugin-compat": "^3.9.0",
"prisma": "2.19.0",
"prisma": "^2.22.1",
"standard": "^16.0.3"
}
}

View File

@ -14,7 +14,14 @@ export async function getServerSideProps ({ params: { id } }) {
export default function FullInvoice ({ id }) {
const query = gql`
{
invoice(id: ${id})
invoice(id: ${id}) {
id
bolt11
msatsReceived
cancelled
confirmedAt
expiresAt
}
}`
return (
<LayoutCenter>
@ -24,10 +31,10 @@ export default function FullInvoice ({ id }) {
}
function LoadInvoice ({ query }) {
const { loading, error, data } = useQuery(query)
const { loading, error, data } = useQuery(query, { pollInterval: 1000 })
if (error) return <div>error</div>
if (!data || loading) {
return <InvoiceSkeleton />
return <InvoiceSkeleton status='loading' />
}
return <Invoice invoice={data.invoice} />

View File

@ -47,11 +47,13 @@ export function FundForm () {
const router = useRouter()
const [createInvoice, { called }] = useMutation(gql`
mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount)
createInvoice(amount: $amount) {
id
}
}`)
if (called) {
return <InvoiceSkeleton />
return <InvoiceSkeleton status='generating' />
}
return (
@ -61,8 +63,8 @@ export function FundForm () {
}}
schema={FundSchema}
onSubmit={async ({ amount }) => {
await createInvoice({ variables: { amount } })
router.push('/invoices/1')
const { data } = await createInvoice({ variables: { amount: Number(amount) } })
router.push(`/invoices/${data.createInvoice.id}`)
}}
>
<Input

View File

@ -0,0 +1,25 @@
-- DropIndex
DROP INDEX "item_gist_path_index";
-- CreateTable
CREATE TABLE "Invoice" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"userId" INTEGER NOT NULL,
"hash" TEXT NOT NULL,
"bolt11" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"confirmedAt" TIMESTAMP(3),
"requested" INTEGER NOT NULL,
"received" INTEGER,
"cancelled" BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Invoice.userId_index" ON "Invoice"("userId");
-- AddForeignKey
ALTER TABLE "Invoice" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `requested` on the `Invoice` table. All the data in the column will be lost.
- You are about to drop the column `received` on the `Invoice` table. All the data in the column will be lost.
- Added the required column `msatsRequested` to the `Invoice` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Invoice" DROP COLUMN "requested",
DROP COLUMN "received",
ADD COLUMN "msatsRequested" INTEGER NOT NULL,
ADD COLUMN "msatsReceived" INTEGER;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "msats" INTEGER NOT NULL DEFAULT 0;

View File

@ -21,6 +21,8 @@ model User {
items Item[]
messages Message[]
votes Vote[]
invoices Invoice[]
msats Int @default(0)
@@map(name: "users")
}
@ -66,6 +68,24 @@ model Vote {
@@index([userId])
}
model Invoice {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
user User @relation(fields: [userId], references: [id])
userId Int
hash String @unique
bolt11 String
expiresAt DateTime
confirmedAt DateTime?
msatsRequested Int
msatsReceived Int?
cancelled Boolean @default(false)
@@index([userId])
}
model Account {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")

View File

@ -117,6 +117,14 @@ body {
fill: grey;
}
.fill-success {
fill: #5c8001;
}
.fill-danger {
fill: #c03221;
}
@keyframes flash {
from { filter: brightness(1);}
2% { filter: brightness(2.3); }
@ -154,4 +162,30 @@ body {
background-size: cover;
background-attachment: fixed;
opacity: .1;
}
@keyframes flipX{
from {
transform: rotateX(180deg);
}
to {
transform: rotateX(-180deg);
}
}
.flipX {
animation: flipX 2s linear infinite;
}
@keyframes flipY{
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(360deg);
}
}
.flipY {
animation: flipY 4s linear infinite;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M11.602 13.76l1.412 1.412 8.466-8.466 1.414 1.414-9.88 9.88-6.364-6.364 1.414-1.414 2.125 2.125 1.413 1.412zm.002-2.828l4.952-4.953 1.41 1.41-4.952 4.953-1.41-1.41zm-2.827 5.655L7.364 18 1 11.636l1.414-1.414 1.413 1.413-.001.001 4.951 4.951z"/></svg>

After

Width:  |  Height:  |  Size: 379 B

1
svgs/close-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636z"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -340,22 +340,22 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353"
integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==
"@prisma/client@^2.19.0":
version "2.19.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.19.0.tgz#a45f17a59fd109e95b61bf4b56d4a7642169ec0e"
integrity sha512-QK4M8TjJh1QesyO9aLM7DeAQUi5+UnNHpEAm5kwqBO1cq/4Ag5yU9ladctJFJleEE5BLewXHwV2t9A+VfCZslg==
"@prisma/client@^2.22.1":
version "2.22.1"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.22.1.tgz#10fdcd1532a6baf46dd1c464cad9a54af0532bc8"
integrity sha512-JQjbsY6QSfFiovXHEp5WeJHa5p2CuR1ZFPAeYXmUsOAQOaMCrhgQmKAL6w2Q3SRA7ALqPjrKywN9/QfBc4Kp1A==
dependencies:
"@prisma/engines-version" "2.19.0-39.c1455d0b443d66b0d9db9bcb1bb9ee0d5bbc511d"
"@prisma/engines-version" "2.22.0-21.60cc71d884972ab4e897f0277c4b84383dddaf6c"
"@prisma/engines-version@2.19.0-39.c1455d0b443d66b0d9db9bcb1bb9ee0d5bbc511d":
version "2.19.0-39.c1455d0b443d66b0d9db9bcb1bb9ee0d5bbc511d"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.19.0-39.c1455d0b443d66b0d9db9bcb1bb9ee0d5bbc511d.tgz#a7f80d481ec6cb8e2975ab530664d4ca5fc9eba6"
integrity sha512-NzhbwC4iMbRQwJxdhNQX6eaVcOuNGtHRk6aesWE4KMf/YmlW5kfi3HDy7WZ/C4P0Iyn9oURDuk+xZV6QDUVjTw==
"@prisma/engines-version@2.22.0-21.60cc71d884972ab4e897f0277c4b84383dddaf6c":
version "2.22.0-21.60cc71d884972ab4e897f0277c4b84383dddaf6c"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.22.0-21.60cc71d884972ab4e897f0277c4b84383dddaf6c.tgz#e98ee17217a0ebb54f2f9314fbbfd610b05e6e31"
integrity sha512-OkkVwk6iTzTbwwl8JIKAENyxmh4TFORal55QMKQzrHEY8UzbD0M90mQnmziz3PAopQUZgTFFMlaPAq1WNrLMtA==
"@prisma/engines@2.19.0-39.c1455d0b443d66b0d9db9bcb1bb9ee0d5bbc511d":
version "2.19.0-39.c1455d0b443d66b0d9db9bcb1bb9ee0d5bbc511d"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.19.0-39.c1455d0b443d66b0d9db9bcb1bb9ee0d5bbc511d.tgz#db2809a6f7f18584e3ca89b1f5bad884155629ec"
integrity sha512-rEWpaG7wZvPuWJC5SwkBB/Iwue//oC5yv58Mse7r+ibtgkA7vGdWc1bFDQ32DT9tDL5WSC6bBwqEASGV/1Gm1Q==
"@prisma/engines@2.22.0-21.60cc71d884972ab4e897f0277c4b84383dddaf6c":
version "2.22.0-21.60cc71d884972ab4e897f0277c4b84383dddaf6c"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.22.0-21.60cc71d884972ab4e897f0277c4b84383dddaf6c.tgz#4ccd255e0823605db3d8387a5195b6fdabe3b0c0"
integrity sha512-KmWdogrsfsSLYvfqY3cS3QcDGzaEFklE+T6dNJf+k/KPQum4A29IwDalafMwh5cMN8ivZobUbowNSwWJrMT08Q==
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
version "1.1.2"
@ -4234,12 +4234,12 @@ pretty-format@^3.8.0:
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
integrity sha1-v77VbV6ad2ZF9LH/eqGjrE+jw4U=
prisma@2.19.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.19.0.tgz#2c14f9cbbfb0ab69c8a9473e16736759713d29ad"
integrity sha512-iartCNVrtR4XT20ABN3zrSi3R/pCBe75Y0ZH8681QIGm8qjRQzf3DnbscPZgZ9iY4KFuVxL8ZrBQVDmRhpN0EQ==
prisma@^2.22.1:
version "2.22.1"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.22.1.tgz#884687a90c7b797b34c6110ea413049078c8da6e"
integrity sha512-hwvCM3zyxgSda/+/p+GW7nz93jRebtMU01wAG7YVVnl0OKU+dpw1wPvPFmQRldkZHk8fTCleYmjc24WaSdVPZQ==
dependencies:
"@prisma/engines" "2.19.0-39.c1455d0b443d66b0d9db9bcb1bb9ee0d5bbc511d"
"@prisma/engines" "2.22.0-21.60cc71d884972ab4e897f0277c4b84383dddaf6c"
process-nextick-args@~2.0.0:
version "2.0.1"