Zhuang's Diary

言之有物,持之以恒

假设一个罪犯,他想要将钱洗干净:

1)通常他会将钱转移到一个干净的地址,很可能会建立控制权,这样可以否认自己是当事方;

2)开始交易:

  • 在DEX (AMM)交易所中交易,可能是几次投资性交易,最终回到stable coin;
  • 在中心化交易所交易,其历史交易记录保存在其中心化order_book_history中;
  • 在桥接协议中去到其他公有链交易,混淆交易路径;
  • 利用零知识证明、混币等隐私交易,可以隐藏交易路径以及金额;
  • 通过 scaling protocol 交易,可以做隐私交易,如zksync;也可以只做rollup,如Optimism;也可能是state channel,如Raiden Network只记录blockhash而已;
  • 隐私合约,桥接合约,HTLC合约等可以是新部署,且只服务若干笔交易;
  • 以上交易方式可以搭配,组合使用。

3)最终从一个干净的地址,甚至(关联交易,关联账户)拆解成为小额交易,从交易所交易成为现金取出。

另外,未来还有一种可能会影响合规和审计:

1)隐私合约,桥接合约,HTLC合约等可以是新部署,且只服务若干笔交易;

2)交易有可能是废弃的,无效的 https://github.com/ethereum/EIPs/blob/master/EIPS/eip-5081.md;

3)以太坊删除无用的state数据。

最后,Even if a contract is removed by selfdestruct, it is still part of the history of the blockchain and probably retained by most Ethereum nodes. So using selfdestruct is not the same as deleting data from a hard disk. 合约删除不会是技术障碍。

请先阅览前文 ==> EVM之源码分析 ,整个分析其实就是为了移植虚拟机做基础。

说明

因为涉及到的代码会比较多,不可能把所有代码都列举出来。所以也只是挑关键的部分进行讲解说明。整个移植的代码已经合到之前的那个简单(无用)demo版本的公链项目上了。 移植的以太坊版本为v1.8.12.

开始移植

首先先创建一个go的新项目, 将go-ethereum项目下core/vm文件夹下的代码全部拷贝到新项目下,我们为新的文件夹名称为cvm。 保存之后, 假设你用的是vscode(带上了go的周边插件)或者goland,这个时候你会发现有大量的报错。 没有关系, 因为很多以太坊包还没有被导入进来。 但是呢, 既然我们只想移植虚拟机部分,又不引入以太坊的其他模块。 这个时候我们就把需要的包直接拷贝到我们的项目中。 彻底分离和go-ethereum的关系。这里需要说明一下, 虽然是开源项目, 拷贝和使用别人的开源代码也要注意license的。

  1. 主要需要拷贝的包如下
  • go-ethereum/common这个文件夹, 我们也将其这个内容均拷贝到cvm这个文件夹下。
  • go-ethereum/params这个文件夹中的gas_tables.go, protocol_params.go两个文件拷贝到cvm/params文件夹下。
  • 创建log.go文件在cvm/types/下, 该文件中主要是智能合约emit提交事件时使用的log对象。 内容如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type Log struct {
// Consensus fields:
// address of the contract that generated the event
Address common.Address `json:"address" gencodec:"required"`
// list of topics provided by the contract.
Topics []common.Hash `json:"topics" gencodec:"required"`
// supplied by the contract, usually ABI-encoded
Data []byte `json:"data" gencodec:"required"`

// Derived fields. These fields are filled in by the node
// but not secured by consensus.
// block in which the transaction was included
BlockNumber uint64 `json:"blockNumber"`
// hash of the transaction
TxHash common.Hash `json:"transactionHash" gencodec:"required"`
// index of the transaction in the block
TxIndex uint `json:"transactionIndex" gencodec:"required"`
// hash of the block in which the transaction was included
BlockHash common.Hash `json:"blockHash"`
// index of the log in the receipt
Index uint `json:"logIndex" gencodec:"required"`

// The Removed field is true if this log was reverted due to a chain reorganisation.
// You must pay attention to this field if you receive logs through a filter query.
Removed bool `json:"removed"`
}
  1. 在cvm目录下创建vm.go文件, 主要是生成evm的上下文对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// NewEVMContext creates a new context for use in the EVM.
func NewEVMContext(from common.Address, blockNum, timeStamp, difficulty int64) vm.Context {
// If we don't have an explicit author (i.e. not mining), extract from the header
return vm.Context{
CanTransfer: CanTransfer,
Transfer: Transfer,
GetHash: GetHashFn(),
Origin: from,
Coinbase: common.Address{},
BlockNumber: new(big.Int).Set(big.NewInt(blockNum)),
Time: new(big.Int).Set(big.NewInt(timeStamp)),
Difficulty: new(big.Int).Set(big.NewInt(difficulty)),
GasLimit: 0xfffffffffffffff, //header.GasLimit,
GasPrice: new(big.Int).Set(big.NewInt(10)),
}
}

// GetHashFn returns a GetHashFunc which retrieves header hashes by number 获取块号码对于的块hash
func GetHashFn() func(n uint64) common.Hash {

return func(n uint64) common.Hash {
// If there's no hash cache yet, make one
// if cache == nil {
// cache = map[uint64]common.Hash{
// ref.Number.Uint64() - 1: ref.ParentHash,
// }
// }
// // Try to fulfill the request from the cache
// if hash, ok := cache[n]; ok {
// return hash
// }
// // Not cached, iterate the blocks and cache the hashes
// for header := chain.GetHeader(ref.ParentHash, ref.Number.Uint64()-1); header != nil; header = chain.GetHeader(header.ParentHash, header.Number.Uint64()-1) {
// cache[header.Number.Uint64()-1] = header.ParentHash
// if n == header.Number.Uint64()-1 {
// return header.ParentHash
// }
// }
return common.Hash{}
}
}

// CanTransfer checks wether there are enough funds in the address' account to make a transfer.
// This does not take the necessary gas in to account to make the transfer valid.
func CanTransfer(db vm.StateDB, addr common.Address, amount *big.Int) bool {
return db.GetBalance(addr).Cmp(amount) >= 0
}

// Transfer subtracts amount from sender and adds amount to recipient using the given Db
func Transfer(db vm.StateDB, sender, recipient common.Address, amount *big.Int) {
db.SubBalance(sender, amount)
db.AddBalance(recipient, amount)
}
  1. 接着我们把go-ethereum/account文件夹下的abi内容拷贝到cvm文件夹下。

abi/bind文件内容可以直接删除掉。此文件夹下是对智能合约进行函数调用进行编码的包。换句话调用智能合约构建的交易中的input内容就是需要此包中函数来生成的。当然如果你看了前面的文章,对智能合约调用了解的话,此处自然就理解这个包的作用了。

到了这里整个需要拷贝的文件就齐全了, 目录结构如下:

  1. 接下来我们需要修改evm的部分代码了。

vm/contracts.go文件我们直接删除掉。 这个是自带的智能合约, 内部主要是一些内置函数, 注意实际使用的时候记得还是要实现的, evm.go文件中run函数忽略掉所有内置的合约函数。

修改evm.go文件中的Call函数 当地址不存在时我们直接认为是创建地址, 忽略掉掉内置合约。

  1. 接着我们要实现evm.StateDB接口的内容了, 因为此接口涉及涉及的主要是个账户状态相关的内容, 也即是说可整个区块的存储是有关联的, 暂时我也只能以一个示例来说明是如何简单的实现这些接口。

我们在cvm文件夹下创建一个account_state.go的文件。 定义的数据结构格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type accountObject struct {
Address common.Address `json:"address,omitempty"`
AddrHash common.Hash `json:"addr_hash,omitempty"` // hash of ethereum address of the account
ByteCode []byte `json:"byte_code,omitempty"`
Data accountData `json:"data,omitempty"`
CacheStorage map[common.Hash]common.Hash `json:"cache_storage,omitempty"` // 用于缓存存储的变量
}

type accountData struct {
Nonce uint64 `json:"nonce,omitempty"`
Balance *big.Int `json:"balance,omitempty"`
Root common.Hash `json:"root,omitempty"` // merkle root of the storage trie
CodeHash []byte `json:"code_hash,omitempty"`
}

//AccountState 实现vm的StateDB的接口 用于进行测试
type AccountState struct {
Accounts map[common.Address]*accountObject `json:"accounts,omitempty"`
}
  1. 接下来我们实现StateDB接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
// CreateAccount 创建账户接口 
func (accSt *AccountState) CreateAccount(addr common.Address) {
if accSt.getAccountObject(addr) != nil {
return
}
obj := newAccountObject(addr, accountData{})
accSt.setAccountObject(obj)
}

// SubBalance 减去某个账户的余额
func (accSt *AccountState) SubBalance(addr common.Address, amount *big.Int) {
stateObject := accSt.getOrsetAccountObject(addr)
if stateObject != nil {
stateObject.SubBalance(amount)
}
}

// AddBalance 增加某个账户的余额
func (accSt *AccountState) AddBalance(addr common.Address, amount *big.Int) {
stateObject := accSt.getOrsetAccountObject(addr)
if stateObject != nil {
stateObject.AddBalance(amount)
}
}

//// GetBalance 获取某个账户的余额
func (accSt *AccountState) GetBalance(addr common.Address) *big.Int {
stateObject := accSt.getOrsetAccountObject(addr)
if stateObject != nil {
return stateObject.Balance()
}
return new(big.Int).SetInt64(0)
}
//GetNonce 获取nonce
func (accSt *AccountState) GetNonce(addr common.Address) uint64 {
stateObject := accSt.getAccountObject(addr)
if stateObject != nil {
return stateObject.Nonce()
}
return 0
}

// SetNonce 设置nonce
func (accSt *AccountState) SetNonce(addr common.Address, nonce uint64) {
stateObject := accSt.getOrsetAccountObject(addr)
if stateObject != nil {
stateObject.SetNonce(nonce)
}
}

// GetCodeHash 获取代码的hash值
func (accSt *AccountState) GetCodeHash(addr common.Address) common.Hash {
stateObject := accSt.getAccountObject(addr)
if stateObject == nil {
return common.Hash{}
}
return common.BytesToHash(stateObject.CodeHash())
}

//GetCode 获取智能合约的代码
func (accSt *AccountState) GetCode(addr common.Address) []byte {
stateObject := accSt.getAccountObject(addr)
if stateObject != nil {
return stateObject.Code()
}
return nil
}

//SetCode 设置智能合约的code
func (accSt *AccountState) SetCode(addr common.Address, code []byte) {
stateObject := accSt.getOrsetAccountObject(addr)
if stateObject != nil {
stateObject.SetCode(crypto.Sha256(code), code)
}
}

// GetCodeSize 获取code的大小
func (accSt *AccountState) GetCodeSize(addr common.Address) int {
stateObject := accSt.getAccountObject(addr)
if stateObject == nil {
return 0
}
if stateObject.ByteCode != nil {
return len(stateObject.ByteCode)
}
return 0
}

// AddRefund 暂时先忽略补偿
func (accSt *AccountState) AddRefund(uint64) {
return
}

//GetRefund ...
func (accSt *AccountState) GetRefund() uint64 {
return 0
}

// GetState 和SetState 是用于保存合约执行时 存储的变量是否发生变化 evm对变量存储的改变消耗的gas是有区别的
func (accSt *AccountState) GetState(addr common.Address, key common.Hash) common.Hash {
stateObject := accSt.getAccountObject(addr)
if stateObject != nil {
return stateObject.GetStorageState(key)
}
return common.Hash{}
}

// SetState 设置变量的状态
func (accSt *AccountState) SetState(addr common.Address, key common.Hash, value common.Hash) {
stateObject := accSt.getOrsetAccountObject(addr)
if stateObject != nil {
fmt.Printf("SetState key: %x value: %s", key, new(big.Int).SetBytes(value[:]).String())
stateObject.SetStorageState(key, value)
}
}

// Suicide 暂时禁止自杀
func (accSt *AccountState) Suicide(common.Address) bool {
return false
}

// HasSuicided ...
func (accSt *AccountState) HasSuicided(common.Address) bool {
return false
}

// Exist 检查账户是否存在
func (accSt *AccountState) Exist(addr common.Address) bool {
return accSt.getAccountObject(addr) != nil
}

//Empty 是否是空账户
func (accSt *AccountState) Empty(addr common.Address) bool {
so := accSt.getAccountObject(addr)
return so == nil || so.Empty()
}

// RevertToSnapshot ...
func (accSt *AccountState) RevertToSnapshot(int) {

}

// Snapshot ...
func (accSt *AccountState) Snapshot() int {
return 0
}

// AddLog 添加事件触发日志
func (accSt *AccountState) AddLog(log *types.Log) {
fmt.Printf("log: %v", log)
}

// AddPreimage
func (accSt *AccountState) AddPreimage(common.Hash, []byte) {

}

// ForEachStorage 暂时没发现vm调用这个接口
func (accSt *AccountState) ForEachStorage(common.Address, func(common.Hash, common.Hash) bool) {

}

// Commit 进行持久存储 这里我们只将其简单的json话之后保存到本地磁盘中。
func (accSt *AccountState) Commit() error {
// 将bincode写入文件
file, err := os.Create("./account_sate.db")
if err != nil {
return err
}
err = json.NewEncoder(file).Encode(accSt)
//fmt.Println("len(binCode): ", len(binCode), " code: ", binCode)
// bufW := bufio.NewWriter(file)
// bufW.Write(binCode)
// // bufW.WriteByte('\n')
// bufW.Flush()
file.Close()
return err
}

//TryLoadFromDisk 尝试从磁盘加载AccountState
func TryLoadFromDisk() (*AccountState, error) {
file, err := os.Open("./account_sate.db")
if err != nil && os.IsNotExist(err) {
return NewAccountStateDb(), nil
}
if err != nil {
return nil, err
}

// stat, _ := file.Stat()
// // buf := stat.Size()
var accStat AccountState

err = json.NewDecoder(file).Decode(&accStat)
return &accStat, err
}
  1. 接下来尝试部署两份智能合约进行测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
pragma solidity ^0.4.21;
interface BaseInterface {
function CurrentVersion() external view returns(string);
}

contract Helloworld {
uint256 balance;
event Triggle(address, string);
mapping(address=>uint256) _mapamount;

constructor() public {
balance = 6000000000;
_mapamount[0] = 100;
_mapamount[1] = 200;
}

function getbalance() public returns (address, uint256) {
emit Triggle(msg.sender, "funck");
return (msg.sender, balance--);
}

function onlytest() public{
_mapamount[1] = 100;
emit Triggle(msg.sender, "onlytest");
}

function setBalance(uint256 tmp) public {
balance = tmp;
}

function getVersion(address contractAddr) public view returns (string) {
BaseInterface baseClass = BaseInterface(contractAddr);
return baseClass.CurrentVersion();
}

}

pragma solidity ^0.4.21;

contract BaseContract {
address public owner;
//
function CurrentVersion() pure public returns(string) {
return "BaseContractV0.1";
}
}

通过这两个合约我们就可以测试到一些view 类型的函数调用, 一些对数据状态有修改的合约调用, 和跨合约的调用。 我们可以将上面的两个合约通过ethereum官方出品的remix进行编译,得到字节码。因为BaseContract没有涉及初始化的内容 所以我们可以直接使用runtime的bytecode。 不过我们直接使用Create函数去部署合约。

1
runtimeBytecode, contractAddr, leftgas, err := vmenv.Create(vm.AccountRef(normalAccount), helloCode, 10000000000, big.NewInt(0))

第一个返回值其实就是需要部署的runtime字节码 , 我们调用stateDb.SetCode(helloWorldcontactAccont, runtimeBytecode)将其部署。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var normalAddress, _ = hex.DecodeString("123456abc")
var hellWorldcontractAddress, _ = hex.DecodeString("987654321")
var baseContractAddress, _ = hex.DecodeString("038f160ad632409bfb18582241d9fd88c1a072ba")
var normalAccount = common.BytesToAddress(normalAddress)
var helloWorldcontactAccont = common.BytesToAddress(hellWorldcontractAddress)
var baseContractAccont = common.BytesToAddress(baseContractAddress)

// 基本账户字节码
var baseCodeStr = "608060405260043610610062576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680632b225f29146100675780638afc3605146100f75780638da5cb5b1461010e578063f2fde38b14610165575b600080fd5b34801561007357600080fd5b5061007c6101a8565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156100bc5780820151818401526020810190506100a1565b50505050905090810190601f1680156100e95780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561010357600080fd5b5061010c6101e5565b005b34801561011a57600080fd5b50610123610227565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b34801561017157600080fd5b506101a6600480360381019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919050505061024c565b005b60606040805190810160405280601081526020017f42617365436f6e747261637456302e3100000000000000000000000000000000815250905090565b336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415156102a757600080fd5b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141515156102e357600080fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508073ffffffffffffffffffffffffffffffffffffffff166000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e060405160405180910390a3505600a165627a7a723058208c3064096245894122f6bcf5e2ee12e30d4775a3b8dca0b21f10d5a5bc386e8b0029"

// hellworld 账户字节码
var hellCodeStr = "6080604052600436106100615763ffffffff7c01000000000000000000000000000000000000000000000000000000006000350416634d9b3d5d81146100665780637e8800a7146100ab578063c3f82bc3146100c2578063fb1669ca14610165575b600080fd5b34801561007257600080fd5b5061007b61017d565b6040805173ffffffffffffffffffffffffffffffffffffffff909316835260208301919091528051918290030190f35b3480156100b757600080fd5b506100c06101fa565b005b3480156100ce57600080fd5b506100f073ffffffffffffffffffffffffffffffffffffffff6004351661028f565b6040805160208082528351818301528351919283929083019185019080838360005b8381101561012a578181015183820152602001610112565b50505050905090810190601f1680156101575780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561017157600080fd5b506100c0600435610389565b60408051338152602081018290526005818301527f66756e636b0000000000000000000000000000000000000000000000000000006060820152905160009182917f08c31d20d5c3a5f2cfe0adf83909e6411f43fe97eb091e15c12f3e5a203e8fde9181900360800190a150506000805460001981019091553391565b600080526001602090815260647fa6eef7e35abe7026729641147f7915573c7e97b47efa546f5f6e3230263bcb4955604080513381529182018190526008828201527f6f6e6c79746573740000000000000000000000000000000000000000000000006060830152517f08c31d20d5c3a5f2cfe0adf83909e6411f43fe97eb091e15c12f3e5a203e8fde9181900360800190a1565b606060008290508073ffffffffffffffffffffffffffffffffffffffff16632b225f296040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401600060405180830381600087803b1580156102fa57600080fd5b505af115801561030e573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f19168201604052602081101561033757600080fd5b81019080805164010000000081111561034f57600080fd5b8201602081018481111561036257600080fd5b815164010000000081118282018710171561037c57600080fd5b5090979650505050505050565b6000555600a165627a7a72305820c63a859d93a3512b52ccaec75bb9aa146648c41b21c8a0cd0cd2e2c1aede35ed0029"

var helloCode, _ = hex.DecodeString(hellCodeStr)
var baseCode, _ = hex.DecodeString(baseCodeStr)

func updateContract() {
// 加载账户State
stateDb, err := cvm.TryLoadFromDisk()
if err != nil {
panic(err)
}
stateDb.SetCode(helloWorldcontactAccont, helloCode)
stateDb.SetCode(baseContractAccont, baseCode)
fmt.Println(stateDb.Commit())
}

当我们调用一个智能合约比如getbalance函数, 代码类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 4d9b3d5d : getbalance  7e8800a7: onlytest fb1669ca000000000000000000000000000000000000000000000000000000000000029a: setbalance 666
var input, _ = hex.DecodeString("7e8800a7")

func main() {
// updateContract()
// return
// 创建账户State
stateDb, err := cvm.TryLoadFromDisk()
if err != nil {
panic(err)
}

evmCtx := cvm.NewEVMContext(normalAccount, 100, 1200000, 1)
vmenv := vm.NewEVM(evmCtx, stateDb, vm.Config{})

ret, leftgas, err := vmenv.Call(vm.AccountRef(normalAccount), helloWorldcontactAccont, input, 1000000, big.NewInt(0))
fmt.Printf("ret: %v, usedGas: %v, err: %v, len(ret): %v, hexret: %v, ", ret, 1000000-leftgas, err, len(ret), hex.EncodeToString(ret))

abiObjet, _ := abi.JSON(strings.NewReader(hellWorldContractABIJson))

// begin, length, _ := lengthPrefixPointsTo(0, ret)
addr := new(common.Address)

value := big.NewInt(0) //new(*big.Int)
restult := []interface{}{addr, &value}
fmt.Println(abiObjet.Unpack(&restult, "getbalance", ret))
//fmt.Println(unpackAtomic(&restult, string(ret[begin:begin+length])))
println(restult[0].(*common.Address).String(), (*restult[1].(**big.Int)).String())
fmt.Println(stateDb.Commit())

}

最后

到了这里, evm移植流程就算完成了。 如果理解evm执行的原理, 大部分的工作其实就是拷贝, 出错的任务。 当然这个移植后的代码肯定是不能在生产中使用的, 但是需要修改和添加的代码主要也就是上文提到的内容。 最后还是想说明白原理和流程就是成功了一大半, 后面的部分主要就是调试和排错的过程了。

I tried here https://github.com/adrien2p/medusa-extender#marketplace-tutorial for new medusa-server, the same as https://github.com/shahednasser/medusa-marketplace, but fail to “npm start”.

But we can run it buy code update directly step by step — https://dev.to/medusajs/create-an-open-source-commerce-marketplace-part-1-3m5k, this is a guide to migrate exciting medusa-server to upgrade about “store_id”.

==================> guide detail <==================

Medusa is an open source headless commerce platform that allows you to create your own store in a matter of minutes. Part of what makes Medusa a good choice for your ecommerce store is its extensibility. Now, it is also possible to create multi-vendor marketplaces using Medusa.

To make things easier for our open source community, Adrien de Peretti, one of our amazing contributors, created a Medusa module that allows you to extend anything and everything you want.

“I’ve been looking for an e-commerce solution that could provide me with some core features while being fully customisable… After some research, where I found that none of the present solutions could provide what I needed, I chose Medusa as it provided me with many of the needed features while being easy to extend. I ended up loving the community atmosphere, especially the proximity with the team, and have been helping those in the community looking for a similar fully-customisable solution by sharing a part of my private project. This is how the medusa-extender was born.” — Adrien de Peretti

In this tutorial, you’ll learn how to install and set up the Medusa Extender module on your Medusa server. You’ll then learn how to use its customization abilities to create a marketplace in your store! The marketplace will have multiple stores or vendors, and each of these stores will be able to add its own products. This tutorial will be the first part of a series that will explore all aspects of creating a marketplace.

What is Medusa Extender

Medusa Extender is an NPM package that you can add to your Medusa store to extend or customize its functionalities. The scope of its customization entails Entities, Repositories, Services, and more.

The Medusa Extender has many use cases aside the marketplace functionality. It can be used in many other use cases, such as adding custom fields, listening to events to perform certain actions like sending emails, customizing Medusa’s validation of request parameters, and more.

What You’ll Be Creating

In this article and the following parts of this series, you’ll learn how to create a marketplace using Medusa and Medusa Extender. A marketplace is an online store that allows multiple vendors to add their products and sell them.

A marketplace has a lot of features, including managing a vendor’s own orders and settings. This part of the tutorial will only showcase how to create stores for each user and attach the products they create to that store.

Code for This Tutorial

If you want to follow along you can find the code for this tutorial in this repository.

Alternatively, if you want to install the marketplace into your existing Medusa store, you can install the Medusa Marketplace plugin. This plugin is created with the code from this tutorial and will be updated with every new part of this series released.

Prerequisites

Before you follow along with this tutorial, make sure you have:

  1. A Medusa server instance was installed. You can follow along with our easy quickstart guide to learn how you can do that.
  2. PostgreSQL installed and your Medusa server connected to it.
  3. Redis installed and your Medusa server connected to it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sudo systemctl restart redis-server
清楚redis 缓存数据
➜ ~ redis-cli
127.0.0.1:6379> select
(error) ERR wrong number of arguments for 'select' command
127.0.0.1:6379> select 1
OK
127.0.0.1:6379[1]> FLUSHDB
OK

➜ ~ sudo /etc/init.d/postgresql star
[sudo] password for zhuang:
➜ ~
➜ ~ sudo -i -u postgres
postgres@elementoryos61:~$ psql
psql (12.9 (Ubuntu 12.9-0ubuntu0.20.04.1))
Type "help" for help.
postgres=# CREATE DATABASE openharbor_marketplace_medusa;
CREATE DATABASE
postgres=#

Building the Marketplace

Project Setup

In the directory that holds your Medusa server, start by installing Medusa Extender using NPM:

1
npm i medusa-extender

It’s recommended that you use TypeScript in your project to get the full benefits of Medusa-Extender. To do that, create the file tsconfig.json in the root of the Medusa project with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"compilerOptions": {
"module": "CommonJS",
"declaration": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"target": "es2017",
"sourceMap": true,
"skipLibCheck": true,
"allowJs": true,
"outDir": "dist",
"rootDir": ".",
"esModuleInterop": true
},
"include": ["src", "medusa-config.js"],
"exclude": ["dist", "node_modules", "**/*.spec.ts"]
}

Next, update the scripts key in package.json with the following content:

1
2
3
4
5
"scripts": {
"seed": "medusa seed -f ./data/seed.json",
"build": "rm -rf dist && tsc",
"start": "npm run build && node dist/src/main.js",
},

These scripts will ensure that your TypeScript files will be transpiled before Medusa is run.

Then, create the file main.ts in the directory src with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Medusa } from 'medusa-extender';
import express = require('express');

async function bootstrap() {
const expressInstance = express();

await new Medusa(__dirname + '/../', expressInstance).load([]);

expressInstance.listen(9000, () => {
console.info('Server successfully started on port 9000');
});
}

bootstrap();

This file will make sure to load all the customizations you’ll add next when you run your Medusa server.

Now, Medusa Extender is fully integrated into your Medusa instance and you can start building the Marketplace.

Customize the Store Entity

You’ll start by customizing the Store entity. You’ll need to use it later on to add relations between the store entity and the users and products entities.

By convention, customizations using Medusa Extender are organized in a module-like structure. However, this is completely optional.

In the src directory, create the directory modules in which you’ll store all the customizations in.

Then, create the directory store inside the modules directory. The store directory will hold all customizations related to the Store.

Create a Store Entity

Create the file src/modules/store/entities/store.entity.ts with the following content:

1
2
3
4
5
6
7
8
9
import { Store as MedusaStore } from '@medusajs/medusa/dist';
import { Entity, JoinColumn, OneToMany } from 'typeorm';
import { Entity as MedusaEntity } from 'medusa-extender';

@MedusaEntity({ override: MedusaStore })
@Entity()
export class Store extends MedusaStore {
//TODO add relations
}

This uses the decorator @Entity from medusa-extender to customize Medusa’s Store entity. You create a Store class that extends Medusa’s Store entity (imported as MedusaStore ).

You’ll, later on, edit this entity to add the relations between the store and users and products.

Create a Store Repository

Next, you need to override Medusa’s StoreRepository. This repository will return Medusa’s Store entity. So, you need to override it to make sure it returns your Store entity that you just created.

Create the file src/modules/store/repositories/store.repository.ts with the following content:

1
2
3
4
5
6
7
8
9
import { EntityRepository } from 'typeorm';
import { StoreRepository as MedusaStoreRepository } from '@medusajs/medusa/dist/repositories/store';
import { Repository as MedusaRepository, Utils } from 'medusa-extender';
import { Store } from '../entities/store.entity';

@MedusaRepository({ override: MedusaStoreRepository })
@EntityRepository(Store)
export default class StoreRepository extends Utils.repositoryMixin<Store, MedusaStoreRepository>(MedusaStoreRepository) {
}

Create the Store Module

For now, these are the only files you’ll add for the store. You can create the Store module using these files.

Create the file src/modules/store/store.module.ts with the following content:

1
2
3
4
5
6
7
8
import { Module } from 'medusa-extender';
import { Store } from './entities/store.entity';
import StoreRepository from './repositories/store.repository';

@Module({
imports: [Store, StoreRepository],
})
export class StoreModule {}

This uses the @Module decorator from medusa-extender and imports the 2 classes you created.

The last thing left is to import this module and use it with Medusa. In src/main.ts import StoreModule at the beginning of the file:

1
import { StoreModule } from './modules/store/store.module';

Then, add the StoreModule in the array passed as a parameter to Medusa.load:

1
2
3
await new Medusa(__dirname + '/../', expressInstance).load([
StoreModule
]);

This is all that you’ll do for now in the Store module. In the next sections, you’ll be adding more classes to it as necessary.

Customize the User Entity

In this section, you’ll customize the user entity mainly to link the user to a store.

Create the User Entity

Create the directory user inside the modules directory and create the file src/modules/user/entities/user.entity.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { User as MedusaUser } from '@medusajs/medusa/dist';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Entity as MedusaEntity } from 'medusa-extender';
import { Store } from '../../store/entities/store.entity';

@MedusaEntity({ override: MedusaUser })
@Entity()
export class User extends MedusaUser {
@Index()
@Column({ nullable: false })
store_id: string;

@ManyToOne(() => Store, (store) => store.members)
@JoinColumn({ name: 'store_id' })
store: Store;
}

This class will add an additional column store_id of type string and will add a relation to the Store entity.

To add the new column to the user table in the database, you need to create a Migration file. Create the file src/modules/user/migrations/user.migration.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Migration } from 'medusa-extender';
import { MigrationInterface, QueryRunner } from 'typeorm';

@Migration()
export default class addStoreIdToUser1644946220401 implements MigrationInterface {
name = 'addStoreIdToUser1644946220401';

public async up(queryRunner: QueryRunner): Promise<void> {
const query = `ALTER TABLE public."user" ADD COLUMN IF NOT EXISTS "store_id" text;`;
await queryRunner.query(query);
}

public async down(queryRunner: QueryRunner): Promise<void> {
const query = `ALTER TABLE public."user" DROP COLUMN "store_id";`;
await queryRunner.query(query);
}
}

The migration is created using the @Migration decorator from medusa-extender. Notice that the migration name should end with a JavaScript timestamp based on typeorm‘s conventions.

The up method is run if the migration hasn’t been run before. It will add the column store_id to the table user if it doesn’t exist.

You’ll also need to add the relation between the Store and the User entities in src/modules/store/entities/store.entity.ts . Replace the //TODO with the following:

1
2
3
@OneToMany(() => User, (user) => user.store)
@JoinColumn({ name: 'id', referencedColumnName: 'store_id' })
members: User[];

Make sure to import the User entity at the beginning of the file:

1
import { User } from '../../user/entities/user.entity';

Create the User Repository

Next, you need to override Medusa’s UserRepository. Create the file src/modules/user/repositories/user.repository.ts with the following content:

1
2
3
4
5
6
7
8
9
import { UserRepository as MedusaUserRepository } from "@medusajs/medusa/dist/repositories/user";
import { Repository as MedusaRepository, Utils } from "medusa-extender";
import { EntityRepository } from "typeorm";
import { User } from "../entities/user.entity";

@MedusaRepository({ override: MedusaUserRepository })
@EntityRepository(User)
export default class UserRepository extends Utils.repositoryMixin<User, MedusaUserRepository>(MedusaUserRepository) {
}

Create the User Service

Next, you need to override Medusa’s UserService class. Create the file src/modules/user/services/user.service.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { Service } from 'medusa-extender';
import { EntityManager } from 'typeorm';
import EventBusService from '@medusajs/medusa/dist/services/event-bus';
import { FindConfig } from '@medusajs/medusa/dist/types/common';
import { UserService as MedusaUserService } from '@medusajs/medusa/dist/services';
import { User } from '../entities/user.entity';
import UserRepository from '../repositories/user.repository';
import { MedusaError } from 'medusa-core-utils';

type ConstructorParams = {
manager: EntityManager;
userRepository: typeof UserRepository;
eventBusService: EventBusService;
};

@Service({ override: MedusaUserService })
export default class UserService extends MedusaUserService {
private readonly manager: EntityManager;
private readonly userRepository: typeof UserRepository;
private readonly eventBus: EventBusService;

constructor(private readonly container: ConstructorParams) {
super(container);
this.manager = container.manager;
this.userRepository = container.userRepository;
this.eventBus = container.eventBusService;

}

public async retrieve(userId: string, config?: FindConfig<User>): Promise<User> {
const userRepo = this.manager.getCustomRepository(this.userRepository);
const validatedId = this.validateId_(userId);
const query = this.buildQuery_({ id: validatedId }, config);

const user = await userRepo.findOne(query);

if (!user) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, `User with id: ${userId} was not found`);
}

return user as User;
}
}

This uses the @Service decorator from medusa-extender to override Medusa’s UserService. The class you create to override it will extend UserService.

This new class overrides the retrieve method to ensure that the user returned is the new User entity class you created earlier.

Create a User Middleware

The loggedInUser is not available natively in Medusa. You’ll need to create a Middleware that, when a request is authenticated, registers the logged-in User within the scope.

Create the file src/modules/user/middlewares/loggedInUser.middleware.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { MedusaAuthenticatedRequest, MedusaMiddleware, Middleware } from 'medusa-extender';
import { NextFunction, Response } from 'express';

import UserService from '../../user/services/user.service';

@Middleware({ requireAuth: true, routes: [{ method: "all", path: '*' }] })
export class LoggedInUserMiddleware implements MedusaMiddleware {
public async consume(req: MedusaAuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
if (req.user && req.user.userId) {
const userService = req.scope.resolve('userService') as UserService;
const loggedInUser = await userService.retrieve(req.user.userId, {
select: ['id', 'store_id'],
});

req.scope.register({
loggedInUser: {
resolve: () => loggedInUser,
},
});
}
next();
}
}

You can use the @Middleware decorator from medusa-extender to create a Middleware that runs on specific requests. This Middleware is run when the request is received from an authenticated user, and it runs for all paths (notice the use of path: '*' ) and for all types of requests (notice the use of method: "all").

Inside the middleware, you retrieve the current user ID from the request, then retrieve the user model and register it in the scope so that it can be accessed from services.

This approach is simplified for the purpose of this tutorial. However, it makes more sense to include this middleware in a separate auth module. Whether you include this middleware in the user module or the auth middleware will not affect its functionality.

Create a Store Service to Handle User Insert Events

You need to ensure that when a user is created, a store is associated with it. You can do that by listening to the User-created event and creating a new store for that user. You’ll add this event handler in a StoreService.

Create the file src/modules/store/services/store.service.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import { StoreService as MedusaStoreService } from '@medusajs/medusa/dist/services';
import { EntityManager } from 'typeorm';
import { CurrencyRepository } from '@medusajs/medusa/dist/repositories/currency';
import { Store } from '../entities/store.entity';
import { EntityEventType, Service, MedusaEventHandlerParams, OnMedusaEntityEvent } from 'medusa-extender';
import { User } from '../../user/entities/user.entity';
import EventBusService from '@medusajs/medusa/dist/services/event-bus';
import StoreRepository from '../repositories/store.repository';

interface ConstructorParams {
loggedInUser: User;
manager: EntityManager;
storeRepository: typeof StoreRepository;
currencyRepository: typeof CurrencyRepository;
eventBusService: EventBusService;
}

@Service({ override: MedusaStoreService, scope: 'SCOPED' })
export default class StoreService extends MedusaStoreService {
private readonly manager: EntityManager;
private readonly storeRepository: typeof StoreRepository;

constructor(private readonly container: ConstructorParams) {
super(container);
this.manager = container.manager;
this.storeRepository = container.storeRepository;
}

withTransaction(transactionManager: EntityManager): StoreService {
if (!transactionManager) {
return this;
}

const cloned = new StoreService({
...this.container,
manager: transactionManager,
});

cloned.transactionManager_ = transactionManager;

return cloned;
}

@OnMedusaEntityEvent.Before.Insert(User, { async: true })
public async createStoreForNewUser(
params: MedusaEventHandlerParams<User, 'Insert'>
): Promise<EntityEventType<User, 'Insert'>> {
const { event } = params;
const createdStore = await this.withTransaction(event.manager).createForUser(event.entity);
if (!!createdStore) {
event.entity.store_id = createdStore.id;
}
return event;
}

public async createForUser(user: User): Promise<Store | void> {
if (user.store_id) {
return;
}
const storeRepo = this.manager.getCustomRepository(this.storeRepository);
const store = storeRepo.create() as Store;
return storeRepo.save(store);
}

public async retrieve(relations: string[] = []) {
if (!this.container.loggedInUser) {
return super.retrieve(relations);
}

const storeRepo = this.manager.getCustomRepository(this.storeRepository);
const store = await storeRepo.findOne({
relations,
join: { alias: 'store', innerJoin: { members: 'store.members' } },
where: (qb) => {
qb.where('members.id = :memberId', { memberId: this.container.loggedInUser.id });
},
});

if (!store) {
throw new Error('Unable to find the user store');
}

return store;
}
}

@OnMedusaEntityEvent.Before.Insert is used to add a listener to an insert event on an entity, which in this case is the User entity. Inside the listener, you create the user using the createForUser method. This method just uses the StoreRepository to create a store.

You also add a helper event retrieve to retrieve the store that belongs to the currently logged-in user.

Notice the use of scope: 'SCOPED' in the @Service decorator. This will allow you to access the logged in user you registered earlier in the scope.

You’ll need to import this new class into the StoreModule. In src/modules/store/store.module.ts add the following import at the beginning:

1
import StoreService from './services/store.service';

Then, add the StoreService to the imports array passed to @Module :

1
imports: [Store, StoreRepository, StoreService],

Create a User Subscriber

For the event listener to work, you need to first emit this event in a subscriber. The event will be emitted before a User is inserted. Create the file src/modules/user/subscribers/user.subscriber.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { Connection, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm';
import { eventEmitter, Utils as MedusaUtils, OnMedusaEntityEvent } from 'medusa-extender';
import { User } from '../entities/user.entity';

@EventSubscriber()
export default class UserSubscriber implements EntitySubscriberInterface<User> {
static attachTo(connection: Connection): void {
MedusaUtils.attachOrReplaceEntitySubscriber(connection, UserSubscriber);
}

public listenTo(): typeof User {
return User;
}

public async beforeInsert(event: InsertEvent<User>): Promise<void> {
return await eventEmitter.emitAsync(OnMedusaEntityEvent.Before.InsertEvent(User), {
event,
transactionalEntityManager: event.manager,
});
}
}

This will create a subscriber using the EventSubscriber decorator from typeorm. Then, before a user is inserted the OnMedusaEntityEvent.Before.InsertEvent event from medusa-extender is emitted, which will trigger creating the store.

To register the subscriber, you need to create a middleware that registers it. Create the file src/modules/user/middlewares/userSubscriber.middleware.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import {
MEDUSA_RESOLVER_KEYS,
MedusaAuthenticatedRequest,
MedusaMiddleware,
Utils as MedusaUtils,
Middleware
} from 'medusa-extender';
import { NextFunction, Response } from 'express';

import { Connection } from 'typeorm';
import UserSubscriber from '../subscribers/user.subscriber';

@Middleware({ requireAuth: false, routes: [{ method: "post", path: '/admin/users' }] })
export class AttachUserSubscriberMiddleware implements MedusaMiddleware {
public async consume(req: MedusaAuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
const { connection } = req.scope.resolve(MEDUSA_RESOLVER_KEYS.manager) as { connection: Connection };
MedusaUtils.attachOrReplaceEntitySubscriber(connection, UserSubscriber);
return next();
}
}

This will register the subscriber when a POST request is sent to /admin/users, which creates a new user.

Create a User Router

The last customization left is an optional one. By default, Medusa’s create user endpoint requires you to be authenticated as an admin. In a marketplace use case, you might want users to register on their own and create their own stores. If this is not the case for you, you can skip creating the following class.

Medusa Extender allows you to also override routes in Medusa. In this case, you’ll be adding the /admin/create-user route to accept non-authenticated requests.

Create the file src/modules/user/routers/user.router.ts and add the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Router } from 'medusa-extender';
import createUserHandler from '@medusajs/medusa/dist/api/routes/admin/users/create-user';
import wrapHandler from '@medusajs/medusa/dist/api/middlewares/await-middleware';

@Router({
routes: [
{
requiredAuth: false,
path: '/admin/create-user',
method: 'post',
handlers: [wrapHandler(createUserHandler)],
},
],
})
export class UserRouter {
}

You use the @Router decorator from medusa-extender to create a router. This router will accept a routes array which will either be added or override existing routes in your Medusa server. In this case, you override the /admin/create-user route and set requiredAuth to false.

To make sure that the AttachUserSubscriberMiddleware also runs for this new route (so that the before insert user event handlers run for this new route), make sure to add a new entry to the routes array:

1
@Middleware({ requireAuth: false, routes: [{ method: "post", path: '/admin/users' }, { method: "post", path: '/admin/create-user' }] })

Create a User Module

You’ve added all the customizations necessary to associate a user with their own store. Now, you can create the User module using these files.

Create the file src/modules/user/user.module.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { AttachUserSubscriberMiddleware } from './middlewares/userSubscriber.middleware';
import { LoggedInUserMiddleware } from "./middlewares/loggedInUser.middleware";
import { Module } from 'medusa-extender';
import { User } from './entities/user.entity';
import UserRepository from './repositories/user.repository';
import { UserRouter } from "./routers/user.router";
import UserService from './services/user.service';
import addStoreIdToUser1644946220401 from './migrations/user.migration';

@Module({
imports: [
User,
UserService,
UserRepository,
addStoreIdToUser1644946220401,
UserRouter,
LoggedInUserMiddleware,
AttachUserSubscriberMiddleware
]
})
export class UserModule {}

If you didn’t create the UserRouter in the previous step then make sure to remove it from the imports array.

The last thing left is to import this Module. In src/main.ts import UserModule at the beginning of the file:

1
import { UserModule } from './modules/user/user.module';

Then, add the UserModule in the array passed as a parameter to Medusa.load:

1
2
3
4
await new Medusa(__dirname + '/../', expressInstance).load([
UserModule,
StoreModule
]);

Test it Out

You are now ready to test out this customization! In your terminal, run your Medusa server:

1
2
3
4
5
// this is upgrade DB, also the same as scripts in package.json
medusa seed -f data/seed.json -m

// this is start medusa server
npm start

Or using Medusa’s CLI:

1
medusa develop

After your run your server, you need to use a tool like Postman to easily send requests to your server.

If you didn’t add the UserRouter, you first need to log in as an admin to be able to add users. You can do that by sending a POST request to localhost:9000/admin/auth. In the body, you should include the email and password. If you’re using a fresh Medusa install you can use the following credentials:

1
2
3
4
{
"email": "admin@medusa-test.com",
"password": "supersecret"
}

Following this request, you can send authenticated requests to the Admin.

Send a POST request to [localhost:9000/admin/users](http://localhost:9000/admin/users) to create a new user. In the body, you need to pass the email and password of the new user:

1
2
3
4
{
"email": "example@gmail.com",
"password": "supersecret"
}

The request will return a user object with the details of the new user:

![Create User Result](Open source ecommerce platform for multi-vendor marketplaces/1.png)

Notice how there’s a store_id field now. If you try to create a couple of users, you’ll see that the store_id will be different each time.

Customize the Products Entity

Similar to how you just customized the User entity, you need to customize the Product entity to also hold the store_id with the relationship as well. You’ll then customize the ProductService as well as other classes to make sure that, when a product is created, the store ID of the user creating it is attached to it. You’ll also make sure that when the list of products is fetched, only the products that belong to the current user’s store are returned.

Create a Product Entity

Create the file src/modules/product/entities/product.entity.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Product as MedusaProduct } from '@medusajs/medusa/dist';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Entity as MedusaEntity } from 'medusa-extender';
import { Store } from '../../store/entities/store.entity';

@MedusaEntity({ override: MedusaProduct })
@Entity()
export class Product extends MedusaProduct {
@Index()
@Column({ nullable: false })
store_id: string;

@ManyToOne(() => Store, (store) => store.members)
@JoinColumn({ name: 'store_id', referencedColumnName: 'id' })
store: Store;
}

This will override Medusa’s Product entity to add the store_id field and relation to the Store entity.

You need to also reflect this relation in the Store entity, so, in src/modules/store/entities/store.entity.ts add the following code below the relation with the User entity you previously added:

1
2
3
@OneToMany(() => Product, (product) => product.store)
@JoinColumn({ name: 'id', referencedColumnName: 'store_id' })
products: Product[];

Make sure to import the Product entity at the beginning of the file:

1
import { Product } from '../../product/entities/product.entity';

Create a Product Migration

Next, create the file src/modules/product/migrations/product.migration.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { MigrationInterface, QueryRunner } from 'typeorm';

import { Migration } from 'medusa-extender';

@Migration()
export default class addStoreIdToProduct1645034402086 implements MigrationInterface {
name = 'addStoreIdToProduct1645034402086';

public async up(queryRunner: QueryRunner): Promise<void> {
const query = `ALTER TABLE public."product" ADD COLUMN IF NOT EXISTS "store_id" text;`;
await queryRunner.query(query);
}

public async down(queryRunner: QueryRunner): Promise<void> {
const query = `ALTER TABLE public."product" DROP COLUMN "store_id";`;
await queryRunner.query(query);
}
}

This will add a migration that will add the store_id column to the product table.

Create a Product Repository

Next, create the file src/modules/product/repositories/product.repository.ts with the following content:

1
2
3
4
5
6
7
8
9
10
import { Repository as MedusaRepository, Utils } from "medusa-extender";

import { EntityRepository } from "typeorm";
import { ProductRepository as MedusaProductRepository } from "@medusajs/medusa/dist/repositories/product";
import { Product } from '../entities/product.entity';

@MedusaRepository({ override: MedusaProductRepository })
@EntityRepository(Product)
export default class ProductRepository extends Utils.repositoryMixin<Product, MedusaProductRepository>(MedusaProductRepository) {
}

This will override Medusa’s ProductRepository to return your new Product entity.

Create a Product Service

Now, you’ll add the customization to ensure that only the products that belong to the currently logged-in user are returned when a request is sent.

Since you created the LoggedInUserMiddleware earlier, you can have access to the logged-in user from any service through the container object passed to the constructor of the service.

Create the file src/modules/product/services/product.service.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { EntityEventType, MedusaEventHandlerParams, OnMedusaEntityEvent, Service } from 'medusa-extender';

import { EntityManager } from "typeorm";
import { ProductService as MedusaProductService } from '@medusajs/medusa/dist/services';
import { Product } from '../entities/product.entity';
import { User } from '../../user/entities/user.entity';
import UserService from '../../user/services/user.service';

type ConstructorParams = {
manager: any;
loggedInUser: User;
productRepository: any;
productVariantRepository: any;
productOptionRepository: any;
eventBusService: any;
productVariantService: any;
productCollectionService: any;
productTypeRepository: any;
productTagRepository: any;
imageRepository: any;
searchService: any;
userService: UserService;
}

@Service({ scope: 'SCOPED', override: MedusaProductService })
export class ProductService extends MedusaProductService {
readonly #manager: EntityManager;

constructor(private readonly container: ConstructorParams) {
super(container);
this.#manager = container.manager;
}

prepareListQuery_(selector: object, config: object): object {
const loggedInUser = this.container.loggedInUser
if (loggedInUser) {
selector['store_id'] = loggedInUser.store_id
}

return super.prepareListQuery_(selector, config);
}
}

This will override the prepareListQuery method in Medusa’s ProductService, which this new class extends, to get the logged-in user. Then, if the user is retrieved successfully the key store_id is added to the selector object to filter the products by the user’s store_id.

Create a Product Module

That’s all the customization you’ll do for now. You just need to import all these files into a Product module.

Create src/modules/product/product.module.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Module } from 'medusa-extender';
import { Product } from './entities/product.entity';
import ProductRepository from './repositories/product.repository';
import { ProductService } from './services/product.service';
import addStoreIdToProduct1645034402086 from './migrations/product.migration';

@Module({
imports: [
Product,
ProductRepository,
ProductService,
addStoreIdToProduct1645034402086,
]
})
export class ProductModule {}

Finally, import the ProductModule at the beginning of src/main.ts:

1
import { ProductModule } from './modules/product/product.module';

And add the ProductModule to the array passed to load along with UserModule:

1
2
3
4
5
await new Medusa(__dirname + '/../', expressInstance).load([
UserModule,
ProductModule,
StoreModule
]);

Test it Out

You can go ahead and test it out now. Run the server if it isn’t running already and log in with the user you created earlier by sending the credentials to localhost:9000/admin/auth.

After that, send a GET request to localhost:9000/admin/products. You’ll receive an empty array of products as the current user does not have any products yet.

![Result of Get Products](Open source ecommerce platform for multi-vendor marketplaces/2.png)

You’ll now add the necessary customization to attach a store ID to a newly created product.

To listen to the product created event, create the file src/modules/product/subscribers/product.subscriber.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Connection, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm';
import { OnMedusaEntityEvent, Utils, eventEmitter } from 'medusa-extender';

import { Product } from '../entities/product.entity';

@EventSubscriber()
export default class ProductSubscriber implements EntitySubscriberInterface<Product> {
static attachTo(connection: Connection): void {
Utils.attachOrReplaceEntitySubscriber(connection, ProductSubscriber);
}

public listenTo(): typeof Product {
return Product;
}

public async beforeInsert(event: InsertEvent<Product>): Promise<void> {
return await eventEmitter.emitAsync(OnMedusaEntityEvent.Before.InsertEvent(Product), {
event,
transactionalEntityManager: event.manager,
});
}
}

Then, you need to register this Subscriber using Middleware. Create the file src/modules/product/middlewares/product.middleware.ts with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import {
MEDUSA_RESOLVER_KEYS,
MedusaAuthenticatedRequest,
MedusaMiddleware,
Utils as MedusaUtils,
Middleware
} from 'medusa-extender';
import { NextFunction, Request, Response } from 'express';

import { Connection } from 'typeorm';
import ProductSubscriber from '../subscribers/product.subscriber';

@Middleware({ requireAuth: true, routes: [{ method: 'post', path: '/admin/products' }] })
export default class AttachProductSubscribersMiddleware implements MedusaMiddleware {
public consume(req: MedusaAuthenticatedRequest | Request, res: Response, next: NextFunction): void | Promise<void> {
const { connection } = req.scope.resolve(MEDUSA_RESOLVER_KEYS.manager) as { connection: Connection };
MedusaUtils.attachOrReplaceEntitySubscriber(connection, ProductSubscriber);
return next();
}
}

This will register the subscriber when a POST request is sent to /admin/products, which creates a new product.

Add Event Listener in Product Service

Next, in src/modules/product/services/product.service.ts add the following inside the class:

1
2
3
4
5
6
7
8
9
@OnMedusaEntityEvent.Before.Insert(Product, { async: true })
public async attachStoreToProduct(
params: MedusaEventHandlerParams<Product, 'Insert'>
): Promise<EntityEventType<Product, 'Insert'>> {
const { event } = params;
const loggedInUser = this.container.loggedInUser;
event.entity.store_id = loggedInUser.store_id;
return event;
}

This will listen to the Insert event using the @OnMedusaEntityEvent decorator from medusa-extender. It will then use the logged-in user and attach the user’s store_id to the newly created product.

Add Middleware to Product Module

Finally, make sure to import the new middleware at the beginning of src/modules/product/product.module.ts:

1
import AttachProductSubscribersMiddleware from './middlewares/product.middleware';

Then, add it in the imports array passed to @Module:

1
2
3
4
5
6
7
imports: [
Product,
ProductRepository,
ProductService,
addStoreIdToProduct1645034402086,
AttachProductSubscribersMiddleware
]

You’re ready to add products into a store now! Run the server if it’s not running and make sure you’re logged in with the user you created earlier. Then, send a POST request to [localhost:9000/admin/products](http://localhost:9000/admin/products) with the following body:

1
2
3
4
{
"title": "my product",
"options": []
}

This is the minimum structure of a product. You can rename the title to anything you want.

After you send the request, you should receive a Product object where you can see the store_id is set to the same store_id of the user you’re logged in with.

![Add Product Request Result](Open source ecommerce platform for multi-vendor marketplaces/3.png)

Now, try sending a GET request to [localhost:9000/admin/products](http://localhost:9000/admin/products) as you did earlier. Instead of an empty array, you’ll see the product you just added.

![Retrieve Products](Open source ecommerce platform for multi-vendor marketplaces/4.png)

Testing it Out Using Medusa’s Admin

If you also have a Medusa Admin instance installed, you can also test this out. Log in with the user you created earlier and you’ll see that you can only see the product they added.

![Admin Dashboard](Open source ecommerce platform for multi-vendor marketplaces/5.png)

Conclusion

In this tutorial, you learned the first steps of creating a Marketplace using Medusa and Medusa Extender! In later points, you’ll learn about how you can add settings, manage orders, and more!

Be sure to support Medusa Extender and check the repository out for more details!

Should you have any issues or questions related to Medusa, then feel free to reach out to the Medusa team via Discord. You can also contact Adrien @adrien2p for more details or help regarding Medusa Extender.

This is a collection of medusa-postman, from the name sequence, we can see the business steps one by one.

It is based on @medusajs/medusa@1.2.1, @medusajs/medusa-cli@1.2.1.

Please import this json as a file into postman, you can call the API step by step.

![](3.Create medusa transaction step by step/1.png)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
{
"info": {
"_postman_id": "d0447766-3c8d-4b70-bf6c-ca66061249f8",
"name": "medusa",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "1.Authenticate a User",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"zwm136200@gmail.com\",\n \"password\": \"zaq12wsx\"\n}\n",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/admin/auth",
"host": [
"localhost"
],
"port": "9000",
"path": [
"admin",
"auth"
]
}
},
"response": []
},
{
"name": "2.Create product",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n\t\"title\": \"nft-04-26-test-01\",\n\t\"subtitle\": \"\",\n\t\"description\": \"this is a test\",\n\t\"is_giftcard\": false,\n\t\"discountable\": false,\n\t\"images\": [],\n\t\"thumbnail\": \"\",\n\t\"handle\": \"test-nft-product-04-27\",\n\t\"weight\": 10,\n\t\"length\": 20,\n\t\"height\": 5,\n\t\"width\": 10,\n\t\"origin_country\": \"\",\n\t\"mid_code\": \"\",\n\t\"material\": \"\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/admin/products",
"host": [
"localhost"
],
"port": "9000",
"path": [
"admin",
"products"
]
}
},
"response": []
},
{
"name": "3.Create a Customer-admin",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test1@gmail.com\",\n \"password\": \"test1\",\n \"first_name\": \"firstname\",\n \"last_name\": \"lastname\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/admin/customers",
"host": [
"localhost"
],
"port": "9000",
"path": [
"admin",
"customers"
]
}
},
"response": []
},
{
"name": "3.Create a Customer-store",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test2@gmail.com\",\n \"password\": \"test2\",\n \"first_name\": \"firstname2\",\n \"last_name\": \"lastname2\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/store/customers",
"host": [
"localhost"
],
"port": "9000",
"path": [
"store",
"customers"
]
}
},
"response": []
},
{
"name": "4.Auth a customer",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"test1@gmail.com\",\n \"password\": \"test1\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/store/auth",
"host": [
"localhost"
],
"port": "9000",
"path": [
"store",
"auth"
]
}
},
"response": []
},
{
"name": "4.1 get regions",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "localhost:9000/store/regions",
"host": [
"localhost"
],
"port": "9000",
"path": [
"store",
"regions"
]
}
},
"response": []
},
{
"name": "5.Create a cart",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"region_id\": \"reg_01G1FPXMTTD926PVCYW9WT9QSN\",\n \"country_code\": \"cn\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/store/carts",
"host": [
"localhost"
],
"port": "9000",
"path": [
"store",
"carts"
]
}
},
"response": []
},
{
"name": "5.1 Add a item",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"variant_id\": \"variant_01G1G2029D4ASNYWGEX9TCSVTY\",\n \"quantity\": 1\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/store/carts/cart_01G1G1R5D2JCA2CP0HV55JHEMJ/line-items",
"host": [
"localhost"
],
"port": "9000",
"path": [
"store",
"carts",
"cart_01G1G1R5D2JCA2CP0HV55JHEMJ",
"line-items"
]
}
},
"response": []
},
{
"name": "5.2Add a Shipping Method",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"option_id\": \"so_01G1FZ76DY5XVRT64639856AR0\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": ""
}
},
"response": []
},
{
"name": "5.3Calculate Cart Taxes",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/store/carts/cart_01G1G1R5D2JCA2CP0HV55JHEMJ/taxes",
"host": [
"localhost"
],
"port": "9000",
"path": [
"store",
"carts",
"cart_01G1G1R5D2JCA2CP0HV55JHEMJ",
"taxes"
]
}
},
"response": []
},
{
"name": "5.4Initialize Payment Sessions",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/store/carts/cart_01G1G1R5D2JCA2CP0HV55JHEMJ/payment-sessions",
"host": [
"localhost"
],
"port": "9000",
"path": [
"store",
"carts",
"cart_01G1G1R5D2JCA2CP0HV55JHEMJ",
"payment-sessions"
]
}
},
"response": []
},
{
"name": "5.5Complete a Cart - create an order",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/store/carts/cart_01G1G1R5D2JCA2CP0HV55JHEMJ/complete",
"host": [
"localhost"
],
"port": "9000",
"path": [
"store",
"carts",
"cart_01G1G1R5D2JCA2CP0HV55JHEMJ",
"complete"
]
}
},
"response": []
},
{
"name": "6.Capture an Order - payment finished",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/admin/orders/order_01G1G6DRGQQX5QBF7RQY1JYWWQ/capture",
"host": [
"localhost"
],
"port": "9000",
"path": [
"admin",
"orders",
"order_01G1G6DRGQQX5QBF7RQY1JYWWQ",
"capture"
]
}
},
"response": []
},
{
"name": "7.Create a Fulfillment - order fulfilled",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"items\": [\n {\n \"item_id\": \"item_01G1G21Z2G7VDXYZ51AZM53YNN\",\n \"quantity\": 1\n }\n ],\n \"metadata\": {},\n \"no_notification\": false\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/admin/orders/order_01G1G6DRGQQX5QBF7RQY1JYWWQ/fulfillment",
"host": [
"localhost"
],
"port": "9000",
"path": [
"admin",
"orders",
"order_01G1G6DRGQQX5QBF7RQY1JYWWQ",
"fulfillment"
]
}
},
"response": []
},
{
"name": "8.Create a Shipment - order shipped",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"fulfillment_id\": \"ful_01G1JKV7Y3V25GDBE8T30EH0GB\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "localhost:9000/admin/orders/order_01G1G6DRGQQX5QBF7RQY1JYWWQ/shipment",
"host": [
"localhost"
],
"port": "9000",
"path": [
"admin",
"orders",
"order_01G1G6DRGQQX5QBF7RQY1JYWWQ",
"shipment"
]
}
},
"response": []
}
]
}

Question

How can I finish (or complete) an order? I already run Capture an Order -> Create a Fulfillment -> Create a Shipment successfully, but the order status is “pending” in DB. (in Ubuntu / medusa version v1.7.12)

Answer

You can create a simple subscriber, that listens for order completion events:

1
2
3
4
5
6
7
8
9
10
11
12
class OrderSubscriber {
constructor({ orderService, eventBusService }) {
this.orderService_ = orderService;
eventBusService.subscribe("order.placed", this.handleOrderPlaced);
}

handleOrderPlaced = async (data) => {
return await this.orderService_.update(data.id, { status: "completed" })
};
}

export default OrderSubscriber;

![](2.How to complete an order in medusa/1.png)