Komponen: Blok Bangunan Seperti Lego untuk Kontrak Pintar
Mengembangkan kontrak yang berbagi logika dan penyimpanan umum dapat menyakitkan dan rentan terhadap bug, karena logika ini sulit untuk digunakan kembali dan perlu diimplementasikan ulang dalam setiap kontrak. Tetapi bagaimana jika ada cara untuk menyisipkan hanya fungsionalitas tambahan yang Anda butuhkan di dalam kontrak Anda, memisahkan logika inti kontrak Anda dari yang lain?
Komponen menyediakan hal tersebut secara tepat. Mereka adalah tambahan modular yang mengemas logika, penyimpanan, dan peristiwa yang dapat digunakan kembali yang dapat disertakan ke dalam beberapa kontrak. Mereka dapat digunakan untuk memperluas fungsionalitas kontrak tanpa harus mengimplementasikan ulang logika yang sama berulang kali.
Pikirkan tentang komponen sebagai blok Lego. Mereka memungkinkan Anda memperkaya kontrak Anda dengan menyematkan modul yang Anda atau orang lain tulis. Modul ini bisa menjadi yang sederhana, seperti komponen kepemilikan, atau lebih kompleks seperti token ERC20 yang lengkap.
Sebuah komponen adalah modul terpisah yang dapat berisi penyimpanan (storage), peristiwa (events), dan fungsi-fungsi. Berbeda dengan kontrak, sebuah komponen tidak dapat dideklarasikan atau didaftarkan. Logikanya pada akhirnya akan menjadi bagian dari bytecode kontrak yang telah disematkan di dalamnya.
What's in a Component?
Sebuah komponen sangat mirip dengan kontrak. Ini dapat berisi:
- Storage variables
- Events
- Fungsi eksternal dan internal
Berbeda dengan kontrak, sebuah komponen tidak dapat dideploy secara independen. Kode komponen menjadi bagian dari kontrak tempat komponen tersebut disematkan.
Membuat Komponen
To create a component, first define it in its own module decorated with a #[starknet::component]
attribute. Within this module, you can declare a Storage
struct and Event
enum, as usually done in contracts.
The next step is to define the component interface, containing the signatures of the functions that will allow external access to the component's logic. You can define the interface of the component by declaring a trait with the #[starknet::interface]
attribute, just as you would with contracts. This interface will be used to enable external access to the component's functions using the dispatcher pattern.
Implementasi aktual logika eksternal komponen dilakukan dalam blok impl
yang ditandai sebagai #[embeddable_as(name)]
. Biasanya, blok impl
ini akan menjadi implementasi dari trait yang mendefinisikan antarmuka dari komponen.
Catatan:
name
adalah nama yang akan kita gunakan dalam kontrak untuk merujuk ke komponen. Ini berbeda dengan nama impl Anda.
Anda juga dapat mendefinisikan fungsi-fungsi internal yang tidak akan dapat diakses secara eksternal, dengan hanya menghilangkan atribut #[embeddable_as(name)]
di atas blok impl
internal. Anda akan dapat menggunakan fungsi-fungsi internal ini di dalam kontrak yang menanamkan komponen, tetapi tidak dapat berinteraksi dengan mereka dari luar, karena mereka bukan bagian dari abi dari kontrak.
Fungsi-fungsi dalam blok impl
ini mengharapkan argumen seperti ref self: ComponentState<TContractState>
(untuk fungsi-fungsi yang memodifikasi state) atau self: @ComponentState<TContractState>
(untuk fungsi-fungsi view). Hal ini membuat impl menjadi generik terhadap TContractState
, memungkinkan kita untuk menggunakan komponen ini dalam berbagai kontrak.
Example: an Ownable Component
⚠️ Contoh yang ditunjukkan di bawah ini belum diaudit dan tidak dimaksudkan untuk digunakan secara produksi. Para penulis tidak bertanggung jawab atas segala kerusakan yang disebabkan oleh penggunaan kode ini.
Antarmuka dari komponen Ownable, yang mendefinisikan metode-metode yang tersedia secara eksternal untuk mengelola kepemilikan sebuah kontrak, akan terlihat seperti ini:
#[starknet::interface]
trait IOwnable<TContractState> {
fn owner(self: @TContractState) -> ContractAddress;
fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress);
fn renounce_ownership(ref self: TContractState);
}
Komponen itu sendiri didefinisikan sebagai:
#[starknet::component]
pub mod ownable_component {
use core::starknet::{ContractAddress, get_caller_address};
use core::starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use super::Errors;
use core::num::traits::Zero;
#[storage]
pub struct Storage {
owner: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
OwnershipTransferred: OwnershipTransferred,
}
#[derive(Drop, starknet::Event)]
struct OwnershipTransferred {
previous_owner: ContractAddress,
new_owner: ContractAddress,
}
#[embeddable_as(Ownable)]
impl OwnableImpl<
TContractState, +HasComponent<TContractState>,
> of super::IOwnable<ComponentState<TContractState>> {
fn owner(self: @ComponentState<TContractState>) -> ContractAddress {
self.owner.read()
}
fn transfer_ownership(
ref self: ComponentState<TContractState>, new_owner: ContractAddress,
) {
assert(!new_owner.is_zero(), Errors::ZERO_ADDRESS_OWNER);
self.assert_only_owner();
self._transfer_ownership(new_owner);
}
fn renounce_ownership(ref self: ComponentState<TContractState>) {
self.assert_only_owner();
self._transfer_ownership(Zero::zero());
}
}
#[generate_trait]
pub impl InternalImpl<
TContractState, +HasComponent<TContractState>,
> of InternalTrait<TContractState> {
fn initializer(ref self: ComponentState<TContractState>, owner: ContractAddress) {
self._transfer_ownership(owner);
}
fn assert_only_owner(self: @ComponentState<TContractState>) {
let owner: ContractAddress = self.owner.read();
let caller: ContractAddress = get_caller_address();
assert(!caller.is_zero(), Errors::ZERO_ADDRESS_CALLER);
assert(caller == owner, Errors::NOT_OWNER);
}
fn _transfer_ownership(
ref self: ComponentState<TContractState>, new_owner: ContractAddress,
) {
let previous_owner: ContractAddress = self.owner.read();
self.owner.write(new_owner);
self
.emit(
OwnershipTransferred { previous_owner: previous_owner, new_owner: new_owner },
);
}
}
}
Syntax ini sebenarnya cukup mirip dengan syntax yang digunakan untuk kontrak. Satu-satunya perbedaan terkait dengan atribut #[embeddable_as]
di atas impl dan genericity dari blok impl yang akan kita analisis secara detail.
Seperti yang dapat Anda lihat, komponen kami memiliki dua blok impl
: satu yang sesuai dengan implementasi dari trait antarmuka, dan satu berisi metode-metode yang tidak seharusnya terpapar secara eksternal dan hanya dimaksudkan untuk penggunaan internal. Memaparkan assert_only_owner
sebagai bagian dari antarmuka tidak akan masuk akal, karena itu hanya dimaksudkan untuk digunakan secara internal oleh sebuah kontrak yang menanamkan komponen.
A Closer Look at the impl
Block
#[embeddable_as(Ownable)]
impl OwnableImpl<
TContractState, +HasComponent<TContractState>,
> of super::IOwnable<ComponentState<TContractState>> {
Atribut #[embeddable_as]
digunakan untuk menandai impl sebagai yang dapat disematkan di dalam sebuah kontrak. Ini memungkinkan kita untuk menentukan nama impl yang akan digunakan dalam kontrak untuk merujuk ke komponen ini. Dalam kasus ini, komponen ini akan dirujuk sebagai Ownable
dalam kontrak yang menanamkannya.
The implementation itself is generic over ComponentState<TContractState>
, with the added restriction that TContractState
must implement the HasComponent<T>
trait. This allows us to use the component in any contract, as long as the contract implements the HasComponent
trait. Understanding this mechanism in details is not required to use components, but if you're curious about the inner workings, you can read more in the "Components Under the Hood" section.
Salah satu perbedaan utama dari sebuah kontrak pintar reguler adalah bahwa akses ke penyimpanan dan peristiwa dilakukan melalui tipe generik ComponentState<TContractState>
dan bukan ContractState
. Perhatikan bahwa meskipun tipe ini berbeda, akses penyimpanan atau pengeluaran peristiwa dilakukan secara serupa melalui self.storage_var_name.read()
atau self.emit(...).
Catatan: Untuk menghindari kebingungan antara nama yang dapat disematkan dan nama impl, kami merekomendasikan untuk tetap menyisipkan akhiran
Impl
dalam nama impl.
Migrasi Kontrak ke Komponen
Karena kedua kontrak dan komponen memiliki banyak kesamaan, sebenarnya sangat mudah untuk bermigrasi dari sebuah kontrak ke sebuah komponen. Satu-satunya perubahan yang diperlukan adalah:
- Menambahkan atribut
#[starknet::component]
ke modul. - Menambahkan atribut
#[embeddable_as(name)]
ke blokimpl
yang akan disematkan dalam kontrak lain. - Menambahkan parameter generik ke blok
impl
:- Menambahkan
TContractState
sebagai parameter generik. - Menambahkan
+HasComponent<TContractState>
sebagai batasan impl.
- Menambahkan
- Mengubah tipe argumen
self
dalam fungsi-fungsi di dalam blokimpl
menjadiComponentState<TContractState>
alih-alihContractState
.
Untuk trait yang tidak memiliki definisi eksplisit dan dihasilkan menggunakan #[generate_trait]
, logikanya sama - tetapi trait tersebut bersifat generic terhadap TContractState
alih-alih ComponentState<TContractState>
, seperti yang ditunjukkan dalam contoh dengan InternalTrait
.
Using Components Inside a Contract
Kekuatan utama dari komponen adalah bagaimana ia memungkinkan penggunaan kembali primitif-primitif yang sudah dibangun di dalam kontrak Anda dengan jumlah boilerplate yang terbatas. Untuk mengintegrasikan sebuah komponen ke dalam kontrak Anda, Anda perlu:
-
Deklarasikan dengan menggunakan macro
component!()
, dengan menentukan- Jalur ke komponen
path::to::component
. - Nama variabel dalam penyimpanan kontrak Anda yang merujuk pada penyimpanan komponen ini (misalnya,
ownable
). - Nama varian dalam enumerasi acara kontrak Anda yang merujuk pada acara komponen ini (misalnya,
OwnableEvent
).
- Jalur ke komponen
-
Tambahkan jalur penyimpanan dan acara komponen ke
Storage
danEvent
kontrak. Mereka harus sesuai dengan nama-nama yang diberikan pada langkah 1 (misalnya,ownable: ownable_component::Storage
danOwnableEvent: ownable_component::Event
).Variabel penyimpanan HARUS diberi anotasi dengan atribut
#[substorage(v0)]
. -
Sematkan logika komponen yang didefinisikan di dalam kontrak Anda, dengan menginstansiasi impl generic komponen dengan
ContractState
konkret menggunakan alias impl. Alias ini harus dianotasi dengan#[abi(embed_v0)]
untuk mengekspos secara eksternal fungsi-fungsi komponen.Seperti yang dapat Anda lihat, InternalImpl tidak ditandai dengan
#[abi(embed_v0)]
. Memang, kita tidak ingin mengekspos secara eksternal fungsi-fungsi yang didefinisikan dalam impl ini. Namun, kita mungkin masih ingin mengaksesnya secara internal.
Sebagai contoh, untuk menyematkan komponen Ownable
yang didefinisikan di atas, kita akan melakukan hal berikut:
#[starknet::contract]
mod OwnableCounter {
use listing_01_ownable::component::ownable_component;
use core::starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
component!(path: ownable_component, storage: ownable, event: OwnableEvent);
#[abi(embed_v0)]
impl OwnableImpl = ownable_component::Ownable<ContractState>;
impl OwnableInternalImpl = ownable_component::InternalImpl<ContractState>;
#[storage]
struct Storage {
counter: u128,
#[substorage(v0)]
ownable: ownable_component::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
OwnableEvent: ownable_component::Event,
}
#[abi(embed_v0)]
fn foo(ref self: ContractState) {
self.ownable.assert_only_owner();
self.counter.write(self.counter.read() + 1);
}
}
Logika komponen sekarang secara mulus menjadi bagian dari kontrak! Kami dapat berinteraksi dengan fungsi-fungsi komponen secara eksternal dengan memanggilnya menggunakan IOwnableDispatcher
yang diinstansiasi dengan alamat kontrak.
#[starknet::interface]
trait IOwnable<TContractState> {
fn owner(self: @TContractState) -> ContractAddress;
fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress);
fn renounce_ownership(ref self: TContractState);
}
Menumpuk Komponen untuk Komposabilitas Maksimum
The composability of components really shines when combining multiple of them together. Each adds its features onto the contract. You can rely on Openzeppelin's implementation of components to quickly plug-in all the common functionalities you need a contract to have.
Pengembang dapat fokus pada logika inti kontrak mereka sambil mengandalkan komponen yang telah diuji pertempuran dan diaudit untuk segala hal lainnya.
Components can even depend on other components by restricting the TContractstate
they're generic on to implement the trait of another component. Before we dive into this mechanism, let's first look at how components work under the hood.