Skip to content

Commit 069214a

Browse files
committed
closer to nice
1 parent c4ba1d2 commit 069214a

File tree

13 files changed

+397
-269
lines changed

13 files changed

+397
-269
lines changed

README.md

Lines changed: 56 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,79 @@
1-
## Foundry
1+
# solidity-bsky-cbor
22

3-
**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.**
3+
**solidity-bsky-cbor is a library for parsing and verifying record inclusion in atproto repositories.**
44

5-
Foundry consists of:
5+
The basic CBOR decoding is forked from [the filecoin CBOR code](https://github.com/Zondax/filecoin-solidity/blob/master/contracts/v0.8/utils/CborDecode.sol) by Zondax AG.
66

7-
- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools).
8-
- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data.
9-
- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network.
10-
- **Chisel**: Fast, utilitarian, and verbose solidity REPL.
7+
The atproto features were developed by reference the [atproto data model](https://atproto.com/specs/data-model).
118

12-
## Documentation
9+
## Usage
1310

14-
https://book.getfoundry.sh/
11+
This library is designed for use with off-chain software which selects appropriate records and formats contract calldata.
1512

16-
## Usage
13+
Your contract should know a trusted pubkey, and track the last seen repo revision to prevent replay.
1714

18-
### Build
15+
### off-chain
1916

20-
```shell
21-
$ forge build
22-
```
17+
You identify a new record of interest.
2318

24-
### Test
19+
1. know the actor `did`, namespaced `collection`, and `rkey` identifying the record of interest
20+
1. query the appropriate PDS with `com.atproto.sync.getRecord` to obtain the proving MST
21+
2. parse the response `application/vnd.ipld.car` and separate:
22+
- the MST node CBORs
23+
- the record CBOR
24+
- the unsigned commit CBOR
25+
- the commit signature `r` and `s` components
2526

26-
```shell
27-
$ forge test
28-
```
27+
Call your contract.
2928

30-
### Format
29+
### on-chain
3130

32-
```shell
33-
$ forge fmt
34-
```
31+
Your established contract is called.
3532

36-
### Gas Snapshots
33+
4. knowing a previous repo revision, parse the commit with `verifyCommit`. a root CID and new revision return.
34+
5. knowing the record key and the root CID, parse the MST with `verifyInclusion`. a value CID returns.
35+
6. knowing the record content and the value CID, confirm the value CID refers to the record content.
36+
7. the record is proven. store the new revision.
3737

38-
```shell
39-
$ forge snapshot
40-
```
38+
Your contract may now proceed to parse and act on the authenticated record.
4139

42-
### Anvil
40+
### Example
4341

44-
```shell
45-
$ anvil
46-
```
42+
Contract use of this library might look like this:
4743

48-
### Deploy
44+
```sol
45+
function exampleUse(
46+
bytes calldata commitCbor,
47+
bytes calldata recordCbor,
48+
bytes[] calldata mstCbors,
49+
bytes32 sig_r,
50+
bytes32 sig_s,
51+
string calldata recordKey,
52+
) external {
53+
Commit memory commit = commitCbor.verifyCommit(sig_r, sig_s, trustedSigner, lastRev);
54+
Tree memory mst = mstCbors.readTree();
55+
Cid valueCid = mst.verifyInclusion(commit.data, recordKey);
56+
require(valueCid.isFor(recordCbor), "Key identifies a different record");
4957
50-
```shell
51-
$ forge script script/Counter.s.sol:CounterScript --rpc-url <your_rpc_url> --private-key <your_private_key>
58+
lastRev = commit.rev;
59+
exampleAction(recordCbor);
60+
}
5261
```
5362

54-
### Cast
63+
## Record parsing
5564

56-
```shell
57-
$ cast <subcommand>
58-
```
65+
~~A few record parsing utilities are provided for some common bluesky lexicons.~~
5966

60-
### Help
67+
A single record parsing utility is provided for Bluesky posts.
6168

62-
```shell
63-
$ forge --help
64-
$ anvil --help
65-
$ cast --help
66-
```
69+
- `app.bsky.feed.post`
70+
71+
Others are planned.
72+
73+
- ~~`app.bsky.feed.like`~~
74+
- ~~`app.bsky.feed.repost`~~
75+
- ~~`app.bsky.graph.block`~~
76+
- ~~`app.bsky.graph.follow`~~
77+
- ~~`app.bsky.richText.facet`~~
78+
79+
Otherwise, you are responsible for implementing your own record parsing.

example.sol

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.28;
3+
4+
import "./src/CommitCbor.sol";
5+
import "./src/TreeCbor.sol";
6+
7+
contract ExampleContract {
8+
using {CommitCbor.verifyCommit} for bytes;
9+
using {TreeCbor.readTree} for bytes[];
10+
using {TreeCbor.verifyInclusion} for Tree;
11+
12+
string private lastRev;
13+
address private trustedSigner;
14+
15+
constructor(string memory initRev, address initSigner) {
16+
lastRev = initRev;
17+
trustedSigner = initSigner;
18+
}
19+
20+
function exampleUse(
21+
bytes calldata commitCbor,
22+
bytes calldata recordCbor,
23+
bytes[] calldata mstCbors,
24+
bytes32 sig_r,
25+
bytes32 sig_s,
26+
string calldata recordKey
27+
) external {
28+
Commit memory commit = commitCbor.verifyCommit(sig_r, sig_s, trustedSigner, lastRev);
29+
Tree memory mst = mstCbors.readTree();
30+
Cid valueCid = mst.verifyInclusion(commit.data, recordKey);
31+
require(valueCid.isFor(recordCbor), "Key identifies a different record");
32+
33+
lastRev = commit.rev;
34+
exampleAction(recordCbor);
35+
}
36+
37+
function exampleAction(bytes calldata recordCbor) internal pure {
38+
/* no-op */
39+
}
40+
}

src/AppBskyCbor.sol

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.28;
3+
4+
import "./CidCbor.sol";
5+
6+
struct AppBskyFeedPost {
7+
string text;
8+
}
9+
10+
library AppBsky {
11+
using CBORDecoder for bytes;
12+
13+
bytes19 private constant nsidFeedPost = "app.bsky.feed.post";
14+
15+
function FeedPost(bytes memory cborData) internal pure returns (AppBskyFeedPost memory feedPost) {
16+
uint byteIdx = 0;
17+
(feedPost.text, byteIdx) = FeedPost(cborData, byteIdx);
18+
require(byteIdx == cborData.length, "expected to read all bytes");
19+
return feedPost;
20+
}
21+
22+
function FeedPost(bytes memory cborData, uint byteIdx) internal pure returns (string memory feedPostText, uint) {
23+
uint mapLen;
24+
(mapLen, byteIdx) = cborData.readFixedMap(byteIdx);
25+
26+
require(mapLen == 4, "expected 4 fields in `app.bsky.feed.post` record");
27+
28+
bytes9 mapKey;
29+
for (uint mapIdx = 0; mapIdx < mapLen; mapIdx++) {
30+
(mapKey, byteIdx) = cborData.readStringBytes9(byteIdx);
31+
if (
32+
// text field is the content of a text post.
33+
bytes5(mapKey) == "text"
34+
) {
35+
(feedPostText, byteIdx) = cborData.readString(byteIdx);
36+
} else if (
37+
// $type field should be "app.bsky.feed.post"
38+
bytes6(mapKey) == "$type"
39+
) {
40+
bytes memory dollarType;
41+
(dollarType, byteIdx) = cborData.readStringBytes(byteIdx);
42+
require(bytes19(bytes(dollarType)) == nsidFeedPost, "unexpected record $type");
43+
} else if (
44+
// langs array unused
45+
bytes6(mapKey) == "langs"
46+
) {
47+
uint langsLength;
48+
(langsLength, byteIdx) = cborData.readFixedArray(byteIdx);
49+
for (uint j = 0; j < langsLength; j++) {
50+
byteIdx = cborData.skipString(byteIdx);
51+
}
52+
} else if (
53+
// createdAt string unused
54+
bytes9(mapKey) == "createdAt"
55+
) {
56+
// createdAt is arbitrary user-defined data. the useful and
57+
// verifiable timestamp is the commit's repo revision field
58+
byteIdx = cborData.skipString(byteIdx);
59+
} else {
60+
revert("unexpected record key");
61+
}
62+
}
63+
64+
return (feedPostText, byteIdx);
65+
}
66+
}

src/CidCbor.sol

Lines changed: 75 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,103 @@
1-
// SPDX-License-Identifier: MIT
1+
// SPDX-License-Identifier: Apache-2.0
22
pragma solidity ^0.8.28;
33

44
import "./CborDecode.sol";
55

6-
using {cidEq as ==, cidNeq as !=} for Cid global;
6+
using {cidEq as ==, cidNeq as !=, isNull, isFor} for Cid global;
77

8-
/**
9-
* we will only encounter Cid v1 dag-cbor sha256, so the entire hash is 32
10-
* bytes and will fit in a uint256
11-
*/
8+
// we will only encounter CID v1 dag-cbor sha256, and sha256 fits a uint256.
9+
// some CID fields may be nullable, so the zero value identifies a 'null' CID.
1210
type Cid is uint256;
1311

1412
function cidEq(Cid a, Cid b) pure returns (bool) {
13+
require(Cid.unwrap(a) != 0 && Cid.unwrap(b) != 0, "Invalid CID comparison: null CID");
1514
return Cid.unwrap(a) == Cid.unwrap(b);
1615
}
1716

1817
function cidNeq(Cid a, Cid b) pure returns (bool) {
19-
return Cid.unwrap(a) != Cid.unwrap(b);
18+
return !cidEq(a, b);
19+
}
20+
21+
function isFor(Cid a, bytes memory b) pure returns (bool) {
22+
require(Cid.unwrap(a) != 0, "Invalid CID check: null CID");
23+
require(b.length != 0, "Invalid CID check: no content");
24+
return Cid.unwrap(a) == uint256(sha256(b));
25+
}
26+
27+
function isNull(Cid a) pure returns (bool) {
28+
return Cid.unwrap(a) == 0;
2029
}
2130

2231
library CidCbor {
2332
using CBORDecoder for bytes;
2433

25-
uint8 private constant TAG_CID = 42;
34+
// any CIDv1 DAG-CBOR sha-256 will always have this 9-byte header
35+
// ─────┬─────────
36+
// hex │ meaning
37+
// ─────┼─────────
38+
// D8 │ CBOR major primitive, minor next byte
39+
// 2A │ CBOR tag value 42 (CID)
40+
// 58 │ CBOR major bytes, minor next byte
41+
// 25 │ CBOR bytes length 37
42+
// 00 │ multibase format
43+
// 01 │ multiformat CID version 1
44+
// 71 │ multicodec DAG-CBOR
45+
// 12 │ multihash type sha-256
46+
// 20 │ multihash size 32 bytes
47+
// ─────┴─────────
48+
bytes4 private constant cbor_tag42_bytes37 = hex"D82A5825";
49+
bytes5 private constant multibase_cidv1_dagcbor_sha256 = hex"0001711220";
2650

27-
uint8 private constant MULTIBASE_FORMAT = 0x00;
28-
uint8 private constant CID_V1 = 0x01;
29-
uint8 private constant MULTICODEC_DAG_CBOR = 0x71;
30-
uint8 private constant MULTIHASH_SHA_256 = 0x12;
31-
uint8 private constant MULTIHASH_SIZE_32 = 0x20;
51+
/**
52+
* @notice Reads a CIDv1 DAG-CBOR sha-256 from CBOR encoded data at the specified byte index
53+
* @dev Expects a 41-byte CID structure: 4 bytes CBOR header + 5 bytes multibase header + 32 bytes SHA-256 hash
54+
* Reverts when:
55+
* - The remaining bytes are less than the expected CID size
56+
* - The CBOR header is not tag 42 with 37-byte item
57+
* - The multibase header is not CIDv1 DAG-CBOR sha256
58+
* - The hash value is zero
59+
* @param cborData The CBOR encoded byte array containing the CID
60+
* @param byteIdx The starting index in the byte array to read from
61+
* @return Cid The decoded CID
62+
* @return uint The next byte index after the CID
63+
*/
64+
function readCid(bytes memory cborData, uint byteIdx) internal pure returns (Cid, uint) {
65+
bytes4 cborHead;
66+
bytes5 multibaseHead;
67+
uint256 cidSha256; // 32 bytes
3268

33-
function expectTagCid(bytes memory cborData, uint byteIdx) internal pure returns (uint) {
34-
uint8 head = uint8(cborData[byteIdx]);
35-
uint8 tagValue = uint8(cborData[byteIdx + 1]);
36-
require(head == MajorTag << shiftMajor | MinorExtend1, "expected tag head with 1-byte extension");
37-
require(tagValue == TAG_CID, "expected tag 42 for CID");
38-
return byteIdx + 2;
39-
}
69+
require(byteIdx + 4 + 5 + 32 <= cborData.length, "Expected CID size is out of range");
70+
71+
assembly ("memory-safe") {
72+
// cbor header at index
73+
cborHead := mload(add(cborData, add(0x20, byteIdx)))
74+
// multibase header at index + cbor header
75+
multibaseHead := mload(add(cborData, add(0x20, add(4, byteIdx))))
76+
// cid hash at index + cbor header + multibase header
77+
cidSha256 := mload(add(cborData, add(0x20, add(9, byteIdx))))
78+
}
4079

41-
function expect37Bytes(bytes memory cborData, uint byteIdx) internal pure returns (uint) {
42-
require(
43-
uint8(cborData[byteIdx]) == MajorBytes << shiftMajor | MinorExtend1,
44-
"expected byte head with 1-byte extension"
45-
);
46-
require(uint8(cborData[byteIdx + 1]) == 37, "expected 37 bytes for CID");
47-
byteIdx += 2;
48-
return byteIdx;
80+
require(cborHead == cbor_tag42_bytes37, "Expected CBOR tag 42 and 37-byte item");
81+
require(multibaseHead == multibase_cidv1_dagcbor_sha256, "Expected multibase CIDv1 DAG-CBOR sha256");
82+
require(cidSha256 != 0, "Expected non-zero sha256 hash");
83+
84+
return (Cid.wrap(cidSha256), byteIdx + 4 + 5 + 32);
4985
}
5086

87+
/**
88+
* @notice Reads a CID that may be null from CBOR encoded data at the specified byte index
89+
* @dev If a CBOR null primitive appears at the byte index, the byte index
90+
* is advanced appropriately and this function returns a 'zero' CID.
91+
* @param cborData The CBOR bytes containing the CID or null
92+
* @param byteIdx The starting index to read from
93+
* @return Cid The decoded CID, or zero CID if null
94+
* @return uint The next byte index after the CID or null value
95+
*/
5196
function readNullableCid(bytes memory cborData, uint byteIdx) internal pure returns (Cid, uint) {
5297
if (cborData.isNullNext(byteIdx)) {
5398
return (Cid.wrap(0), byteIdx + 1);
99+
} else {
100+
return readCid(cborData, byteIdx);
54101
}
55-
return readCid(cborData, byteIdx);
56-
}
57-
58-
function readCid(bytes memory cborData, uint byteIdx) internal pure returns (Cid, uint) {
59-
bytes9 cidHead;
60-
assembly ("memory-safe") {
61-
cidHead := mload(add(cborData, add(0x20, byteIdx)))
62-
}
63-
64-
// all cids encountered will contain this 9-byte header
65-
// D8 2A cbor tag(42) cid
66-
// 58 25 cbor bytes(37) total length
67-
// 00 multibase format
68-
// 01 CID version
69-
// 71 multicodec DAG-CBOR
70-
// 12 multihash sha-256
71-
// 20 hash size 32 bytes
72-
require(cidHead == hex"D82A58250001711220", "expected CIDv1 dag-cbor sha256 header sequence");
73-
byteIdx += 9;
74-
uint256 cidHash;
75-
assembly ("memory-safe") {
76-
cidHash := mload(add(cborData, add(0x20, byteIdx)))
77-
}
78-
return (Cid.wrap(cidHash), byteIdx + 32);
79102
}
80103
}

0 commit comments

Comments
 (0)