Skip to main content
Glama
index.js28 kB
var assert = require('assert') var API = require('cs-insight') var sinon = require('sinon') var async = require('async') var bitcoin = require('bitcoinjs-lib') var Transaction = bitcoin.Transaction var TransactionBuilder = bitcoin.TransactionBuilder var HDNode = bitcoin.HDNode var Address = bitcoin.address var testnet = bitcoin.networks.testnet var fixtures = require('./wallet') var addressFixtures = require('./addresses') var transactionsFixtures = require('./transactions') var balanceFixtures = require('./balance') var history = require('./history') var rewire = require('rewire') var Wallet = require('../') var wif = require('wif') var BigInteger = require('bigi') var noop = function() {} describe('Common Blockchain Wallet', function() { var externalAccount var internalAccount before(function() { externalAccount = "tprv8eTJwLvUafTQT1irF54EtaYbzakkbvDaAiP3FfHzpfLiamKqGSJDzG2pQfXQDWVWmmHhM3ERRdeG7V2b7aYNZWKy8UYNQL511sWZuUNNBNR" internalAccount = "tprv8eTJwLvUafTQVZgNArJSWQgwu6vtBgEzB9LACG4GPhwofFCSaCMzqj5eYqmZ1qmTJVmsa82EfUqkdpUtMmnKp5RJaUdc1KCjzSUc3nRsoXf" }) describe('process env', function() { it('should setup wrapper api url by process env (bitcoin)', function(done) { process.env.API_BTC_URL = 'hello-world' var wallet = new Wallet({ externalAccount: externalAccount, internalAccount: internalAccount, networkName: 'bitcoin', done: function() { done() } }) process.env.API_BTC_URL = undefined assert.equal(wallet.api.apiURL, 'hello-world') }) it('should setup wrapper api url by process env (litecoin)', function(done) { process.env.API_LTC_URL = 'hello-lite-world' var wallet = new Wallet({ externalAccount: externalAccount, internalAccount: internalAccount, networkName: 'litecoin', done: function() { done() } }) process.env.API_LTC_URL = undefined assert.equal(wallet.api.apiURL, 'hello-lite-world') }) it('should setup insight API wrapper', function(done) { this.timeout(100000) var wallet = new Wallet({ externalAccount: externalAccount, internalAccount: internalAccount, networkName: 'testnet', done: function() { done() } }) assert(wallet.api instanceof require('cs-insight')) }) }) describe.skip('network dependent tests', function() { this.timeout(100000) var wallet var addresses = ["n2DTZtbDbNAX315g3vtUdLKLWeAGtLUKd5","n3Ry7ra8hFmhPWdFJJNY5YBFNyED8T2F6L","mjuRrrDwYY4qfK5Cea3PcaqE1Wix7ieBWM"] var changeAddresses = ['mk1iSBkdVGkj4rHsgvnNj78sViw8Pq2KBY', 'n4QqWKg6kzgDGtLybDcHCMVPLZuJgT3MZr', 'mk3vi2jCLzzxCUMxvrgL2zumS3CQGedWqN', 'mhoDWJcxYfmKAu57trrhaG9uHWPx5q8W9z', 'mqsxmdRAZ52UUbmrsovDvLuAmFxyXFiaE5', 'mtmw3orgdjNhCJSCCVMLFMf38txz6CzHB9', 'n38DfSrxRr9BoydsXt6NpQDUWAhZCoGWkb', 'muAYGwrAkM4iAAWyGWt9bvat6hGiYAaN1t'] before(function(done) { wallet = new Wallet({ externalAccount: externalAccount, internalAccount: internalAccount, networkName: 'testnet', done: done }) }) describe('constructor', function() { it('returns error when externalAccount and internalAccount are missing', function(done) { new Wallet({ networkName: 'testnet', done: function(err) { assert(err) done() } }) }) it('accepts externalAccount and internalAccount as objects', function() { new Wallet({ externalAccount: HDNode.fromBase58(externalAccount), internalAccount: HDNode.fromBase58(internalAccount), networkName: 'testnet', done: function(err, w) { assert.ifError(err) assert.equal(w.externalAccount.toBase58(), externalAccount) assert.equal(w.internalAccount.toBase58(), internalAccount) } }) }) describe('wallet properties', function() { it('initializes a txGraph', function() { assert(wallet.txGraph) assert.equal(wallet.txGraph.heads.length, 1) }) it('assigns externalAccount and internalAccount', function() { assert.equal(wallet.externalAccount.toBase58(), externalAccount) assert.equal(wallet.internalAccount.toBase58(), internalAccount) }) it('assigns addresses and changeAddresses', function() { assert.deepEqual(wallet.addresses, addresses) assert.deepEqual(wallet.changeAddresses, changeAddresses) }) it('assigns networkName', function() { assert.equal(wallet.networkName, 'testnet') }) it('assigns api', function() { assert(wallet.api instanceof API) assert.equal(wallet.api.getProxyURL(), undefined) }) it('api uses a proxy url passed in as an environment variable (blockr)', function(done) { var url = 'https://proxy.coin.space/?url=' process.env.INSIGHT_PROXY_URL = url var wallet = new Wallet({ externalAccount: externalAccount, internalAccount: internalAccount, networkName: 'testnet', done: function(err, w) { process.env.INSIGHT_PROXY_URL = undefined assert.ifError(err) assert.equal(wallet.api.getProxyURL(), url) done() } }) }) it('assigns txMetadata', function() { var txIds = wallet.txGraph.getAllNodes().filter(function(n) { return n.tx != null }).map(function(n) { return n.id }) assert.deepEqual(Object.keys(wallet.txMetadata).sort(), txIds.sort()) for(var key in wallet.txMetadata) { assert.equal(typeof wallet.txMetadata[key].confirmations, 'number') assert.equal(typeof wallet.txMetadata[key].timestamp, 'number') } }) }) }) describe('serialization & deserialization', function() { it('works', function() { var parsed = Wallet.deserialize(wallet.serialize()) assert.equal(parsed.txGraph.heads.length, wallet.txGraph.heads.length) assert.equal(parsed.txGraph.heads[0].id, wallet.txGraph.heads[0].id) assert.equal(parsed.externalAccount.toBase58(), wallet.externalAccount.toBase58()) assert.equal(parsed.internalAccount.toBase58(), wallet.internalAccount.toBase58()) assert.equal(parsed.addressIndex, wallet.addressIndex) assert.equal(parsed.changeAddressIndex, wallet.changeAddressIndex) assert.equal(parsed.networkName, wallet.networkName) assert.equal(typeof parsed.api, typeof wallet.api) assert.deepEqual(parsed.txMetadata, wallet.txMetadata) }) }) }) describe('non-network dependent tests', function() { var readOnlyWallet var addresses = addressFixtures.addresses var changeAddresses = addressFixtures.changeAddresses var sandbox = sinon.sandbox.create(); before(function() { // this should be treated as a convenient read-only wallet readOnlyWallet = Wallet.deserialize(JSON.stringify(fixtures)) }) afterEach(function(){ sandbox.restore(); }) describe('getBalance', function() { it('works', function() { assert.equal(readOnlyWallet.getBalance(), 0) }) it('calculates it correctly when one of the head transactions has value 0', function(done) { var myWallet = Wallet.deserialize(JSON.stringify(fixtures)) sandbox.stub(myWallet.api.transactions, 'get').callsArgWith(2, null, [transactionsFixtures.fundedAddressZero]) fundAddressZero(myWallet, function(err, fundingTx) { if (err) return done(err); myWallet.api.transactions.get.restore() sandbox.stub(myWallet.api.transactions, 'get').callsArgWith(2, null, [transactionsFixtures.fundedChangeAddress]) var tx = new Transaction() tx.addInput(fundingTx.getHash(), 0) tx.addOutput(Address.toOutputScript(myWallet.changeAddresses[0], testnet), 200000) myWallet.processTx(tx, function(err) { if (err) return done(err); assert.equal(myWallet.getBalance(), 200000) done() }) }) }) function fundAddressZero(wallet, done) { var tx = new Transaction() tx.addInput((new Transaction()).getHash(), 0) tx.addOutput(Address.toOutputScript(wallet.addresses[0], testnet), 200000) wallet.processTx(tx, function(err) { if (err) return done(err); done(null, tx) }) } }) describe('getNextAddress', function() { it('works', function() { assert.deepEqual(readOnlyWallet.getNextAddress(), 'mk9p4BPMSTK5C5zZ3Gf6mWZNtBQyC3RC7K') }) }) describe('getNextChangeAddress', function() { it('works', function() { assert.deepEqual(readOnlyWallet.getNextChangeAddress(), 'mrsMaRK7PNQt1i9sv11Dx8ZCE6aZxDKCyi') }) }) describe('getPrivateKeyForAddress', function(){ it('returns the private key for the given address', function(){ assert.equal( readOnlyWallet.getPrivateKeyForAddress(addresses[1]).toWIF(), wif.encode(testnet.wif, readOnlyWallet.externalAccount.deriveChild(1).privateKey, true) ) assert.equal( readOnlyWallet.getPrivateKeyForAddress(changeAddresses[0]).toWIF(), wif.encode(testnet.wif, readOnlyWallet.internalAccount.deriveChild(0).privateKey, true) ) }) it('raises an error when address is not found', function(){ assert.throws(function() { readOnlyWallet.getPrivateKeyForAddress(changeAddresses[changeAddresses.length]) }, /Unknown address. Make sure the address is from the keychain and has been generated./) }) }) describe('processTx', function() { var tx, externalAddress, myWallet, nextAddress, nextChangeAddress before(function(done) { externalAddress = 'mh8evwuteapNy7QgSDWeUXTGvFb4mN1qvs' myWallet = Wallet.deserialize(JSON.stringify(fixtures)) nextAddress = myWallet.getNextAddress() nextChangeAddress = myWallet.getNextChangeAddress() prevTx = new Transaction() prevTx.addInput((new Transaction()).getHash(), 0) prevTx.addOutput(Address.toOutputScript(nextAddress, testnet), 200000) tx = new Transaction() tx.addInput((new Transaction()).getHash(), 0) tx.addOutput(Address.toOutputScript(externalAddress, testnet), 50000) tx.addOutput(Address.toOutputScript(nextChangeAddress, testnet), 130000) sandbox.stub(myWallet.api.transactions, 'get').callsArgWith(2, null, [transactionsFixtures.fundedAddressZero]) async.series([ function(cb) { myWallet.processTx(prevTx, cb)}, function(cb) { myWallet.processTx(tx, cb)} ], function(err) { myWallet.api.transactions.get.restore() done(err) }) }) describe('address derivation', function() { var myWalletSnapshot before(function() { myWalletSnapshot = myWallet.serialize() }) after(function() { myWallet = Wallet.deserialize(myWalletSnapshot) }) it('adds the next change address to changeAddresses if the it is used to receive funds', function() { assert.equal(myWallet.changeAddresses.indexOf(nextChangeAddress), myWallet.changeAddresses.length - 1) }) it('adds the next address to addresses if the it is used to receive funds', function() { assert.equal(myWallet.addresses.indexOf(nextAddress), myWallet.addresses.length - 1) }) it('does not add the same address more than once', function(done) { sandbox.stub(myWallet.api.transactions, 'get').callsArgWith(2, null, [transactionsFixtures.fundedAddressZero]) var nextNextAddress = myWallet.getNextAddress() var aTx = new Transaction() aTx.addInput((new Transaction()).getHash(), 1) aTx.addOutput(Address.toOutputScript(myWallet.getNextAddress(), testnet), 200000) var bTx = new Transaction() bTx.addInput((new Transaction()).getHash(), 2) bTx.addOutput(Address.toOutputScript(myWallet.getNextAddress(), testnet), 200000) async.series([ function(cb) { myWallet.processTx(aTx, cb)}, function(cb) { myWallet.processTx(bTx, cb)} ], function(err) { if (err) return done(err); assert.equal(myWallet.addresses.indexOf(nextNextAddress), myWallet.addresses.length - 1) done() }) }) }) }) describe('getTransactionHistory', function() { var actualHistory before(function() { actualHistory = readOnlyWallet.getTransactionHistory() }) it('returns the expected transactions in expected order', function() { var txIds = actualHistory.map(function(tx) { return tx.txId }) var expectedIds = history.txs.map(function(tx) { return tx.id }) assert.deepEqual(txIds, expectedIds) }) it('returns the transactions with the expected values & fees', function() { var actual = actualHistory.map(function(tx) { return { id: tx.txId, fee: tx.fees, value: tx.amount } }) var expected = history.txs.map(function(tx) { return { id: tx.id, fee: tx.fee, value: tx.value } }) assert.deepEqual(actual, expected) }) }) describe('createTx', function() { var to, value before(function(){ to = 'mh8evwuteapNy7QgSDWeUXTGvFb4mN1qvs' value = 500000 }) describe('with utxos passed in', function() { var utxos = [{ txId: '98440fe7035aaec39583f68a251602a5623d34f95dbd9f54e7bc8ff29551729f', address: 'mwrRQPbo9Ck2BypSWT74vfG3kEE99Aungq', value: 400000, vout: 0, confirmations: 3 }, { txId: '97bad8569bbd71f27b562b49cc65b5fa683e96c7912fac2f9d68e343a59d570e', address: 'mwrRQPbo9Ck2BypSWT74vfG3kEE99Aungq', value: 500000, vout: 0, confirmations: 2 }, { txId: '7e6be25012e2ee3450b1435d5115d68a9be1cb376e094877df12a1508f003937', address: 'mkGgTrTSX5szqJf2xMUY6ab7LE5wVJvNYA', value: 510000, vout: 0, confirmations: 1 }, { txId: 'a3fa16de242caaa97d69f2d285377a04847edbab4eec13e9ff083e14f77b71c8', address: 'mkGgTrTSX5szqJf2xMUY6ab7LE5wVJvNYA', value: 520000, vout: 0, confirmations: 0 }] describe('transaction outputs', function(){ it('includes the specified address and amount', function(){ var tx = readOnlyWallet.createTx(to, value, null, null, utxos) assert.equal(tx.outs.length, 2) var out = tx.outs[0] var outAddress = Address.fromOutputScript(out.script, testnet) assert.equal(outAddress.toString(), to) assert.equal(out.value, value) }) describe('change', function(){ it('uses the next change address', function(){ var fee = 0 var tx = readOnlyWallet.createTx(to, value, fee, null, utxos) assert.equal(tx.outs.length, 2) var out = tx.outs[1] var outAddress = Address.fromOutputScript(out.script, testnet) assert.equal(outAddress.toString(), readOnlyWallet.getNextChangeAddress()) assert.equal(out.value, 10000) }) it('skips change if it is not above dust threshold', function(){ var fee = 9454 var tx = readOnlyWallet.createTx(to, value, fee, null, utxos) assert.equal(tx.outs.length, 1) }) }) }) describe('choosing utxo', function(){ it('takes fees into account', function(){ var tx = readOnlyWallet.createTx(to, value, null, null, utxos) assert.equal(tx.ins.length, 1) hash = new Buffer(utxos[2].txId, 'hex').reverse(); assert.deepEqual(tx.ins[0].hash, hash) assert.equal(tx.ins[0].index, 0) }) it('respects specified minConf', function(){ var tx = readOnlyWallet.createTx(to, value, null, 0, utxos) assert.equal(tx.ins.length, 1) hash = new Buffer(utxos[3].txId, 'hex').reverse(); assert.deepEqual(tx.ins[0].hash, hash) assert.equal(tx.ins[0].index, 0) }) }) describe('validations', function(){ it('errors on invalid utxos', function(){ assert.throws(function() { readOnlyWallet.createTx(to, value, null, null, {}) }) }) }) }) describe('without utxos passed in', function() { var address1, address2, unspentTxs before(function(){ unspentTxs = [] address1 = readOnlyWallet.addresses[0] address2 = readOnlyWallet.changeAddresses[0] var pair0 = createTxPair(address1, 400000) // not enough for value unspentTxs.push(pair0.tx) var pair1 = createTxPair(address1, 500000) // not enough for only value unspentTxs.push(pair1.tx) var pair2 = createTxPair(address2, 510000) // enough for value and fee unspentTxs.push(pair2.tx) var pair3 = createTxPair(address2, 520000) // enough for value and fee unspentTxs.push(pair3.tx) function createTxPair(address, amount) { var prevTx = new Transaction() prevTx.addInput((new Transaction()).getHash(), 0) prevTx.addOutput(Address.toOutputScript(to, testnet), amount) var tx = new Transaction() tx.addInput(prevTx.getHash(), 0) tx.addOutput(Address.toOutputScript(address, testnet), amount) return { prevTx: prevTx, tx: tx } } }) describe('transaction outputs', function(){ it('includes the specified address and amount', function(){ var tx = readOnlyWallet.createTx(to, value) assert.equal(tx.outs.length, 2) var out = tx.outs[0] var outAddress = Address.fromOutputScript(out.script, testnet) assert.equal(outAddress.toString(), to) assert.equal(out.value, value) }) describe('change', function(){ it('uses the next change address', function(){ var fee = 0 var tx = readOnlyWallet.createTx(to, value, fee) assert.equal(tx.outs.length, 2) var out = tx.outs[1] var outAddress = Address.fromOutputScript(out.script, testnet) assert.equal(outAddress.toString(), readOnlyWallet.getNextChangeAddress()) assert.equal(out.value, 10000) }) it('skips change if it is not above dust threshold', function(){ var fee = 9454 var tx = readOnlyWallet.createTx(to, value, fee) assert.equal(tx.outs.length, 1) }) }) }) describe('choosing utxo', function(){ it('takes fees into account', function(){ var tx = readOnlyWallet.createTx(to, value) assert.equal(tx.ins.length, 1) assert.deepEqual(tx.ins[0].hash, unspentTxs[2].getHash()) assert.equal(tx.ins[0].index, 0) }) it('respects specified minConf', function(){ var tx = readOnlyWallet.createTx(to, value, null, 0) assert.equal(tx.ins.length, 1) assert.deepEqual(tx.ins[0].hash, unspentTxs[3].getHash()) assert.equal(tx.ins[0].index, 0) }) }) describe('transaction fee', function(){ it('allows fee to be specified', function(){ var fee = 30000 var tx = readOnlyWallet.createTx(to, value, fee) assert.equal(getFee(tx), fee) }) it('allows fee to be set to zero', function(){ value = 510000 var fee = 0 var tx = readOnlyWallet.createTx(to, value, fee) assert.equal(getFee(tx), fee) }) function getFee(tx) { var inputValue = tx.ins.reduce(function(memo, input){ var id = Array.prototype.reverse.call(input.hash).toString('hex') var prevTx = unspentTxs.filter(function(t) { return t.getId() === id })[0] return memo + prevTx.outs[0].value }, 0) return tx.outs.reduce(function(memo, output){ return memo - output.value }, inputValue) } }) describe('signing', function(){ it('signes the inputs with respective keys', function(){ var fee = 30000 sandbox.stub(TransactionBuilder.prototype, "sign") sandbox.stub(TransactionBuilder.prototype, "build") var tx = readOnlyWallet.createTx(to, value, fee) assert(TransactionBuilder.prototype.sign.calledWith(0, readOnlyWallet.getPrivateKeyForAddress(address2))) assert(TransactionBuilder.prototype.sign.calledWith(1, readOnlyWallet.getPrivateKeyForAddress(address1))) assert(TransactionBuilder.prototype.build.calledWith()) }) }) describe('validations', function(){ it('errors on invalid address', function(){ assert.throws(function() { readOnlyWallet.createTx('123', value) }) }) it('errors on address with the wrong version', function(){ assert.throws(function() { readOnlyWallet.createTx('LNjYu1akN22USK3sUrSuJn5WoLMKX5Az9B', value) }) }) it('errors on below dust value', function(){ assert.throws(function() { readOnlyWallet.createTx(to, 546) }) }) it('errors on insufficient funds', function(){ assert.throws(function() { readOnlyWallet.createTx(to, 1405001) }) }) }) }) }) describe('estimateFees', function() { var address, to before(function(){ readOnlyWallet = Wallet.deserialize(JSON.stringify(fixtures)) // reset wallet to = 'mh8evwuteapNy7QgSDWeUXTGvFb4mN1qvs' }) it('calculates it correctly with single tx input', function() { assert.deepEqual(readOnlyWallet.estimateFees(to, 20000, [10000]), [2260]) }) it('calculates it correctly with multiple tx inputs', function() { assert.deepEqual(readOnlyWallet.estimateFees(to, 520000, [10000, 200000]), [3740, 74800]) }) it('calculates it correctly with utxos passed in', function() { var utxos = [{ txId: '98440fe7035aaec39583f68a251602a5623d34f95dbd9f54e7bc8ff29551729f', address: 'mwrRQPbo9Ck2BypSWT74vfG3kEE99Aungq', value: 1520000, vout: 0, confirmations: 3 }]; assert.deepEqual(readOnlyWallet.estimateFees(to, 520000, [10000, 200000], utxos), [2260, 45200]) }) it('throws error when unspents are invalid', function() { assert.throws(function() { readOnlyWallet.estimateFees(to, 20000, [10000], 300); }, function(e) { assert.equal(e.message, 'Expect utxos to be an array'); return true }) }) }) describe('sendTx', function() { var tx = new Transaction() beforeEach(function(){ sandbox.stub(Wallet.prototype, "processTx").callsArg(1) }) it('propagates the transaction through the API', function(done) { sandbox.stub(readOnlyWallet.api.transactions, 'propagate').callsArg(1) readOnlyWallet.sendTx(tx, function(err) { assert.ifError(err) assert(readOnlyWallet.api.transactions.propagate.calledWith(tx.toHex())) done() }) }) it('processes the transaction on success', function(done) { sandbox.stub(readOnlyWallet.api.transactions, 'propagate').callsArg(1) readOnlyWallet.sendTx(tx, function(err) { assert.ifError(err) assert(Wallet.prototype.processTx.calledWith(tx)) done() }) }) it('invokes callback with error on error', function(done) { var error = new Error('oops') sandbox.stub(readOnlyWallet.api.transactions, 'propagate').callsArgWith(1, error) readOnlyWallet.sendTx(tx, function(err) { assert.equal(err, error) done() }) }) }) describe('createPrivateKey', function() { it('works', function() { var privateKey = readOnlyWallet.createPrivateKey('91tphZbASvHRsscCgB6TZibcSYwVNHzBX6xKvjFSMTNvzizaMyo') assert(privateKey instanceof bitcoin.ECPair) }) }) describe('createImportTx', function() { var options beforeEach(function() { var node = readOnlyWallet.internalAccount.deriveChild(0); var privateKey = new bitcoin.ECPair(BigInteger.fromBuffer(node.privateKey), null, { network: testnet }) options = { privateKey: privateKey, unspents: [{ txId: 'a3fa16de242caaa97d69f2d285377a04847edbab4eec13e9ff083e14f77b71c8', address: 'mkGgTrTSX5szqJf2xMUY6ab7LE5wVJvNYA', value: 10000, vout: 0, confirmations: 10 }], amount: 10000, to: 'n4j3tshEMhXrgzmw8eCTqBujpdGWeVcpCD', fee: 1000 } }) it('works', function() { var tx = readOnlyWallet.createImportTx(options) assert(tx instanceof bitcoin.Transaction) }) it('errors on amount less than fee', function() { options.fee = 20000 assert.throws(function() { readOnlyWallet.createImportTx(options) }) }) }) describe('getImportTxOptions', function() { it('works', function(done) { var unspents = [{ txId: 'a3fa16de242caaa97d69f2d285377a04847edbab4eec13e9ff083e14f77b71c8', address: 'mkGgTrTSX5szqJf2xMUY6ab7LE5wVJvNYA', value: 10000, vout: 0, confirmations: 10 }, { txId: '7e6be25012e2ee3450b1435d5115d68a9be1cb376e094877df12a1508f003937', address: 'mkGgTrTSX5szqJf2xMUY6ab7LE5wVJvNYA', value: 10000, vout: 0, confirmations: 0 }] sandbox.stub(readOnlyWallet.api.addresses, 'unspents').returns(Promise.resolve(unspents)) var node = readOnlyWallet.internalAccount.deriveChild(0); var privateKey = new bitcoin.ECPair(BigInteger.fromBuffer(node.privateKey), null, { network: testnet }) readOnlyWallet.getImportTxOptions(privateKey).then(function(options) { assert.equal(options.privateKey, privateKey) assert.equal(options.amount, 10000) assert.equal(options.unspents.length, 1) assert.deepEqual(options.unspents[0], unspents[0]) done() }).catch(done) }) }) describe('exportPrivateKeys', function() { it('works', function() { var csv = readOnlyWallet.exportPrivateKeys(); assert.equal(typeof csv, 'string'); }) it('errors on missing unspent address', function() { var myWallet = Wallet.deserialize(JSON.stringify(fixtures)) myWallet.unspents.push('missing_address') assert.throws(function() { myWallet.exportPrivateKeys() }, /Unknown address. Make sure the address is from the keychain and has been generated./) }) }) }) })

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/fefergrgrgrg/frontend-review-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server