feat: support for subnets (#1625)

* feat: beta release with subnets support

* chore(release): 7.1.0-beta.1 [skip ci]

## [7.1.0-beta.1](https://github.com/hirosystems/stacks-blockchain-api/compare/v7.0.0...v7.1.0-beta.1) (2023-02-14)

### Features

* beta release with subnets support ([06164eb](06164eb1dd))
* support for subnets ([#1549](https://github.com/hirosystems/stacks-blockchain-api/issues/1549)) ([5d7056c](5d7056c1ba))

### Bug Fixes

* fixed the order of microblocks_streamed returned in reverse order in block endpoint ([#1528](https://github.com/hirosystems/stacks-blockchain-api/issues/1528)) ([764f64a](764f64a538))

* Merge master into beta

* chore: update l1 and l2 subnet contracts

* chore: update subnets docker compose to latest 2.1 image

* chore: enable subnet STX transfer tests

* chore: progress on fixing test for subnet to L1 FT withdrawal

* feat: support register asset event synthetic tx parsing (#1583)

* feat: support register asset event synthetic tx parsing

* test: integration tests for register-new-ft-contract and register-new-nft-contract synthetic txs

* feat: update 'register-asset-contract' event parsing

* chore: bump to latest subnet docker image (also include subnets.Dockerfile for local dev)

* chore(release): 7.1.0-beta.2 [skip ci]

## [7.1.0-beta.2](https://github.com/hirosystems/stacks-blockchain-api/compare/v7.1.0-beta.1...v7.1.0-beta.2) (2023-03-16)

### Features

* support register asset event synthetic tx parsing ([#1583](https://github.com/hirosystems/stacks-blockchain-api/issues/1583)) ([57d58f2](57d58f2f8d))

---------

Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
This commit is contained in:
Matthew Little
2023-04-20 17:45:04 +02:00
committed by GitHub
parent 1a6fde145b
commit bfac932f09
13 changed files with 399 additions and 24 deletions

View File

@@ -2,7 +2,7 @@
version: '3.7'
services:
stacks-blockchain:
image: "hirosystems/stacks-api-e2e:stacks2.1-ecb1872"
image: "hirosystems/stacks-api-e2e:stacks2.1-a50d830"
command: |
bash -c "rm /event-log.ndjson && /root/run.sh"
ports:
@@ -19,8 +19,9 @@ services:
- "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"
image: "hirosystems/stacks-subnets:7012d22"
# build:
# dockerfile: ./subnet-node.Dockerfile
command: subnet-node start --config=/app/config/Stacks-subnet.toml
ports:
- "30443:30443" # subnet-node RPC interface

View File

@@ -0,0 +1,25 @@
FROM rust:bullseye as build
ARG STACKS_NODE_VERSION="No Version Info"
ARG GIT_BRANCH='No Branch Info'
ARG GIT_COMMIT='No Commit Info'
WORKDIR /src
RUN git clone https://github.com/hirosystems/stacks-subnets.git .
RUN git checkout 77c6625947cdf66ab02acc5c03c08e5142911494
RUN mkdir /out /contracts
RUN cd testnet/stacks-node && cargo build --features monitoring_prom,slog_json --release
RUN cp target/release/subnet-node /out
FROM debian:bullseye-backports
COPY --from=build /out/ /bin/
# Add the core contracts to the image, so that clarinet can retrieve them.
COPY --from=build /src/core-contracts/contracts/subnet.clar /contracts/subnet.clar
COPY --from=build /src/core-contracts/contracts/helper/subnet-traits.clar /contracts/subnet-traits.clar
CMD ["subnet-node", "start"]

View File

@@ -148,6 +148,28 @@ interface FtBurnEvent extends CoreNodeEventBase {
};
}
interface BurnchainOpRegisterAssetNft {
register_asset: {
asset_type: 'nft';
burn_header_hash: string;
l1_contract_id: string;
l2_contract_id: string;
txid: string;
};
}
interface BurnchainOpRegisterAssetFt {
register_asset: {
asset_type: 'ft';
burn_header_hash: string;
l1_contract_id: string;
l2_contract_id: string;
txid: string;
};
}
export type BurnchainOp = BurnchainOpRegisterAssetNft | BurnchainOpRegisterAssetFt;
export type CoreNodeEvent =
| SmartContractEvent
| StxTransferEvent
@@ -174,6 +196,7 @@ export interface CoreNodeTxMessage {
microblock_sequence: number | null;
microblock_hash: string | null;
microblock_parent_hash: string | null;
burnchain_op?: BurnchainOp | null;
}
export interface CoreNodeMicroblockTxMessage extends CoreNodeTxMessage {

View File

@@ -1,4 +1,5 @@
import {
BurnchainOp,
CoreNodeBlockMessage,
CoreNodeEvent,
CoreNodeEventType,
@@ -31,6 +32,7 @@ import {
TxPublicKeyEncoding,
TxSpendingConditionSingleSigHashMode,
decodeClarityValueList,
ClarityValueBuffer,
} from 'stacks-encoding-native-js';
import {
DbMicroblockPartial,
@@ -45,6 +47,7 @@ import {
I32_MAX,
bufferToHexPrefixString,
hexToBuffer,
SubnetContractIdentifer,
} from '../helpers';
import {
TransactionVersion,
@@ -83,6 +86,89 @@ export function getTxSponsorAddress(tx: DecodedTxResult): string | undefined {
return sponsorAddress;
}
function createSubnetTransactionFromL1RegisterAsset(
chainId: ChainID,
burnchainOp: BurnchainOp,
subnetEvent: SmartContractEvent,
txId: string
): DecodedTxResult {
if (
burnchainOp.register_asset.asset_type !== 'ft' &&
burnchainOp.register_asset.asset_type !== 'nft'
) {
throw new Error(
`Unexpected L1 register asset type: ${JSON.stringify(burnchainOp.register_asset)}`
);
}
const [contractAddress, contractName] = subnetEvent.contract_event.contract_identifier
.split('::')[0]
.split('.');
const decContractAddress = decodeStacksAddress(contractAddress);
const decodedLogEvent = decodeClarityValue<
ClarityValueTuple<{
'burnchain-txid': ClarityValueBuffer;
}>
>(subnetEvent.contract_event.raw_value);
// (define-public (register-asset-contract
// (asset-type (string-ascii 3))
// (l1-contract principal)
// (l2-contract principal)
// (burnchain-txid (buff 32))
const fnName = 'register-asset-contract';
const legacyClarityVals = [
stringAsciiCV(burnchainOp.register_asset.asset_type),
principalCV(burnchainOp.register_asset.l1_contract_id),
principalCV(burnchainOp.register_asset.l2_contract_id),
bufferCV(hexToBuffer(decodedLogEvent.data['burnchain-txid'].buffer)),
];
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: decContractAddress[0],
address_hash_bytes: decContractAddress[1],
address: contractAddress,
},
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: fnName,
function_args: clarityFnArgs,
function_args_buffer: rawFnArgs,
},
};
return tx;
}
function createSubnetTransactionFromL1NftDeposit(
chainId: ChainID,
event: NftMintEvent,
@@ -573,6 +659,14 @@ export function parseMessageTransaction(
})
.find(e => !!e);
const subnetEvents = events.filter(
(e): e is SmartContractEvent =>
e.type === CoreNodeEventType.ContractEvent &&
e.contract_event.topic === 'print' &&
(e.contract_event.contract_identifier === SubnetContractIdentifer.mainnet ||
e.contract_event.contract_identifier === SubnetContractIdentifer.testnet)
);
if (stxTransferEvent) {
rawTx = createTransactionFromCoreBtcTxEvent(chainId, stxTransferEvent, coreTx.txid);
txSender = stxTransferEvent.stx_transfer_event.sender;
@@ -608,6 +702,18 @@ export function parseMessageTransaction(
} else if (stxMintEvent) {
rawTx = createSubnetTransactionFromL1StxDeposit(chainId, stxMintEvent, coreTx.txid);
txSender = getTxSenderAddress(rawTx);
} else if (
subnetEvents.length > 0 &&
coreTx.burnchain_op &&
coreTx.burnchain_op.register_asset
) {
rawTx = createSubnetTransactionFromL1RegisterAsset(
chainId,
coreTx.burnchain_op,
subnetEvents[0],
coreTx.txid
);
txSender = getTxSenderAddress(rawTx);
} else {
logError(
`BTC transaction found, but no STX transfer event available to recreate transaction. TX: ${JSON.stringify(

View File

@@ -1074,6 +1074,11 @@ export function getBnsSmartContractId(chainId: ChainID): string {
: 'ST000000000000000000002AMW42H.bns::names';
}
export const enum SubnetContractIdentifer {
mainnet = 'SP000000000000000000002Q6VF78.subnet',
testnet = 'ST000000000000000000002AMW42H.subnet',
}
export function getSendManyContract(chainId: ChainID) {
const contractId =
chainId === ChainID.Mainnet

View File

@@ -1,3 +1,5 @@
;; https://github.com/hirosystems/stacks-subnets/blob/master/core-contracts/contracts/helper/simple-ft.clar
(define-constant ERR_NOT_AUTHORIZED (err u1001))
(impl-trait 'STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP.sip-traits.ft-trait)
@@ -7,8 +9,7 @@
;; get the token balance of owner
(define-read-only (get-balance (owner principal))
(begin
(ok (ft-get-balance ft-token owner))))
(ok (ft-get-balance ft-token owner)))
;; returns the total number of tokens
(define-read-only (get-total-supply)
@@ -49,9 +50,9 @@
(define-read-only (get-token-uri)
(ok none))
(define-public (gift-tokens (recipient principal))
(define-public (gift-tokens (amount uint) (recipient principal))
(begin
(asserts! (is-eq tx-sender recipient) ERR_NOT_AUTHORIZED)
(ft-mint? ft-token u1 recipient)
(ft-mint? ft-token amount recipient)
)
)

View File

@@ -1,9 +1,11 @@
;; https://github.com/hirosystems/stacks-subnets/blob/master/core-contracts/contracts/helper/simple-nft.clar
(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.sip-traits.nft-trait)
(impl-trait 'STRYYQQ9M8KAF4NS7WNZQYY59X93XEKR31JP64CP.subnet-traits.mint-from-subnet-trait)
(define-data-var lastId uint u0)

View File

@@ -1,3 +1,5 @@
;; https://github.com/hirosystems/stacks-subnets/blob/master/core-contracts/contracts/helper/sip-traits.clar
(define-trait nft-trait
(
;; Last token ID, limited to uint range
@@ -37,4 +39,4 @@
;; an optional URI that represents metadata of this token
(get-token-uri () (response (optional (string-utf8 256)) uint))
)
)
)

View File

@@ -1,3 +1,5 @@
;; https://github.com/hirosystems/stacks-subnets/blob/master/core-contracts/contracts/helper/subnet-traits.clar
;; In order to process deposits and withdrawals to a subnet, an asset
;; contract must implement this trait.
(define-trait mint-from-subnet-trait
@@ -13,4 +15,4 @@
(response bool uint)
)
)
)
)

View File

@@ -1,3 +1,5 @@
;; https://github.com/hirosystems/stacks-subnets/blob/master/core-contracts/contracts/subnet.clar
;; The .subnet contract
(define-constant CONTRACT_ADDRESS (as-contract tx-sender))
@@ -31,8 +33,10 @@
;; 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
;; principal that can commit blocks
(define-data-var miner principal 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6)
;; principal that can register contracts
(define-data-var admin principal 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6)
;; Map of allowed contracts for asset transfers - maps L1 contract principal to L2 contract principal
(define-map allowed-contracts principal principal)
@@ -53,13 +57,20 @@
;; 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))
;; Verify that tx-sender is an authorized admin
(asserts! (is-admin 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))
(print {
event: "register-contract",
asset-type: "ft",
l1-contract: (contract-of ft-contract),
l2-contract: l2-contract
})
(ok true)
)
)
@@ -67,13 +78,20 @@
;; 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))
;; Verify that tx-sender is an authorized admin
(asserts! (is-admin 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))
(print {
event: "register-contract",
asset-type: "nft",
l1-contract: (contract-of nft-contract),
l2-contract: l2-contract
})
(ok true)
)
)
@@ -84,6 +102,12 @@
(is-eq miner-to-check (var-get miner))
)
;; Helper function: returns a boolean indicating whether the given principal is an admin
;; Returns bool
(define-private (is-admin (addr-to-check principal))
(is-eq addr-to-check (var-get admin))
)
;; Helper function: determines whether the commit-block operation satisfies pre-conditions
;; listed in `commit-block`.
;; Returns response<bool, int>

View File

@@ -1,3 +1,5 @@
;; https://github.com/hirosystems/stacks-subnets/blob/master/core-contracts/contracts/helper/simple-ft-l2.clar
(define-constant ERR_NOT_AUTHORIZED (err u1001))
(impl-trait 'ST000000000000000000002AMW42H.subnet.ft-trait)
@@ -6,8 +8,7 @@
;; get the token balance of owner
(define-read-only (get-balance (owner principal))
(begin
(ok (ft-get-balance ft-token owner))))
(ok (ft-get-balance ft-token owner)))
;; returns the total number of tokens
(define-read-only (get-total-supply)
@@ -38,8 +39,11 @@
(ok none)
)
(define-read-only (get-token-balance (user principal))
(ft-get-balance ft-token user)
(define-public (gift-tokens (amount uint) (recipient principal))
(begin
(asserts! (is-eq tx-sender recipient) ERR_NOT_AUTHORIZED)
(ft-mint? ft-token amount recipient)
)
)
(impl-trait 'ST000000000000000000002AMW42H.subnet.subnet-asset)

View File

@@ -1,3 +1,5 @@
;; https://github.com/hirosystems/stacks-subnets/blob/master/core-contracts/contracts/helper/simple-nft-l2.clar
(define-constant CONTRACT_OWNER tx-sender)
(define-constant CONTRACT_ADDRESS (as-contract tx-sender))

View File

@@ -272,6 +272,96 @@ describe('Subnets tests', () => {
}
});
test('Step 2b: Validate register-asset-contract synthetic tx', async () => {
while (true) {
const expectedContractID = `ST000000000000000000002AMW42H.subnet`;
const resp = await supertest(testEnv.api.server)
.get(`/extended/v1/tx?limit=1&type=contract_call`)
.expect(200);
const txListResp = resp.body as TransactionResults;
const tx = txListResp.results[0] as ContractCallTransaction;
if (
txListResp.total === 0 ||
tx.contract_call.contract_id !== expectedContractID ||
tx.contract_call.function_name !== 'register-asset-contract'
) {
await timeout(200);
continue;
}
expect(tx).toEqual(
expect.objectContaining({
anchor_mode: 'any',
canonical: true,
contract_call: {
contract_id: expectedContractID,
function_args: [
expect.objectContaining({
name: 'asset-type',
repr: '"nft"',
type: '(string-ascii 3)',
}),
{
hex: '0x061a43596b5386f466863e25658ddf94bd0fadab00480d73696d706c652d6e66742d6c31',
name: 'l1-contract',
repr: `'${accounts.USER.addr}.simple-nft-l1`,
type: 'principal',
},
{
hex: '0x061a43596b5386f466863e25658ddf94bd0fadab00480d73696d706c652d6e66742d6c32',
name: 'l2-contract',
repr: `'${accounts.USER.addr}.simple-nft-l2`,
type: 'principal',
},
expect.objectContaining({
name: 'burnchain-txid',
type: '(buff 32)',
}),
],
function_name: 'register-asset-contract',
function_signature:
'(define-public (register-asset-contract (asset-type (string-ascii 3)) (l1-contract principal) (l2-contract principal) (burnchain-txid (buff 32))))',
},
event_count: 1,
events: [],
fee_rate: '0',
post_condition_mode: 'allow',
post_conditions: [],
sender_address: 'ST000000000000000000002AMW42H',
sponsored: false,
tx_index: 0,
tx_result: {
hex: '0x0703',
repr: '(ok true)',
},
tx_status: 'success',
tx_type: 'contract_call',
})
);
const respEvents = await supertest(testEnv.api.server)
.get(`/extended/v1/tx/events?tx_id=${tx.tx_id}`)
.expect(200);
const txEvents = respEvents.body.events as TransactionEventsResponse['results'];
expect(txEvents).toEqual([
{
contract_log: {
contract_id: 'ST000000000000000000002AMW42H.subnet',
topic: 'print',
value: expect.objectContaining({
repr: expect.stringContaining(
`(l1-contract '${accounts.USER.addr}.simple-nft-l1) (l2-contract '${accounts.USER.addr}.simple-nft-l2)`
),
}),
},
event_index: 0,
event_type: 'smart_contract_log',
tx_id: tx.tx_id,
},
]);
break;
}
});
test('Step 3: Mint an NFT on the L1 chain', async () => {
const tx = await makeContractCall({
contractAddress: accounts.USER.addr,
@@ -617,12 +707,100 @@ describe('Subnets tests', () => {
}
});
test('Step 2b: Validate register-asset-contract synthetic tx', async () => {
while (true) {
const expectedContractID = `ST000000000000000000002AMW42H.subnet`;
const resp = await supertest(testEnv.api.server)
.get(`/extended/v1/tx?limit=1&type=contract_call`)
.expect(200);
const txListResp = resp.body as TransactionResults;
const tx = txListResp.results[0] as ContractCallTransaction;
if (
txListResp.total === 0 ||
tx.contract_call.contract_id !== expectedContractID ||
tx.contract_call.function_name !== 'register-asset-contract'
) {
await timeout(200);
continue;
}
expect(tx).toEqual(
expect.objectContaining({
anchor_mode: 'any',
canonical: true,
contract_call: {
contract_id: expectedContractID,
function_args: [
expect.objectContaining({
name: 'asset-type',
repr: '"ft"',
type: '(string-ascii 3)',
}),
expect.objectContaining({
name: 'l1-contract',
repr: `'${accounts.USER.addr}.simple-ft-l1`,
type: 'principal',
}),
expect.objectContaining({
name: 'l2-contract',
repr: `'${accounts.USER.addr}.simple-ft-l2`,
type: 'principal',
}),
expect.objectContaining({
name: 'burnchain-txid',
type: '(buff 32)',
}),
],
function_name: 'register-asset-contract',
function_signature:
'(define-public (register-asset-contract (asset-type (string-ascii 3)) (l1-contract principal) (l2-contract principal) (burnchain-txid (buff 32))))',
},
event_count: 1,
events: [],
fee_rate: '0',
post_condition_mode: 'allow',
post_conditions: [],
sender_address: 'ST000000000000000000002AMW42H',
sponsored: false,
tx_index: 0,
tx_result: {
hex: '0x0703',
repr: '(ok true)',
},
tx_status: 'success',
tx_type: 'contract_call',
})
);
const respEvents = await supertest(testEnv.api.server)
.get(`/extended/v1/tx/events?tx_id=${tx.tx_id}`)
.expect(200);
const txEvents = respEvents.body.events as TransactionEventsResponse['results'];
expect(txEvents).toEqual([
{
contract_log: {
contract_id: 'ST000000000000000000002AMW42H.subnet',
topic: 'print',
value: expect.objectContaining({
repr: expect.stringContaining(
`(l1-contract '${accounts.USER.addr}.simple-ft-l1) (l2-contract '${accounts.USER.addr}.simple-ft-l2)`
),
}),
},
event_index: 0,
event_type: 'smart_contract_log',
tx_id: tx.tx_id,
},
]);
break;
}
});
test('Step 3: Mint FT on the L1 chain', async () => {
const tx = await makeContractCall({
contractAddress: accounts.USER.addr,
contractName: 'simple-ft-l1',
functionName: 'gift-tokens',
functionArgs: [standardPrincipalCV(accounts.USER.addr)],
functionArgs: [uintCV(1), standardPrincipalCV(accounts.USER.addr)],
senderKey: accounts.USER.key,
validateWithAbi: false,
network: l1Network,
@@ -804,7 +982,7 @@ describe('Subnets tests', () => {
withdrawal_root: string;
sibling_hashes: string;
}>(
`v2/withdrawal/ft/${withdrawalBlockHeight}/${accounts.ALT_USER.addr}/${withdrawalId}/${accounts.USER.addr}/simple-ft-l2/5`
`v2/withdrawal/ft/${withdrawalBlockHeight}/${accounts.ALT_USER.addr}/${withdrawalId}/${accounts.USER.addr}/simple-ft-l2/1`
);
console.log(json_merkle_entry);
const cv_merkle_entry = {
@@ -843,8 +1021,8 @@ describe('Subnets tests', () => {
const result = await callReadOnlyFunction({
contractAddress: accounts.USER.addr,
contractName: 'simple-ft-l1',
functionName: 'get-owner',
functionArgs: [uintCV(5)],
functionName: 'get-balance',
functionArgs: [standardPrincipalCV(accounts.ALT_USER.addr)],
network: l1Network,
senderAddress: accounts.ALT_USER.addr,
});
@@ -860,7 +1038,7 @@ describe('Subnets tests', () => {
});
});
describe.skip('STX use-case test', () => {
describe('STX use-case test', () => {
test('Step 1: Publish STX contract to L2', async () => {
const curBlock = await l2Client.getInfo();
await standByUntilBlock(curBlock.stacks_tip_height + 1);