diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2d0624e117..df3c26a412 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,6 +10,7 @@ /website/ @fuxingloh /packages/jellyfish/ @fuxingloh +/packages/jellyfish-address/ @fuxingloh @ivan-zynesis /packages/jellyfish-api-core/ @fuxingloh @canonbrother @jingyi2811 /packages/jellyfish-api-jsonrpc/ @fuxingloh /packages/jellyfish-crypto/ @fuxingloh diff --git a/.github/governance.yml b/.github/governance.yml index b11bed73ca..57435564ba 100644 --- a/.github/governance.yml +++ b/.github/governance.yml @@ -34,6 +34,7 @@ issue: - workflow - website - jellyfish + - jellyfish-address - jellyfish-api-core - jellyfish-api-jsonrpc - jellyfish-crypto diff --git a/.github/labeler.yml b/.github/labeler.yml index 70811902b7..728e8be8e1 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -16,6 +16,11 @@ labels: matcher: files: "packages/jellyfish/**" + - label: area/jellyfish-address + sync: true + matcher: + files: "packages/jellyfish-address/**" + - label: area/jellyfish-api-core sync: true matcher: @@ -51,7 +56,6 @@ labels: matcher: files: "packages/jellyfish-wallet-mnemonic/**" - - label: area/testcontainers sync: true matcher: diff --git a/.github/labels.yml b/.github/labels.yml index f66cd6246f..29c66cdddf 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -58,6 +58,8 @@ name: area/website - color: fbca04 name: area/jellyfish +- color: fbca04 + name: area/jellyfish-address - color: fbca04 name: area/jellyfish-api-core - color: fbca04 diff --git a/.gitignore b/.gitignore index e273786d3c..5e06f83584 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ node_modules dist *.tgz +# debug log +lerna-debug.log + coverage diff --git a/README.md b/README.md index 6e885dd71c..6b7d149c1b 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ version tag. Package | Description ---------------------------------------------------|------------- `@defichain/jellyfish` | Library bundled usage entrypoint with conventional defaults for 4 bundles: umd, esm, cjs and d.ts +`@defichain/jellyfish-address` | Provide address builder, parser, validator utility library for DeFi Blockchain. `@defichain/jellyfish-api-core` | A protocol agnostic DeFi Blockchain client interfaces, with a "foreign function interface" design. `@defichain/jellyfish-api-jsonrpc` | Implements the [JSON-RPC 1.0](https://www.jsonrpc.org/specification_v1) specification for api-core. `@defichain/jellyfish-crypto` | Cryptography operations for jellyfish, includes a simple 'secp256k1' EllipticPair. diff --git a/package-lock.json b/package-lock.json index b17d521903..d24ce79aeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1484,6 +1484,10 @@ "resolved": "packages/jellyfish", "link": true }, + "node_modules/@defichain/jellyfish-address": { + "resolved": "packages/jellyfish-address", + "link": true + }, "node_modules/@defichain/jellyfish-api-core": { "resolved": "packages/jellyfish-api-core", "link": true @@ -29316,6 +29320,20 @@ "node": ">=12.x" } }, + "packages/jellyfish-address": { + "name": "@defichain/jellyfish-address", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@defichain/jellyfish-crypto": "0.0.0", + "@defichain/jellyfish-network": "0.0.0", + "@defichain/jellyfish-transaction": "0.0.0", + "bs58": "^4.0.1" + }, + "devDependencies": { + "@types/bs58": "^4.0.1" + } + }, "packages/jellyfish-api-core": { "name": "@defichain/jellyfish-api-core", "version": "0.0.0", @@ -30600,6 +30618,16 @@ "parcel": "2.0.0-beta.1" } }, + "@defichain/jellyfish-address": { + "version": "file:packages/jellyfish-address", + "requires": { + "@defichain/jellyfish-crypto": "0.0.0", + "@defichain/jellyfish-network": "0.0.0", + "@defichain/jellyfish-transaction": "0.0.0", + "@types/bs58": "^4.0.1", + "bs58": "^4.0.1" + } + }, "@defichain/jellyfish-api-core": { "version": "file:packages/jellyfish-api-core", "requires": { diff --git a/packages/jellyfish-address/README.md b/packages/jellyfish-address/README.md new file mode 100644 index 0000000000..50ebadef58 --- /dev/null +++ b/packages/jellyfish-address/README.md @@ -0,0 +1,6 @@ +[![npm](https://img.shields.io/npm/v/@defichain/jellyfish-address)](https://www.npmjs.com/package/@defichain/jellyfish-address/v/latest) +[![npm@next](https://img.shields.io/npm/v/@defichain/jellyfish-address/next)](https://www.npmjs.com/package/@defichain/jellyfish-address/v/next) + +# @defichain/jellyfish-address + +DeFi blockchain address type builder, parser, validator library. diff --git a/packages/jellyfish-address/__tests__/base58_address.test.ts b/packages/jellyfish-address/__tests__/base58_address.test.ts new file mode 100644 index 0000000000..472316653b --- /dev/null +++ b/packages/jellyfish-address/__tests__/base58_address.test.ts @@ -0,0 +1,57 @@ +import { Script } from '@defichain/jellyfish-transaction/src/tx' +import { Network } from '@defichain/jellyfish-network' +import { Base58Address } from '../src' + +class DummyB58Address extends Base58Address { + getScript (): Script { + return { + stack: [] + } + } + + getPrefix (): number { + return this.network.pubKeyHashPrefix // match the fixture p2pkh prefix + } +} + +describe('Base58Address', () => { + const b58Fixture = { + p2sh: 'dFFPENo7FPMJpDV6fUcfo4QfkZrfrV1Uf8', // prefix = 0x12 + p2pkh: '8JBuS81VT8ouPrT6YS55qoS74D13Cw7h1Y' + } + + const dummyNetwork: Network = { + name: 'regtest', + bech32: { + hrp: 'dummy' + }, + bip32: { + publicPrefix: 0x00000000, + privatePrefix: 0x00000000 + }, + wifPrefix: 0x00, + pubKeyHashPrefix: 0x12, + scriptHashPrefix: 0x00, + messagePrefix: '\x00Dummy Msg Prefix:\n' + } + + describe('extensible, should work for any defined network protocol', () => { + it('fromAddress() - valid', () => { + const valid = Base58Address.fromAddress(dummyNetwork, b58Fixture.p2pkh, DummyB58Address) + expect(valid.validate()).toBeTruthy() + }) + + it('fromAddress() - invalid character set', () => { + const invalid = Base58Address.fromAddress(dummyNetwork, 'invalid b58 address', DummyB58Address) + expect(invalid.validate()).toBeFalsy() + }) + + it('fromAddress() - invalid prefix', () => { + const invalid = Base58Address.fromAddress(dummyNetwork, b58Fixture.p2sh, DummyB58Address) + expect(invalid.validate()).toBeFalsy() + + const valid = Base58Address.fromAddress(dummyNetwork, b58Fixture.p2pkh, DummyB58Address) + expect(valid.validate()).toBeTruthy() + }) + }) +}) diff --git a/packages/jellyfish-address/__tests__/bech32_address.test.ts b/packages/jellyfish-address/__tests__/bech32_address.test.ts new file mode 100644 index 0000000000..a481267f1a --- /dev/null +++ b/packages/jellyfish-address/__tests__/bech32_address.test.ts @@ -0,0 +1,51 @@ +import { Script } from '@defichain/jellyfish-transaction/src/tx' +import { Network } from '@defichain/jellyfish-network' +import { Bech32Address } from '../src' + +class DummyBech32Address extends Bech32Address { + getScript (): Script { + return { + stack: [] + } + } +} + +describe('Bech32Address', () => { + const bech32Fixture = { + p2wpkh: 'dummy1qpe7q4vvtxpdunpazvmwqdh3xlnatfdt2xr8mpv', // edited prefix to match test network + invalidPrefix: 'prefix1qpe7q4vvtxpdunpazvmwqdh3xlnatfdt2xr8mpv', // original p2wpkh address sample + invalidCharset: 'dummy1qpe7q4vvtxpdunpazvmwqdh3xlnatfdt2xr8mpo' // character 'o' + } + + const dummyNetwork: Network = { + name: 'regtest', + bech32: { + hrp: 'dummy' + }, + bip32: { + publicPrefix: 0x00000000, + privatePrefix: 0x00000000 + }, + wifPrefix: 0x00, + pubKeyHashPrefix: 0x00, + scriptHashPrefix: 0x00, + messagePrefix: '\x00Dummy Msg Prefix:\n' + } + + describe('extensible, should work for any defined network protocol', () => { + it('fromAddress() - valid', () => { + const valid = Bech32Address.fromAddress(dummyNetwork, bech32Fixture.p2wpkh, DummyBech32Address) + expect(valid.validate()).toBeTruthy() + }) + + it('fromAddress() - invalid character set', () => { + const invalid = Bech32Address.fromAddress(dummyNetwork, bech32Fixture.invalidCharset, DummyBech32Address) + expect(invalid.validate()).toBeFalsy() + }) + + it('fromAddress() - invalid prefix', () => { + const invalid = Bech32Address.fromAddress(dummyNetwork, bech32Fixture.invalidPrefix, DummyBech32Address) + expect(invalid.validate()).toBeFalsy() + }) + }) +}) diff --git a/packages/jellyfish-address/__tests__/p2pkh.test.ts b/packages/jellyfish-address/__tests__/p2pkh.test.ts new file mode 100644 index 0000000000..8389060b50 --- /dev/null +++ b/packages/jellyfish-address/__tests__/p2pkh.test.ts @@ -0,0 +1,142 @@ +import bs58 from 'bs58' +import { MainNet, RegTest, TestNet } from '@defichain/jellyfish-network' +import { OP_CODES } from '@defichain/jellyfish-transaction/src/script' +import { RegTestContainer } from '@defichain/testcontainers' +import * as DeFiAddress from '../src' +import { P2PKH } from '../src' + +describe('P2PKH', () => { + const container = new RegTestContainer() + const p2pkhFixture = { + mainnet: '8JBuS81VT8ouPrT6YS55qoS74D13Cw7h1Y', + testnet: '7LMorkhKTDjbES6DfRxX2RiNMbeemUkxmp', + regtest: '', + + invalid: 'JBuS81VT8ouPrT6YS55qoS74D13Cw7h1Y', // edited, removed prefix + invalidChecksum: '8JBuS81VT8ouPrT6YS55qoS74D13Cw7h1X' // edited checksum (last char) + } + + beforeAll(async () => { + await container.start() + await container.waitForReady() + p2pkhFixture.regtest = await container.getNewAddress('', 'legacy') + }) + + afterAll(async () => await container.stop()) + + describe('from() - valid address', () => { + it('should get the type precisely', () => { + const p2pkh = DeFiAddress.from('mainnet', p2pkhFixture.mainnet) + expect(p2pkh.valid).toBeTruthy() + expect(p2pkh.type).toBe('P2PKH') + expect(p2pkh.constructor.name).toBe('P2PKH') + expect(p2pkh.network).toBe(MainNet) + }) + + it('should work for all recognized network type', () => { + const testnet = DeFiAddress.from('testnet', p2pkhFixture.testnet) + expect(testnet.valid).toBeTruthy() + expect(testnet.type).toBe('P2PKH') + expect(testnet.constructor.name).toBe('P2PKH') + expect(testnet.network).toBe(TestNet) + + const regtest = DeFiAddress.from('regtest', p2pkhFixture.regtest) + expect(regtest.valid).toBeTruthy() + expect(regtest.type).toBe('P2PKH') + expect(regtest.constructor.name).toBe('P2PKH') + expect(regtest.network).toBe(RegTest) + }) + }) + + describe('from() - invalid address', () => { + it('should be able to validate in address prefix with network', () => { + const invalid = DeFiAddress.from('mainnet', p2pkhFixture.invalid) + expect(invalid.valid).toBeFalsy() + }) + + it('should be able to validate in address prefix with network', () => { + // valid address, used on different network + const p2pkh = DeFiAddress.from('testnet', p2pkhFixture.mainnet) + expect(p2pkh.valid).toBeFalsy() + // expect(p2pkh.type).toBe('P2PKH') // invalid address guessed type is not promising, as p2pkh and p2sh are versy similar + expect(p2pkh.network).toBe(TestNet) + }) + + it('should get the type precisely', () => { + const invalid = DeFiAddress.from('mainnet', p2pkhFixture.invalidChecksum) + expect(invalid.valid).toBeFalsy() + }) + }) + + describe('to()', () => { + it('should be able to build a new address using a public key hash (20 bytes, 40 char hex string)', () => { + const pubKeyHash = '134b0749882c225e8647df3a3417507c6f5b2797' + expect(pubKeyHash.length).toEqual(40) + + const p2pkh = P2PKH.to('regtest', pubKeyHash) + expect(p2pkh.type).toEqual('P2PKH') + expect(p2pkh.valid).toBeTruthy() + + const scriptStack = p2pkh.getScript() + expect(scriptStack.stack.length).toEqual(5) + expect(scriptStack.stack[0]).toEqual(OP_CODES.OP_DUP) + expect(scriptStack.stack[1]).toEqual(OP_CODES.OP_HASH160) + expect(scriptStack.stack[2]).toEqual(OP_CODES.OP_PUSHDATA_HEX_LE(pubKeyHash)) + expect(scriptStack.stack[3]).toEqual(OP_CODES.OP_EQUALVERIFY) + expect(scriptStack.stack[4]).toEqual(OP_CODES.OP_CHECKSIG) + }) + + it('should reject invalid data - not 20 bytes data', () => { + const pubKeyHash = '134b0749882c225e8647df3a3417507c6f5b27' + expect(pubKeyHash.length).toEqual(38) + + expect(() => { + P2PKH.to('regtest', pubKeyHash) + }).toThrow('InvalidDataLength') + }) + }) + + describe('getScript()', () => { + it('should refuse to build ops code stack for invalid address', () => { + const invalid = DeFiAddress.from('testnet', p2pkhFixture.mainnet) + expect(invalid.valid).toBeFalsy() + try { + invalid.getScript() + } catch (e) { + expect(e.message).toBe('InvalidDefiAddress') + } + }) + + it('should be able to build script', async () => { + const p2pkh = DeFiAddress.from('mainnet', p2pkhFixture.mainnet) + const scriptStack = p2pkh.getScript() + + expect(scriptStack.stack.length).toEqual(5) + expect(scriptStack.stack[0]).toEqual(OP_CODES.OP_DUP) + expect(scriptStack.stack[1]).toEqual(OP_CODES.OP_HASH160) + expect(scriptStack.stack[2].type).toEqual('OP_PUSHDATA') // tested in `to()` + expect(scriptStack.stack[3]).toEqual(OP_CODES.OP_EQUALVERIFY) + expect(scriptStack.stack[4]).toEqual(OP_CODES.OP_CHECKSIG) + }) + }) + + it('validate()', () => { + const hex = bs58.decode(p2pkhFixture.mainnet).toString('hex').substring(2, 42) // take 20 bytes data only + const p2pkh = new P2PKH(MainNet, p2pkhFixture.mainnet, hex) + + expect(p2pkh.validatorPassed).toEqual(0) + expect(p2pkh.valid).toBeFalsy() + + const isValid = p2pkh.validate() + expect(p2pkh.validatorPassed).toEqual(5) + expect(isValid).toBeTruthy() + }) + + it('guess()', () => { + const p2pkh = DeFiAddress.guess(p2pkhFixture.mainnet) + expect(p2pkh.valid).toBeTruthy() + expect(p2pkh.type).toBe('P2PKH') + expect(p2pkh.constructor.name).toBe('P2PKH') + expect(p2pkh.network).toBe(MainNet) + }) +}) diff --git a/packages/jellyfish-address/__tests__/p2sh.test.ts b/packages/jellyfish-address/__tests__/p2sh.test.ts new file mode 100644 index 0000000000..550b5099b3 --- /dev/null +++ b/packages/jellyfish-address/__tests__/p2sh.test.ts @@ -0,0 +1,141 @@ +import bs58 from 'bs58' +import { MainNet, RegTest, TestNet } from '@defichain/jellyfish-network' +import { OP_CODES } from '@defichain/jellyfish-transaction/src/script' +import { RegTestContainer } from '@defichain/testcontainers' +import * as DeFiAddress from '../src' +import { P2SH } from '../src' + +describe('P2SH', () => { + const container = new RegTestContainer() + const p2shFixture = { + mainnet: 'dFFPENo7FPMJpDV6fUcfo4QfkZrfrV1Uf8', + testnet: 'trsUzSh3Qcu1MURY1BKDjttJN6hxtoRxM2', + regtest: '', + + invalid: 'FFPENo7FPMJpDV6fUcfo4QfkZrfrV1Uf8', // edited mainnet address, removed prefix + invalidChecksum: 'dFFPENo7FPMJpDV6fUcfo4QfkZrfrV1Uf' // edited mainnet address, trim checksum + } + + beforeAll(async () => { + await container.start() + await container.waitForReady() + p2shFixture.regtest = await container.getNewAddress('', 'p2sh-segwit') + }) + + afterAll(async () => await container.stop()) + + describe('from() - valid address', () => { + it('should get the type precisely', () => { + const p2sh = DeFiAddress.from('mainnet', p2shFixture.mainnet) + expect(p2sh.valid).toBeTruthy() + expect(p2sh.type).toBe('P2SH') + expect(p2sh.constructor.name).toBe('P2SH') + expect(p2sh.network).toBe(MainNet) + }) + + it('should work for all recognized network type', () => { + const testnet = DeFiAddress.from('testnet', p2shFixture.testnet) + expect(testnet.valid).toBeTruthy() + expect(testnet.type).toBe('P2SH') + expect(testnet.constructor.name).toBe('P2SH') + expect(testnet.network).toBe(TestNet) + + const regtest = DeFiAddress.from('regtest', p2shFixture.regtest) + expect(regtest.valid).toBeTruthy() + expect(regtest.type).toBe('P2SH') + expect(regtest.constructor.name).toBe('P2SH') + expect(regtest.network).toBe(RegTest) + }) + }) + + describe('from() - invalid address', () => { + it('trimmed prefix', () => { + const invalid = DeFiAddress.from('mainnet', p2shFixture.invalid) + expect(invalid.valid).toBeFalsy() + }) + + it('should be able to validate in address prefix with network', () => { + // valid address, used on different network + const p2sh = DeFiAddress.from('testnet', p2shFixture.mainnet) + expect(p2sh.valid).toBeFalsy() + // expect(p2sh.type).toBe('P2SH') // invalid address guessed type is not promising, as p2sh and p2sh are versy similar + expect(p2sh.network).toBe(TestNet) + }) + + it('should get the type precisely', () => { + const invalid = DeFiAddress.from('mainnet', p2shFixture.invalidChecksum) + expect(invalid.valid).toBeFalsy() + }) + }) + + describe('to()', () => { + it('should be able to build a new address using a public key hash (20 bytes, 40 char hex string)', () => { + const scriptHash = '134b0749882c225e8647df3a3417507c6f5b2797' + expect(scriptHash.length).toEqual(40) + + const p2sh = P2SH.to('regtest', scriptHash) + expect(p2sh.type).toEqual('P2SH') + expect(p2sh.valid).toBeTruthy() + + const scriptStack = p2sh.getScript() + expect(scriptStack.stack.length).toEqual(3) + expect(scriptStack.stack[0]).toEqual(OP_CODES.OP_HASH160) + expect(scriptStack.stack[1]).toEqual(OP_CODES.OP_PUSHDATA_HEX_LE(scriptHash)) + expect(scriptStack.stack[2]).toEqual(OP_CODES.OP_EQUAL) + }) + + it('should reject invalid data - not 20 bytes data', () => { + const scriptHash = '134b0749882c225e8647df3a3417507c6f5b27' + expect(scriptHash.length).toEqual(38) + + try { + P2SH.to('regtest', scriptHash) + throw new Error('should had failed') + } catch (e) { + expect(e.message).toEqual('InvalidDataLength') + } + }) + }) + + describe('getScript()', () => { + it('should refuse to build ops code stack for invalid address', () => { + const invalid = DeFiAddress.from('testnet', p2shFixture.mainnet) + expect(invalid.valid).toBeFalsy() + try { + invalid.getScript() + } catch (e) { + expect(e.message).toBe('InvalidDefiAddress') + } + }) + + it('should be able to build script', async () => { + const p2sh = DeFiAddress.from('mainnet', p2shFixture.mainnet) + const scriptStack = p2sh.getScript() + + expect(scriptStack.stack.length).toEqual(3) + expect(scriptStack.stack[0]).toEqual(OP_CODES.OP_HASH160) + expect(scriptStack.stack[1].type).toEqual('OP_PUSHDATA') + expect(scriptStack.stack[2]).toEqual(OP_CODES.OP_EQUAL) + }) + }) + + it('validate()', () => { + const hex = bs58.decode(p2shFixture.mainnet).toString('hex').substring(2, 42) // take 20 bytes data only + const p2sh = new P2SH(MainNet, p2shFixture.mainnet, hex) + + expect(p2sh.validatorPassed).toEqual(0) + expect(p2sh.valid).toBeFalsy() + + const isValid = p2sh.validate() + expect(p2sh.validatorPassed).toEqual(5) + expect(isValid).toBeTruthy() + }) + + it('guess()', () => { + const p2sh = DeFiAddress.guess(p2shFixture.mainnet) + expect(p2sh.valid).toBeTruthy() + expect(p2sh.type).toBe('P2SH') + expect(p2sh.constructor.name).toBe('P2SH') + expect(p2sh.network).toBe(MainNet) + }) +}) diff --git a/packages/jellyfish-address/__tests__/p2wpkh.test.ts b/packages/jellyfish-address/__tests__/p2wpkh.test.ts new file mode 100644 index 0000000000..7b3791f452 --- /dev/null +++ b/packages/jellyfish-address/__tests__/p2wpkh.test.ts @@ -0,0 +1,138 @@ +import { MainNet, RegTest, TestNet } from '@defichain/jellyfish-network' +import { OP_CODES } from '@defichain/jellyfish-transaction/src/script' +import { RegTestContainer } from '@defichain/testcontainers' +import * as DeFiAddress from '../src' +import { P2WPKH } from '../src' + +describe('P2WPKH', () => { + const container = new RegTestContainer() + const p2wpkhFixture = { + mainnet: 'df1qpe7q4vvtxpdunpazvmwqdh3xlnatfdt2xr8mpv', + testnet: 'tf1qpe7q4vvtxpdunpazvmwqdh3xlnatfdt24nagpg', + regtest: '', + + trimmedPrefix: 'f1qpe7q4vvtxpdunpazvmwqdh3xlnatfdt2xr8mpv', // edited mainnet address with broken prefix + invalid: 'df1pe7q4vvtxpdunpazvmwqdh3xlnatfdt2ncrpqo' // edited mainnet address, letter 'o' + } + + beforeAll(async () => { + await container.start() + await container.waitForReady() + p2wpkhFixture.regtest = await container.getNewAddress('', 'bech32') + }) + + afterAll(async () => await container.stop()) + + describe('from() - valid address', () => { + it('should get the type precisely', () => { + const p2wpkh = DeFiAddress.from('mainnet', p2wpkhFixture.mainnet) + expect(p2wpkh.valid).toBeTruthy() + expect(p2wpkh.type).toBe('P2WPKH') + expect(p2wpkh.constructor.name).toBe('P2WPKH') + expect(p2wpkh.network).toBe(MainNet) + }) + + it('should work for all recognized network type', () => { + const testnet = DeFiAddress.from('testnet', p2wpkhFixture.testnet) + expect(testnet.valid).toBeTruthy() + expect(testnet.type).toBe('P2WPKH') + expect(testnet.constructor.name).toBe('P2WPKH') + expect(testnet.network).toBe(TestNet) + + const regtest = DeFiAddress.from('regtest', p2wpkhFixture.regtest) + expect(regtest.valid).toBeTruthy() + expect(regtest.type).toBe('P2WPKH') + expect(regtest.constructor.name).toBe('P2WPKH') + expect(regtest.network).toBe(RegTest) + }) + }) + + describe('from() - invalid address', () => { + it('trimmed prefix', () => { + const invalid = DeFiAddress.from('mainnet', p2wpkhFixture.trimmedPrefix) + expect(invalid.valid).toBeFalsy() + }) + + it('invalid character set', () => { + const invalid = DeFiAddress.from('mainnet', p2wpkhFixture.invalid) + expect(invalid.valid).toBeFalsy() + }) + + it('should be able to validate in address prefix with network', () => { + // valid address, used on different network + const p2wpkh = DeFiAddress.from('testnet', p2wpkhFixture.mainnet) + expect(p2wpkh.valid).toBeFalsy() + // expect(p2wpkh.type).toBe('P2WPKH') // invalid address guessed type is not promising, as p2wpkh and p2wpkh are versy similar + expect(p2wpkh.network).toBe(TestNet) + }) + }) + + describe('to()', () => { + it('should be able to build a new address using a public key hash (20 bytes, 40 char hex string)', () => { + const data = '0e7c0ab18b305bc987a266dc06de26fcfab4b56a' // 20 bytes + expect(data.length).toEqual(40) + + const p2wpkh = P2WPKH.to('regtest', data) + expect(p2wpkh.type).toEqual('P2WPKH') + expect(p2wpkh.valid).toBeTruthy() + + const scriptStack = p2wpkh.getScript() + expect(scriptStack.stack.length).toEqual(2) + expect(scriptStack.stack[0]).toEqual(OP_CODES.OP_0) + expect(scriptStack.stack[1]).toEqual(OP_CODES.OP_PUSHDATA_HEX_LE(data)) + }) + + it('should reject invalid data - not 32 bytes data', () => { + const pubKeyHash = '9e1be07558ea5cc8e02ed1d80c0911048afad949affa36d5c3951e3159dbea19' + expect(pubKeyHash.length).toEqual(64) // testing with a p2wpkh data + + try { + P2WPKH.to('regtest', pubKeyHash) + throw new Error('should had failed') + } catch (e) { + expect(e.message).toEqual('InvalidPubKeyHashLength') + } + }) + }) + + describe('getScript()', () => { + it('should refuse to build ops code stack for invalid address', () => { + const invalid = DeFiAddress.from('testnet', p2wpkhFixture.mainnet) + expect(invalid.valid).toBeFalsy() + try { + invalid.getScript() + } catch (e) { + expect(e.message).toBe('InvalidDefiAddress') + } + }) + + it('should be able to build script', async () => { + const p2wpkh = DeFiAddress.from('mainnet', p2wpkhFixture.mainnet) + const scriptStack = p2wpkh.getScript() + + expect(scriptStack.stack.length).toEqual(2) + expect(scriptStack.stack[0]).toEqual(OP_CODES.OP_0) + expect(scriptStack.stack[1].type).toEqual('OP_PUSHDATA') + }) + }) + + it('validate()', () => { + const data = '0e7c0ab18b305bc987a266dc06de26fcfab4b56a' // 20 bytes + + const p2wpkh = new P2WPKH(RegTest, p2wpkhFixture.regtest, data) + expect(p2wpkh.validatorPassed).toEqual(0) + expect(p2wpkh.valid).toBeFalsy() + + const isValid = p2wpkh.validate() + expect(p2wpkh.validatorPassed).toEqual(5) // length, network prefix, data character set + expect(isValid).toBeTruthy() + }) + + it('guess()', () => { + const p2wpkh = DeFiAddress.guess(p2wpkhFixture.mainnet) + expect(p2wpkh.valid).toBeTruthy() + expect(p2wpkh.type).toBe('P2WPKH') + expect(p2wpkh.constructor.name).toBe('P2WPKH') + expect(p2wpkh.network).toBe(MainNet) + }) +}) diff --git a/packages/jellyfish-address/__tests__/p2wsh.test.ts b/packages/jellyfish-address/__tests__/p2wsh.test.ts new file mode 100644 index 0000000000..bc8ee39d6a --- /dev/null +++ b/packages/jellyfish-address/__tests__/p2wsh.test.ts @@ -0,0 +1,128 @@ +import { MainNet, RegTest, TestNet } from '@defichain/jellyfish-network' +import { OP_CODES } from '@defichain/jellyfish-transaction/src/script' +import * as DeFiAddress from '../src' +import { P2WSH } from '../src' + +describe('P2WSH', () => { + const p2wshFixture = { + mainnet: 'df1qncd7qa2cafwv3cpw68vqczg3qj904k2f4lard4wrj50rzkwmagvsfkkf88', // built using jellyfish-transaction P2PSH test case example, no way to find back raw data using random address + testnet: 'tf1qncd7qa2cafwv3cpw68vqczg3qj904k2f4lard4wrj50rzkwmagvsemeex5', + regtest: 'bcrt1qncd7qa2cafwv3cpw68vqczg3qj904k2f4lard4wrj50rzkwmagvssfsq3t', + + trimmedPrefix: 'f1qncd7qa2cafwv3cpw68vqczg3qj904k2f4lard4wrj50rzkwmagvsfkkf88', // edited mainnet valid address, broken prefix + invalid: 'df1qncd7qa2cafwv3cpw68vqczg3qj904k2f4lard4wrj50rzkwmagvsfkkf8o' // edited mainnet address, letter 'o' + } + + describe('from() - valid address', () => { + it('should get the type precisely', () => { + const p2sh = DeFiAddress.from('mainnet', p2wshFixture.mainnet) + expect(p2sh.valid).toBeTruthy() + expect(p2sh.type).toBe('P2WSH') + expect(p2sh.constructor.name).toBe('P2WSH') + expect(p2sh.network).toBe(MainNet) + }) + + it('should work for all recognized network type', () => { + const testnet = DeFiAddress.from('testnet', p2wshFixture.testnet) + expect(testnet.valid).toBeTruthy() + expect(testnet.type).toBe('P2WSH') + expect(testnet.constructor.name).toBe('P2WSH') + expect(testnet.network).toBe(TestNet) + + const regtest = DeFiAddress.from('regtest', p2wshFixture.regtest) + expect(regtest.valid).toBeTruthy() + expect(regtest.type).toBe('P2WSH') + expect(regtest.constructor.name).toBe('P2WSH') + expect(regtest.network).toBe(RegTest) + }) + }) + + describe('from() - invalid address', () => { + it('trimmed prefix', () => { + const invalid = DeFiAddress.from('mainnet', p2wshFixture.trimmedPrefix) + expect(invalid.valid).toBeFalsy() + }) + + it('invalid character set', () => { + const invalid = DeFiAddress.from('mainnet', p2wshFixture.invalid) + expect(invalid.valid).toBeFalsy() + }) + + it('should be able to validate in address prefix with network', () => { + // valid address, used on different network + const p2sh = DeFiAddress.from('testnet', p2wshFixture.mainnet) + expect(p2sh.valid).toBeFalsy() + // expect(p2sh.type).toBe('P2WSH') // invalid address guessed type is not promising, as p2wsh and p2sh are versy similar + expect(p2sh.network).toBe(TestNet) + }) + }) + + describe('to()', () => { + it('should be able to build a new address using a script hash (32 bytes, 64 char hex string)', () => { + const data = '9e1be07558ea5cc8e02ed1d80c0911048afad949affa36d5c3951e3159dbea19' // 32 bytes + expect(data.length).toEqual(64) + + const p2wsh = P2WSH.to('regtest', data) + expect(p2wsh.type).toEqual('P2WSH') + expect(p2wsh.valid).toBeTruthy() + + const scriptStack = p2wsh.getScript() + expect(scriptStack.stack.length).toEqual(2) + expect(scriptStack.stack[0]).toEqual(OP_CODES.OP_0) + expect(scriptStack.stack[1]).toEqual(OP_CODES.OP_PUSHDATA_HEX_LE(data)) + }) + + it('should reject invalid data - not 32 bytes data', () => { + const pubKeyHash = '134b0749882c225e8647df3a3417507c6f5b27' + expect(pubKeyHash.length).toEqual(38) + + try { + P2WSH.to('regtest', pubKeyHash) + throw new Error('should had failed') + } catch (e) { + expect(e.message).toEqual('InvalidScriptHashLength') + } + }) + }) + + describe('getScript()', () => { + it('should refuse to build ops code stack for invalid address', () => { + const invalid = DeFiAddress.from('testnet', p2wshFixture.mainnet) + expect(invalid.valid).toBeFalsy() + try { + invalid.getScript() + } catch (e) { + expect(e.message).toBe('InvalidDefiAddress') + } + }) + + it('should be able to build script', async () => { + const p2wsh = DeFiAddress.from('mainnet', p2wshFixture.mainnet) + const scriptStack = p2wsh.getScript() + + expect(scriptStack.stack.length).toEqual(2) + expect(scriptStack.stack[0]).toEqual(OP_CODES.OP_0) + expect(scriptStack.stack[1].type).toEqual('OP_PUSHDATA') + }) + }) + + it('validate()', () => { + const data = '9e1be07558ea5cc8e02ed1d80c0911048afad949affa36d5c3951e3159dbea19' // 32 bytes + + const p2wsh = new P2WSH(RegTest, p2wshFixture.regtest, data) + expect(p2wsh.validatorPassed).toEqual(0) + expect(p2wsh.valid).toBeFalsy() + + const isValid = p2wsh.validate() + // expect(p2wsh.validatorPassed).toEqual(4) // length, network prefix, data character set + expect(isValid).toBeTruthy() + }) + + it('guess()', () => { + const p2wsh = DeFiAddress.guess(p2wshFixture.mainnet) + expect(p2wsh.valid).toBeTruthy() + expect(p2wsh.type).toBe('P2WSH') + expect(p2wsh.constructor.name).toBe('P2WSH') + expect(p2wsh.network).toBe(MainNet) + }) +}) diff --git a/packages/jellyfish-address/jest.config.js b/packages/jellyfish-address/jest.config.js new file mode 100644 index 0000000000..11d9802ff4 --- /dev/null +++ b/packages/jellyfish-address/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + testEnvironment: 'node', + testMatch: [ + '**/__tests__/**/*.test.ts' + ], + transform: { + '^.+\\.ts$': 'ts-jest' + }, + verbose: true, + clearMocks: true, + testTimeout: 120000 +} diff --git a/packages/jellyfish-address/package.json b/packages/jellyfish-address/package.json new file mode 100644 index 0000000000..b272575e7a --- /dev/null +++ b/packages/jellyfish-address/package.json @@ -0,0 +1,46 @@ +{ + "private": false, + "name": "@defichain/jellyfish-address", + "version": "0.0.0", + "description": "A collection of TypeScript + JavaScript tools and libraries for DeFi Blockchain developers to build decentralized finance on Bitcoin", + "keywords": [ + "DeFiChain", + "DeFi", + "Blockchain", + "API", + "Bitcoin" + ], + "repository": "DeFiCh/jellyfish", + "bugs": "https://github.com/DeFiCh/jellyfish/issues", + "license": "MIT", + "contributors": [ + { + "name": "DeFiChain Foundation", + "email": "engineering@defichain.com", + "url": "https://defichain.com/" + }, + { + "name": "DeFi Blockchain Contributors" + }, + { + "name": "DeFi Jellyfish Contributors" + } + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@defichain/jellyfish-crypto": "0.0.0", + "@defichain/jellyfish-network": "0.0.0", + "@defichain/jellyfish-transaction": "0.0.0", + "bs58": "^4.0.1" + }, + "devDependencies": { + "@types/bs58": "^4.0.1" + } +} diff --git a/packages/jellyfish-address/src/address.ts b/packages/jellyfish-address/src/address.ts new file mode 100644 index 0000000000..d9369e42b2 --- /dev/null +++ b/packages/jellyfish-address/src/address.ts @@ -0,0 +1,58 @@ +import { Network } from '@defichain/jellyfish-network' +import { Script } from '@defichain/jellyfish-transaction' + +export type AddressType = 'Unknown' | 'P2PKH' | 'P2SH' | 'P2WPKH' | 'P2WSH' +export type Validator = () => boolean + +export abstract class Address { + network: Network + utf8String: string + type: AddressType + valid: boolean + validatorPassed: number + + constructor (network: Network, utf8String: string, valid: boolean, type: AddressType) { + this.network = network + this.utf8String = utf8String + this.valid = valid + this.type = type + this.validatorPassed = 0 + } + + abstract validators (): Validator[] + abstract getScript (): Script + + validate (): boolean { + this.valid = true + this.validatorPassed = 0 + this.validators().forEach((validator, index) => { + const passed = validator() + this.valid = this.valid && passed + if (passed) { + this.validatorPassed += 1 + } + }) + return this.valid + } +} + +/** + * Default Address implementation when parsed address do not matched any type + */ +export class UnknownTypeAddress extends Address { + constructor (network: Network, raw: string) { + super(network, raw, false, 'Unknown') + } + + validators (): Validator[] { + return [] + } + + validate (): boolean { + return false + } + + getScript (): Script { + throw new Error('InvalidDeFiAddress') + } +} diff --git a/packages/jellyfish-address/src/base58_address.ts b/packages/jellyfish-address/src/base58_address.ts new file mode 100644 index 0000000000..55a47c4c81 --- /dev/null +++ b/packages/jellyfish-address/src/base58_address.ts @@ -0,0 +1,60 @@ +import { Bs58 } from '@defichain/jellyfish-crypto' +import { Network } from '@defichain/jellyfish-network' +import { Address, AddressType, Validator } from './address' + +export abstract class Base58Address extends Address { + static MIN_LENGTH = 26 + static MAX_LENGTH = 35 + + // 20 bytes data + hex: string + static DATA_HEX_LENGTH = 40 // hex char count + + constructor (network: Network, utf8String: string, hex: string, valid: boolean, type: AddressType) { + super(network, utf8String, valid, type) + this.hex = hex + } + + abstract getPrefix (): number + + validators (): Validator[] { + return [ + () => (this.utf8String.length >= Base58Address.MIN_LENGTH), + () => (this.utf8String.length <= Base58Address.MAX_LENGTH), + () => { + const charset = '[1-9A-HJ-NP-Za-km-z]' + return new RegExp(`${charset}{${this.utf8String.length}}$`).test(this.utf8String) + }, + () => { + try { + const { prefix } = Bs58.toHash160(this.utf8String) // built in checksum check + return prefix === this.getPrefix() + } catch (e) { + return false + } + }, + () => { + try { + const { buffer } = Bs58.toHash160(this.utf8String) // built in checksum check + return buffer.toString('hex') === this.hex + } catch (e) { + return false + } + } + ] + } + + getPrefixString (): string { + return Buffer.from([this.getPrefix()]).toString('hex') + } + + static fromAddress (network: Network, utf8String: string, AddressClass: new (...a: any[]) => T): T { + try { + const { buffer } = Bs58.toHash160(utf8String) + return new AddressClass(network, utf8String, buffer.toString('hex')) + } catch (e) { + // non b58 string, invalid address + return new AddressClass(network, utf8String, '', false, 'Unknown') + } + } +} diff --git a/packages/jellyfish-address/src/bech32_address.ts b/packages/jellyfish-address/src/bech32_address.ts new file mode 100644 index 0000000000..6366f546af --- /dev/null +++ b/packages/jellyfish-address/src/bech32_address.ts @@ -0,0 +1,50 @@ +import { Network } from '@defichain/jellyfish-network' +import { bech32 } from 'bech32' +import { Address, AddressType, Validator } from './address' + +export abstract class Bech32Address extends Address { + static MAX_LENGTH = 90 + static MAX_HUMAN_READABLE_LENGTH = 83 + + constructor (network: Network, utf8String: string, valid: boolean, addressType: AddressType) { + super(network, utf8String.toLowerCase(), valid, addressType) + } + + validators (): Validator[] { + return [ + () => (new RegExp(`^${this.getHrp()}`).test(this.utf8String)), + () => { + const charset = '[02-9ac-hj-np-z]' // 0-9, a-z, and reject: [1, b, i, o] + const arr = this.utf8String.split('1') + const excludeHrp = arr[arr.length - 1] + const regex = new RegExp(`${charset}{${excludeHrp.length}}$`) + return regex.test(excludeHrp) + } + ] + } + + getHrp (): string { + return this.network.bech32.hrp + } + + static fromAddress(network: Network, raw: string, AddressClass: new (...a: any[]) => T): T { + let valid: boolean + let prefix: string + let data: string = '' + try { + const decoded = bech32.decode(raw) + valid = true + prefix = decoded.prefix + const trimmedVersion = decoded.words.slice(1) + data = Buffer.from(bech32.fromWords(trimmedVersion)).toString('hex') + + if (prefix !== network.bech32.hrp) { + valid = false + } + } catch (e) { + valid = false + } + + return new AddressClass(network, raw, data, valid) + } +} diff --git a/packages/jellyfish-address/src/index.ts b/packages/jellyfish-address/src/index.ts new file mode 100644 index 0000000000..6da29629c3 --- /dev/null +++ b/packages/jellyfish-address/src/index.ts @@ -0,0 +1,82 @@ + +import { getNetwork, NetworkName } from '@defichain/jellyfish-network' +import { Address, AddressType, UnknownTypeAddress } from './address' +import { Base58Address } from './base58_address' +import { Bech32Address } from './bech32_address' +import { P2PKH } from './p2pkh' +import { P2SH } from './p2sh' +import { P2WSH } from './p2wsh' +import { P2WPKH } from './p2wpkh' + +export * from './address' +export * from './base58_address' +export * from './bech32_address' +export * from './p2pkh' +export * from './p2sh' +export * from './p2wpkh' +export * from './p2wsh' + +/** + * When insist to use the "network" decoded from raw address, instead of passing one based on running application environment + * @param address raw human readable address (utf-8) + * @returns DefiAddress or a child class + */ +function guess (address: string): Address { + const networks: NetworkName[] = ['mainnet', 'testnet', 'regtest'] + const defaultOne = new UnknownTypeAddress(getNetwork('mainnet'), address) + for (let i = 0; i < networks.length; i += 1) { + const guessed = from(networks[i], address) + if (guessed.valid) { + return guessed + } + } + return defaultOne +} + +/** + * @param net to be validated against the decoded one from the raw address + * @param address raw human readable address (utf-8) + * @returns DefiAddress or a child class + */ +function from (net: NetworkName, address: string): T { + const network = getNetwork(net) + const possible: Map = new Map() + possible.set('Unknown', new UnknownTypeAddress(network, address)) + possible.set('P2PKH', Base58Address.fromAddress(network, address, P2PKH)) + possible.set('P2SH', Base58Address.fromAddress(network, address, P2SH)) + possible.set('P2WPKH', Bech32Address.fromAddress(network, address, P2WPKH)) + possible.set('P2WSH', Bech32Address.fromAddress(network, address, P2WSH)) + + possible.forEach(each => each.validate()) + + let valid + possible.forEach(each => { + if (each.valid) { + valid = each + } + }) + + /* eslint-disable @typescript-eslint/strict-boolean-expressions */ + if (valid) { + // find if any has all validator passed + return valid + } + + // else select the closest guess (most validator passed) + // default, when non have validator passed + let highestKey: AddressType = 'Unknown' + let highestCount = 0 + + possible.forEach((val, key) => { + if (val.validatorPassed > highestCount) { + highestKey = key + highestCount = val.validatorPassed + } + }) + return (possible.get(highestKey) as T) +} + +export { + guess, + from +} diff --git a/packages/jellyfish-address/src/p2pkh.ts b/packages/jellyfish-address/src/p2pkh.ts new file mode 100644 index 0000000000..5143ed9d5a --- /dev/null +++ b/packages/jellyfish-address/src/p2pkh.ts @@ -0,0 +1,45 @@ +import { Bs58 } from '@defichain/jellyfish-crypto' +import { getNetwork, Network, NetworkName } from '@defichain/jellyfish-network' +import { Script } from '@defichain/jellyfish-transaction' +import { OP_CODES, OP_PUSHDATA } from '@defichain/jellyfish-transaction/src/script' +import { Base58Address } from './base58_address' + +export class P2PKH extends Base58Address { + constructor (network: Network, utf8String: string, hex: string, validated: boolean = false) { + super(network, utf8String, hex, validated, 'P2PKH') + } + + getPrefix (): number { + return this.network.pubKeyHashPrefix + } + + getScript (): Script { + if (!this.valid) { + this.validate() + } + + if (!this.valid) { + throw new Error('InvalidDefiAddress') + } + + return { + stack: [ + OP_CODES.OP_DUP, + OP_CODES.OP_HASH160, + new OP_PUSHDATA(Buffer.from(this.hex, 'hex'), 'little'), + OP_CODES.OP_EQUALVERIFY, + OP_CODES.OP_CHECKSIG + ] + } + } + + static to (net: NetworkName | Network, h160: string): P2PKH { + if (h160.length !== Base58Address.DATA_HEX_LENGTH) { + throw new Error('InvalidDataLength') + } + + const network = typeof net === 'string' ? getNetwork(net) : net + const address = Bs58.fromHash160(h160, network.pubKeyHashPrefix) + return new P2PKH(network, address, h160, true) + } +} diff --git a/packages/jellyfish-address/src/p2sh.ts b/packages/jellyfish-address/src/p2sh.ts new file mode 100644 index 0000000000..29472d25e5 --- /dev/null +++ b/packages/jellyfish-address/src/p2sh.ts @@ -0,0 +1,45 @@ +import { Bs58 } from '@defichain/jellyfish-crypto' +import { getNetwork, Network, NetworkName } from '@defichain/jellyfish-network' +import { Script } from '@defichain/jellyfish-transaction' +import { OP_CODES, OP_PUSHDATA } from '@defichain/jellyfish-transaction/src/script' +import { Base58Address } from './base58_address' + +export class P2SH extends Base58Address { + static SCRIPT_HASH_LENGTH = 50 // 25 bytes, 50 char + + constructor (network: Network, utf8String: string, hex: string, validated: boolean = false) { + super(network, utf8String, hex, validated, 'P2SH') + } + + getPrefix (): number { + return this.network.scriptHashPrefix + } + + getScript (): Script { + if (!this.valid) { + this.validate() + } + + if (!this.valid) { + throw new Error('InvalidDefiAddress') + } + + return { + stack: [ + OP_CODES.OP_HASH160, + new OP_PUSHDATA(Buffer.from(this.hex, 'hex'), 'little'), + OP_CODES.OP_EQUAL + ] + } + } + + static to (net: NetworkName | Network, h160: string): P2SH { + if (h160.length !== Base58Address.DATA_HEX_LENGTH) { + throw new Error('InvalidDataLength') + } + + const network = typeof net === 'string' ? getNetwork(net) : net + const address = Bs58.fromHash160(h160, network.scriptHashPrefix) + return new P2SH(network, address, h160, true) + } +} diff --git a/packages/jellyfish-address/src/p2wpkh.ts b/packages/jellyfish-address/src/p2wpkh.ts new file mode 100644 index 0000000000..5a8bd0fd65 --- /dev/null +++ b/packages/jellyfish-address/src/p2wpkh.ts @@ -0,0 +1,71 @@ +import { bech32 } from 'bech32' +import { getNetwork, Network, NetworkName } from '@defichain/jellyfish-network' +import { Script } from '@defichain/jellyfish-transaction' +import { OP_CODES, OP_PUSHDATA } from '@defichain/jellyfish-transaction/src/script' +import { Bech32Address } from './bech32_address' +import { Validator } from './address' + +export class P2WPKH extends Bech32Address { + static SAMPLE = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + static LENGTH_EXCLUDE_HRP = 39 // exclude hrp and separator + + // 20 bytes, data only, 40 char + pubKeyHash: string + static PUB_KEY_HASH_LENGTH = 40 + + constructor (network: Network, utf8String: string, pubKeyHash: string, validated: boolean = false) { + super(network, utf8String, validated, 'P2WPKH') + this.pubKeyHash = pubKeyHash + } + + validators (): Validator[] { + const rawAdd = this.utf8String + return [ + ...super.validators(), + () => (rawAdd.length <= P2WPKH.LENGTH_EXCLUDE_HRP + this.getHrp().length + 1), + () => (rawAdd.length === P2WPKH.LENGTH_EXCLUDE_HRP + this.getHrp().length + 1), + () => (this.pubKeyHash.length === P2WPKH.PUB_KEY_HASH_LENGTH) + ] + } + + getHrp (): string { + return this.network.bech32.hrp + } + + getScript (): Script { + if (!this.valid) { + this.validate() + } + + if (!this.valid) { + throw new Error('InvalidDefiAddress') + } + + return { + stack: [ + OP_CODES.OP_0, + new OP_PUSHDATA(Buffer.from(this.pubKeyHash, 'hex'), 'little') + ] + } + } + + /** + * @param net network + * @param hex data, public key hash (20 bytes, 40 characters) + * @param witnessVersion default 0 + * @returns + */ + static to (net: Network | NetworkName, h160: string, witnessVersion = 0x00): P2WPKH { + const network: Network = typeof net === 'string' ? getNetwork(net) : net + + if (h160.length !== P2WPKH.PUB_KEY_HASH_LENGTH) { + throw new Error('InvalidPubKeyHashLength') + } + + const numbers = Buffer.from(h160, 'hex') + const fiveBitsWords = bech32.toWords(numbers) + const includeVersion = [witnessVersion, ...fiveBitsWords] + const utf8 = bech32.encode(network.bech32.hrp, includeVersion) + return new P2WPKH(network, utf8, h160, true) + } +} diff --git a/packages/jellyfish-address/src/p2wsh.ts b/packages/jellyfish-address/src/p2wsh.ts new file mode 100644 index 0000000000..ac5a061145 --- /dev/null +++ b/packages/jellyfish-address/src/p2wsh.ts @@ -0,0 +1,67 @@ +import { bech32 } from 'bech32' +import { getNetwork, Network, NetworkName } from '@defichain/jellyfish-network' +import { Script } from '@defichain/jellyfish-transaction' +import { OP_CODES, OP_PUSHDATA } from '@defichain/jellyfish-transaction/src/script' +import { Bech32Address } from './bech32_address' +import { Validator } from './address' + +export class P2WSH extends Bech32Address { + // the raw utf8, eg bc1... + // supposed to be 62, regtest prefix is longer + static MAX_LENGTH = 64 + + // 32 bytes, data only, 64 char + data: string + static SCRIPT_HASH_LENGTH = 64 + + constructor (network: Network, utf8String: string, data: string, validated: boolean = false) { + super(network, utf8String, validated, 'P2WSH') + this.data = data + } + + // bcrt1ncd7qa2cafwv3cpw68vqczg3qj904k2f4lard4wrj50rzkwmagvs3ttd5f + validators (): Validator[] { + return [ + ...super.validators(), + () => (this.utf8String.length <= P2WSH.MAX_LENGTH), + () => (this.data.length === P2WSH.SCRIPT_HASH_LENGTH) + ] + } + + getScript (): Script { + if (!this.valid) { + this.validate() + } + + if (!this.valid) { + throw new Error('InvalidDefiAddress') + } + + return { + stack: [ + OP_CODES.OP_0, + new OP_PUSHDATA(Buffer.from(this.data, 'hex'), 'little') + ] + } + } + + /** + * @param net network + * @param hex data, redeem script (32 bytes, 64 characters) + * @param witnessVersion default 0 + * @returns + */ + static to (net: Network | NetworkName, hex: string, witnessVersion = 0x00): P2WSH { + const network: Network = typeof net === 'string' ? getNetwork(net) : net + + if (hex.length !== P2WSH.SCRIPT_HASH_LENGTH) { + throw new Error('InvalidScriptHashLength') + } + + const numbers = Buffer.from(hex, 'hex') + const fiveBitsWords = bech32.toWords(numbers) + const includeVersion = [witnessVersion, ...fiveBitsWords] + const utf8 = bech32.encode(network.bech32.hrp, includeVersion) + return new P2WSH(network, utf8, hex, true) + } +} diff --git a/packages/jellyfish-address/tsconfig.json b/packages/jellyfish-address/tsconfig.json new file mode 100644 index 0000000000..9670bde91d --- /dev/null +++ b/packages/jellyfish-address/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "./src/**/*" + ], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + } +} diff --git a/website/docs/introduction.md b/website/docs/introduction.md index e6c57e5396..5038c7b51e 100644 --- a/website/docs/introduction.md +++ b/website/docs/introduction.md @@ -28,6 +28,7 @@ maintained in this repo are published with the same version tag and follows the Package | Description ---------------------------------------------------|------------- `@defichain/jellyfish` | Library bundled usage entrypoint with conventional defaults for 4 bundles: umd, esm, cjs and d.ts +`@defichain/jellyfish-address` | Provide address builder, parser, validator utility library for DeFi Blockchain. `@defichain/jellyfish-api-core` | A protocol agnostic DeFi Blockchain client interfaces, with a "foreign function interface" design. `@defichain/jellyfish-api-jsonrpc` | Implements the [JSON-RPC 1.0](https://www.jsonrpc.org/specification_v1) specification for api-core. `@defichain/jellyfish-crypto` | Cryptography operations for jellyfish, includes a simple 'secp256k1' EllipticPair.