feat: support for subnets (#1549)

* test: initial integration test for subnets

* test: ensure first subnet block is mined

* feat: synthetic nft deposit tx

* test: increase timeout for subnets test

* test: attempt fix test flakiness in CI

* test: much faster subnet block mining time, subnet tests 5-10x faster

* ci: bump subnet-node docker image

* test: complete withdrawal on stacks chain

* test: try waiting for subnet contract interface to be ready

* test: verify NFT ownership on L1 after withdrawal

* test: fix subnet node port env vars

* feat: implement parsing for FT deposit synthetic tx

* chore: rename test file

* chore: do not skip test

* test: progress on subnet STX use-case test

* feat: implement parsing for STX deposit synthetic tx

* test: validation of synthetic tx for L1 deposit-stx

* test: validation of synthetic tx for L1 deposit-nft-asset

* test: validation of synthetic tx for L1 deposit-ft-asset

* chore: lint fix

* chore: remove debug comments
This commit is contained in:
Matthew Little
2023-02-14 13:30:41 +01:00
committed by GitHub
parent 0d87d4b518
commit 5d7056c1ba
25 changed files with 2824 additions and 29 deletions

View File

@@ -401,6 +401,65 @@ jobs:
flag-name: run-${{ github.job }}
parallel: true
test-subnets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
path: |
~/.npm
**/node_modules
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install deps
run: npm ci --audit=false
- name: Setup env vars
run: echo "STACKS_CORE_EVENT_HOST=http://0.0.0.0" >> $GITHUB_ENV
- name: Setup integration environment
run: |
sudo ufw disable
npm run devenv:deploy:subnets -- -d
npm run devenv:logs:subnets -- --no-color &> docker-compose-logs.txt &
- name: Run tests
run: npm run test:subnets
- name: Print integration environment logs
run: cat docker-compose-logs.txt
if: failure()
- name: Teardown integration environment
run: npm run devenv:stop:subnets
if: always()
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2
if: always()
- name: Upload coverage to Coveralls
uses: coverallsapp/github-action@master
if: always()
with:
github-token: ${{ secrets.github_token }}
flag-name: run-${{ github.job }}
parallel: true
test-2_1:
strategy:
fail-fast: false

19
.vscode/launch.json vendored
View File

@@ -229,6 +229,25 @@
"preLaunchTask": "stacks-node:deploy-dev",
"postDebugTask": "stacks-node:stop-dev"
},
{
"type": "node",
"request": "launch",
"name": "Jest: Subnets",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": [
"--testTimeout=3600000",
"--runInBand",
"--no-cache",
"--config",
"${workspaceRoot}/tests/jest.config.subnets.js"
],
"outputCapture": "std",
"console": "integratedTerminal",
"preLaunchTask": "deploy:subnets",
"postDebugTask": "stop:subnets",
"smartStep": false,
"sourceMaps": true,
},
{
"type": "node",
"request": "launch",

23
.vscode/tasks.json vendored
View File

@@ -22,6 +22,29 @@
},
"presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "dedicated", "clear": false }
},
{
"label": "deploy:subnets",
"type": "shell",
"command": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.subnets.yml up --force-recreate -V",
"isBackground": true,
"problemMatcher": {
"pattern": { "regexp": ".", "file": 1, "location": 2, "message": 3 },
"background": { "activeOnStart": true, "beginsPattern": ".", "endsPattern": "." }
},
"presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "dedicated", "clear": false }
},
{
"label": "stop:subnets",
"type": "shell",
"command": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.subnets.yml down -v -t 0",
"presentation": {
"echo": true,
"reveal": "silent",
"focus": false,
"panel": "shared",
"clear": false
}
},
{
"label": "deploy:krypton",
"type": "shell",

View File

@@ -0,0 +1,36 @@
version: '3.7'
services:
stacks-blockchain:
image: "hirosystems/stacks-api-e2e:stacks2.1-ecb1872"
command: |
bash -c "rm /event-log.ndjson && /root/run.sh"
ports:
- "18443:18443" # bitcoin regtest JSON-RPC interface
- "18444:18444" # bitcoin regtest p2p
- "20443:20443" # stacks-node RPC interface
- "20444:20444" # stacks-node p2p
environment:
MINE_INTERVAL: 1s
STACKS_EVENT_OBSERVER: host.docker.internal:30445
# STACKS_LOG_TRACE: 1
# STACKS_LOG_DEBUG: 1
extra_hosts:
- "host.docker.internal:host-gateway" # fixes `host.docker.internal` on linux hosts
stacks-subnet:
# restart: on-failure
#image: "hirosystems/stacks-subnets:206-merge-stretch"
image: "hirosystems/stacks-subnets@sha256:7e296cd319f81b87b5a9c11f4c478dafb122c114dc805d832d68f39be150af49"
command: subnet-node start --config=/app/config/Stacks-subnet.toml
ports:
- "30443:30443" # subnet-node RPC interface
- "30444:30444" # subnet-node p2p
- "30445:30445" # subnet-node event-observer
environment:
STACKS_EVENT_OBSERVER: host.docker.internal:3700
# STACKS_LOG_TRACE: 1
# STACKS_LOG_DEBUG: 1
volumes:
- ../stacks-blockchain/:/app/config
extra_hosts:
- "host.docker.internal:host-gateway" # fixes `host.docker.internal` on linux hosts

View File

@@ -8,6 +8,7 @@
"dev:integrated": "npm run devenv:build && concurrently npm:dev npm:devenv:deploy",
"dev:follower": "npm run devenv:build && concurrently npm:dev npm:devenv:follower",
"test": "cross-env NODE_ENV=development jest --config ./tests/jest.config.js --coverage --runInBand",
"test:subnets": "cross-env NODE_ENV=development jest --config ./tests/jest.config.subnets.js --coverage --runInBand",
"test:2.1": "cross-env NODE_ENV=development jest --config ./tests/jest.config.2.1.js --coverage --runInBand",
"test:2.1-transition": "cross-env NODE_ENV=development jest --config ./tests/jest.config.2.1-transition.js --coverage --runInBand",
"test:rosetta": "cross-env NODE_ENV=development jest --config ./tests/jest.config.rosetta.js --coverage --runInBand",
@@ -20,6 +21,7 @@
"test:tokens": "cross-env NODE_ENV=development jest --config ./tests/jest.config.tokens.js --coverage --runInBand",
"test:watch": "cross-env NODE_ENV=development jest --config ./tests/jest.config.js --watch",
"test:integration": "concurrently \"docker compose -f docker/docker-compose.dev.postgres.yml up --force-recreate -V\" \"cross-env NODE_ENV=development jest --config ./tests/jest.config.js --no-cache --runInBand; npm run devenv:stop:pg\"",
"test:integration:subnets": "concurrently --hide \"devenv:deploy:subnets\" \"npm:devenv:deploy:subnets\" \"cross-env NODE_ENV=development jest --config ./tests/jest.config.subnets.js --no-cache --runInBand; npm run devenv:stop:subnets\"",
"test:integration:2.1": "concurrently --hide \"devenv:deploy:2.1\" \"npm:devenv:deploy:2.1\" \"cross-env NODE_ENV=development jest --config ./tests/jest.config.2.1.js --no-cache --runInBand; npm run devenv:stop:2.1\"",
"test:integration:2.1-transition": "concurrently --hide \"devenv:deploy:2.1-transition\" \"npm:devenv:deploy:2.1-transition\" \"cross-env NODE_ENV=development jest --config ./tests/jest.config.2.1-transition.js --no-cache --runInBand; npm run devenv:stop:2.1-transition\"",
"test:integration:rosetta": "concurrently \"docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-blockchain.yml up --force-recreate -V\" \"cross-env NODE_ENV=development jest --config ./tests/jest.config.rosetta.js --no-cache --runInBand; npm run devenv:stop\"",
@@ -45,12 +47,15 @@
"devenv:deploy": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-blockchain.yml -f docker/docker-compose.dev.bitcoind.yml up --force-recreate -V",
"devenv:follower": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-blockchain-follower.yml up --force-recreate -V",
"devenv:stop": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-blockchain.yml -f docker/docker-compose.dev.bitcoind.yml down -v -t 0",
"devenv:deploy:subnets": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.subnets.yml up --force-recreate -V",
"devenv:stop:subnets": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.subnets.yml down -v -t 0",
"devenv:deploy:2.1": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-krypton.yml up --force-recreate -V",
"devenv:deploy:2.1-transition": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-krypton-2.1-transition.yml up --force-recreate -V",
"devenv:stop:2.1": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-krypton.yml down -v -t 0",
"devenv:stop:2.1-transition": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-krypton-2.1-transition.yml down -v -t 0",
"devenv:stop:pg": "docker compose -f docker/docker-compose.dev.postgres.yml down -v -t 0",
"devenv:logs": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-blockchain.yml -f docker/docker-compose.dev.bitcoind.yml logs -t -f",
"devenv:logs:subnets": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.subnets.yml logs -t -f",
"devenv:logs:2.1": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-krypton.yml logs -t -f",
"devenv:logs:2.1-transition": "docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.stacks-krypton-2.1-transition.yml logs -t -f"
},

View File

@@ -48,7 +48,7 @@ export interface StxTransferEvent extends CoreNodeEventBase {
};
}
interface StxMintEvent extends CoreNodeEventBase {
export interface StxMintEvent extends CoreNodeEventBase {
type: CoreNodeEventType.StxMintEvent;
stx_mint_event: {
recipient: string;
@@ -93,7 +93,7 @@ export interface NftTransferEvent extends CoreNodeEventBase {
};
}
interface NftMintEvent extends CoreNodeEventBase {
export interface NftMintEvent extends CoreNodeEventBase {
type: CoreNodeEventType.NftMintEvent;
nft_mint_event: {
/** Fully qualified asset ID, e.g. "ST2ZRX0K27GW0SP3GJCEMHD95TQGJMKB7G9Y0X1MH.contract-name.asset-name" */
@@ -128,7 +128,7 @@ interface FtTransferEvent extends CoreNodeEventBase {
};
}
interface FtMintEvent extends CoreNodeEventBase {
export interface FtMintEvent extends CoreNodeEventBase {
type: CoreNodeEventType.FtMintEvent;
ft_mint_event: {
/** Fully qualified asset ID, e.g. "ST2ZRX0K27GW0SP3GJCEMHD95TQGJMKB7G9Y0X1MH.contract-name.asset-name" */

View File

@@ -5,9 +5,12 @@ import {
CoreNodeMicroblockTxMessage,
CoreNodeParsedTxMessage,
CoreNodeTxMessage,
FtMintEvent,
isTxWithMicroblockInfo,
NftMintEvent,
SmartContractEvent,
StxLockEvent,
StxMintEvent,
StxTransferEvent,
} from './core-node-message';
import {
@@ -58,6 +61,8 @@ import {
SomeCV,
NoneCV,
UIntCV,
stringAsciiCV,
hexToCV,
} from '@stacks/transactions';
import { poxAddressToTuple } from '@stacks/stacking';
import { c32ToB58 } from 'c32check';
@@ -78,6 +83,172 @@ export function getTxSponsorAddress(tx: DecodedTxResult): string | undefined {
return sponsorAddress;
}
function createSubnetTransactionFromL1NftDeposit(
chainId: ChainID,
event: NftMintEvent,
txId: string
): DecodedTxResult {
const decRecipientAddress = decodeStacksAddress(event.nft_mint_event.recipient);
const [contractAddress, contractName] = event.nft_mint_event.asset_identifier
.split('::')[0]
.split('.');
const decContractAddress = decodeStacksAddress(contractAddress);
const legacyClarityVals = [
hexToCV(event.nft_mint_event.raw_value),
principalCV(event.nft_mint_event.recipient),
];
const fnLenBuffer = Buffer.alloc(4);
fnLenBuffer.writeUInt32BE(legacyClarityVals.length);
const serializedClarityValues = legacyClarityVals.map(c => serializeCV(c));
const rawFnArgs = bufferToHexPrefixString(
Buffer.concat([fnLenBuffer, ...serializedClarityValues])
);
const clarityFnArgs = decodeClarityValueList(rawFnArgs);
const tx: DecodedTxResult = {
tx_id: txId,
version: chainId === ChainID.Mainnet ? TransactionVersion.Mainnet : TransactionVersion.Testnet,
chain_id: chainId,
auth: {
type_id: PostConditionAuthFlag.Standard,
origin_condition: {
hash_mode: TxSpendingConditionSingleSigHashMode.P2PKH,
signer: {
address_version: decRecipientAddress[0],
address_hash_bytes: decRecipientAddress[1],
address: event.nft_mint_event.recipient,
},
nonce: '0',
tx_fee: '0',
key_encoding: TxPublicKeyEncoding.Compressed,
signature: '0x',
},
},
anchor_mode: AnchorModeID.Any,
post_condition_mode: PostConditionModeID.Allow,
post_conditions: [],
post_conditions_buffer: '0x0100000000',
payload: {
type_id: TxPayloadTypeID.ContractCall,
address_version: decContractAddress[0],
address_hash_bytes: decContractAddress[1],
address: contractAddress,
contract_name: contractName,
function_name: 'deposit-from-burnchain',
function_args: clarityFnArgs,
function_args_buffer: rawFnArgs,
},
};
return tx;
}
function createSubnetTransactionFromL1FtDeposit(
chainId: ChainID,
event: FtMintEvent,
txId: string
): DecodedTxResult {
const decRecipientAddress = decodeStacksAddress(event.ft_mint_event.recipient);
const [contractAddress, contractName] = event.ft_mint_event.asset_identifier
.split('::')[0]
.split('.');
const decContractAddress = decodeStacksAddress(contractAddress);
const legacyClarityVals = [
uintCV(event.ft_mint_event.amount),
principalCV(event.ft_mint_event.recipient),
];
const fnLenBuffer = Buffer.alloc(4);
fnLenBuffer.writeUInt32BE(legacyClarityVals.length);
const serializedClarityValues = legacyClarityVals.map(c => serializeCV(c));
const rawFnArgs = bufferToHexPrefixString(
Buffer.concat([fnLenBuffer, ...serializedClarityValues])
);
const clarityFnArgs = decodeClarityValueList(rawFnArgs);
const tx: DecodedTxResult = {
tx_id: txId,
version: chainId === ChainID.Mainnet ? TransactionVersion.Mainnet : TransactionVersion.Testnet,
chain_id: chainId,
auth: {
type_id: PostConditionAuthFlag.Standard,
origin_condition: {
hash_mode: TxSpendingConditionSingleSigHashMode.P2PKH,
signer: {
address_version: decRecipientAddress[0],
address_hash_bytes: decRecipientAddress[1],
address: event.ft_mint_event.recipient,
},
nonce: '0',
tx_fee: '0',
key_encoding: TxPublicKeyEncoding.Compressed,
signature: '0x',
},
},
anchor_mode: AnchorModeID.Any,
post_condition_mode: PostConditionModeID.Allow,
post_conditions: [],
post_conditions_buffer: '0x0100000000',
payload: {
type_id: TxPayloadTypeID.ContractCall,
address_version: decContractAddress[0],
address_hash_bytes: decContractAddress[1],
address: contractAddress,
contract_name: contractName,
function_name: 'deposit-from-burnchain',
function_args: clarityFnArgs,
function_args_buffer: rawFnArgs,
},
};
return tx;
}
function createSubnetTransactionFromL1StxDeposit(
chainId: ChainID,
event: StxMintEvent,
txId: string
): DecodedTxResult {
const recipientAddress = decodeStacksAddress(event.stx_mint_event.recipient);
const bootAddressString =
chainId === ChainID.Mainnet ? 'SP000000000000000000002Q6VF78' : 'ST000000000000000000002AMW42H';
const bootAddress = decodeStacksAddress(bootAddressString);
const tx: DecodedTxResult = {
tx_id: txId,
version: chainId === ChainID.Mainnet ? TransactionVersion.Mainnet : TransactionVersion.Testnet,
chain_id: chainId,
auth: {
type_id: PostConditionAuthFlag.Standard,
origin_condition: {
hash_mode: TxSpendingConditionSingleSigHashMode.P2PKH,
signer: {
address_version: bootAddress[0],
address_hash_bytes: bootAddress[1],
address: bootAddressString,
},
nonce: '0',
tx_fee: '0',
key_encoding: TxPublicKeyEncoding.Compressed,
signature: '0x',
},
},
anchor_mode: AnchorModeID.Any,
post_condition_mode: PostConditionModeID.Allow,
post_conditions: [],
post_conditions_buffer: '0x0100000000',
payload: {
type_id: TxPayloadTypeID.TokenTransfer,
recipient: {
type_id: PrincipalTypeID.Standard,
address_version: recipientAddress[0],
address_hash_bytes: recipientAddress[1],
address: event.stx_mint_event.recipient,
},
amount: BigInt(event.stx_mint_event.amount).toString(),
memo_hex: '0x',
},
};
return tx;
}
function createTransactionFromCoreBtcStxLockEvent(
chainId: ChainID,
event: StxLockEvent,
@@ -372,6 +543,15 @@ export function parseMessageTransaction(
const stxLockEvent = events.find(
(e): e is StxLockEvent => e.type === CoreNodeEventType.StxLockEvent
);
const nftMintEvent = events.find(
(e): e is NftMintEvent => e.type === CoreNodeEventType.NftMintEvent
);
const ftMintEvent = events.find(
(e): e is FtMintEvent => e.type === CoreNodeEventType.FtMintEvent
);
const stxMintEvent = events.find(
(e): e is StxMintEvent => e.type === CoreNodeEventType.StxMintEvent
);
const pox2Event = events
.filter(
@@ -419,11 +599,20 @@ export function parseMessageTransaction(
coreTx.txid
);
txSender = pox2Event.decodedEvent.stacker;
} else if (nftMintEvent) {
rawTx = createSubnetTransactionFromL1NftDeposit(chainId, nftMintEvent, coreTx.txid);
txSender = nftMintEvent.nft_mint_event.recipient;
} else if (ftMintEvent) {
rawTx = createSubnetTransactionFromL1FtDeposit(chainId, ftMintEvent, coreTx.txid);
txSender = ftMintEvent.ft_mint_event.recipient;
} else if (stxMintEvent) {
rawTx = createSubnetTransactionFromL1StxDeposit(chainId, stxMintEvent, coreTx.txid);
txSender = getTxSenderAddress(rawTx);
} else {
logError(
`BTC transaction found, but no STX transfer event available to recreate transaction. TX: ${JSON.stringify(
coreTx
)}`
)}, event: ${JSON.stringify(events)}`
);
throw new Error('Unable to generate transaction from BTC tx');
}

View File

@@ -180,6 +180,7 @@ export async function standByForPoxCycleEnd(): Promise<CoreRpcPoxInfo> {
}
export async function standByUntilBurnBlock(burnBlockHeight: number): Promise<DbBlock> {
let blockFound = false;
const dbBlock = await new Promise<DbBlock>(async resolve => {
const listener: (blockHash: string) => void = async blockHash => {
const dbBlockQuery = await testEnv.api.datastore.getBlock({ hash: blockHash });
@@ -187,21 +188,27 @@ export async function standByUntilBurnBlock(burnBlockHeight: number): Promise<Db
return;
}
testEnv.api.datastore.eventEmitter.removeListener('blockUpdate', listener);
blockFound = true;
resolve(dbBlockQuery.result);
};
testEnv.api.datastore.eventEmitter.addListener('blockUpdate', listener);
// Check if block height already reached
const curHeight = await testEnv.api.datastore.getCurrentBlock();
if (curHeight.found && curHeight.result.burn_block_height >= burnBlockHeight) {
const dbBlock = await testEnv.api.datastore.getBlock({
height: curHeight.result.block_height,
});
if (!dbBlock.found) {
throw new Error('Unhandled missing block');
while (!blockFound) {
const curHeight = await testEnv.api.datastore.getCurrentBlock();
if (curHeight.found && curHeight.result.burn_block_height >= burnBlockHeight) {
const dbBlock = await testEnv.api.datastore.getBlock({
height: curHeight.result.block_height,
});
if (!dbBlock.found) {
throw new Error('Unhandled missing block');
}
testEnv.api.datastore.eventEmitter.removeListener('blockUpdate', listener);
blockFound = true;
resolve(dbBlock.result);
} else {
await timeout(200);
}
testEnv.api.datastore.eventEmitter.removeListener('blockUpdate', listener);
resolve(dbBlock.result);
}
});
@@ -218,6 +225,7 @@ export async function standByUntilBurnBlock(burnBlockHeight: number): Promise<Db
}
export async function standByForTx(expectedTxId: string): Promise<DbTx> {
let txFound = false;
const tx = await new Promise<DbTx>(async resolve => {
const listener: (txId: string) => void = async txId => {
if (txId !== expectedTxId) {
@@ -231,18 +239,24 @@ export async function standByForTx(expectedTxId: string): Promise<DbTx> {
return;
}
testEnv.api.datastore.eventEmitter.removeListener('txUpdate', listener);
txFound = true;
resolve(dbTxQuery.result);
};
testEnv.api.datastore.eventEmitter.addListener('txUpdate', listener);
// Check if tx is already received
const dbTxQuery = await testEnv.api.datastore.getTx({
txId: expectedTxId,
includeUnanchored: false,
});
if (dbTxQuery.found) {
testEnv.api.datastore.eventEmitter.removeListener('txUpdate', listener);
resolve(dbTxQuery.result);
while (!txFound) {
// Check if tx is already received
const dbTxQuery = await testEnv.api.datastore.getTx({
txId: expectedTxId,
includeUnanchored: false,
});
if (dbTxQuery.found) {
testEnv.api.datastore.eventEmitter.removeListener('txUpdate', listener);
txFound = true;
resolve(dbTxQuery.result);
} else {
await timeout(200);
}
}
});
@@ -269,6 +283,7 @@ export async function standByForTxSuccess(expectedTxId: string): Promise<DbTx> {
}
export async function standByUntilBlock(blockHeight: number): Promise<DbBlock> {
let blockFound = false;
const dbBlock = await new Promise<DbBlock>(async resolve => {
const listener: (blockHash: string) => void = async blockHash => {
const dbBlockQuery = await testEnv.api.datastore.getBlock({ hash: blockHash });
@@ -276,20 +291,26 @@ export async function standByUntilBlock(blockHeight: number): Promise<DbBlock> {
return;
}
testEnv.api.datastore.eventEmitter.removeListener('blockUpdate', listener);
blockFound = true;
resolve(dbBlockQuery.result);
};
testEnv.api.datastore.eventEmitter.addListener('blockUpdate', listener);
// Check if block height already reached
const curHeight = await testEnv.api.datastore.getCurrentBlockHeight();
if (curHeight.found && curHeight.result >= blockHeight) {
const dbBlock = await testEnv.api.datastore.getBlock({ height: curHeight.result });
if (!dbBlock.found) {
throw new Error('Unhandled missing block');
while (!blockFound) {
const curHeight = await testEnv.api.datastore.getCurrentBlockHeight();
if (curHeight.found && curHeight.result >= blockHeight) {
const dbBlock = await testEnv.api.datastore.getBlock({ height: curHeight.result });
if (!dbBlock.found) {
throw new Error('Unhandled missing block');
}
testEnv.api.datastore.eventEmitter.removeListener('blockUpdate', listener);
blockFound = true;
resolve(dbBlock.result);
return;
} else {
await timeout(200);
}
testEnv.api.datastore.eventEmitter.removeListener('blockUpdate', listener);
resolve(dbBlock.result);
return;
}
});

View File

@@ -0,0 +1,63 @@
import { StacksTestnet } from '@stacks/network';
import { ChainID } from '@stacks/transactions';
import { RPCClient } from 'rpc-bitcoin';
import { startApiServer } from '../api/init';
import { StacksCoreRpcClient } from '../core-rpc/client';
import { PgWriteStore } from '../datastore/pg-write-store';
import { loadDotEnv } from '../helpers';
import { TestEnvContext } from '../test-utils/test-helpers';
let testEnv: TestEnvContext;
beforeAll(async () => {
console.log('Jest - setup..');
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = 'test';
}
loadDotEnv();
process.env.PG_DATABASE = 'postgres';
process.env.STACKS_CHAIN_ID = '0x80000000';
const db = await PgWriteStore.connect({ usageName: 'tests' });
const api = await startApiServer({ datastore: db, writeDatastore: db, chainId: ChainID.Testnet });
const client = new StacksCoreRpcClient();
const stacksNetwork = new StacksTestnet({ url: `http://${client.endpoint}` });
const { BTC_RPC_PORT, BTC_RPC_HOST, BTC_RPC_PW, BTC_RPC_USER } = process.env;
if (!BTC_RPC_PORT || !BTC_RPC_HOST || !BTC_RPC_PW || !BTC_RPC_USER) {
throw new Error('Bitcoin JSON-RPC env vars not fully configured.');
}
const bitcoinRpcClient = new RPCClient({
url: BTC_RPC_HOST,
port: Number(BTC_RPC_PORT),
user: BTC_RPC_USER,
pass: BTC_RPC_PW,
timeout: 120000,
wallet: '',
});
testEnv = {
db,
api,
client,
stacksNetwork,
bitcoinRpcClient,
};
Object.assign(global, { testEnv });
console.log('Jest - setup done');
});
afterAll(async () => {
process.on('uncaughtException', (error, origin) => {
console.error(`____[env-setup] uncaughtException: ${error}, ${origin}`);
});
process.on('unhandledRejection', (reason, promise) => {
console.error(`____[env-setup] unhandledRejection: ${reason}, ${promise}`);
});
console.log('Jest - teardown..');
await testEnv.api.terminate();
await testEnv.db?.close();
console.log('Jest - teardown done');
});

View File

@@ -0,0 +1,74 @@
import { loadDotEnv, timeout } from '../helpers';
import { StacksCoreRpcClient } from '../core-rpc/client';
import { PgWriteStore } from '../datastore/pg-write-store';
import { cycleMigrations } from '../datastore/migrations';
import { EventStreamServer, startEventServer } from '../event-stream/event-server';
import { ApiServer, startApiServer } from '../api/init';
import { ChainID } from '@stacks/transactions';
import { StacksNetwork, StacksTestnet } from '@stacks/network';
export interface GlobalTestEnv {
db: PgWriteStore;
eventServer: EventStreamServer;
}
async function standByForInfoToBeReady(client: StacksCoreRpcClient): Promise<void> {
let tries = 0;
while (true) {
try {
tries++;
await client.getInfo();
return;
await timeout(500);
} catch (error) {
console.log(`Waiting on /v2/info to be ready, retrying after ${error}`);
await timeout(500);
}
}
}
async function standByForPox2ToBeReady(client: StacksCoreRpcClient): Promise<void> {
let tries = 0;
while (true) {
try {
tries++;
const poxInfo = await client.getPox();
if (poxInfo.contract_id.includes('pox-2')) {
return;
}
await timeout(500);
} catch (error) {
console.log(`Waiting on PoX-2 to be ready, retrying after ${error}`);
await timeout(500);
}
}
}
// ts-unused-exports:disable-next-line
export default async (): Promise<void> => {
console.log('Jest - global setup..');
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = 'test';
}
loadDotEnv();
process.env.PG_DATABASE = 'postgres';
process.env.STACKS_CHAIN_ID = '0x80000000';
const db = await PgWriteStore.connect({ usageName: 'tests', withNotifier: false });
await cycleMigrations();
const eventServer = await startEventServer({ datastore: db, chainId: ChainID.Testnet });
const subnetClient = new StacksCoreRpcClient();
await standByForInfoToBeReady(subnetClient);
const l1Client = new StacksCoreRpcClient({ port: 20443 });
await standByForPox2ToBeReady(l1Client);
const testEnv: GlobalTestEnv = {
db: db,
eventServer: eventServer,
};
Object.assign(global, { globalTestEnv: testEnv });
console.log('Jest - global setup done');
};

View File

@@ -0,0 +1,19 @@
import type { GlobalTestEnv } from './global-setup';
// ts-unused-exports:disable-next-line
export default async (): Promise<void> => {
process.on('uncaughtException', (error, origin) => {
console.error(`____[global-teardown] uncaughtException: ${error}, ${origin}`);
});
process.on('unhandledRejection', (reason, promise) => {
console.error(`____[global-teardown] unhandledRejection: ${reason}, ${promise}`);
});
console.log('Jest - global teardown..');
const testEnv: GlobalTestEnv = (global as any).globalTestEnv;
await testEnv.eventServer.closeAsync();
await testEnv.db.close();
console.log('Jest - global teardown done');
};

View File

@@ -0,0 +1,15 @@
(define-trait nft-trait
(
;; Last token ID, limited to uint range
(get-last-token-id () (response uint uint))
;; URI for metadata associated with the token
(get-token-uri (uint) (response (optional (string-ascii 256)) uint))
;; Owner of a given token identifier
(get-owner (uint) (response (optional principal) uint))
;; Transfer from the sender to a new principal
(transfer (uint principal principal) (response bool uint))
)
)

View File

@@ -0,0 +1,57 @@
(define-constant ERR_NOT_AUTHORIZED (err u1001))
(impl-trait 'STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP.sip-traits.ft-trait)
(impl-trait 'STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP.subnet-traits.mint-from-subnet-trait)
(define-fungible-token ft-token)
;; get the token balance of owner
(define-read-only (get-balance (owner principal))
(begin
(ok (ft-get-balance ft-token owner))))
;; returns the total number of tokens
(define-read-only (get-total-supply)
(ok (ft-get-supply ft-token)))
;; returns the token name
(define-read-only (get-name)
(ok "ft-token"))
;; the symbol or "ticker" for this token
(define-read-only (get-symbol)
(ok "EXFT"))
;; the number of decimals used
(define-read-only (get-decimals)
(ok u0))
;; Implement mint-from-subnet trait
(define-public (mint-from-subnet (amount uint) (sender principal) (recipient principal))
(begin
;; Check that the tx-sender is the provided sender
(asserts! (is-eq tx-sender sender) ERR_NOT_AUTHORIZED)
(ft-mint? ft-token amount recipient)
)
)
;; Transfers tokens to a recipient
(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34))))
(begin
(try! (ft-transfer? ft-token amount sender recipient))
(print memo)
(ok true)
)
)
(define-read-only (get-token-uri)
(ok none))
(define-public (gift-tokens (recipient principal))
(begin
(asserts! (is-eq tx-sender recipient) ERR_NOT_AUTHORIZED)
(ft-mint? ft-token u1 recipient)
)
)

View File

@@ -0,0 +1,55 @@
(define-constant CONTRACT_OWNER tx-sender)
(define-constant CONTRACT_ADDRESS (as-contract tx-sender))
(define-constant ERR_NOT_AUTHORIZED (err u1001))
(impl-trait 'STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP.nft-trait.nft-trait)
(impl-trait 'STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP.subnet-traits.mint-from-subnet-trait)
(define-data-var lastId uint u0)
(define-map CFG_BASE_URI bool (string-ascii 256))
(define-non-fungible-token nft-token uint)
(define-read-only (get-last-token-id)
(ok (var-get lastId))
)
(define-read-only (get-owner (id uint))
(ok (nft-get-owner? nft-token id))
)
(define-read-only (get-token-uri (id uint))
(ok (map-get? CFG_BASE_URI true))
)
(define-public (transfer (id uint) (sender principal) (recipient principal))
(begin
(asserts! (is-eq tx-sender sender) ERR_NOT_AUTHORIZED)
(nft-transfer? nft-token id sender recipient)
)
)
;; test functions
(define-public (test-mint (recipient principal))
(let
((newId (+ (var-get lastId) u1)))
(var-set lastId newId)
(nft-mint? nft-token newId recipient)
)
)
(define-public (mint-from-subnet (id uint) (sender principal) (recipient principal))
(begin
;; Check that the tx-sender is the provided sender
(asserts! (is-eq tx-sender sender) ERR_NOT_AUTHORIZED)
(nft-mint? nft-token id recipient)
)
)
(define-public (gift-nft (recipient principal) (id uint))
(begin
(nft-mint? nft-token id recipient)
)
)

View File

@@ -0,0 +1,24 @@
(define-trait sip-010-trait
(
;; Transfer from the caller to a new principal
(transfer (uint principal principal (optional (buff 34))) (response bool uint))
;; the human readable name of the token
(get-name () (response (string-ascii 32) uint))
;; the ticker symbol, or empty if none
(get-symbol () (response (string-ascii 32) uint))
;; the number of decimals used, e.g. 6 would mean 1_000_000 represents 1 token
(get-decimals () (response uint uint))
;; the balance of the passed principal
(get-balance (principal) (response uint uint))
;; the current total supply (which does not need to be a constant)
(get-total-supply () (response uint uint))
;; an optional URI that represents metadata of this token
(get-token-uri () (response (optional (string-utf8 256)) uint))
)
)

View File

@@ -0,0 +1,40 @@
(define-trait nft-trait
(
;; Last token ID, limited to uint range
(get-last-token-id () (response uint uint))
;; URI for metadata associated with the token
(get-token-uri (uint) (response (optional (string-ascii 256)) uint))
;; Owner of a given token identifier
(get-owner (uint) (response (optional principal) uint))
;; Transfer from the sender to a new principal
(transfer (uint principal principal) (response bool uint))
)
)
(define-trait ft-trait
(
;; Transfer from the caller to a new principal
(transfer (uint principal principal (optional (buff 34))) (response bool uint))
;; the human readable name of the token
(get-name () (response (string-ascii 32) uint))
;; the ticker symbol, or empty if none
(get-symbol () (response (string-ascii 32) uint))
;; the number of decimals used, e.g. 6 would mean 1_000_000 represents 1 token
(get-decimals () (response uint uint))
;; the balance of the passed principal
(get-balance (principal) (response uint uint))
;; the current total supply (which does not need to be a constant)
(get-total-supply () (response uint uint))
;; an optional URI that represents metadata of this token
(get-token-uri () (response (optional (string-utf8 256)) uint))
)
)

View File

@@ -0,0 +1,16 @@
;; In order to process deposits and withdrawals to a subnet, an asset
;; contract must implement this trait.
(define-trait mint-from-subnet-trait
(
;; Process a withdrawal from the subnet for an asset which does not yet
;; exist on this network, and thus requires a mint.
(mint-from-subnet
(
uint ;; asset-id (NFT) or amount (FT)
principal ;; sender
principal ;; recipient
)
(response bool uint)
)
)
)

View File

@@ -0,0 +1,782 @@
;; The .subnet contract
(define-constant CONTRACT_ADDRESS (as-contract tx-sender))
;; Error codes
(define-constant ERR_BLOCK_ALREADY_COMMITTED 1)
(define-constant ERR_INVALID_MINER 2)
(define-constant ERR_CONTRACT_CALL_FAILED 3)
(define-constant ERR_TRANSFER_FAILED 4)
(define-constant ERR_DISALLOWED_ASSET 5)
(define-constant ERR_ASSET_ALREADY_ALLOWED 6)
(define-constant ERR_MERKLE_ROOT_DOES_NOT_MATCH 7)
(define-constant ERR_INVALID_MERKLE_ROOT 8)
(define-constant ERR_WITHDRAWAL_ALREADY_PROCESSED 9)
(define-constant ERR_VALIDATION_FAILED 10)
;;; The value supplied for `target-chain-tip` does not match the current chain tip.
(define-constant ERR_INVALID_CHAIN_TIP 11)
;;; The contract was called before reaching this-chain height reaches 1.
(define-constant ERR_CALLED_TOO_EARLY 12)
(define-constant ERR_MINT_FAILED 13)
(define-constant ERR_ATTEMPT_TO_TRANSFER_ZERO_AMOUNT 14)
(define-constant ERR_IN_COMPUTATION 15)
;; The contract does not own this NFT to withdraw it.
(define-constant ERR_NFT_NOT_OWNED_BY_CONTRACT 16)
(define-constant ERR_VALIDATION_LEAF_FAILED 30)
;; Map from Stacks block height to block commit
(define-map block-commits uint (buff 32))
;; Map recording withdrawal roots
(define-map withdrawal-roots-map (buff 32) bool)
;; Map recording processed withdrawal leaves
(define-map processed-withdrawal-leaves-map { withdrawal-leaf-hash: (buff 32), withdrawal-root-hash: (buff 32) } bool)
;; List of miners
(define-data-var miner principal 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6)
;; Map of allowed contracts for asset transfers - maps L1 contract principal to L2 contract principal
(define-map allowed-contracts principal principal)
;; Use trait declarations
(use-trait nft-trait 'STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP.nft-trait.nft-trait)
(use-trait ft-trait 'STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP.sip-010-trait-ft-standard.sip-010-trait)
(use-trait mint-from-subnet-trait .subnet-traits.mint-from-subnet-trait)
;; Update the miner for this contract.
(define-public (update-miner (new-miner principal))
(begin
(asserts! (is-eq tx-sender (var-get miner)) (err ERR_INVALID_MINER))
(ok (var-set miner new-miner))
)
)
;; Register a new FT contract to be supported by this subnet.
(define-public (register-new-ft-contract (ft-contract <ft-trait>) (l2-contract principal))
(begin
;; Verify that tx-sender is an authorized miner
(asserts! (is-miner tx-sender) (err ERR_INVALID_MINER))
;; Set up the assets that the contract is allowed to transfer
(asserts! (map-insert allowed-contracts (contract-of ft-contract) l2-contract)
(err ERR_ASSET_ALREADY_ALLOWED))
(ok true)
)
)
;; Register a new NFT contract to be supported by this subnet.
(define-public (register-new-nft-contract (nft-contract <nft-trait>) (l2-contract principal))
(begin
;; Verify that tx-sender is an authorized miner
(asserts! (is-miner tx-sender) (err ERR_INVALID_MINER))
;; Set up the assets that the contract is allowed to transfer
(asserts! (map-insert allowed-contracts (contract-of nft-contract) l2-contract)
(err ERR_ASSET_ALREADY_ALLOWED))
(ok true)
)
)
;; Helper function: returns a boolean indicating whether the given principal is a miner
;; Returns bool
(define-private (is-miner (miner-to-check principal))
(is-eq miner-to-check (var-get miner))
)
;; Helper function: determines whether the commit-block operation satisfies pre-conditions
;; listed in `commit-block`.
;; Returns response<bool, int>
(define-private (can-commit-block? (commit-block-height uint) (target-chain-tip (buff 32)))
(begin
;; check no block has been committed at this height
(asserts! (is-none (map-get? block-commits commit-block-height)) (err ERR_BLOCK_ALREADY_COMMITTED))
;; check that `target-chain-tip` matches the burn chain tip
(asserts! (is-eq
target-chain-tip
(unwrap! (get-block-info? id-header-hash (- block-height u1)) (err ERR_CALLED_TOO_EARLY)) )
(err ERR_INVALID_CHAIN_TIP))
;; check that the tx sender is one of the miners
(asserts! (is-miner tx-sender) (err ERR_INVALID_MINER))
;; check that the miner called this contract directly
(asserts! (is-miner contract-caller) (err ERR_INVALID_MINER))
(ok true)
)
)
;; Helper function: modifies the block-commits map with a new commit and prints related info
;; Returns response<(buff 32), ?>
(define-private (inner-commit-block (block (buff 32)) (commit-block-height uint) (withdrawal-root (buff 32)))
(begin
(map-set block-commits commit-block-height block)
(map-set withdrawal-roots-map withdrawal-root true)
(print {
event: "block-commit",
block-commit: block,
withdrawal-root: withdrawal-root,
block-height: commit-block-height
})
(ok block)
)
)
;; The subnet miner calls this function to commit a block at a particular height.
;; `block` is the hash of the block being submitted.
;; `target-chain-tip` is the `id-header-hash` of the burn block (i.e., block on
;; this chain) that the miner intends to build off.
;;
;; Fails if:
;; 1) we have already committed at this block height
;; 2) `target-chain-tip` is not the burn chain tip (i.e., on this chain)
;; 3) the sender is not a miner
(define-public (commit-block (block (buff 32)) (target-chain-tip (buff 32)) (withdrawal-root (buff 32)))
(let ((commit-block-height block-height))
(try! (can-commit-block? commit-block-height target-chain-tip))
(inner-commit-block block commit-block-height withdrawal-root)
)
)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FOR NFT ASSET TRANSFERS
;; Helper function that transfers the specified NFT from the given sender to the given recipient.
;; Returns response<bool, int>
(define-private (inner-transfer-nft-asset
(nft-contract <nft-trait>)
(id uint)
(sender principal)
(recipient principal)
)
(let (
(call-result (contract-call? nft-contract transfer id sender recipient))
(transfer-result (unwrap! call-result (err ERR_CONTRACT_CALL_FAILED)))
)
;; Check that the transfer succeeded
(asserts! transfer-result (err ERR_TRANSFER_FAILED))
(ok true)
)
)
(define-private (inner-mint-nft-asset
(nft-mint-contract <mint-from-subnet-trait>)
(id uint)
(sender principal)
(recipient principal)
)
(let (
(call-result (as-contract (contract-call? nft-mint-contract mint-from-subnet id sender recipient)))
(mint-result (unwrap! call-result (err ERR_CONTRACT_CALL_FAILED)))
)
;; Check that the transfer succeeded
(asserts! mint-result (err ERR_MINT_FAILED))
(ok true)
)
)
(define-private (inner-transfer-or-mint-nft-asset
(nft-contract <nft-trait>)
(nft-mint-contract <mint-from-subnet-trait>)
(id uint)
(recipient principal)
)
(let (
(call-result (contract-call? nft-contract get-owner id))
(nft-owner (unwrap! call-result (err ERR_CONTRACT_CALL_FAILED)))
(contract-owns-nft (is-eq nft-owner (some CONTRACT_ADDRESS)))
(no-owner (is-eq nft-owner none))
)
(if contract-owns-nft
(inner-transfer-nft-asset nft-contract id CONTRACT_ADDRESS recipient)
(if no-owner
;; Try minting the asset if there is no existing owner of this NFT
(inner-mint-nft-asset nft-mint-contract id CONTRACT_ADDRESS recipient)
;; In this case, a principal other than this contract owns this NFT, so minting is not possible
(err ERR_MINT_FAILED)
)
)
)
)
;; A user calls this function to deposit an NFT into the contract.
;; The function emits a print with details of this event.
;; Returns response<bool, int>
(define-public (deposit-nft-asset
(nft-contract <nft-trait>)
(id uint)
(sender principal)
)
(let (
;; Check that the asset belongs to the allowed-contracts map
(subnet-contract-id (unwrap! (map-get? allowed-contracts (contract-of nft-contract)) (err ERR_DISALLOWED_ASSET)))
)
;; Try to transfer the NFT to this contract
(asserts! (try! (inner-transfer-nft-asset nft-contract id sender CONTRACT_ADDRESS)) (err ERR_TRANSFER_FAILED))
;; Emit a print event - the node consumes this
(print {
event: "deposit-nft",
l1-contract-id: (as-contract nft-contract),
nft-id: id,
sender: sender,
subnet-contract-id: subnet-contract-id,
})
(ok true)
)
)
;; Helper function for `withdraw-nft-asset`
;; Returns response<bool, int>
(define-public (inner-withdraw-nft-asset
(nft-contract <nft-trait>)
(l2-contract principal)
(id uint)
(recipient principal)
(withdrawal-id uint)
(height uint)
(nft-mint-contract (optional <mint-from-subnet-trait>))
(withdrawal-root (buff 32))
(withdrawal-leaf-hash (buff 32))
(sibling-hashes (list 50 {
hash: (buff 32),
is-left-side: bool,
}))
)
(let ((hashes-are-valid (check-withdrawal-hashes withdrawal-root withdrawal-leaf-hash sibling-hashes)))
(asserts! (try! hashes-are-valid) (err ERR_VALIDATION_FAILED))
;; check that the withdrawal request data matches the supplied leaf hash
(asserts! (is-eq withdrawal-leaf-hash
(leaf-hash-withdraw-nft l2-contract id recipient withdrawal-id height))
(err ERR_VALIDATION_LEAF_FAILED))
(asserts!
(try!
(match nft-mint-contract
mint-contract (as-contract (inner-transfer-or-mint-nft-asset nft-contract mint-contract id recipient))
(as-contract (inner-transfer-without-mint-nft-asset nft-contract id recipient))
)
)
(err ERR_TRANSFER_FAILED)
)
(asserts!
(finish-withdraw { withdrawal-leaf-hash: withdrawal-leaf-hash, withdrawal-root-hash: withdrawal-root })
(err ERR_WITHDRAWAL_ALREADY_PROCESSED)
)
(ok true)
)
)
;; A user calls this function to withdraw the specified NFT from this contract.
;; In order for this withdrawal to go through, the given withdrawal must have been included
;; in a withdrawal Merkle tree a subnet miner submitted. The user must provide the leaf
;; hash of their withdrawal and the root hash of the specific Merkle tree their withdrawal
;; is included in. They must also provide a list of sibling hashes. The withdraw function
;; uses the provided hashes to ensure the requested withdrawal is valid.
;; The function emits a print with details of this event.
;; Returns response<bool, int>
(define-public (withdraw-nft-asset
(nft-contract <nft-trait>)
(id uint)
(recipient principal)
(withdrawal-id uint)
(height uint)
(nft-mint-contract (optional <mint-from-subnet-trait>))
(withdrawal-root (buff 32))
(withdrawal-leaf-hash (buff 32))
(sibling-hashes (list 50 {
hash: (buff 32),
is-left-side: bool,
}))
)
(let (
;; Check that the asset belongs to the allowed-contracts map
(l2-contract (unwrap! (map-get? allowed-contracts (contract-of nft-contract)) (err ERR_DISALLOWED_ASSET)))
)
(asserts!
(try! (inner-withdraw-nft-asset
nft-contract
l2-contract
id
recipient
withdrawal-id
height
nft-mint-contract
withdrawal-root
withdrawal-leaf-hash
sibling-hashes
))
(err ERR_TRANSFER_FAILED)
)
;; Emit a print event
(print {
event: "withdraw-nft",
l1-contract-id: (as-contract nft-contract),
nft-id: id,
recipient: recipient
})
(ok true)
)
)
;; Like `inner-transfer-or-mint-nft-asset but without allowing or requiring a mint function. In order to withdraw, the user must
;; have the appropriate balance.
(define-private (inner-transfer-without-mint-nft-asset
(nft-contract <nft-trait>)
(id uint)
(recipient principal)
)
(let (
(call-result (contract-call? nft-contract get-owner id))
(nft-owner (unwrap! call-result (err ERR_CONTRACT_CALL_FAILED)))
(contract-owns-nft (is-eq nft-owner (some CONTRACT_ADDRESS)))
)
(asserts! contract-owns-nft (err ERR_NFT_NOT_OWNED_BY_CONTRACT))
(inner-transfer-nft-asset nft-contract id CONTRACT_ADDRESS recipient)
)
)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FOR FUNGIBLE TOKEN ASSET TRANSFERS
;; Helper function that transfers a specified amount of the fungible token from the given sender to the given recipient.
;; Returns response<bool, int>
(define-private (inner-transfer-ft-asset
(ft-contract <ft-trait>)
(amount uint)
(sender principal)
(recipient principal)
(memo (optional (buff 34)))
)
(let (
(call-result (contract-call? ft-contract transfer amount sender recipient memo))
(transfer-result (unwrap! call-result (err ERR_CONTRACT_CALL_FAILED)))
)
;; FIXME: SIP-010 doesn't require that transfer returns (ok true) on success, so is this check necessary?
;; Check that the transfer succeeded
(asserts! transfer-result (err ERR_TRANSFER_FAILED))
(ok true)
)
)
(define-private (inner-mint-ft-asset
(ft-mint-contract <mint-from-subnet-trait>)
(amount uint)
(sender principal)
(recipient principal)
)
(let (
(call-result (as-contract (contract-call? ft-mint-contract mint-from-subnet amount sender recipient)))
(mint-result (unwrap! call-result (err ERR_CONTRACT_CALL_FAILED)))
)
;; Check that the transfer succeeded
(asserts! mint-result (err ERR_MINT_FAILED))
(ok true)
)
)
(define-private (inner-transfer-or-mint-ft-asset
(ft-contract <ft-trait>)
(ft-mint-contract <mint-from-subnet-trait>)
(amount uint)
(recipient principal)
(memo (optional (buff 34)))
)
(let (
(call-result (contract-call? ft-contract get-balance CONTRACT_ADDRESS))
(contract-ft-balance (unwrap! call-result (err ERR_CONTRACT_CALL_FAILED)))
(contract-owns-enough (>= contract-ft-balance amount))
(amount-to-transfer (if contract-owns-enough amount contract-ft-balance))
(amount-to-mint (- amount amount-to-transfer))
)
;; Check that the total balance between the transfer and mint is equal to the original balance
(asserts! (is-eq amount (+ amount-to-transfer amount-to-mint)) (err ERR_IN_COMPUTATION))
(and
(> amount-to-transfer u0)
(try! (inner-transfer-ft-asset ft-contract amount-to-transfer CONTRACT_ADDRESS recipient memo))
)
(and
(> amount-to-mint u0)
(try! (inner-mint-ft-asset ft-mint-contract amount-to-mint CONTRACT_ADDRESS recipient))
)
(ok true)
)
)
;; A user calls this function to deposit a fungible token into the contract.
;; The function emits a print with details of this event.
;; Returns response<bool, int>
(define-public (deposit-ft-asset
(ft-contract <ft-trait>)
(amount uint)
(sender principal)
(memo (optional (buff 34)))
)
(let (
;; Check that the asset belongs to the allowed-contracts map
(subnet-contract-id (unwrap! (map-get? allowed-contracts (contract-of ft-contract)) (err ERR_DISALLOWED_ASSET)))
)
;; Try to transfer the FT to this contract
(asserts! (try! (inner-transfer-ft-asset ft-contract amount sender CONTRACT_ADDRESS memo)) (err ERR_TRANSFER_FAILED))
(let (
(ft-name (unwrap! (contract-call? ft-contract get-name) (err ERR_CONTRACT_CALL_FAILED)))
)
;; Emit a print event - the node consumes this
(print {
event: "deposit-ft",
l1-contract-id: (as-contract ft-contract),
ft-name: ft-name,
ft-amount: amount,
sender: sender,
subnet-contract-id: subnet-contract-id,
})
)
(ok true)
)
)
;; This function performs validity checks related to the withdrawal and performs the withdrawal as well.
;; Returns response<bool, int>
(define-private (inner-withdraw-ft-asset
(ft-contract <ft-trait>)
(amount uint)
(recipient principal)
(withdrawal-id uint)
(height uint)
(memo (optional (buff 34)))
(ft-mint-contract (optional <mint-from-subnet-trait>))
(withdrawal-root (buff 32))
(withdrawal-leaf-hash (buff 32))
(sibling-hashes (list 50 {
hash: (buff 32),
is-left-side: bool,
}))
)
(let ((hashes-are-valid (check-withdrawal-hashes withdrawal-root withdrawal-leaf-hash sibling-hashes)))
(asserts! (try! hashes-are-valid) (err ERR_VALIDATION_FAILED))
;; check that the withdrawal request data matches the supplied leaf hash
(asserts! (is-eq withdrawal-leaf-hash
(leaf-hash-withdraw-ft (contract-of ft-contract) amount recipient withdrawal-id height))
(err ERR_VALIDATION_LEAF_FAILED))
(asserts!
(try!
(match ft-mint-contract
mint-contract (as-contract (inner-transfer-or-mint-ft-asset ft-contract mint-contract amount recipient memo))
(as-contract (inner-transfer-ft-asset ft-contract amount CONTRACT_ADDRESS recipient memo))
)
)
(err ERR_TRANSFER_FAILED)
)
(asserts!
(finish-withdraw { withdrawal-leaf-hash: withdrawal-leaf-hash, withdrawal-root-hash: withdrawal-root })
(err ERR_WITHDRAWAL_ALREADY_PROCESSED))
(ok true)
)
)
;; A user can call this function to withdraw some amount of a fungible token asset from the
;; contract and send it to a recipient.
;; In order for this withdrawal to go through, the given withdrawal must have been included
;; in a withdrawal Merkle tree a subnet miner submitted. The user must provide the leaf
;; hash of their withdrawal and the root hash of the specific Merkle tree their withdrawal
;; is included in. They must also provide a list of sibling hashes. The withdraw function
;; uses the provided hashes to ensure the requested withdrawal is valid.
;; The function emits a print with details of this event.
;; Returns response<bool, int>
(define-public (withdraw-ft-asset
(ft-contract <ft-trait>)
(amount uint)
(recipient principal)
(withdrawal-id uint)
(height uint)
(memo (optional (buff 34)))
(ft-mint-contract (optional <mint-from-subnet-trait>))
(withdrawal-root (buff 32))
(withdrawal-leaf-hash (buff 32))
(sibling-hashes (list 50 {
hash: (buff 32),
is-left-side: bool,
}))
)
(begin
;; Check that the withdraw amount is positive
(asserts! (> amount u0) (err ERR_ATTEMPT_TO_TRANSFER_ZERO_AMOUNT))
;; Check that the asset belongs to the allowed-contracts map
(unwrap! (map-get? allowed-contracts (contract-of ft-contract)) (err ERR_DISALLOWED_ASSET))
(asserts!
(try! (inner-withdraw-ft-asset
ft-contract
amount
recipient
withdrawal-id
height
memo
ft-mint-contract
withdrawal-root
withdrawal-leaf-hash
sibling-hashes))
(err ERR_TRANSFER_FAILED)
)
(let (
(ft-name (unwrap! (contract-call? ft-contract get-name) (err ERR_CONTRACT_CALL_FAILED)))
)
;; Emit a print event
(print {
event: "withdraw-ft",
l1-contract-id: (as-contract ft-contract),
ft-name: ft-name,
ft-amount: amount,
recipient: recipient,
})
)
(ok true)
)
)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FOR STX TRANSFERS
;; Helper function that transfers the given amount from the specified fungible token from the given sender to the given recipient.
;; Returns response<bool, int>
(define-private (inner-transfer-stx (amount uint) (sender principal) (recipient principal))
(let (
(call-result (stx-transfer? amount sender recipient))
(transfer-result (unwrap! call-result (err ERR_TRANSFER_FAILED)))
)
;; Check that the transfer succeeded
(asserts! transfer-result (err ERR_TRANSFER_FAILED))
(ok true)
)
)
;; A user calls this function to deposit STX into the contract.
;; The function emits a print with details of this event.
;; Returns response<bool, int>
(define-public (deposit-stx (amount uint) (sender principal))
(begin
;; Try to transfer the STX to this contract
(asserts! (try! (inner-transfer-stx amount sender CONTRACT_ADDRESS)) (err ERR_TRANSFER_FAILED))
;; Emit a print event - the node consumes this
(print { event: "deposit-stx", sender: sender, amount: amount })
(ok true)
)
)
(define-read-only (leaf-hash-withdraw-stx
(amount uint)
(recipient principal)
(withdrawal-id uint)
(height uint)
)
(sha512/256 (concat 0x00 (unwrap-panic (to-consensus-buff?
{
type: "stx",
amount: amount,
recipient: recipient,
withdrawal-id: withdrawal-id,
height: height
})))
)
)
(define-read-only (leaf-hash-withdraw-nft
(asset-contract principal)
(nft-id uint)
(recipient principal)
(withdrawal-id uint)
(height uint)
)
(sha512/256 (concat 0x00 (unwrap-panic (to-consensus-buff?
{
type: "nft",
nft-id: nft-id,
asset-contract: asset-contract,
recipient: recipient,
withdrawal-id: withdrawal-id,
height: height
})))
)
)
(define-read-only (leaf-hash-withdraw-ft
(asset-contract principal)
(amount uint)
(recipient principal)
(withdrawal-id uint)
(height uint)
)
(sha512/256 (concat 0x00 (unwrap-panic (to-consensus-buff?
{
type: "ft",
amount: amount,
asset-contract: asset-contract,
recipient: recipient,
withdrawal-id: withdrawal-id,
height: height
})))
)
)
;; A user calls this function to withdraw STX from this contract.
;; In order for this withdrawal to go through, the given withdrawal must have been included
;; in a withdrawal Merkle tree a subnet miner submitted. The user must provide the leaf
;; hash of their withdrawal and the root hash of the specific Merkle tree their withdrawal
;; is included in. They must also provide a list of sibling hashes. The withdraw function
;; uses the provided hashes to ensure the requested withdrawal is valid.
;; The function emits a print with details of this event.
;; Returns response<bool, int>
(define-public (withdraw-stx
(amount uint)
(recipient principal)
(withdrawal-id uint)
(height uint)
(withdrawal-root (buff 32))
(withdrawal-leaf-hash (buff 32))
(sibling-hashes (list 50 {
hash: (buff 32),
is-left-side: bool,
}))
)
(let ((hashes-are-valid (check-withdrawal-hashes withdrawal-root withdrawal-leaf-hash sibling-hashes)))
(asserts! (try! hashes-are-valid) (err ERR_VALIDATION_FAILED))
;; check that the withdrawal request data matches the supplied leaf hash
(asserts! (is-eq withdrawal-leaf-hash
(leaf-hash-withdraw-stx amount recipient withdrawal-id height))
(err ERR_VALIDATION_LEAF_FAILED))
(asserts! (try! (as-contract (inner-transfer-stx amount tx-sender recipient))) (err ERR_TRANSFER_FAILED))
(asserts!
(finish-withdraw { withdrawal-leaf-hash: withdrawal-leaf-hash, withdrawal-root-hash: withdrawal-root })
(err ERR_WITHDRAWAL_ALREADY_PROCESSED))
;; Emit a print event
(print { event: "withdraw-stx", recipient: recipient, amount: amount })
(ok true)
)
)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GENERAL WITHDRAWAL FUNCTIONS
;; This function concats the two given hashes in the correct order. It also prepends the buff `0x01`, which is
;; a tag denoting a node (versus a leaf).
;; Returns a buff
(define-private (create-node-hash
(curr-hash (buff 32))
(sibling-hash (buff 32))
(is-sibling-left-side bool)
)
(let (
(concatted-hash (if is-sibling-left-side
(concat sibling-hash curr-hash)
(concat curr-hash sibling-hash)
))
)
(concat 0x01 concatted-hash)
)
)
;; This function hashes the curr hash with its sibling hash.
;; Returns (buff 32)
(define-private (hash-help
(sibling {
hash: (buff 32),
is-left-side: bool,
})
(curr-node-hash (buff 32))
)
(let (
(sibling-hash (get hash sibling))
(is-sibling-left-side (get is-left-side sibling))
(new-buff (create-node-hash curr-node-hash sibling-hash is-sibling-left-side))
)
(sha512/256 new-buff)
)
)
;; This function checks:
;; - That the provided withdrawal root matches a previously submitted one (passed to the function `commit-block`)
;; - That the computed withdrawal root matches a previous valid withdrawal root
;; - That the given withdrawal leaf hash has not been previously processed
;; Returns response<bool, int>
(define-private (check-withdrawal-hashes
(withdrawal-root (buff 32))
(withdrawal-leaf-hash (buff 32))
(sibling-hashes (list 50 {
hash: (buff 32),
is-left-side: bool,
}))
)
(begin
;; Check that the user submitted a valid withdrawal root
(asserts! (is-some (map-get? withdrawal-roots-map withdrawal-root)) (err ERR_INVALID_MERKLE_ROOT))
;; Check that this withdrawal leaf has not been processed before
(asserts!
(is-none
(map-get? processed-withdrawal-leaves-map
{ withdrawal-leaf-hash: withdrawal-leaf-hash, withdrawal-root-hash: withdrawal-root }))
(err ERR_WITHDRAWAL_ALREADY_PROCESSED))
(let ((calculated-withdrawal-root (fold hash-help sibling-hashes withdrawal-leaf-hash))
(roots-match (is-eq calculated-withdrawal-root withdrawal-root)))
(if roots-match
(ok true)
(err ERR_MERKLE_ROOT_DOES_NOT_MATCH))
)
)
)
;; This function should be called after the asset in question has been transferred.
;; It adds the withdrawal leaf hash to a map of processed leaves. This ensures that
;; this withdrawal leaf can't be used again to withdraw additional funds.
;; Returns bool
(define-private (finish-withdraw
(withdraw-info {
withdrawal-leaf-hash: (buff 32),
withdrawal-root-hash: (buff 32)
})
)
(map-insert processed-withdrawal-leaves-map withdraw-info true)
)

View File

@@ -0,0 +1,61 @@
(define-constant ERR_NOT_AUTHORIZED (err u1001))
(impl-trait 'ST000000000000000000002AMW42H.subnet.ft-trait)
(define-fungible-token ft-token)
;; get the token balance of owner
(define-read-only (get-balance (owner principal))
(begin
(ok (ft-get-balance ft-token owner))))
;; returns the total number of tokens
(define-read-only (get-total-supply)
(ok (ft-get-supply ft-token)))
;; returns the token name
(define-read-only (get-name)
(ok "ft-token"))
;; the symbol or "ticker" for this token
(define-read-only (get-symbol)
(ok "EXFT"))
;; the number of decimals used
(define-read-only (get-decimals)
(ok u0))
;; Transfers tokens to a recipient
(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34))))
(begin
(try! (ft-transfer? ft-token amount sender recipient))
(print memo)
(ok true)
)
)
(define-read-only (get-token-uri)
(ok none)
)
(define-read-only (get-token-balance (user principal))
(ft-get-balance ft-token user)
)
(impl-trait 'ST000000000000000000002AMW42H.subnet.subnet-asset)
;; Called for deposit from the burnchain to the subnet
(define-public (deposit-from-burnchain (amount uint) (recipient principal))
(begin
(asserts! (is-eq tx-sender 'ST000000000000000000002AMW42H) ERR_NOT_AUTHORIZED)
(ft-mint? ft-token amount recipient)
)
)
;; Called for withdrawal from the subnet to the burnchain
(define-public (burn-for-withdrawal (amount uint) (owner principal))
(begin
(asserts! (is-eq tx-sender owner) ERR_NOT_AUTHORIZED)
(ft-burn? ft-token amount owner)
)
)

View File

@@ -0,0 +1,68 @@
(define-constant CONTRACT_OWNER tx-sender)
(define-constant CONTRACT_ADDRESS (as-contract tx-sender))
(define-constant ERR_NOT_AUTHORIZED (err u1001))
(impl-trait 'ST000000000000000000002AMW42H.subnet.nft-trait)
(define-data-var lastId uint u0)
(define-non-fungible-token nft-token uint)
;; NFT trait functions
(define-read-only (get-last-token-id)
(ok (var-get lastId))
)
(define-read-only (get-owner (id uint))
(ok (nft-get-owner? nft-token id))
)
(define-read-only (get-token-uri (id uint))
(ok (some "unimplemented"))
)
(define-public (transfer (id uint) (sender principal) (recipient principal))
(begin
(asserts! (is-eq tx-sender sender) ERR_NOT_AUTHORIZED)
(nft-transfer? nft-token id sender recipient)
)
)
;; mint functions
(define-public (mint-next (recipient principal))
(let
((newId (+ (var-get lastId) u1)))
(var-set lastId newId)
(nft-mint? nft-token newId recipient)
)
)
(define-public (gift-nft (recipient principal) (id uint))
(begin
(nft-mint? nft-token id recipient)
)
)
(define-read-only (get-token-owner (id uint))
(nft-get-owner? nft-token id)
)
(impl-trait 'ST000000000000000000002AMW42H.subnet.subnet-asset)
;; Called for deposit from the burnchain to the subnet
(define-public (deposit-from-burnchain (id uint) (recipient principal))
(begin
(asserts! (is-eq tx-sender 'ST000000000000000000002AMW42H) ERR_NOT_AUTHORIZED)
(nft-mint? nft-token id recipient)
)
)
;; Called for withdrawal from the subnet to the burnchain
(define-public (burn-for-withdrawal (id uint) (owner principal))
(begin
(asserts! (is-eq tx-sender owner) ERR_NOT_AUTHORIZED)
(nft-burn? nft-token id owner)
)
)

View File

@@ -0,0 +1,3 @@
(define-public (subnet-withdraw-stx (amount uint) (sender principal))
(contract-call? 'ST000000000000000000002AMW42H.subnet stx-withdraw? amount sender)
)

View File

@@ -0,0 +1,2 @@
process.env['STACKS_CORE_RPC_PORT'] = '30443';
process.env['STACKS_CORE_PROXY_PORT'] = '30443';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
[node]
# working_dir = "./devnet"
rpc_bind = "0.0.0.0:30443"
p2p_bind = "0.0.0.0:30444"
miner = true
seed = "6a1a754ba863d7bab14adbbc3f8ebb090af9e871ace621d3e5ab634e1422885e01"
mining_key = "cb3df38053d132895220b9ce471f6b676db5b9bf0b4adefb55f2118ece2478df01"
local_peer_seed = "6a1a754ba863d7bab14adbbc3f8ebb090af9e871ace621d3e5ab634e1422885e01"
wait_time_for_microblocks = 50
wait_before_first_anchored_block = 0
[miner]
first_attempt_time_ms = 500
subsequent_attempt_time_ms = 1000
# microblock_attempt_time_ms = 15_000
[burnchain]
chain = "stacks_layer_1"
mode = "subnet"
first_burn_header_height = 0
peer_host = "host.docker.internal"
rpc_port = 20443
peer_port = 20444
contract_identifier = "STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP.subnet"
observer_port = 30445
poll_time_secs = 1
[[ustx_balance]]
address = "STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6"
amount = 10000000000000000
# secretKey = "cb3df38053d132895220b9ce471f6b676db5b9bf0b4adefb55f2118ece2478df01"
[[ustx_balance]]
address = "ST11NJTTKGVT6D1HY4NJRVQWMQM7TVAR091EJ8P2Y"
amount = 10000000000000000
# secretKey = "21d43d2ae0da1d9d04cfcaac7d397a33733881081f0b2cd038062cf0ccbb752601"
[[ustx_balance]]
address = "ST1HB1T8WRNBYB0Y3T7WXZS38NKKPTBR3EG9EPJKR"
amount = 10000000000000000
# secretKey = "c71700b07d520a8c9731e4d0f095aa6efb91e16e25fb27ce2b72e7b698f8127a01"
[[ustx_balance]]
address = "STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP"
amount = 10000000000000000
# secretKey = "e75dcb66f84287eaf347955e94fa04337298dbd95aa0dbb985771104ef1913db01"
[[ustx_balance]]
address = "STF9B75ADQAVXQHNEQ6KGHXTG7JP305J2GRWF3A2"
amount = 10000000000000000
# secretKey = "ce109fee08860bb16337c76647dcbc02df0c06b455dd69bcf30af74d4eedd19301",
[[ustx_balance]]
address = "ST18MDW2PDTBSCR1ACXYRJP2JX70FWNM6YY2VX4SS"
amount = 10000000000000000
# secretKey = "08c14a1eada0dd42b667b40f59f7c8dedb12113613448dc04980aea20b268ddb01",

View File

@@ -0,0 +1,26 @@
/** @type {import('jest').Config} */
const config = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: `${require('path').dirname(__dirname)}/src`,
testMatch: ['<rootDir>/tests-subnets/**/*.ts'],
testPathIgnorePatterns: [
'<rootDir>/tests-subnets/global-setup.ts',
'<rootDir>/tests-subnets/global-teardown.ts',
'<rootDir>/tests-subnets/env-setup.ts',
'<rootDir>/tests-subnets/test-helpers.ts',
'<rootDir>/tests-subnets/set-env.ts',
],
collectCoverageFrom: ['<rootDir>/**/*.ts'],
coveragePathIgnorePatterns: ['<rootDir>/tests*'],
coverageDirectory: '<rootDir>/../coverage',
globalSetup: '<rootDir>/tests-subnets/global-setup.ts',
globalTeardown: '<rootDir>/tests-subnets/global-teardown.ts',
setupFilesAfterEnv: ['<rootDir>/tests-subnets/env-setup.ts'],
setupFiles: ['<rootDir>/tests-subnets/set-env.ts'],
testTimeout: 60_000,
verbose: true,
bail: true,
};
module.exports = config;