From a4da30fd36ad3b58b66aa2a05ae8d6f2827602f7 Mon Sep 17 00:00:00 2001 From: Tenor Date: Mon, 16 Dec 2024 15:52:34 -0500 Subject: [PATCH 1/3] feat(main): include changes needed for SDK to update identities --- src/main.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/rpc/mod.rs | 15 ++++++++- src/types.rs | 10 ++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 17e03b9..f31d18f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,8 +37,8 @@ use tx::types::{ TransactionOpCode, TransactionOutpoint, TransactionOutput, }; use types::{ - BlockDetailsPayload, BlockHeightPayload, FaucetOutputPayload, HealthCheckResponse, - ValueOutputEntry, ValueOutputsPayload, + BlockDetailsPayload, BlockHeightPayload, ClaimsPayload, FaucetOutputPayload, + HealthCheckResponse, OutpointsPayload, ValueOutputEntry, ValueOutputsPayload, }; use rpc::QuibleRpcServer; @@ -702,6 +702,89 @@ impl rpc::QuibleRpcServer for QuibleRpcServerImpl { } } + async fn get_unspent_object_outputs_by_object_id( + &self, + object_id: [u8; 32], + ) -> Result { + let result = self + .db + .query( + "\ + SELECT * FROM transaction_outputs\n\ + WHERE spent = false\n\ + AND output.object_id.raw = $object_id", + ) + .bind(("object_id", surrealdb::sql::Bytes::from(object_id.to_vec()))) + .await; + + let output_rows: Vec = result + .and_then(|mut response| response.take(0)) + .map_err(|err| { + ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + "call execution failed: database query error", + Some(err.to_string()), + ) + })?; + + let mut outpoints: Vec = vec![]; + + for row in output_rows { + let mut transaction_hash = [0u8; 32]; + hex::decode_to_slice(row.transaction_hash, &mut transaction_hash).map_err(|err| { + ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + "call execution failed: failed to decode transaction hash hex", + Some(err.to_string()), + ) + })?; + + outpoints.push(TransactionOutpoint { + txid: transaction_hash, + index: row.output_index, + }) + } + + Ok(OutpointsPayload { outpoints }) + } + + async fn get_claims_by_object_id( + &self, + object_id: [u8; 32], + ) -> Result { + let object_id_hex = hex::encode(object_id); + let surreal_object_id = SurrealID(Thing::from(( + "objects".to_string(), + object_id_hex.to_string(), + ))); + + let result = self + .db + .query("SELECT claims FROM objects WHERE id = $id LIMIT 1") + .bind(("id", surreal_object_id)) + .await; + + let claims_rows_option: Option>> = result + .and_then(|mut response| response.take((0, "claims"))) + .map_err(|err| { + ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + "call execution failed: database query error", + Some(err.to_string()), + ) + })?; + + let claims_rows = claims_rows_option.ok_or(ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + "call execution failed: could not find object", + None as Option, + ))?; + + Ok(ClaimsPayload { + claims: claims_rows, + }) + } + async fn get_block_height(&self) -> Result { let Some(block_height) = self .db diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index a5cc4ee..f1eac90 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -7,7 +7,8 @@ use jsonrpsee::types::ErrorObjectOwned; use crate::cert; use crate::tx::types::Transaction; use crate::types::{ - self, BlockDetailsPayload, BlockHeightPayload, FaucetOutputPayload, ValueOutputsPayload, + self, BlockDetailsPayload, BlockHeightPayload, FaucetOutputPayload, OutpointsPayload, + ValueOutputsPayload, }; #[rpc(server, client, namespace = "quible")] @@ -40,6 +41,18 @@ pub trait QuibleRpc { #[method(name = "requestFaucetOutput")] async fn request_faucet_output(&self) -> Result; + #[method(name = "getUnspentObjectOutputsByObjectId")] + async fn get_unspent_object_outputs_by_object_id( + &self, + object_id: [u8; 32], + ) -> Result; + + #[method(name = "getClaimsByObjectId")] + async fn get_claims_by_object_id( + &self, + object_id: [u8; 32], + ) -> Result; + #[method(name = "getBlockHeight")] async fn get_block_height(&self) -> Result; diff --git a/src/types.rs b/src/types.rs index 71289ff..ef35b15 100644 --- a/src/types.rs +++ b/src/types.rs @@ -251,3 +251,13 @@ pub struct BlockDetailsPayload { #[serde_as(as = "DisplayFromStr")] pub transaction_count: u64, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutpointsPayload { + pub outpoints: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaimsPayload { + pub claims: Vec>, +} From 702cf0b6f8787784292cdc9961cf8bf403522bb3 Mon Sep 17 00:00:00 2001 From: Tenor Date: Mon, 16 Dec 2024 15:55:13 -0500 Subject: [PATCH 2/3] feat(main): add features necessary for SDK identity updates --- sdk/packages/js-sdk/src/index.ts | 194 ++++++++++++++++++++++-- sdk/packages/js-sdk/src/signing.test.ts | 4 +- sdk/packages/js-sdk/src/signing.ts | 16 +- 3 files changed, 193 insertions(+), 21 deletions(-) diff --git a/sdk/packages/js-sdk/src/index.ts b/sdk/packages/js-sdk/src/index.ts index 36ba85a..eb2a78e 100644 --- a/sdk/packages/js-sdk/src/index.ts +++ b/sdk/packages/js-sdk/src/index.ts @@ -4,6 +4,7 @@ import { privateKeyToAccount } from 'viem/accounts' import { EIP191Signer } from '@lukso/eip191-signer.js' import { RawSignedTransaction, Signer } from './signing' import { + convertHexStringToFixedLengthUint8Array, convertHexStringToUint8Array, convertUint8ArrayToBigInt, convertUint8ArrayToHexString, @@ -14,22 +15,22 @@ import { TransactionOpCode, TransactionOutpoint, } from './types' +import { encodeTransaction } from './encoding' const eip191Signer = new EIP191Signer() -export class Identity { - public id: { toBytes: () => Uint8Array; toHexString: () => string } +export type QuibleClaim = { hex: string } | { raw: Uint8Array } | string - constructor(id: Uint8Array) { - this.id = { - toBytes() { - return id - }, - toHexString() { - return convertUint8ArrayToHexString(id) - }, - } - } +export type QuibleIdentityUpdateParams = { + wallet: QuibleWallet + insert?: QuibleClaim[] + delete?: QuibleClaim[] + certificateLifespan?: bigint +} + +export type IdentityId = { + toBytes: () => Uint8Array & { length: 32 } + toHexString: () => string } export type CreateIdentityParams = { @@ -113,6 +114,169 @@ export class QuibleProvider { return { signer, signingKey, outpoint } } + + async fetchOutputsByObjectId(objectId: Uint8Array & { length: 32 }): Promise<{ + outpoint: TransactionOutpoint + }> { + const response = await fetch(this.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'quible_getUnspentObjectOutputsByObjectId', + id: 67, + params: [objectId], + }), + }) + + const { result } = await response.json() + + if (result.outpoints.length === 0) { + throw new Error('failed to fetch outputs by object id: no outputs') + } + + const outpoint: TransactionOutpoint = { + txid: new Uint8Array(result.outpoints[0].txid) as Uint8Array & { + length: 32 + }, + index: convertUint8ArrayToBigInt(result.outpoints[0].index), + } + + return { outpoint } + } + + async fetchClaimsByObjectId(objectId: Uint8Array & { length: 32 }): Promise<{ + claims: Uint8Array[] + }> { + const response = await fetch(this.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'quible_getClaimsByObjectId', + id: 67, + params: [objectId], + }), + }) + + const { + result: { claims }, + } = await response.json() + + return { claims } + } +} + +export type GetCertificateParams = { + claims: QuibleClaim[] +} + +export class Identity { + public id: IdentityId + + async update(params: QuibleIdentityUpdateParams) { + const { wallet } = params + const { signer: faucetSigner, outpoint: faucetOutpoint } = + await wallet.provider.fetchFaucetOutput() + + const { outpoint } = await wallet.provider.fetchOutputsByObjectId( + this.id.toBytes(), + ) + + const transaction: TransactionContents = { + inputs: [ + { outpoint: faucetOutpoint, signatureScript: [] }, + { outpoint, signatureScript: [] }, + ], + outputs: [ + { + type: 'Object', + data: { + objectId: { + raw: this.id.toBytes(), + mode: { type: 'Existing', permitIndex: 0n }, + }, + dataScript: [ + // { code: 'SETCERTTTL', data: BigInt(params.certificateLifespan) }, + ...((params.insert ?? []).map((claim) => { + let data: Uint8Array + + if (typeof claim === 'string') { + data = new TextEncoder().encode(claim) + } else if ('hex' in claim) { + data = convertHexStringToUint8Array(claim.hex) + } else { + data = claim.raw + } + + return { + code: 'INSERT', + data, + } + }) as TransactionOpCode[]), + ], + pubkeyScript: [ + { code: 'DUP' }, + { + code: 'PUSH', + data: wallet.signer.address.toBytes(), + }, + { code: 'EQUALVERIFY' }, + { code: 'CHECKSIGVERIFY' }, + ], + }, + }, + ], + locktime: 0n, + } + + const faucetSignedTransaction = + await faucetSigner.signTransaction(transaction) + const walletSignedTransaction = + await wallet.signer.signTransaction(transaction) + + faucetSignedTransaction.contents.inputs[1].signatureScript = + walletSignedTransaction.contents.inputs[1].signatureScript + + await wallet.provider.sendTransaction( + encodeTransaction(faucetSignedTransaction.contents), + ) + } + + public static fromHexString(identityId: string) { + return new Identity(convertHexStringToFixedLengthUint8Array(identityId, 32)) + } + + public static fromUint8Array(identityId: Uint8Array) { + if (identityId.length === 32) { + return new Identity(identityId as Uint8Array & { length: 32 }) + } + + throw new Error('Identity.fromUint8Array: expected length 32') + } + + private constructor(id: Uint8Array & { length: 32 }) { + this.id = { + toBytes() { + return id + }, + toHexString() { + return convertUint8ArrayToHexString(id) + }, + } + } + + async getCertificate(params: GetCertificateParams) { + if (params.claims.length !== 1) { + throw new Error( + 'Identity#getCertificate: only one claim per certificate allowed', + ) + } + } } export class QuibleWallet { @@ -167,11 +331,11 @@ export class QuibleWallet { locktime: 0n, } - const encodedSignedIdentityTransaction = + const signedIdentityTransaction = await faucetSigner.signTransaction(identityTransaction) - await this.provider.sendTransaction(encodedSignedIdentityTransaction) + await this.provider.sendTransaction(signedIdentityTransaction.encode()) - return new Identity(objectId) + return Identity.fromUint8Array(objectId) } } diff --git a/sdk/packages/js-sdk/src/signing.test.ts b/sdk/packages/js-sdk/src/signing.test.ts index 5889ca8..9246c78 100644 --- a/sdk/packages/js-sdk/src/signing.test.ts +++ b/sdk/packages/js-sdk/src/signing.test.ts @@ -56,6 +56,8 @@ describe('signer', () => { ) const result = await signer.signTransaction(sampleTransaction) - expect(convertUint8ArrayToHexString(result.toBytes())).toBe(expected) + expect(convertUint8ArrayToHexString(result.encode().toBytes())).toBe( + expected, + ) }) }) diff --git a/sdk/packages/js-sdk/src/signing.ts b/sdk/packages/js-sdk/src/signing.ts index 49f5b64..1e11195 100644 --- a/sdk/packages/js-sdk/src/signing.ts +++ b/sdk/packages/js-sdk/src/signing.ts @@ -38,17 +38,22 @@ export class Signer { public address: Address, public signTransaction: ( transaction: TransactionContents, - ) => Promise, + ) => Promise, ) {} } +export type SignedTransaction = { + contents: TransactionContents + encode: () => RawSignedTransaction +} + export const makeSigner = ( address: Uint8Array, signMessage: (message: Uint8Array) => Promise, ): Signer => { const signTransaction = async ( transactionContents: TransactionContents, - ): Promise => { + ): Promise => { const encodedUnsignedTransaction = encodeTransaction(transactionContents) const signature = await signMessage(encodedUnsignedTransaction.toBytes()) const signedTransactionContents: TransactionContents = { @@ -63,9 +68,10 @@ export const makeSigner = ( locktime: transactionContents.locktime, } - console.log(signedTransactionContents) - - return encodeTransaction(signedTransactionContents) + return { + contents: signedTransactionContents, + encode: () => encodeTransaction(signedTransactionContents), + } } return new Signer(new Address(address), signTransaction) From 56d9e51cb62825b78b3a40ce696938431a88f036 Mon Sep 17 00:00:00 2001 From: Tenor Date: Mon, 16 Dec 2024 15:55:40 -0500 Subject: [PATCH 3/3] wip: commit wip changes for example project --- docs.quible.network/docs/usage/SDK.md | 20 ++++++------ .../nft-minting-example/contracts/MyNFT.sol | 12 +++---- .../src/components/Minting.tsx | 31 ++++++++++++++----- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/docs.quible.network/docs/usage/SDK.md b/docs.quible.network/docs/usage/SDK.md index cd00bf5..0293b62 100644 --- a/docs.quible.network/docs/usage/SDK.md +++ b/docs.quible.network/docs/usage/SDK.md @@ -40,7 +40,7 @@ const identity = await wallet.createIdentity({ certificateLifespan: 86400 }) -console.log('identity id: ', identity.id.toHex()) +console.log('identity id: ', identity.id.toHexString()) ``` ## Updating identities @@ -54,7 +54,7 @@ const privateKey = '...' const identityId = '...' const wallet = Wallet.fromPrivateKey(privateKey) -const identity = Identity.fromId(identityId) +const identity = Identity.fromHexString(identityId) await identity.update({ wallet, @@ -72,7 +72,7 @@ import {Identity} from '@quible/sdk' const identityId = '...' -const identity = Identity.fromId(identityId) +const identity = Identity.fromHexString(identityId) const claims = [ 'utf8 string', @@ -86,13 +86,13 @@ const certificates = await Promise.all(claims.map(async (claim) => { }) console.log([ - certificate.toBytes(), // encoded certificate data as UInt8Array - certificate.claims[0].toBytes(), // first attested claim value as UInt8Array - certificate.claims[0].toString(), // first attested claim value encoded as utf8 string - certificate.identity.id.toHex(), // identity id in hexadecimal format - certificate.expiresAt, // expiration date in seconds since unix epoch - certificate.toJSON(), // JSON-serializable representation of an certificate - certificate.signature.toHex(), // Secp256k1 32-byte ECDSA signature from the Quible network, in hexadecimal format + certificate.toBytes(), // encoded certificate data as UInt8Array + certificate.claims[0].toBytes(), // first attested claim value as UInt8Array + certificate.claims[0].toString(), // first attested claim value encoded as utf8 string + certificate.identity.id.toHexString(), // identity id in hexadecimal format + certificate.expiresAt, // expiration date in seconds since unix epoch + certificate.toJSON(), // JSON-serializable representation of an certificate + certificate.signature.toHexString(), // Secp256k1 32-byte ECDSA signature from the Quible network, in hexadecimal format ]) return certificate diff --git a/sdk/apps/nft-minting-example/contracts/MyNFT.sol b/sdk/apps/nft-minting-example/contracts/MyNFT.sol index a81f0d0..27ce0a5 100644 --- a/sdk/apps/nft-minting-example/contracts/MyNFT.sol +++ b/sdk/apps/nft-minting-example/contracts/MyNFT.sol @@ -9,17 +9,17 @@ import "@quible/verifier-solidity-sdk/contracts/QuibleVerifier.sol"; contract MyNFT is ERC721, ERC721Enumerable, Ownable { uint256 private _nextTokenId; - bytes32 public quirkleRoot; + bytes32 public accessListIdentityId; - constructor(address initialOwner, bytes32 _quirkleRoot) + constructor(address initialOwner, bytes32 _accessListIdentityId) ERC721("MyNFT", "QMNFT") Ownable(initialOwner) { - quirkleRoot = _quirkleRoot; + accessListIdentityId = _accessListIdentityId; } modifier membersOnly(address to, uint64 expires_at, bytes memory signature) { - QuibleVerifier.verifyProof(quirkleRoot, to, expires_at, signature); + QuibleVerifier.verifyProof(accessListIdentityId, to, expires_at, signature); _; } @@ -54,7 +54,7 @@ contract MyNFT is ERC721, ERC721Enumerable, Ownable { return super.supportsInterface(interfaceId); } - function getQuirkleRoot() public view returns (bytes32) { - return quirkleRoot; + function getAccessListIdentityId() public view returns (bytes32) { + return accessListIdentityId; } } diff --git a/sdk/apps/nft-minting-example/src/components/Minting.tsx b/sdk/apps/nft-minting-example/src/components/Minting.tsx index 3237440..502c21e 100644 --- a/sdk/apps/nft-minting-example/src/components/Minting.tsx +++ b/sdk/apps/nft-minting-example/src/components/Minting.tsx @@ -1,13 +1,24 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { waitForTransactionReceipt, readContract } from '@wagmi/core' import { useReadContract, useWriteContract, useConfig } from 'wagmi' import MyNFTArtifacts from '../../artifacts/contracts/MyNFT.sol/MyNFT.json' import { convertHexStringToUint8Array } from '@quible/js-sdk/lib/utils' const Minting = (props: { accountAddress: string; tokenAddress: string }) => { + const [accessListUpdateIsPending, setAccessListUpdateIsPending] = + useState(false) + const [accessList, setAccessList] = useState([]) const config = useConfig() const { data: hash, writeContractAsync } = useWriteContract() + const { data: ownerAddressData, isSuccess: ownerAddressDataIsSuccess } = + useReadContract({ + abi: MyNFTArtifacts.abi, + address: props.tokenAddress as unknown as `0x${string}`, + functionName: 'owner', + args: [], + }) + const { data, isSuccess, refetch } = useReadContract({ abi: MyNFTArtifacts.abi, address: props.tokenAddress as unknown as `0x${string}`, @@ -15,15 +26,20 @@ const Minting = (props: { accountAddress: string; tokenAddress: string }) => { args: [props.accountAddress], }) + const handleAccessListUpdate = useCallback(async () => { + setAccessListUpdateIsPending(true) + setAccessListUpdateIsPending(false) + }, []) + const handleMint = useCallback(async () => { - console.log('querying quirkle root', props.tokenAddress) - const quirkleRoot = await readContract(config, { + console.log(`querying object id tokenAddress=${props.tokenAddress}`) + const identityId = await readContract(config, { abi: MyNFTArtifacts.abi, address: props.tokenAddress as `0x${string}`, - functionName: 'getQuirkleRoot', + functionName: 'getAccessListIdentityId', }) - console.log('got quirkle root', quirkleRoot) + console.log('got identity id', identityId) const response = await fetch('http://localhost:9013', { method: 'POST', @@ -35,7 +51,7 @@ const Minting = (props: { accountAddress: string; tokenAddress: string }) => { method: 'quible_requestCertificate', id: 67, params: [ - [...convertHexStringToUint8Array(quirkleRoot as string)], + [...convertHexStringToUint8Array(identityId as string)], [...convertHexStringToUint8Array(props.accountAddress.toLowerCase())], ], }), @@ -71,12 +87,13 @@ const Minting = (props: { accountAddress: string; tokenAddress: string }) => { writeContractAsync, ]) - if (!isSuccess) { + if (!isSuccess || !ownerAddressDataIsSuccess) { return
Loading...
} return (
+ {ownerAddressData === props.accountAddress &&

You are the owner

}

total NFT count: {`${data}`}

{hash &&

Transaction hash: {hash}

}