IcpKit Build Status Code Coverage Platforms License: MIT

IcpKit 是一个全面的 iOS 软件包,用于编写与互联网计算机协议 (ICP) 交互的移动应用程序,使用 Swift 编写。IcpKit 旨在促进 iOS 应用程序与 ICP 区块链之间的交互。

有关 ICP 开发的更多信息,我们建议从 https://internetcomputer.org/docs/current/references/ 开始。

贡献者

此软件包的主要开发者是 Konstantinos Gaitanis

此软件包由 Bity SADFinity 基金会开发者资助计划的帮助下构建。

许可证

MIT 许可证适用于所有 Swift 代码(参见 LICENSE)。

BLS12381 Rust 库已获得 Levi Feldman 的许可(参见 LICENSE)。

安装

Swift Package Manager

将 IcpKit 作为依赖项添加到您的 Xcode 项目非常容易,只需将其添加到 Package.swiftdependencies 值中即可。

...
dependencies: [
    .package(url: "https://github.com/kosta-bity/IcpKit.git", .upToNextMajor(from: "0.2.0"))
]
...

IcpKit 软件包定义了两个库 CandidIcpKitCandid 实现了 Candid 规范,IcpKit 实现了与容器的通信。要在您的代码中使用

import Candid  // not needed when using the CodeGenerator
import IcpKit

它有什么作用?

IcpKit 将负责与 ICP 通信所需的所有编码、序列化和密码学操作,使开发人员能够专注于应用程序的实际功能并启动其开发周期。

主要功能

IcpKit 软件包分为 3 个产品

Candid 库概述

Candid 是用于与任何 ICP 容器通信的语言。它描述了容器使用的数据类型,以及可以在容器上调用的方法。Candid 本质上是这些类型的文本和二进制表示形式。二进制表示形式是实际发送/接收到/从容器的数据,而文本表示形式用于 Candid 接口定义文件 (.did) 中,以描述容器的接口。

如果您使用 CodeGenerator 工具,那么您将永远不必直接使用 Candid 与容器交互。

Candid 库的主要类如下

描述
CandidValue Swift 中表示 CandidValue。例如 .bool(true)
CandidType 表示 CandidValue 的类型。例如 .option(.integer)
CandidSerialiserCandidDeserialiser 将任何 CandidValue 转换为/从其二进制表示形式转换,该二进制表示形式可以直接发送/接收到/从容器。
注意: 不支持序列化递归 candid 值。
CandidEncoderCandidDecoder 将任何 Encodable/ Decodable Swift 对象转换为/从 CandidValue 转换。参见 Swift/Candid 编码规则
CandidParser 解析 .did 文件的内容,并根据解析的文件类型返回 CandidInterfaceDefinitionCandidTypeCandidValue(参见 Candid 接口定义文件)。
CandidParser 主要用于提供的命令行工具,但也可以直接从您的应用程序中调用,用于从在线 .did 文件读取值或类似情况。

IcpKit 库概述

IcpKit 库构建于 Candid 之上,负责与容器的实际通信。IcpKit 的作用是抽象出调用容器函数时使用的底层数据结构。

IcpKit 库的主要类如下

描述
ICPRequestClient 负责向容器的方法发出 HTTP 请求。它接收方法的参数作为 CandidValue,并将容器的结果作为 CandidValue 返回。在内部,它将使用 CandidSerialiser 序列化 candid 参数,当提供 ICPSigningPrincipal 时对请求进行签名,最后将所有内容包装在 CBOR 中。当收到响应时,将执行相反的过程以返回 CandidValue
ICPFunction<T,R> ICPRequestClient 周围包装 CandidEncoder,以便可以使用 Swift 对象而不是 CandidValue 调用容器方法。同样,容器的响应使用 CandidDecoder 解码,以返回 Swift 对象。
LedgerCanister 作为示例代码提供,并且是使用 CodeGeneratorLedger.did 文件生成的代码。它允许查询任何 ICP 帐户的 ICP 余额,并获取任何 ICP 区块的详细信息。
ICPSigningPrincipal 您的应用程序必须实现的协议,以便对请求进行签名。该库提供了使用私钥 ICPCryptography.ellipticSign() 对任意数据进行签名的功能,但处理私钥是您的应用程序的责任。实现具体的 ICPSigningPrincipal 需要您获取所需的私钥,然后调用 ellipticSign 并返回结果。
参见 如何创建 Principal

ICPRequestClient 类是该库的通信主力,允许向任何容器发出方法调用。

Ledger Canister 作为示例实现提供,也允许轻松创建 ICP 钱包应用程序。

CodeGenerator 命令行工具概述

要使用 CodeGenerator,您必须首先克隆项目

git clone https://github.com/kosta-bity/IcpKit
cd IcpKit

然后,您可以使用 Swift 编译和运行,或者自己构建可执行文件。

构建可执行文件

IcpKit 根文件夹

./compile_code_generator.sh

您将在文件夹 .build/release/CodeGenerator 中找到可执行文件。运行不带任何参数的 CodeGenerator 将显示帮助信息。

使用 Swift 执行

或者,您可以使用 swift 从 IcpKit 根文件夹运行它

swift run CodeGenerator

Candid 接口定义文件 (.did)

容器由用于与其交互的所有类型和方法定义。大多数容器以 此处 定义的 .did 格式发布其定义。

IcpKit 可以解析这些文件并生成 Swift 代码,该代码可以包含在您的 iOS/Mac 项目中以与容器交互。这有效地抽象化了有关通信的所有技术细节。

.did 文件可以是以下两种类型之一

在您的应用程序中集成容器的开发过程

假设您有一个 .did 服务接口定义文件,与容器交互的步骤如下

为接口 .did 文件生成代码

这是一个简单的接口 MyDid.did

type MyVector = vec opt bool;
type FooResult = record { name: text; count: int8 };
service: { 
    foo: (input: MyVector; sorted: bool) -> (FooResult) query
};

使用命令

CodeGenerator -n SimpleExample MyDid.did

我们得到了生成的 SimpleExample.swift 文件,其内容如下

import IcpKit

enum SimpleExample {
	typealias MyVector = [Bool?]
    
	struct FooResult: Codable {
		let name: String
		let count: Int8
	}  
  
	class Service: ICPService {
		func foo(input: MyVector, sorted: Bool) async throws -> FooResult {
			let caller = ICPQuery<CandidTuple2<MyVector, Bool>, FooResult>(canister, "foo")
			let response = try await caller.callMethod(.init(input, sorted), client, sender: sender)
			return response
		}
	}
}

然后,我们可以将此文件添加到我们的项目中,并在我们的代码中的任何位置使用它,如下所示

let client = ICPRequestClient()
let myService = SimpleExample.Service("aaaaa-aa", client)
let myVector: SimpleExample.MyVector = [true, false, nil]
let record = try await myService.foo(input: myVector, sorted: false)
print(record.name)
print(record.count)

生成的代码是完全类型安全的,并避免了常见的错误,例如错误的数字类型等...

为值 .did 文件生成代码

.did 文件的示例是以下 phone_book.did

(
	vec {
	   record { name = "MyName"; phone = 1234 : nat; };
	   record { name = "AnotherName"; phone = 4321 : nat; };
	}
)

可以使用以下命令解析这些类型的文件

./CodeGenerator value -n PhoneBook phone_book.did

这将生成 PhoneBook.swift,其内容如下

enum PhoneBook {
   struct UnnamedType0: Codable {
      let name: String
      let phone: BigUInt
   }
   
   let cValue: [UnnamedType0] = [
   		.init(name: "MyName", phone: 1234),
      .init(name: "AnotherName", phone: 4321),
   ]
}

在运行时解析 .did 文件

您的应用程序可能需要下载 .did 文件,解析它并从中提取一些值。这可以使用 CandidParser 完成。例如,如果我们想下载并读取上面的 phone_book.did

let phoneBookDidContents: String = try await downloadDid() // download phone_book.did file and read it 
let phoneBookCandidValue: CandidValue = try CandidParser().parseValue(phoneBookContents)

我们可以更进一步,将解析的 CandidValue 解码为 Swift struct

struct PhoneBookEntry: Decodable {
  let name: String
	let phone: BigUInt
}
let phoneBook: [PhoneBookEntry] = try CandidDecoder().decode(phoneBookCandidValue)

Swift/Candid 编码规则

Candid Swift 注释
bool Bool
text String utf8 编码
int BigInt
nat BigUInt
int<n> Int<n> n = 8, 16, 32, 64Int 对应于 int64
nat<n> UInt<n> n = 8, 16, 32, 64UInt 对应于 nat64
blob Data
opt <candid_type> <swift_type>? 由于 Swift 编译器的限制,具有 nil 值的可选结构体被编码为 opt empty
CandidEncoder().encode(MyStruct?.none) // .option(.empty) 无法确定 nil 结构体的类型)
vec <candid_type> [<swift_type>]
record struct <name>: Codable { ... } 每个 record 都被编码/解码为 Swift Codable struct。每个 record 项对应于具有相同名称的 struct 成员值。如果 candid 项仅按数字键入,则名称为 _<number>
struct MyStruct: Codable { let a: Bool; let b: String? } 被编码为 record { a: bool; b: opt text; },而 record { bool; text; } 解码为 struct MyStruct2: Codable { let _0: Bool; let _1: String }
variant enum <name>: Codable { ... } 每个 variant 都被编码/解码为 Swift Codable enum。每个 variant 情况对应于具有相同名称的 enum 情况。关联值使用其名称附加到每个情况(如果可用)。
variant { winter, summer } 编码为 enum Season: Codable { case winter, summer }
variant { status: int; error: bool; } 编码为 enum Status { case status(Int); case error(Bool) }
function CandidFunctionProtocol 不支持将 Swift 函数自动编码为 Candid Value,因为我们无法在没有值的情况下从 Swift 类型推断函数签名。这是由于 Swift 的类型系统限制。但是,允许解码。
service CandidServiceProtocol 由于与函数相同的原因,不支持编码。
principal CandidPrincipal
null nil
empty nil
reserved nil

nullreservedempty vs nil

当从 CandidValue 解码 nullreservedemptySwift 时,它们始终在 Swift 中解码为 nil

当从 Swift 编码 nilCandidValue 时,它始终编码为 opt <sub_type>

CodingKeys

Candid 二进制格式不支持 record、variant、function 等的键的文本表示形式...而是使用 此处 定义的简单哈希方法

hash(id) = ( Sum_(i=0..k) utf8(id)[i] * 223^(k-i) ) mod 2^32 where k = |utf8(id)|-1

这意味着当从二进制格式反序列化 CandidValue 时,我们无法访问生成哈希的原始键。

CandidDecoder 会自动处理所有 Swift structs 的此问题,方法是哈希 swift 名称,然后比较哈希值。

但是,由于 Swift 解码系统的工作方式,我们无法对 Swift enums 执行相同的操作。这意味着为了从反序列化的 CandidValue(基本上是通过 ICPRequestClient 接收的每个值)解码 Swift enum,我们需要定义 CodingKeys 如下

有关更深入的理解,请参阅 Swift 文档中关于编码、解码和 Coding Keys 的内容。

高级主题

如何手动编写代码以与容器交互?

有几种方法可以执行请求。取决于它是简单的查询还是请求实际更改了区块链的状态。

定义您希望调用的方法

let method = ICPMethod(
  canister: ICPSystemCanisters.ledger,
  methodName: "account_balance",
  arg: .record([
    "account": .blob(account.accountId)
  ])
)

发出简单的查询请求

let response = try await client.query(method, effectiveCanister: ICPSystemCanisters.ledger)

等效地,我们也可以这样做

let response = try await client.query(.uncertified, method, effectiveCanister: ICPSystemCanisters.ledger)

发出调用请求,然后轮询响应

let requestId = try await client.call(method, effectiveCanister: ICPSystemCanisters.ledger)
let response = try await client.pollRequestStatus(requestId: requestId, effectiveCanister: ICPSystemCanisters.ledger)

等效地,我们也可以这样做

let response = try await client.callAndPoll(requestId: requestId, effectiveCanister: ICPSystemCanisters.ledger)

甚至

let response = try await client.query(.certified, method, effectiveCanister: ICPSystemCanisters.ledger)

所有这些都具有完全相同的结果。

如何使用 ICPFunction

可以使用以下代码实现相同的目的,只是这次使用 ICPFunction。这允许我们直接将 Encodable 馈送到函数,并接收回 DecodableICPFunction 将确保输入从输入 Swift 类型正确编码为 CandidValue,并且输出从 CandidValue 解码为输出 Swift 类型。

struct Result: Decodable {
	let name: String
	let count: Int?
}
// equivalent Candid definition:
// function (nat) -> ( record { name: text; count: opt int64; } )
let function = ICPFunction<BigUInt, Result>(canister, "foo")
let result = try await function.callMethod(12345)

如何创建 Principal?

从种子开始

我们建议使用 HorizontalSystems 的 HdWalletKit.Swift,以便从种子派生公钥/私钥对。ICP 派生路径为 m/44'/223'/0'/0/0

从公钥/私钥对开始

公钥必须采用未压缩形式。

  1. 使用 ICPCryptography.selfAuthenticatingPrincipal(uncompressedPublicKey:) 创建 ICPPrincipal 实例。
  2. 如果您需要对请求进行签名(例如,发送交易),您还需要创建 ICPSigningPrincipal
  3. 可以使用 ICPAccount.mainAccount(of:) 创建此 principal 的主帐户。

实现简单的 ICPSigningPrincipal

这是一个最小的 ICPSigningPrincipal 实现。这仅作为示例给出。请勿在生产环境中使用此代码,因为将私钥保存在内存中通常不是一个好习惯。

sign 方法特意设置为 async,以允许任何类型的延迟获取私钥(例如,远程访问或访问 iOS Secure Enclave)。

class SimpleSigningPrincipal: ICPSigningPrincipal {
  private let privateKey: Data
  let rawPublicKey: Data
  let principal: ICPPrincipal
  
  init(publicKey: Data, privateKey: Data) throws {
    self.privateKey = privateKey
    self.rawPublicKey = publicKey
    self.principal = try ICPCryptography.selfAuthenticatingPrincipal(uncompressedPublicKey: publicKey)    
  }
  
  func sign(_ message: Data, domain: ICPDomainSeparator) async throws -> Data {
    return try ICPCryptography.ellipticSign(message, domain: domain, with: privateKey)
  }
  
  static func fromMnemonic(_ words: [String]) throws -> SimplePrincipal {
    let seed = HdWalletKit.Mnemonic.seed(mnemonic: words)!
    let xPrivKey = HDExtendedKeyVersion.xprv.rawValue
    let privateKey = try HDPrivateKey(seed: seed, xPrivKey: xPrivKey)
      .derived(at: 44, hardened: true)
      .derived(at: 223, hardened: true)
      .derived(at: 0, hardened: true)
      .derived(at: 0, hardened: false)
      .derived(at: 0, hardened: false)
    let publicKey = privateKey.publicKey(compressed: false)
    return try SimplePrincipal(privateKey: privateKey.raw, uncompressedPublicKey: publicKey.raw)
  }
}

已知限制